Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ run `npm run sync:changelog` there, build with `npm test`, and publish the site
the release. Patch releases fold into their minor's entry. The page fell 95 minor
releases behind (v2.21 shown at v2.116) before this step was written down.

## Releasing: bump a plugin's version when its files change
The `plugins/` marketplace sets an explicit `version` in each `plugin.json`, and Claude
Code ships an update only when that version changes. After changing a plugin, bump its
version, then run `npm run gen:plugin-releases`. `test/plugin-releases.test.js` fails
until you do, and the generator refuses to record a change that kept the old version.

## Data Persistence
If settings or configuration appear wiped, always check `/data` first.
AppCrane stores all persistent state (database, env vars, app configs) under the `DATA_DIR` path (default: `./data`).
Expand Down
11 changes: 6 additions & 5 deletions examples/work-review-hub/digest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ const SETUP_FIXES = {
'mcp-unused': 'An MCP server you never call: disable it with /mcp',
'allow-all-bash': 'Bash(*) is allowed: narrow it, or use auto mode',
'skill-side-effect-auto': 'A deploy-style skill Claude can run on its own: set disable-model-invocation',
'skill-installed-twice': 'A skill installed twice, as your own copy and from a plugin: delete your copy',
};

const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const usd = n => (n >= 100 ? `$${n.toFixed(0)}` : `$${(n || 0).toFixed(2)}`);
const usd = n => (n == null ? 'not measured' : n >= 100 ? `$${n.toFixed(0)}` : `$${n.toFixed(2)}`);
Comment thread
gitayg marked this conversation as resolved.

