diff --git a/CLAUDE.md b/CLAUDE.md index 3f420301..26db5802 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`). diff --git a/examples/work-review-hub/digest.mjs b/examples/work-review-hub/digest.mjs index 707331ad..73791a47 100644 --- a/examples/work-review-hub/digest.mjs +++ b/examples/work-review-hub/digest.mjs @@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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)}`); /** ISO week label, e.g. 2026-W44, for "once per week" markers and idempotency. */ export function isoWeek(d) { @@ -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'); diff --git a/examples/work-review-hub/index.html b/examples/work-review-hub/index.html index ec5903c6..3df878e1 100644 --- a/examples/work-review-hub/index.html +++ b/examples/work-review-hub/index.html @@ -69,9 +69,11 @@

Team

// 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; } @@ -83,22 +85,23 @@

Team

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. @@ -106,13 +109,14 @@

Team

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], ]; @@ -146,14 +150,14 @@

Team

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')); - 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); @@ -173,7 +177,7 @@

Team

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) => { diff --git a/examples/work-review-hub/server.mjs b/examples/work-review-hub/server.mjs index a190462b..627b3cf9 100644 --- a/examples/work-review-hub/server.mjs +++ b/examples/work-review-hub/server.mjs @@ -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,}\.)/; @@ -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'), @@ -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); @@ -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), diff --git a/package-lock.json b/package-lock.json index 8a1b39b7..7566c2bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "appcrane", - "version": "2.125.14", + "version": "2.125.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "appcrane", - "version": "2.125.14", + "version": "2.125.15", "license": "AGPL-3.0-only", "dependencies": { "@node-saml/node-saml": "^5.1.0", diff --git a/package.json b/package.json index cd290290..dc96eac6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appcrane", - "version": "2.125.14", + "version": "2.125.15", "engines": { "node": ">=22" }, @@ -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" diff --git a/plugins/releases.json b/plugins/releases.json new file mode 100644 index 00000000..6da15b5e --- /dev/null +++ b/plugins/releases.json @@ -0,0 +1,6 @@ +{ + "work-review": { + "version": "1.1.4", + "hash": "22061fa102d238ee478654105b4b3c75349a296c84a42f48c8226d283d8a1783" + } +} diff --git a/plugins/work-review/.claude-plugin/plugin.json b/plugins/work-review/.claude-plugin/plugin.json index 59216a96..cf865ae8 100644 --- a/plugins/work-review/.claude-plugin/plugin.json +++ b/plugins/work-review/.claude-plugin/plugin.json @@ -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", diff --git a/plugins/work-review/skills/work-review/references/best-practices.md b/plugins/work-review/skills/work-review/references/best-practices.md index bf3f7378..26b47bcf 100644 --- a/plugins/work-review/skills/work-review/references/best-practices.md +++ b/plugins/work-review/skills/work-review/references/best-practices.md @@ -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). diff --git a/plugins/work-review/skills/work-review/scripts/audit-setup.mjs b/plugins/work-review/skills/work-review/scripts/audit-setup.mjs index d29f527f..c7fc74e0 100644 --- a/plugins/work-review/skills/work-review/scripts/audit-setup.mjs +++ b/plugins/work-review/skills/work-review/scripts/audit-setup.mjs @@ -11,7 +11,7 @@ // // Read-only. Prints a markdown summary; --json writes the findings. -import { readdirSync, readFileSync, statSync, writeFileSync, existsSync } from 'fs'; +import { readdirSync, readFileSync, statSync, writeFileSync, existsSync, realpathSync, lstatSync } from 'fs'; import { join, relative, basename, dirname } from 'path'; import { homedir } from 'os'; @@ -166,13 +166,19 @@ for (let i = 0; i < agents.length; i++) for (let j = i + 1; j < agents.length; j } // ── skills ─────────────────────────────────────────────────────── -const skillFiles = [join(PROJECT, '.claude', 'skills'), join(USER_DIR, 'skills')].filter(isDir) - .flatMap(d => walk(d, (p, n) => n === 'SKILL.md', 4)); -const skills = skillFiles.map(f => { +// Installed plugins are read too (inventoried, never judged), so a personal +// copy of a skill a plugin also ships is caught below. +const skillFiles = [[join(PROJECT, '.claude', 'skills'), 'project', 4], [join(USER_DIR, 'skills'), 'user', 4], [join(USER_DIR, 'plugins', 'cache'), 'plugin', 7]] + .filter(([d]) => isDir(d)) + .flatMap(([d, scope, depth]) => walk(d, (p, n) => n === 'SKILL.md', depth).map(f => ({ f, scope: scope === 'user' && /[\\/]synced[\\/]/.test(f) ? 'synced' : scope }))); +const real = f => { try { return realpathSync(f); } catch { return f; } }; +// The skill's own folder or file is a link (a symlinked parent such as /tmp is not). +const isLink = f => { try { return lstatSync(f).isSymbolicLink(); } catch { return false; } }; +const skills = skillFiles.map(({ f, scope }) => { const text = read(f) || ''; const { fm } = frontmatter(text); const name = fm?.name || basename(dirname(f)); - const s = { name, file: rel(f), description: fm?.description || '', lines: lines(text).length, used: usedCount(usage?.skills, name) }; + const s = { name, scope, file: rel(f), real: real(f), linked: isLink(f) || isLink(dirname(f)), description: fm?.description || '', lines: lines(text).length, used: usedCount(usage?.skills, name) }; // Skills synced from an organisation or plugin are inventoried, not judged: // the person running the review cannot edit them. s.managed = /[\\/](synced|plugins)[\\/]/.test(f); @@ -188,7 +194,35 @@ const skills = skillFiles.map(f => { if (s.used === 0 && enoughUsage) add('skill-unused', 'Skills', 'low', name, `not invoked in ${usage.sessions} recent sessions`, 'Check the description triggers on real requests, or remove it', s); return s; }); +// The same skill installed twice — a personal or project copy beside a plugin's, +// say — loads twice and competes for the same requests, and a plugin update +// moves only one of them: the copies drift with nothing saying so. A symlink to +// the same file is one install, not two. +// Grouped by name, so a skill at project, user and plugin scope is one finding +// that names every copy, and the plugin's drift warning is never lost. +const twice = new Set(), byName = new Map(); +skills.forEach((s, i) => { if (!byName.has(s.name)) byName.set(s.name, []); byName.get(s.name).push(i); }); +for (const [name, idx] of byName) { + // One entry per real file. Where a file is also reached through a symlink, + // name the real location: deleting the link would leave the file installed. + const byReal = new Map(); + for (const i of idx) { const c = skills[i], had = byReal.get(c.real); if (!had || (had.linked && !c.linked)) byReal.set(c.real, c); } + const copies = [...byReal.values()]; + if (new Set(copies.map(c => c.scope)).size < 2) continue; + for (const i of idx) for (const j of idx) if (i < j) twice.add(i + ':' + j); + // Only copies this person can edit can be removed; two managed copies (synced + // beside a plugin's) are nobody's to fix here. + const editable = copies.filter(c => !c.managed); + if (!editable.length) continue; + const plugin = copies.find(c => c.scope === 'plugin'); + const where = copies.filter(c => c.scope !== 'plugin' || c === plugin).map(c => `${c.scope} scope (${c.file})`).join(', '); + add('skill-installed-twice', 'Skills', 'medium', name, `installed at ${where}; every copy loads and competes${plugin ? ', and a plugin update moves only the plugin\'s copy' : ''}`, + plugin ? `Delete the ${[...new Set(editable.map(c => c.scope))].join(' and ')} cop${editable.length > 1 ? 'ies' : 'y'} and keep the plugin's, so updates reach it` : 'Keep one copy: delete the others, or link them to one', { files: copies.map(c => c.file) }); +} for (let i = 0; i < skills.length; i++) for (let j = i + 1; j < skills.length; j++) { + // Two skills nobody here can edit (synced, or plugin copies such as two cached + // versions of one plugin) are not the user's overlap to fix. + if (twice.has(i + ':' + j) || skills[i].real === skills[j].real || (skills[i].managed && skills[j].managed)) continue; const sim = jaccard(shingles(skills[i].description), shingles(skills[j].description)); if (sim >= LIMITS.similarDescriptions) add('skill-overlap', 'Skills', 'medium', `${skills[i].name} / ${skills[j].name}`, `descriptions overlap ${Math.round(sim * 100)}%`, 'Merge them or sharpen when each applies', {}); } diff --git a/plugins/work-review/skills/work-review/scripts/upload.mjs b/plugins/work-review/skills/work-review/scripts/upload.mjs index af7ea633..eb77243d 100644 --- a/plugins/work-review/skills/work-review/scripts/upload.mjs +++ b/plugins/work-review/skills/work-review/scripts/upload.mjs @@ -73,7 +73,10 @@ export function scrub(s) { return t.length > EXCERPT_CHARS ? t.slice(0, EXCERPT_CHARS) + '…' : t; } -const num = v => (typeof v === 'number' && Number.isFinite(v) ? v : 0); +// A figure the analyzer could not measure travels as null, never as 0: a week +// with no commits has no cost per commit, and a stored 0 would read a month +// later exactly like a perfect week. +const num = v => (typeof v === 'number' && Number.isFinite(v) ? v : null); const pickNums = (o, keys) => Object.fromEntries(keys.map(k => [k, num(o?.[k])])); const group = g => pickNums(g, ['prompts', 'medianCost', 'medianUsd', 'medianExploreBeforeFirstEdit', 'reworkRate']); @@ -99,6 +102,9 @@ export function buildPayload(report, { now = new Date(), setup = null, allowExce wastedTokensEstimate: pickNums(T.wastedTokensEstimate, ['redundantReads', 'idleReruns', 'errorOutputs']), // API-list-price dollars (see analyze.mjs): the API-equivalent value, not a bill. usd: num(T.usd), + // Tokens from models with no known price: their cost is missing from usd, + // so the hub says the total is incomplete instead of showing it as whole. + unpricedTokens: Object.values(T.unpricedModels || {}).reduce((a, n) => a + (num(n) || 0), 0), }, instructionQuality: { totalCost: num(Q.totalCost), reworkCost: num(Q.reworkCost), reworkShare: num(Q.reworkShare), diff --git a/release-notes.json b/release-notes.json index 5d7db412..9eded4f9 100644 --- a/release-notes.json +++ b/release-notes.json @@ -1,4 +1,9 @@ { + "2.125.15": [ + "Work Review shows a figure it could not measure as a dash, never as zero: a week with no commits no longer shows $0.00 per commit, and when some usage comes from a model without a known price, the cost says it is incomplete.", + "The work-review setup audit flags a skill installed twice, such as your own copy beside the same skill from a plugin: both load and compete, and a plugin update only moves one of them.", + "The work-review plugin is now version 1.1.4, so people who installed it receive these changes; a test now fails whenever the plugin changes without a version bump." + ], "2.125.14": [ "Hosted apps can trust X-AppCrane-Auth-Via (which says a request came from a script using a personal app sign-in key). The identity signature did not cover it, so on a route that skips the proxy it could be removed to make a script look like a person in a browser. A second signature, X-AppCrane-Identity-Sig-V2, now covers it; appcrane-tenant 1.3.0 checks it and returns authVia. Apps on older appcrane-tenant keep working unchanged.", "The upgrade asks each hosted app that reads X-AppCrane-* identity headers without checking the v2 signature to start checking it: about a minute after the first start on 2.125.14, AppCrane scans every app's live release and files one request per such app (naming the files and lines), which e-mails its owners and admins. Each app is asked once; a request you close is not filed again. Apps deployed from a prebuilt image are not scanned." diff --git a/scripts/gen-plugin-releases.mjs b/scripts/gen-plugin-releases.mjs new file mode 100644 index 00000000..8b5c84a7 --- /dev/null +++ b/scripts/gen-plugin-releases.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node +// Regenerates plugins/releases.json: for each plugin in this repo's marketplace, +// the version it ships and a hash of every file in it. +// +// Why: plugin.json sets an explicit `version`, and Claude Code uses that version +// to decide whether an update exists. Change a plugin's files without bumping it +// and nobody who already installed the plugin ever receives the change — +// `claude plugin update` reports it is already at the latest version, and +// nothing goes red. test/plugin-releases.test.js fails the moment the files and +// the recorded hash disagree; this script refuses to re-record a changed plugin +// whose version was not bumped, so regenerating cannot paper over it. +// +// npm run gen:plugin-releases # after changing a plugin and bumping its version +import { readFileSync, writeFileSync, readdirSync, lstatSync, existsSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, relative } from 'node:path'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +export const RELEASES = join(ROOT, 'plugins', 'releases.json'); + +function files(dir, out = []) { + for (const name of readdirSync(dir).sort()) { + const p = join(dir, name), st = lstatSync(p); + if (st.isDirectory()) files(p, out); + else out.push(p); + } + return out; +} + +/** Hash of a plugin's files: paths and contents, line endings normalised. */ +export function pluginHash(dir) { + const h = createHash('sha256'); + for (const f of files(dir)) { + const st = lstatSync(f); + const body = st.isSymbolicLink() ? 'symlink' : readFileSync(f).toString('latin1').replace(/\r\n/g, '\n'); + h.update(relative(dir, f).replace(/\\/g, '/') + '\0' + body + '\0'); + } + return h.digest('hex'); +} + +/** The marketplace's local plugins as { name: { version, hash } }. */ +export function currentReleases() { + const mkt = JSON.parse(readFileSync(join(ROOT, '.claude-plugin', 'marketplace.json'), 'utf8')); + const out = {}; + for (const p of mkt.plugins) { + if (typeof p.source !== 'string' || !p.source.startsWith('./')) continue; + const dir = join(ROOT, p.source); + const { version } = JSON.parse(readFileSync(join(dir, '.claude-plugin', 'plugin.json'), 'utf8')); + out[p.name] = { version, hash: pluginHash(dir) }; + } + return out; +} + +/** Plugins whose files changed while their version stayed the same. */ +export function unbumped(recorded, current) { + return Object.entries(current).filter(([n, c]) => recorded[n] && recorded[n].hash !== c.hash && recorded[n].version === c.version).map(([n]) => n); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const recorded = existsSync(RELEASES) ? JSON.parse(readFileSync(RELEASES, 'utf8')) : {}; + const current = currentReleases(); + const stuck = unbumped(recorded, current); + if (stuck.length) { + console.error(`refusing: ${stuck.join(', ')} changed but kept version ${stuck.map(n => current[n].version).join(', ')}. Bump "version" in its .claude-plugin/plugin.json first, or installed copies never receive the change.`); + process.exit(1); + } + writeFileSync(RELEASES, JSON.stringify(current, null, 2) + '\n'); + console.log(`wrote ${Object.keys(current).length} plugin(s) to ${relative(ROOT, RELEASES)}`); +} diff --git a/test/plugin-releases.test.js b/test/plugin-releases.test.js new file mode 100644 index 00000000..91e07ad0 --- /dev/null +++ b/test/plugin-releases.test.js @@ -0,0 +1,33 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'fs'; + +// Pins each plugin in this repo's marketplace against plugins/releases.json. +// +// plugin.json sets an explicit `version`, and that version is what Claude Code +// compares to decide whether an update exists. So a change to a plugin's files +// that leaves the version alone reaches nobody who already installed it, and +// nothing anywhere goes red: `claude plugin update` just says "latest". This +// test is that red light. It fails when a plugin's files no longer match the +// recorded hash; the generator refuses to re-record a change without a bump. + +const { currentReleases, unbumped, RELEASES } = await import('../scripts/gen-plugin-releases.mjs'); + +test('every plugin change ships with a version bump', () => { + const recorded = JSON.parse(readFileSync(RELEASES, 'utf8')); + const current = currentReleases(); + const stuck = unbumped(recorded, current); + assert.deepEqual(stuck, [], `${stuck.join(', ')} changed without a version bump: bump "version" in its .claude-plugin/plugin.json, then run npm run gen:plugin-releases`); + for (const [name, c] of Object.entries(current)) { + assert.ok(recorded[name], `${name} is not recorded: run npm run gen:plugin-releases`); + assert.equal(recorded[name].hash, c.hash, `${name} was bumped to ${c.version} but not re-recorded: run npm run gen:plugin-releases`); + assert.equal(recorded[name].version, c.version, `${name}: run npm run gen:plugin-releases`); + } +}); + +test('the guard refuses a changed plugin that kept its version, and accepts a bumped one', () => { + const before = { p: { version: '1.0.0', hash: 'a' } }; + assert.deepEqual(unbumped(before, { p: { version: '1.0.0', hash: 'b' } }), ['p']); + assert.deepEqual(unbumped(before, { p: { version: '1.0.1', hash: 'b' } }), []); + assert.deepEqual(unbumped(before, { p: { version: '1.0.0', hash: 'a' } }), []); +}); diff --git a/test/work-review-digest.test.js b/test/work-review-digest.test.js index dbe3fa22..d4c401c5 100644 --- a/test/work-review-digest.test.js +++ b/test/work-review-digest.test.js @@ -50,6 +50,15 @@ test('the digest says the week\'s cost, the change, and the top fixes', () => { assert.equal(isoWeek(MONDAY), '2026-W44'); }); +test('a week whose cost was not measured is never mailed as $0.00', () => { + const history = [{ day: '2026-10-18', totals: { usd: null } }, + { day: '2026-10-25', totals: { usd: null }, findings: [{ id: 'rework', usd: null, share: null }] }]; + const m = digestFor('Kim', history, { now: MONDAY }); + assert.doesNotMatch(m.subject + m.text, /\$0\.00|0% of spend|null/); + assert.match(m.text, /not measured/); + assert.match(m.subject, /^Your week with Claude: 1 thing to try$/); +}); + test('sent on Monday only, once a week, and never to someone who switched it off', async () => { const f = [{ id: 'rework', usd: 3, share: 0.2 }]; assert.equal((await upload('kim@opswat.com', '2026-10-25', report(20, f))).status, 201); diff --git a/test/work-review-setup-audit.test.js b/test/work-review-setup-audit.test.js index 3090941e..e4db6293 100644 --- a/test/work-review-setup-audit.test.js +++ b/test/work-review-setup-audit.test.js @@ -108,3 +108,60 @@ test('a skill installed as a symlink is inventoried', async () => { const r = run(proj, user, null); assert.ok(r.inventory.skills.some(s => s.name === 'linked')); }); + +test('a skill installed both as a personal copy and from a plugin is caught; a symlink is not', async () => { + const { symlinkSync } = await import('fs'); + const root = mkdtempSync(join(tmpdir(), 'audit-')); + const proj = join(root, 'proj'), user = join(root, 'user'); + mkdirSync(join(root, 'home'), { recursive: true }); + const skill = '---\nname: spec\ndescription: Runs the spec lifecycle from intent to release; use when starting a feature\n---\nsteps'; + w(join(user, 'skills', 'spec', 'SKILL.md'), skill); + w(join(user, 'plugins', 'cache', 'mkt', 'productizer', '4.73.0', 'skills', 'spec', 'SKILL.md'), skill); + // An older cached version of the same plugin: still one plugin skill, not an overlap. + w(join(user, 'plugins', 'cache', 'mkt', 'productizer', '4.72.0', 'skills', 'spec', 'SKILL.md'), skill); + // The project links to the plugin's own copy: one install, not two. + w(join(root, 'checkout', 'review', 'SKILL.md'), '---\nname: review\ndescription: Reviews pull requests for missing tests; use when asked to review a PR\n---\nsteps'); + w(join(user, 'plugins', 'cache', 'mkt', 'tools', '1.0.0', 'skills', 'review', '.keep'), ''); + symlinkSync(join(root, 'checkout', 'review', 'SKILL.md'), join(user, 'plugins', 'cache', 'mkt', 'tools', '1.0.0', 'skills', 'review', 'SKILL.md')); + mkdirSync(join(proj, '.claude', 'skills'), { recursive: true }); + symlinkSync(join(root, 'checkout', 'review'), join(proj, '.claude', 'skills', 'review')); + + const r = run(proj, user, null); + const twice = r.findings.filter(f => f.kind === 'skill-installed-twice'); + assert.deepEqual(twice.map(f => f.item), ['spec'], `one finding per skill, however many cached versions; got ${kinds(r).join(', ')}`); + assert.match(twice[0].fix, /Delete the user copy and keep the plugin's/); + assert.ok(!kinds(r).includes('skill-overlap'), 'a duplicate install is reported once, not also as an overlap'); +}); + +test('a skill at project, user and plugin scope is one finding that keeps the plugin warning; two managed copies are not flagged', () => { + const root = mkdtempSync(join(tmpdir(), 'audit-')); + const proj = join(root, 'proj'), user = join(root, 'user'); + mkdirSync(join(root, 'home'), { recursive: true }); + const md = n => `---\nname: ${n}\ndescription: Runs the ${n} lifecycle from intent to release; use when starting a feature\n---\nsteps`; + for (const d of [join(proj, '.claude', 'skills', 'spec'), join(user, 'skills', 'spec'), join(user, 'plugins', 'cache', 'mkt', 'p', '1.0.0', 'skills', 'spec')]) w(join(d, 'SKILL.md'), md('spec')); + // An org-synced copy beside a plugin's: neither is editable here. + for (const d of [join(user, 'skills', 'synced', 'org', 'sdk'), join(user, 'plugins', 'cache', 'mkt', 'p', '1.0.0', 'skills', 'sdk')]) w(join(d, 'SKILL.md'), md('sdk')); + const r = run(proj, user, null); + const twice = r.findings.filter(f => f.kind === 'skill-installed-twice'); + assert.deepEqual(twice.map(f => f.item), ['spec'], `got ${kinds(r).join(', ')}`); + assert.match(twice[0].finding, /project scope.*user scope.*plugin scope.*plugin update moves only/); + assert.match(twice[0].fix, /Delete the project and user copies and keep the plugin's/); +}); + +test('when a project skill is also linked from user scope, the finding names the real file, not the link', async () => { + const { symlinkSync } = await import('fs'); + const root = mkdtempSync(join(tmpdir(), 'audit-')); + const proj = join(root, 'proj'), user = join(root, 'user'); + mkdirSync(join(root, 'home'), { recursive: true }); + const md = '---\nname: spec\ndescription: Runs the spec lifecycle from intent to release; use when starting a feature\n---\nsteps'; + w(join(proj, '.claude', 'skills', 'spec', 'SKILL.md'), md); + mkdirSync(join(user, 'skills'), { recursive: true }); + symlinkSync(join(proj, '.claude', 'skills', 'spec'), join(user, 'skills', 'spec')); + w(join(user, 'plugins', 'cache', 'mkt', 'p', '1.0.0', 'skills', 'spec', 'SKILL.md'), md); + const r = run(proj, user, null); + const f = r.findings.find(x => x.kind === 'skill-installed-twice'); + assert.ok(f, `got ${kinds(r).join(', ')}`); + assert.match(f.finding, /project scope/); + assert.doesNotMatch(f.finding, /user scope/, 'the symlink is not reported as a separate copy'); + assert.match(f.fix, /Delete the project copy/); +}); diff --git a/test/work-review-upload.test.js b/test/work-review-upload.test.js index 0e636546..91454d1c 100644 --- a/test/work-review-upload.test.js +++ b/test/work-review-upload.test.js @@ -63,6 +63,19 @@ test('hub accepts exactly what the uploader builds', () => { assert.equal(v.instructionQuality.costliestBadPrompts.length, 1); }); +test('what was not measured reaches the hub as null, never as 0', () => { + // No commits, so no cost per commit; one model nobody priced. + const r = { ...report, totals: { ...report.totals, unpricedModels: { 'mystery-model': 1200 } }, outcomes: { commits: 0, usdPerCommit: null } }; + const p = buildPayload(r, OK); + assert.equal(p.outcomes.usdPerCommit, null, 'the uploader does not invent a $0 cost per commit'); + assert.equal(p.outcomes.linesAdded, null, 'a field the analyzer did not send is not measured, not zero'); + assert.equal(p.totals.unpricedTokens, 1200, 'the hub is told the dollar total is incomplete'); + const v = validate(p); + assert.equal(v.outcomes.usdPerCommit, null, 'the hub stores it as not measured'); + assert.equal(v.totals.unpricedTokens, 1200); + assert.throws(() => validate({ ...p, outcomes: { ...p.outcomes, usdPerCommit: 'free' } }), /not a number/); +}); + // ── hub over HTTP ──────────────────────────────────────────────── let server, base; before(async () => { server = createServer(handler); await new Promise(r => server.listen(0, '127.0.0.1', r)); base = `http://127.0.0.1:${server.address().port}`; });