/** ISO week label, e.g. 2026-W44, for "once per week" markers and idempotency. */
export function isoWeek(d) {
Expand All @@ -47,16 +48,16 @@ export function digestFor(name, history, { now = new Date(), hubUrl = '' } = {})
const weekAgo = new Date(now.getTime() - 7 * 864e5).toISOString().slice(0, 10);
if (!latest || latest.day < weekAgo) return null;
const prev = [...history].reverse().find(s => s.day <= new Date(Date.parse(latest.day) - 6 * 864e5).toISOString().slice(0, 10));
const cost = latest.totals.usd || 0;
const cost = latest.totals.usd ?? null;
const lines = [];
lines.push(`Cost at API list prices, last 7 days: ${usd(cost)}${prev ? ` (the week before: ${usd(prev.totals.usd || 0)})` : ''}. On a seat plan this is the API-equivalent value of the work, not a bill.`);
lines.push(`Cost at API list prices, last 7 days: ${usd(cost)}${prev ? ` (the week before: ${usd(prev.totals.usd ?? null)})` : ''}. On a seat plan this is the API-equivalent value of the work, not a bill.`);
if (latest.outcomes?.commits) lines.push(`Commits landed: ${latest.outcomes.commits}, ${usd(latest.outcomes.usdPerCommit)} each.`);
const fixes = [
...(latest.findings || []).filter(f => FIXES[f.id]).map(f => `${FIXES[f.id][0]} (${usd(f.usd)}, ${Math.round(f.share * 100)}% of spend): ${FIXES[f.id][1]}.`),
...(latest.findings || []).filter(f => FIXES[f.id]).map(f => `${FIXES[f.id][0]} (${usd(f.usd)}${f.share == null ? '' : `, ${Math.round(f.share * 100)}% of spend`}): ${FIXES[f.id][1]}.`),
...Object.keys(latest.setup?.findings || {}).filter(k => SETUP_FIXES[k]).map(k => `${SETUP_FIXES[k]}.`),
].slice(0, 3);
const first = (name || '').split(/[\s@]/)[0] || 'there';
const subject = fixes.length ? `Your week with Claude: ${usd(cost)}, ${fixes.length} thing${fixes.length > 1 ? 's' : ''} to try` : `Your week with Claude: ${usd(cost)}`;
const subject = fixes.length ? `Your week with Claude: ${cost == null ? '' : `${usd(cost)}, `}${fixes.length} thing${fixes.length > 1 ? 's' : ''} to try` : `Your week with Claude${cost == null ? '' : `: ${usd(cost)}`}`;
const footer = 'Only you get this email. Turn it off on your Work Review page.';
const text = [`Hi ${first},`, '', ...lines, '', fixes.length ? 'Worth trying this week:' : 'Nothing stood out this week. Nice.', ...fixes.map((f, i) => `${i + 1}. ${f}`),
'', hubUrl ? `Your page: ${hubUrl}` : 'Open Work Review in AppCrane for the details.', '', footer].join('\n');
Expand Down
38 changes: 21 additions & 17 deletions examples/work-review-hub/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,11 @@ <h2>Team</h2>
// only, never as HTML.
const $ = id => document.getElementById(id);
const el = (tag, text, cls) => { const e = document.createElement(tag); if (text != null) e.textContent = text; if (cls) e.className = cls; return e; };
const k = n => n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'k' : String(Math.round(n));
const pct = x => (x * 100).toFixed(0) + '%';
const usd = n => n >= 100 ? `$${n.toFixed(0)}` : `$${(n || 0).toFixed(2)}`;
// null is "not measured": shown as a dash, never as 0.
const k = n => n == null ? '—' : n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'k' : String(Math.round(n));
const n0 = v => v == null ? '—' : String(v);
const pct = x => x == null ? '—' : (x * 100).toFixed(0) + '%';
const usd = n => n == null ? '—' : n >= 100 ? `$${n.toFixed(0)}` : `$${n.toFixed(2)}`;
const api = p => fetch(p, { credentials: 'same-origin' }).then(r => { if (!r.ok) throw new Error(r.status); return r.json(); });

function kpi(label, value, cls) { const d = el('div', null, 'kpi'); d.append(el('div', value, 'v' + (cls ? ' ' + cls : '')), el('div', label, 'l')); return d; }
Expand All @@ -83,36 +85,38 @@ <h2>Team</h2>
const r = reports[reports.length - 1], T = r.totals, Q = r.instructionQuality, O = r.overcomplication;
$('asof').textContent = `· snapshot ${r.day}`;
$('kpis').replaceChildren(
kpi('Cost at API list prices (not your seat bill)', usd(T.usd)),
kpi(T.unpricedTokens ? `Cost at API list prices: incomplete, ${k(T.unpricedTokens)} tokens from unpriced models not counted` : 'Cost at API list prices (not your seat bill)', usd(T.usd), T.unpricedTokens ? 'warn' : ''),
kpi('Spent on prompts that needed correcting', `${pct(Q.reworkShare)} · ${usd(Q.reworkUsd)}`, Q.reworkShare > 0.15 ? 'warn' : ''),
kpi('Commits landed · cost per commit', r.outcomes ? `${r.outcomes.commits} · ${r.outcomes.commits ? usd(r.outcomes.usdPerCommit) : '—'}` : '—'),
kpi('Vague prompts', `${Q.vague.prompts} / ${Q.vague.prompts + Q.anchored.prompts}`),
kpi('Commits landed · cost per commit', r.outcomes ? `${n0(r.outcomes.commits)} · ${r.outcomes.commits ? usd(r.outcomes.usdPerCommit) : '—'}` : '—'),
kpi('Vague prompts', Q.vague.prompts == null || Q.anchored.prompts == null ? '—' : `${Q.vague.prompts} / ${Q.vague.prompts + Q.anchored.prompts}`),
kpi('Cache hit rate', pct(T.cacheHitRate), T.cacheHitRate > 0.85 ? 'good' : ''),
kpi('Tool error rate', pct(T.errorRate), T.errorRate > 0.1 ? 'warn' : ''),
kpi('Sessions · prompts', `${T.sessions} · ${T.userPrompts}`));
kpi('Sessions · prompts', `${n0(T.sessions)} · ${n0(T.userPrompts)}`));

// Trend: totalCost per daily snapshot, as a simple line.
const pts = reports.map(x => x.totals.usd || 0), max = Math.max(1, ...pts), W = 1000, H = 120, pad = 8;
// A snapshot whose cost was not measured is left off the line, not drawn at $0.
const pts = reports.map(x => x.totals.usd).filter(v => v != null), max = Math.max(1, ...pts), W = 1000, H = 120, pad = 8;
const svg = $('trend'); svg.setAttribute('viewBox', `0 0 ${W} ${H}`); svg.replaceChildren();
const ns = 'http://www.w3.org/2000/svg', line = document.createElementNS(ns, 'polyline');
line.setAttribute('points', pts.map((v, i) => `${pad + i * (W - 2 * pad) / Math.max(1, pts.length - 1)},${H - pad - v / max * (H - 2 * pad)}`).join(' '));
line.setAttribute('fill', 'none'); line.setAttribute('stroke', 'currentColor'); line.setAttribute('stroke-width', '2.5'); line.style.color = 'var(--accent)';
svg.append(line);
$('trendNote').textContent = `${reports.length} daily snapshots (each a 7-day window), ${reports[0].day} → ${r.day}. Peak ${usd(max)} at API list prices.`;
$('trendNote').textContent = `${pts.length} daily snapshots (each a 7-day window), ${reports[0].day} → ${r.day}. Peak ${usd(max)} at API list prices.`;

// Since about two weeks ago: did the fixes work? The newest snapshot at least
// 10 days older than today's, so the two 7-day windows do not overlap much.
const cutoff = new Date(Date.parse(r.day) - 10 * 864e5).toISOString().slice(0, 10);
const then = [...reports].reverse().find(x => x.day <= cutoff);
if (then) {
const per = x => Math.max(1, x.totals.sessions);
const vagueShare = x => { const v = x.instructionQuality.vague.prompts, a = x.instructionQuality.anchored.prompts; return v + a ? v / (v + a) : 0; };
const vagueShare = x => { const v = x.instructionQuality.vague.prompts, a = x.instructionQuality.anchored.prompts; return v == null || a == null || !(v + a) ? null : v / (v + a); };
const perSession = n => x => (n(x) == null ? null : n(x) / per(x));
const signals = [
['Cost per session', x => x.totals.usd / per(x), 'lower', usd],
['Cost per landed commit', x => x.outcomes?.usdPerCommit || null, 'lower', usd],
['Cost per session', perSession(x => x.totals.usd), 'lower', usd],
['Cost per landed commit', x => (x.outcomes?.commits ? x.outcomes.usdPerCommit : null), 'lower', usd],
['Spend on prompts that needed correcting', x => x.instructionQuality.reworkShare, 'lower', pct],
['Vague prompts', vagueShare, 'lower', pct],
['Re-reads of unchanged files per session', x => x.totals.redundantReads / per(x), 'lower', n => n.toFixed(1)],
['Re-reads of unchanged files per session', perSession(x => x.totals.redundantReads), 'lower', n => n.toFixed(1)],
['Setup findings', x => x.setup ? Object.values(x.setup.findings || {}).reduce((a, n) => a + n, 0) : null, 'lower', n => String(n)],
['Cache hit rate', x => x.totals.cacheHitRate, 'higher', pct],
];
Expand Down Expand Up @@ -146,14 +150,14 @@ <h2>Team</h2>
const SU = r.setup;
if (SU) {
const fk = Object.entries(SU.findings || {});
oc.append(el('div', `Setup: ~${k(SU.alwaysOnTokens)} tokens of CLAUDE.md on every request · ${SU.agents} subagents · ${SU.skills} skills · ${SU.mcpServers} MCP servers · ${SU.hooks} hooks${SU.multiAgentFrameworks ? ` · multi-agent code in this project` : ''}`, 'mute'));
oc.append(el('div', `Setup: ${SU.alwaysOnTokens == null ? '—' : '~' + k(SU.alwaysOnTokens)} tokens of CLAUDE.md on every request · ${n0(SU.agents)} subagents · ${n0(SU.skills)} skills · ${n0(SU.mcpServers)} MCP servers · ${n0(SU.hooks)} hooks${SU.multiAgentFrameworks ? ` · multi-agent code in this project` : ''}`, 'mute'));
const sf = el('div'); sf.style.margin = '6px 0';
fk.length ? fk.forEach(([f, n]) => sf.append(el('span', `${f} ×${n}`, 'tag'))) : sf.append(el('span', 'setup: no findings', 'tag'));
oc.append(sf);
}
const SA = r.subagents;
if (SA && SA.runs) oc.append(el('div', `Subagents: ${SA.runs} runs, ${usd(SA.usd)} (${usd(SA.startupUsd)} start-up), ${Math.round(SA.parallelShare * 100)}% parallel · no benefit from delegating: ${usd(SA.flaggedUsd)}${SA.repeatedDelegations ? ` · ${SA.repeatedDelegations} procedures delegated 3+ times (skill candidates)` : ''}`, 'mute'));
Comment thread
gitayg marked this conversation as resolved.
oc.append(el('div', `Always-on context ~${k(O.medianBaselineContextTokens)} tokens/session · ${O.toolCallsPerPrompt} tool calls per prompt · ${O.subagentRuns} subagents · ${O.nudgePrompts} "continue/yes" prompts`, 'mute'));
if (SA && SA.runs) oc.append(el('div', `Subagents: ${SA.runs} runs, ${usd(SA.usd)} (${usd(SA.startupUsd)} start-up), ${pct(SA.parallelShare)} parallel · no benefit from delegating: ${usd(SA.flaggedUsd)}${SA.repeatedDelegations ? ` · ${SA.repeatedDelegations} procedures delegated 3+ times (skill candidates)` : ''}`, 'mute'));
oc.append(el('div', `Always-on context ${O.medianBaselineContextTokens == null ? "—" : "~" + k(O.medianBaselineContextTokens)} tokens/session · ${n0(O.toolCallsPerPrompt)} tool calls per prompt · ${n0(O.subagentRuns)} subagents · ${n0(O.nudgePrompts)} "continue/yes" prompts`, 'mute'));
const fl = el('div'); fl.style.margin = '8px 0';
flags.length ? flags.forEach(([f, n]) => fl.append(el('span', `${f} ×${n}`, 'tag'))) : fl.append(el('span', 'No flags', 'tag'));
oc.append(fl);
Expand All @@ -173,7 +177,7 @@ <h2>Team</h2>
b.append(el('p', `${T.people} people active in the last ${T.activeDays} days · ${usd(T.usdTotal)} at API list prices (7-day windows).`, 'mute'));
const m = T.median, t = el('table');
t.append(row(['Median per person', ''], true));
for (const [l, v] of [['Cost at API list prices', usd(m.usd)], ['Cost per landed commit', m.usdPerCommit ? usd(m.usdPerCommit) : '—'], ['Spend on prompts that needed correcting', pct(m.reworkShare)], ['Share of prompts that were vague', pct(m.vagueShare)],
for (const [l, v] of [['Cost at API list prices', usd(m.usd)], ['Cost per landed commit', usd(m.usdPerCommit)], ['Spend on prompts that needed correcting', pct(m.reworkShare)], ['Share of prompts that were vague', pct(m.vagueShare)],
['Cache hit rate', pct(m.cacheHitRate)], ['Tool error rate', pct(m.errorRate)], ['CLAUDE.md tokens on every request', k(m.alwaysOnTokens)]]) t.append(row([l, v]));
b.append(t);
const list = (title, obj) => {
Expand Down
17 changes: 10 additions & 7 deletions examples/work-review-hub/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ const isNum = v => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <
function nums(o, keys, path) {
if (!o || typeof o !== 'object') throw new Error(`${path} missing`);
const out = {};
for (const k of keys) { if (!isNum(o[k] ?? 0)) throw new Error(`${path}.${k} not a number`); out[k] = o[k] ?? 0; }
// null (or absent) means "not measured" and is kept as null, never stored as 0.
for (const k of keys) { if (o[k] != null && !isNum(o[k])) throw new Error(`${path}.${k} not a number`); out[k] = o[k] ?? null; }
return out;
}
const SECRETISH = /(sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|dhk_[A-Za-z0-9_-]{8,}|AKIA[0-9A-Z]{16}|-----BEGIN|eyJ[A-Za-z0-9_-]{10,}\.)/;
Expand All @@ -69,7 +70,7 @@ export function validate(p) {
'searchBursts', 'parallelToolTurnShare'], 'totals'),
tokens: nums(T.tokens, ['input', 'output', 'cacheRead', 'cacheWrite'], 'totals.tokens'),
wastedTokensEstimate: nums(T.wastedTokensEstimate, ['redundantReads', 'idleReruns', 'errorOutputs'], 'totals.wastedTokensEstimate'),
usd: nums(T, ['usd'], 'totals').usd,
...nums(T, ['usd', 'unpricedTokens'], 'totals'),
},
instructionQuality: {
...nums(Q, ['totalCost', 'reworkCost', 'reworkShare', 'totalUsd', 'reworkUsd'], 'instructionQuality'),
Expand Down Expand Up @@ -126,7 +127,9 @@ function history(dir, limit = 90) {
// nothing is shown but the count.
export const TEAM_MIN_PEOPLE = Math.max(3, Number(process.env.TEAM_MIN_PEOPLE || 5));
const TEAM_ACTIVE_DAYS = 14;
const median = xs => { const s = xs.filter(Number.isFinite).sort((a, b) => a - b); return s.length ? s[Math.floor(s.length / 2)] : 0; };
// null when nobody measured it: an empty median is not a zero.
const median = xs => { const s = xs.filter(Number.isFinite).sort((a, b) => a - b); return s.length ? s[Math.floor(s.length / 2)] : null; };
const share = (v, a) => (v == null || a == null || !(v + a) ? null : v / (v + a));
function team() {
if (!existsSync(DATA)) return { people: 0, minPeople: TEAM_MIN_PEOPLE, enough: false };
const since = new Date(Date.now() - TEAM_ACTIVE_DAYS * 86400_000).toISOString().slice(0, 10);
Expand All @@ -139,13 +142,13 @@ function team() {
...base, enough: true,
usdTotal: +latest.reduce((a, s) => a + (s.totals.usd || 0), 0).toFixed(2),
median: {
usd: median(latest.map(s => s.totals.usd || 0)),
usdPerCommit: median(latest.map(s => s.outcomes?.usdPerCommit).filter(x => x > 0)),
usd: median(latest.map(s => s.totals.usd)),
usdPerCommit: median(latest.map(s => s.outcomes?.commits ? s.outcomes.usdPerCommit : null)),
reworkShare: median(latest.map(s => s.instructionQuality.reworkShare)),
vagueShare: median(latest.map(s => { const v = s.instructionQuality.vague.prompts, a = s.instructionQuality.anchored.prompts; return v + a ? v / (v + a) : 0; })),
vagueShare: median(latest.map(s => share(s.instructionQuality.vague.prompts, s.instructionQuality.anchored.prompts))),
cacheHitRate: median(latest.map(s => s.totals.cacheHitRate)),
errorRate: median(latest.map(s => s.totals.errorRate)),
alwaysOnTokens: median(latest.map(s => s.setup?.alwaysOnTokens).filter(x => x != null)),
alwaysOnTokens: median(latest.map(s => s.setup?.alwaysOnTokens)),
},
subagentUsdNoBenefit: +latest.reduce((a, s) => a + (s.subagents?.flaggedUsd || 0), 0).toFixed(2),
peopleWithSetupFinding: peopleWith(s => s.setup?.findings),
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "appcrane",
"version": "2.125.14",
"version": "2.125.15",
"engines": {
"node": ">=22"
},
Expand All @@ -25,7 +25,8 @@
"dev": "node --watch server/index.js",
"test": "node --test --test-timeout=120000 test/*.test.js packages/tenant/*.test.js",
"test:serial": "node --test --test-concurrency=1 --test-timeout=300000 test/serial/*.test.js",
"gen:mcp-snapshot": "node scripts/gen-mcp-snapshot.mjs"
"gen:mcp-snapshot": "node scripts/gen-mcp-snapshot.mjs",
"gen:plugin-releases": "node scripts/gen-plugin-releases.mjs"
},
"overrides": {
"follow-redirects": "^1.16.0"
Expand Down
6 changes: 6 additions & 0 deletions plugins/releases.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"work-review": {
"version": "1.1.4",
"hash": "22061fa102d238ee478654105b4b3c75349a296c84a42f48c8226d283d8a1783"
}
}
2 changes: 1 addition & 1 deletion plugins/work-review/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "work-review",
"version": "1.0.0",
"version": "1.1.4",
"description": "Reviews how you work with Claude Code: what each prompt cost and why, waste and overcomplicated setups, with concrete fixes. Optional daily upload of a redacted summary to a Work Review hub.",
"author": { "name": "AppCrane" },
"repository": "https://github.com/gitayg/appCrane",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Sources:
- **Commands are merged into skills:** "Prefer a skill for new work, since skills also support supporting files" (skills).
- **Description:** put the key use case first. Description and `when_to_use` are "truncated at 1,536 characters" in the listing (skills).
- **Side effects:** use `disable-model-invocation: true`, because "You don't want Claude deciding to deploy because your code looks ready" (skills).
- **One copy per skill (ours):** the docs do not cover a skill installed twice. Plugin skills are namespaced, so a personal copy beside a plugin's does not replace it: both load and compete for the same requests, and a plugin update moves only one. `skill-installed-twice` flags it; a symlink to the same file is one install.
- **Size:** "Keep `SKILL.md` under 500 lines. Move detailed reference material to separate files." (skills)
- **Scripts are "executed, not loaded".** Reference them via `${CLAUDE_SKILL_DIR}` (skills).

Expand Down
Loading
Loading