crewforth 3.4.0 → 3.5.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/CHANGELOG.md +273 -0
  2. package/README.md +3 -2
  3. package/VERSION +1 -1
  4. package/adopt.sh +40 -5
  5. package/bin/cli.js +138 -2
  6. package/kit/AGENT_TEMPLATE.md +15 -0
  7. package/kit/CLAUDE.md +50 -18
  8. package/kit/agents/crew-backend-expert.md +4 -3
  9. package/kit/agents/crew-commit-agent.md +2 -0
  10. package/kit/agents/crew-database-expert.md +2 -1
  11. package/kit/agents/crew-devops-expert.md +1 -0
  12. package/kit/agents/crew-docs-agent.md +3 -1
  13. package/kit/agents/crew-frontend-expert.md +2 -0
  14. package/kit/agents/crew-performance-expert.md +7 -2
  15. package/kit/agents/crew-planner.md +15 -3
  16. package/kit/agents/crew-privacy-agent.md +2 -0
  17. package/kit/agents/crew-review-agent.md +8 -1
  18. package/kit/agents/crew-security-expert.md +4 -3
  19. package/kit/agents/crew-test-expert.md +6 -2
  20. package/kit/eval/doctor.sh +201 -1
  21. package/kit/eval/gate-report.sh +28 -0
  22. package/kit/eval/golden-holdout.txt +2 -0
  23. package/kit/eval/golden-routing.txt +17 -0
  24. package/kit/eval/lib/crew-map/contracts.mjs +77 -0
  25. package/kit/eval/lib/crew-map/crew-map.sh +2 -1
  26. package/kit/eval/lib/crew-map/detect.mjs +2 -1
  27. package/kit/eval/lib/crew-map/narrative.mjs +14 -3
  28. package/kit/eval/lib/crew-map/project.mjs +45 -6
  29. package/kit/eval/lib/crew-map/scan.mjs +153 -14
  30. package/kit/eval/lib/findings/findings.schema.json +11 -1
  31. package/kit/eval/lib/findings/findings.sh +24 -1
  32. package/kit/eval/lib/hook-inject.sh +159 -0
  33. package/kit/eval/lib/lockfiles.sh +71 -0
  34. package/kit/eval/lib/payload-check.sh +29 -0
  35. package/kit/eval/lib/preview.sh +3 -3
  36. package/kit/eval/lib/review-bench/bench.mjs +14 -2
  37. package/kit/eval/lib/review-prepass/prepass.mjs +32 -3
  38. package/kit/eval/lib/settings-json.awk +18 -0
  39. package/kit/eval/lib/statusline.sh +22 -5
  40. package/kit/eval/lib/todo.awk +167 -0
  41. package/kit/eval/lib/todo.sh +120 -0
  42. package/kit/eval/lib/untrusted-text.awk +38 -0
  43. package/kit/eval/scan-skill.sh +67 -20
  44. package/kit/eval/smoke-test.sh +1759 -41
  45. package/kit/eval/utilization.sh +77 -3
  46. package/kit/hooks/agent-outcome.sh +9 -1
  47. package/kit/hooks/board-sync.sh +49 -3
  48. package/kit/hooks/board.sh +8 -8
  49. package/kit/hooks/branch-fresh.sh +101 -0
  50. package/kit/hooks/floor-blocklist.txt +19 -1
  51. package/kit/hooks/guard-bash.sh +92 -8
  52. package/kit/hooks/guard-question.sh +2 -1
  53. package/kit/hooks/loop-guard.sh +445 -0
  54. package/kit/hooks/map-stale.sh +437 -0
  55. package/kit/hooks/pre-commit +48 -7
  56. package/kit/hooks/precompact-snapshot.sh +102 -6
  57. package/kit/hooks/prompt-approval.sh +50 -14
  58. package/kit/hooks/route-hint.sh +36 -7
  59. package/kit/hooks/secret-blocklist.txt +55 -1
  60. package/kit/hooks/session-guard.sh +45 -15
  61. package/kit/hooks/session-rehydrate.sh +34 -11
  62. package/kit/hooks/session-stats.sh +169 -8
  63. package/kit/hooks/session-update-check.sh +5 -1
  64. package/kit/hooks/skill-trust.sh +23 -14
  65. package/kit/hooks/turn-check.sh +190 -0
  66. package/kit/owned-blobs.tsv +4 -4
  67. package/kit/settings.json +64 -0
  68. package/kit/skills/a11y/SKILL.md +50 -3
  69. package/kit/skills/a11y/references/recipes.md +41 -0
  70. package/kit/skills/adr/SKILL.md +14 -2
  71. package/kit/skills/api-design/SKILL.md +6 -1
  72. package/kit/skills/api-design/references/idempotency.md +39 -0
  73. package/kit/skills/brainstorm/SKILL.md +4 -2
  74. package/kit/skills/brainstorm/references/asking.md +47 -0
  75. package/kit/skills/ci-pipeline/SKILL.md +11 -1
  76. package/kit/skills/ci-pipeline/references/agent-job.md +33 -0
  77. package/kit/skills/commit-message/SKILL.md +9 -3
  78. package/kit/skills/confidence-check/SKILL.md +9 -7
  79. package/kit/skills/confidence-check/references/source-check.md +37 -0
  80. package/kit/skills/crew-approve/SKILL.md +3 -0
  81. package/kit/skills/crew-audit/SKILL.md +63 -0
  82. package/kit/skills/crew-board/SKILL.md +2 -2
  83. package/kit/skills/crew-code-review/SKILL.md +27 -4
  84. package/kit/skills/crew-code-review/references/panel-mode.md +9 -9
  85. package/kit/skills/crew-code-review/references/simplify.md +28 -0
  86. package/kit/skills/crew-doctor/SKILL.md +3 -2
  87. package/kit/skills/crew-gates/SKILL.md +3 -0
  88. package/kit/skills/crew-gates/references/enforcers.md +30 -0
  89. package/kit/skills/crew-interview/SKILL.md +42 -0
  90. package/kit/skills/crew-map/SKILL.md +26 -6
  91. package/kit/skills/crew-plan/SKILL.md +10 -2
  92. package/kit/skills/crew-pr/SKILL.md +57 -0
  93. package/kit/skills/crew-pr/references/checks.md +35 -0
  94. package/kit/skills/crew-pr/references/threads.md +23 -0
  95. package/kit/skills/crew-review/SKILL.md +34 -1
  96. package/kit/skills/crew-ship/SKILL.md +5 -2
  97. package/kit/skills/crew-skill/SKILL.md +26 -1
  98. package/kit/skills/crew-todo/SKILL.md +31 -0
  99. package/kit/skills/crew-update/SKILL.md +4 -1
  100. package/kit/skills/db-migration/SKILL.md +15 -0
  101. package/kit/skills/db-migration/references/expand-contract.md +52 -0
  102. package/kit/skills/dependency-audit/SKILL.md +34 -1
  103. package/kit/skills/dependency-audit/scripts/record.sh +37 -0
  104. package/kit/skills/dependency-upgrade/SKILL.md +29 -1
  105. package/kit/skills/deploy/SKILL.md +18 -0
  106. package/kit/skills/deploy/references/topology-a.md +7 -0
  107. package/kit/skills/deploy/references/topology-b.md +2 -2
  108. package/kit/skills/docs-writer/SKILL.md +37 -2
  109. package/kit/skills/eval-grader/SKILL.md +9 -0
  110. package/kit/skills/eval-grader/references/method.md +26 -0
  111. package/kit/skills/frontend/SKILL.md +5 -2
  112. package/kit/skills/frontend/references/from-design.md +29 -0
  113. package/kit/skills/frontend/references/page-metadata.md +17 -0
  114. package/kit/skills/frontend/references/verify-contract.md +18 -1
  115. package/kit/skills/frontend/references/worst-data.md +35 -0
  116. package/kit/skills/frontend-design/SKILL.md +20 -4
  117. package/kit/skills/frontend-design/references/design-review.md +21 -0
  118. package/kit/skills/frontend-design/references/detection.md +44 -0
  119. package/kit/skills/frontend-design/references/motion.md +54 -0
  120. package/kit/skills/frontend-design/references/ui-copy.md +35 -0
  121. package/kit/skills/frontend-rn-expo/SKILL.md +3 -0
  122. package/kit/skills/handoff/SKILL.md +28 -3
  123. package/kit/skills/handoff/references/cold-reader.md +13 -6
  124. package/kit/skills/i18n-integrity/SKILL.md +3 -0
  125. package/kit/skills/iterate/SKILL.md +7 -2
  126. package/kit/skills/mcp-builder/SKILL.md +21 -4
  127. package/kit/skills/mcp-builder/references/building.md +74 -13
  128. package/kit/skills/mcp-builder/references/cli-for-agents.md +26 -0
  129. package/kit/skills/observability/SKILL.md +13 -1
  130. package/kit/skills/performance/SKILL.md +25 -1
  131. package/kit/skills/performance/references/bottlenecks.md +11 -0
  132. package/kit/skills/performance/references/web-vitals.md +31 -0
  133. package/kit/skills/red-team/SKILL.md +25 -3
  134. package/kit/skills/reflect/SKILL.md +7 -2
  135. package/kit/skills/release/SKILL.md +13 -0
  136. package/kit/skills/security-scan/SKILL.md +6 -2
  137. package/kit/skills/security-scan/references/ai-agents.md +16 -0
  138. package/kit/skills/security-scan/references/fronts.md +59 -2
  139. package/kit/skills/security-scan/references/reporting.md +13 -0
  140. package/kit/skills/security-scan/references/verify.md +9 -0
  141. package/kit/skills/sonarqube-check/SKILL.md +32 -2
  142. package/kit/skills/sonarqube-check/references/ratchet.md +32 -0
  143. package/kit/skills/spec-planning/SKILL.md +32 -8
  144. package/kit/skills/systematic-debugging/SKILL.md +15 -7
  145. package/kit/skills/systematic-debugging/references/techniques.md +5 -0
  146. package/kit/skills/teamboard/SKILL.md +3 -3
  147. package/kit/skills/testing/SKILL.md +18 -3
  148. package/kit/skills/testing/references/browser-headless.md +21 -0
  149. package/kit/skills/token-budget/SKILL.md +26 -5
  150. package/kit/skills/worktree/SKILL.md +13 -5
  151. package/kit/studio/server/lib/session.js +23 -2
  152. package/kit/studio/server/lib/worktree-changes.js +64 -0
  153. package/kit/studio/web/agent-spend.js +32 -0
  154. package/kit/studio/web/app.js +245 -236
  155. package/kit/studio/web/approvals.js +14 -13
  156. package/kit/studio/web/ask.js +10 -9
  157. package/kit/studio/web/canvas.js +56 -54
  158. package/kit/studio/web/chat.js +41 -41
  159. package/kit/studio/web/convo.js +33 -16
  160. package/kit/studio/web/dock.js +20 -19
  161. package/kit/studio/web/graph-plan.js +62 -22
  162. package/kit/studio/web/i18n-tr.js +760 -0
  163. package/kit/studio/web/i18n.js +79 -0
  164. package/kit/studio/web/index.html +1 -0
  165. package/kit/studio/web/inspect.js +27 -24
  166. package/kit/studio/web/list-plan.js +16 -15
  167. package/kit/studio/web/list.js +6 -5
  168. package/kit/studio/web/liveness.js +11 -9
  169. package/kit/studio/web/map-plan.js +86 -36
  170. package/kit/studio/web/map.js +85 -72
  171. package/kit/studio/web/models-plan.js +47 -42
  172. package/kit/studio/web/models.js +21 -20
  173. package/kit/studio/web/names.js +13 -10
  174. package/kit/studio/web/nav.js +34 -33
  175. package/kit/studio/web/newsession.js +25 -24
  176. package/kit/studio/web/style.css +2 -0
  177. package/kit/studio/web/theme.js +5 -2
  178. package/kit/studio/web/timeline-plan.js +19 -18
  179. package/kit/studio/web/timeline.js +23 -22
  180. package/kit/studio/web/usage.js +36 -32
  181. package/package.json +5 -4
  182. package/start.sh +11 -2
@@ -16,9 +16,13 @@ export const SKIP_DIRS = new Set(['.git', '.hg', '.svn', 'node_modules', 'bower_
16
16
  const MAX_BYTES = 2 * 1024 * 1024;
17
17
 
18
18
  // Every child process the scanner starts goes through here, so the gate can count them.
19
+ // A git call never runs a program the scanned repository's own config names: /crew-map on a freshly cloned
20
+ // third-party repo would otherwise start its `core.fsmonitor` (measured: `ls-files -co` runs it, git 2.54).
21
+ export const GIT_SAFE = ['-c', 'core.fsmonitor=', '-c', 'core.quotePath=false'];
19
22
  export const spawned = { count: 0 };
20
23
  export function run(cmd, args, cwd) {
21
24
  spawned.count++;
25
+ if (cmd === 'git') args = [...GIT_SAFE, ...args];
22
26
  const r = spawnSync(cmd, args, { cwd, encoding: 'buffer', maxBuffer: 256 * 1024 * 1024, windowsHide: true,
23
27
  env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C' } });
24
28
  return { ok: r.status === 0 && !r.error, out: r.stdout ? r.stdout.toString('utf8') : '' };
@@ -26,6 +30,34 @@ export function run(cmd, args, cwd) {
26
30
 
27
31
  const skipped = (rel) => rel.split('/').some((seg) => SKIP_DIRS.has(seg));
28
32
 
33
+ // The project's own exclusions: `.crewforth/crew-map-ignore`, one pattern a line, `#` for a comment. A pattern with
34
+ // no `/` but a trailing one matches a name at any depth (`fixtures/`, `*.generated.ts`); one with a `/` inside is
35
+ // anchored at the root (`docs/legacy/`, `/tools/gen.js`). `*` stays inside one segment, `**` crosses them, and a
36
+ // trailing `/` matches the folder and everything in it. No negation: a map that leaves a file out says so in
37
+ // `coverage.excluded`, and a rule that could bring it back is one more thing to read wrong.
38
+ export const IGNORE_FILE = '.crewforth/crew-map-ignore';
39
+ export function ignoreRules(text) {
40
+ const out = [];
41
+ for (const raw of String(text).split('\n')) {
42
+ let p = raw.replace(/\r$/, '').trim();
43
+ if (!p || p.startsWith('#') || p.startsWith('!')) continue;
44
+ const dir = p.endsWith('/'); p = p.replace(/\/+$/, '');
45
+ const anchored = p.startsWith('/') || p.includes('/'); p = p.replace(/^\/+/, '');
46
+ if (!p) continue;
47
+ let re = '';
48
+ for (let i = 0; i < p.length; i++) {
49
+ const c = p[i];
50
+ if (c === '*' && p[i + 1] === '*') { i++; if (p[i + 1] === '/') { re += '(.*/)?'; i++; } else re += '.*'; }
51
+ else if (c === '*') re += '[^/]*';
52
+ else if (c === '?') re += '[^/]';
53
+ else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
54
+ }
55
+ out.push(new RegExp(`${anchored ? '^' : '(^|/)'}${re}${dir ? '/' : '(/|$)'}`));
56
+ }
57
+ return out;
58
+ }
59
+ const excludedBy = (rules, rel) => rules.some((re) => re.test(rel));
60
+
29
61
  export function openProject(root) {
30
62
  const real = fs.realpathSync(root);
31
63
  let files = null; let commit = null;
@@ -48,10 +80,14 @@ export function openProject(root) {
48
80
  };
49
81
  walk(real, '');
50
82
  }
51
- files = [...new Set(files)].sort();
83
+ let ignore = []; try { ignore = ignoreRules(fs.readFileSync(path.join(real, IGNORE_FILE), 'utf8')); } catch { /* none */ }
84
+ const listed = [...new Set(files)];
85
+ files = listed.filter((f) => !excludedBy(ignore, f)).sort();
86
+ const excluded = listed.length - files.length;
52
87
  const cache = new Map();
53
- const tooBig = [];
54
- // { text, lines } or null (unreadable, binary or over the size limit)
88
+ const tooBig = []; const unreadable = []; const binary = [];
89
+ // { text, lines } or null (unreadable, binary or over the size limit). Each null lands in a list, so a file the
90
+ // scanner asked for and could not use is reported as not measured instead of vanishing.
55
91
  const read = (rel) => {
56
92
  if (cache.has(rel)) return cache.get(rel);
57
93
  let r = null;
@@ -61,16 +97,17 @@ export function openProject(root) {
61
97
  if (st.size > MAX_BYTES) tooBig.push(rel);
62
98
  else {
63
99
  const buf = fs.readFileSync(abs);
64
- if (!buf.subarray(0, 8000).includes(0)) {
100
+ if (buf.subarray(0, 8000).includes(0)) binary.push(rel);
101
+ else {
65
102
  const text = buf.toString('utf8').replace(/^/, '');
66
103
  r = { text, lines: text.split('\n').map((l) => l.replace(/\r$/, '')) };
67
104
  }
68
105
  }
69
- } catch { r = null; }
106
+ } catch { r = null; unreadable.push(rel); }
70
107
  cache.set(rel, r);
71
108
  return r;
72
109
  };
73
- return { root: real, files, commit, read, tooBig, git: git.ok };
110
+ return { root: real, files, commit, read, tooBig, unreadable, binary, git: git.ok, excluded, ignoreRules: ignore.length };
74
111
  }
75
112
 
76
113
  // A citation. `anchor` must be a piece of the cited line (the validator checks it, collapsing whitespace); the
@@ -112,6 +149,8 @@ export function bearsValues(rel) {
112
149
 
113
150
  // A test, a fixture or an example: its routes, URLs and environment reads describe a fake system, not this one.
114
151
  export const isTestPath = (rel) => /(^|\/)(tests?|__tests__|spec|specs|fixtures?|e2e|testdata|examples?|[\w-]*-test)(\/|$)|\.(test|spec)\.[\w]+$|(^|\/)test_[^/]*\.py$|_test\.go$|Tests?\.cs$|Tests?\.(java|kt)$/i.test(rel);
152
+ // A module whose every source file is a test (Api.Tests, a tests package): nothing references it, by design.
153
+ export const isTestModule = (m) => m.files.length > 0 && m.files.every((f) => isTestPath(f));
115
154
  // A host that is this machine or a name reserved for tests and documentation is not an external resource.
116
155
  export const isLocalHost = (h) => !h || /^(localhost|127\.\d+\.\d+\.\d+|0\.0\.0\.0|\[::1\]|::1)(:\d*)?$/.test(h) || /\.(invalid|example|test|localhost)(:\d*)?$/.test(h);
117
156
 
@@ -5,6 +5,7 @@
5
5
  // node scan.mjs [--root DIR] scan, verify, write DIR/.crewforth/crew-map.json, print its path
6
6
  // node scan.mjs [--root DIR] --stdout scan and verify; print the document, write nothing
7
7
  // node scan.mjs [--root DIR] --check rc 0 when the file on disk is what a scan writes now, 1 when it is not
8
+ // node scan.mjs [--root DIR] --contracts rc 0 when .crewforth/contracts.md is absent or every rule and cite holds
8
9
  // rc 1 = the scan produced a document that does not verify (nothing is written) · rc 2 = usage
9
10
  //
10
11
  // What it guarantees, each one a gate in packaging/crew-map-test:
@@ -17,19 +18,20 @@
17
18
  // no machine paths are relative to the project root; the findings core refuses a document that carries the
18
19
  // machine's own directories
19
20
  // honest what it cannot read goes to notMeasured with the reason, never read partly
20
- // cheap two child processes per scan (git ls-files, git rev-parse), none per file
21
+ // cheap three child processes per scan (git ls-files, git rev-parse, git log), none per file
21
22
  //
22
23
  // Host-independent like the findings core: plain JSON out, Node's standard library only, no hook payload read.
23
24
 
24
25
  import fs from 'node:fs';
25
26
  import path from 'node:path';
26
27
  import { fileURLToPath } from 'node:url';
27
- import { openProject, loadSecretPatterns, makeHostFilter, sortEv, bearsValues, risky, spawned, isTestPath, ev } from './project.mjs';
28
+ import { openProject, loadSecretPatterns, makeHostFilter, sortEv, bearsValues, risky, spawned, isTestPath, isTestModule, ev, run } from './project.mjs';
28
29
  import { scanStacks, applyTech } from './stacks.mjs';
29
30
  import { externals, configuration, codeUrls, servedCalls, endpoints, containers } from './surface.mjs';
30
31
  import { detect } from './detect.mjs';
31
32
  import { stamp, write, serialize, validate, OUT_DIR } from '../findings/findings.mjs';
32
- import { carry, strip, validate as validateNarrative } from './narrative.mjs';
33
+ import { carry, steady, validate as validateNarrative } from './narrative.mjs';
34
+ import { CONTRACTS_FILE, validate as validateContracts } from './contracts.mjs';
33
35
 
34
36
  // Languages with source the scanner has no reader for, by extension.
35
37
  const UNREAD = { swift: 'Swift', rb: 'Ruby', php: 'PHP', rs: 'Rust', scala: 'Scala', ex: 'Elixir', exs: 'Elixir',
@@ -87,6 +89,81 @@ function structure(m) {
87
89
  return { folders: shown, ...(rest ? { otherFiles: rest } : {}), testFiles: tests };
88
90
  }
89
91
 
92
+ /**
93
+ * Where a module's tests are, by their names: test files among its own source (x.test.ts, test_x.py, x_test.go,
94
+ * XTests.cs, a tests/ folder), and the files of a test project named for it (Api.Tests, api-test, core_tests).
95
+ * Twelve paths at most, the count always whole, the order the same on every machine. A reader about to change the
96
+ * module learns which tests to run before guessing.
97
+ */
98
+ const MAX_TESTS = 12;
99
+ const TEST_NAME = /[._-]?(unit|integration|functional|e2e)?[._-]?tests?$/i;
100
+ function testsOf(modules) {
101
+ const byName = new Map();
102
+ for (const m of modules) byName.set(`${m.stack}\u0000${m.name.toLowerCase()}`, m);
103
+ const linked = new Map();
104
+ for (const t of modules) {
105
+ if (!isTestModule(t) || !TEST_NAME.test(t.name)) continue;
106
+ const base = t.name.replace(TEST_NAME, '').toLowerCase();
107
+ const target = base && byName.get(`${t.stack}\u0000${base}`);
108
+ if (target && target !== t) (linked.get(target.id) ?? linked.set(target.id, []).get(target.id)).push(t);
109
+ }
110
+ const out = new Map();
111
+ for (const m of modules) {
112
+ const from = linked.get(m.id) ?? [];
113
+ const files = [...new Set([...m.files.filter((f) => isTestPath(f)), ...from.flatMap((t) => t.files)])].sort(cmp);
114
+ if (files.length) out.set(m.id, { files: files.slice(0, MAX_TESTS), total: files.length, ...(from.length ? { modules: from.map((t) => t.id).sort(cmp) } : {}) });
115
+ }
116
+ return out;
117
+ }
118
+
119
+ /**
120
+ * How often each module changed: in how many of the last WINDOW commits on HEAD a file under its folder changed
121
+ * (the deepest module folder that holds the file; renames followed; paths and commits limited to the scanned root
122
+ * when it is a folder inside a larger repository). One `git log` for the whole project, never one
123
+ * per module or file, and none of the repository's own programs: no fsmonitor, signature check, external diff or
124
+ * textconv. A count window, not a date window, so the same commit gives the same numbers on every machine. These
125
+ * numbers move with every commit, so --check and the rescan ledger leave them out.
126
+ */
127
+ const WINDOW = 500;
128
+ function churnOf(pj, modules) {
129
+ if (!pj.git || !pj.commit) return null;
130
+ const r = run('git', ['-c', 'log.showSignature=false', '-c', 'log.follow=false', 'log', '-M', '--name-only', '--no-ext-diff', '--no-textconv', '--no-notes',
131
+ '--relative', '--pretty=format:%x00', '-n', String(WINDOW), 'HEAD', '--', '.'], pj.root);
132
+ if (!r.ok) return null;
133
+ const dirs = [...new Set(modules.map((m) => m.dir))].sort((a, b) => b.length - a.length || cmp(a, b));
134
+ const owner = (f) => dirs.find((d) => d === '.' || f === d || f.startsWith(`${d}/`));
135
+ const counts = new Map();
136
+ const commits = r.out.split('\0').slice(1);
137
+ for (const c of commits) {
138
+ const hit = new Set();
139
+ for (const f of c.split('\n')) { const d = f && owner(f.replace(/\r$/, '')); if (d) hit.add(d); }
140
+ for (const d of hit) counts.set(d, (counts.get(d) ?? 0) + 1);
141
+ }
142
+ return { window: commits.length, of: (m) => counts.get(m.dir) ?? 0 };
143
+ }
144
+
145
+ /**
146
+ * What the code says about itself where it explains a choice or admits a debt: a comment whose first word is WHY,
147
+ * HACK or FIXME, in the module's own source (tests apart). The citation is the marker and what follows it on the
148
+ * line, cut at 72 characters; a line the secret net flags is cited by the marker word alone. Five lines at most, the
149
+ * count always whole, so /crew-map's tour can point at the reason a piece of code looks the way it does.
150
+ */
151
+ const MARKER = /(?:^|[\s;])(?:\/\/+|#+|\/\*+|\*|--|<!--)\s*((?:WHY|HACK|FIXME)\b(?![\w-]).*)$/;
152
+ function markersOf(pj, m, patterns) {
153
+ const out = [];
154
+ for (const f of m.files) {
155
+ if (isTestPath(f)) continue;
156
+ const src = pj.read(f); if (!src) continue;
157
+ src.lines.forEach((l, i) => {
158
+ const x = MARKER.exec(l); if (!x) return;
159
+ const word = /^\w+/.exec(x[1])[0];
160
+ const quote = x[1].replace(/\s*(\*\/|-->)\s*$/, '').slice(0, 72).trimEnd();
161
+ out.push(ev(f, i + 1, risky(quote, patterns) >= 0 ? word : quote));
162
+ });
163
+ }
164
+ return out;
165
+ }
166
+
90
167
  // opts.hostFilter replaces the masking: ONLY the gate's must-fail twin uses it. No command-line flag reaches it.
91
168
  export function scan(root = '.', opts = {}) {
92
169
  const pj = openProject(root);
@@ -104,6 +181,8 @@ export function scan(root = '.', opts = {}) {
104
181
  const eps = endpoints(pj, st, out);
105
182
  const services = containers(pj, st, rules, hostOf, ex, cfg, out);
106
183
  const findings = detect({ st, cfg, eps, names, stdlib, notMeasured: out.notMeasured, configUrls: out.configUrls, codeHosts: out.codeHosts });
184
+ const tests = testsOf(st.modules);
185
+ const churn = churnOf(pj, st.modules);
107
186
 
108
187
  const ids = new Set([...st.modules.map((m) => m.id), ...services.map((s) => s.id), ...ex.ext.keys()]);
109
188
  for (const m of st.modules) {
@@ -144,7 +223,10 @@ export function scan(root = '.', opts = {}) {
144
223
  ...(m.entry ? { entryPoint: m.entryEv ? { evidence: [m.entryEv] } : { file: m.entryFile, evidence: [] } } : {}),
145
224
  dependencies: m.deps.length,
146
225
  structure: structure(m),
226
+ ...(tests.has(m.id) ? { tests: tests.get(m.id) } : {}),
227
+ ...(churn ? { commits: churn.of(m) } : {}),
147
228
  ...((() => { const a = aboutOf(pj, m); return a ? { about: a } : {}; })()),
229
+ ...((() => { const k = markersOf(pj, m, patterns); return k.length ? { markers: capped(k) } : {}; })()),
148
230
  ...capped(m.evidence), narrative: null })),
149
231
  ...services.map((s) => ({ id: s.id, kind: 'service', name: s.name, ...(s.image ? { image: s.image } : {}), ...capped(s.evidence), narrative: null })),
150
232
  ...[...ex.ext.values()].map((n) => ({ id: n.id, kind: 'external', resource: n.resource, label: n.label, hosts: [...n.hosts].sort(), ...capped(n.evidence), narrative: null })),
@@ -157,15 +239,18 @@ export function scan(root = '.', opts = {}) {
157
239
  [b.module, b.path, b.method, b.evidence[0].file, String(b.evidence[0].line).padStart(9, '0')].join('\u0000')));
158
240
  };
159
241
  const nm = new Map();
242
+ // After every read (the nodes' README included), so a file that failed late is still said.
243
+ for (const f of pj.unreadable) out.notMeasured.push({ what: f, reason: 'could not be read' });
244
+ for (const f of pj.binary) out.notMeasured.push({ what: f, reason: 'binary, not read' });
160
245
  for (const x of out.notMeasured) nm.set(`${x.what}\u0000${x.reason}`, x);
161
246
 
162
247
  const stacks = [...new Set([...st.modules.map((m) => m.stack), ...(services.length ? ['container'] : [])])].sort();
163
248
  const doc = {
164
249
  schema: SCHEMA,
165
250
  producer: { name: NAME, version: '1' },
166
- ...(pj.commit ? { source: { commit: pj.commit } } : {}),
251
+ ...(pj.commit ? { source: { commit: pj.commit, ...(churn ? { commitsRead: churn.window } : {}) } } : {}),
167
252
  stacks,
168
- coverage: { files: pj.files.length, modules: st.modules.length, listing: pj.git ? 'git' : 'walk' },
253
+ coverage: { files: pj.files.length, modules: st.modules.length, listing: pj.git ? 'git' : 'walk', ...(pj.ignoreRules ? { excluded: pj.excluded } : {}) },
169
254
  nodes,
170
255
  edges,
171
256
  provides: { endpoints: epSort(eps.provides) },
@@ -193,12 +278,12 @@ export function scan(root = '.', opts = {}) {
193
278
  }
194
279
  const report = stamp(doc, pj.root);
195
280
  for (const c of cites) if (c.sha256 && (bearsValues(c.file) || risky(lineAt(c), patterns) >= 0)) delete c.sha256;
196
- return { doc, report, spawned: spawned.count };
281
+ return { doc, report, spawned: spawned.count, files: new Set(pj.files), read: pj.read };
197
282
  }
198
283
 
199
284
  function usage(msg) {
200
285
  if (msg) console.error(`crew-map: ${msg}`);
201
- console.error('usage: scan.mjs [--root DIR] [--stdout | --check | --validate]');
286
+ console.error('usage: scan.mjs [--root DIR] [--stdout | --check | --validate | --contracts | --force]');
202
287
  return 2;
203
288
  }
204
289
 
@@ -211,17 +296,51 @@ function summary(doc) {
211
296
  + `${doc.findings.length} finding(s) (${hidden} hidden by default) · ${doc.notMeasured.length} not measured`;
212
297
  }
213
298
 
299
+ // What a rescan changed, by stable id, so the reader sees which modules came, went or moved and not only a byte
300
+ // count: nodes added, removed and changed (any scanner field but the commit count; descriptions are not the scan's), edges added and
301
+ // removed by from → to and kind. At most eight ids are named per list; the count is always whole.
302
+ export function ledger(was, doc) {
303
+ const bare = (n) => { const { narrative, commits, ...rest } = n; return JSON.stringify(rest); };
304
+ const before = new Map((was.nodes ?? []).map((n) => [n.id, bare(n)]));
305
+ const after = new Map(doc.nodes.map((n) => [n.id, bare(n)]));
306
+ const added = [...after.keys()].filter((k) => !before.has(k));
307
+ const removed = [...before.keys()].filter((k) => !after.has(k));
308
+ const changed = [...after.keys()].filter((k) => before.has(k) && before.get(k) !== after.get(k));
309
+ const ek = (e) => `${e.from} → ${e.to} (${e.kind})`;
310
+ const eb = new Set((was.edges ?? []).map(ek)); const ea = new Set(doc.edges.map(ek));
311
+ const eAdded = [...ea].filter((k) => !eb.has(k)); const eRemoved = [...eb].filter((k) => !ea.has(k));
312
+ if (!added.length && !removed.length && !changed.length && !eAdded.length && !eRemoved.length) return 'crew-map: since the last scan: no node or edge changed';
313
+ const list = (xs) => (xs.length ? ` ${xs.length}: ${xs.slice(0, 8).join(', ')}${xs.length > 8 ? `, … ${xs.length - 8} more` : ''}` : ' 0');
314
+ return ['crew-map: since the last scan:', ` nodes added${list(added)}`, ` nodes removed${list(removed)}`, ` nodes changed${list(changed)}`,
315
+ ` edges added${list(eAdded)}`, ` edges removed${list(eRemoved)}`].join('\n');
316
+ }
317
+
214
318
  export function main(argv) {
215
- let root = '.'; let mode = 'write';
319
+ let root = '.'; let mode = 'write'; let force = false;
216
320
  for (let i = 0; i < argv.length; i++) {
217
321
  const a = argv[i];
218
322
  if (a === '--root') { root = argv[++i]; if (root === undefined) return usage('--root needs a directory'); }
219
- else if (a === '--stdout' || a === '--check' || a === '--validate') { if (mode !== 'write') return usage('--stdout, --check and --validate do not go together'); mode = a.slice(2); }
323
+ else if (a === '--force') force = true;
324
+ else if (a === '--stdout' || a === '--check' || a === '--validate' || a === '--contracts') { if (mode !== 'write') return usage('--stdout, --check, --validate and --contracts do not go together'); mode = a.slice(2); }
220
325
  else return usage(`no such option: ${a}`);
221
326
  }
222
327
  try { if (!fs.statSync(root).isDirectory()) throw new Error('not a directory'); }
223
328
  catch (e) { console.error(`crew-map: --root ${root}: ${e.message}`); return 2; }
224
- const { doc, report } = scan(root);
329
+ const { doc, report, files, read } = scan(root);
330
+ // The project's own rules (contracts.mjs): every cite must land on a name in a file of this tree.
331
+ const contracts = () => {
332
+ let text = null; try { text = fs.readFileSync(path.join(root, CONTRACTS_FILE), 'utf8'); } catch { /* none */ }
333
+ if (text === null) return { n: 0, problems: [], absent: true };
334
+ const r = validateContracts(text, files, read);
335
+ return { n: r.contracts.length, problems: r.problems.map((p) => ({ at: `contracts ${p.at}`, message: p.message })) };
336
+ };
337
+ if (mode === 'contracts') {
338
+ const c = contracts();
339
+ if (c.absent) { console.log(`crew-map: ${CONTRACTS_FILE} does not exist, so there is nothing to hold`); return 0; }
340
+ for (const p of c.problems) console.log(` FAIL ${p.at}: ${p.message}`);
341
+ console.log(c.problems.length ? `crew-map: ${c.problems.length} problem(s) in ${CONTRACTS_FILE}` : `crew-map: ${CONTRACTS_FILE} holds: ${c.n} contract(s), every cite names a symbol in a file of this tree`);
342
+ return c.problems.length ? 1 : 0;
343
+ }
225
344
  if (!report.ok) {
226
345
  for (const p of report.problems) console.error(` FAIL ${p.at}: ${p.code} — ${p.message}`);
227
346
  console.error('crew-map: the scan produced citations that do not verify, so nothing was written (a scanner defect)');
@@ -233,11 +352,12 @@ export function main(argv) {
233
352
  let was = null; try { was = old === null ? null : JSON.parse(old); } catch { /* unreadable: different */ }
234
353
  if (mode === 'validate') {
235
354
  if (!was) { console.log(`crew-map: ${OUT_DIR}/${NAME}.json ${old === null ? 'does not exist' : 'is not JSON'}`); return 1; }
236
- const problems = validateNarrative(was, doc, serialize);
355
+ const c = contracts();
356
+ const problems = [...validateNarrative(was, doc, serialize), ...c.problems];
237
357
  const written = (was.nodes ?? []).filter((n) => n.narrative != null).length + (was.overview != null ? 1 : 0);
238
- if (!problems.length) { console.log(`crew-map: ${OUT_DIR}/${NAME}.json holds: ${written} description(s), each citing lines the scan read; the scanner's fields untouched`); return 0; }
358
+ if (!problems.length) { console.log(`crew-map: ${OUT_DIR}/${NAME}.json holds: ${written} description(s), each citing lines the scan read; the scanner's fields untouched${c.absent ? '' : `; ${c.n} contract(s) in ${CONTRACTS_FILE}, every cite landing`}`); return 0; }
239
359
  for (const p of problems) console.log(` FAIL ${p.at}: ${p.message}`);
240
- console.log(`crew-map: ${problems.length} problem(s); fix the descriptions, never the scanner's fields`);
360
+ console.log(`crew-map: ${problems.length} problem(s); fix the descriptions or contracts.md, never the scanner's fields`);
241
361
  return 1;
242
362
  }
243
363
  if (mode === 'check') {
@@ -245,7 +365,8 @@ export function main(argv) {
245
365
  // The content is compared, not the commit it was written at: a map committed to the repository is always
246
366
  // one commit behind the commit that carries it, and that alone does not make it stale. What the model wrote
247
367
  // (narrative, overview) is not the scan's, so it is left out of the comparison.
248
- const bare = (d) => { const { source, ...rest } = strip(d); return serialize(rest); };
368
+ // How often each module changed moves with every commit too, so it is left out the same way.
369
+ const bare = (d) => serialize(steady(d));
249
370
  const then = was && was.source && was.source.commit; const now = doc.source && doc.source.commit;
250
371
  const at = then && now && then !== now ? ` (written at ${then.slice(0, 12)}, HEAD is ${now.slice(0, 12)})` : '';
251
372
  if (was && bare(was) === bare(doc)) { console.log(`crew-map: ${OUT_DIR}/${NAME}.json is up to date${at}`); return 0; }
@@ -254,8 +375,26 @@ export function main(argv) {
254
375
  }
255
376
  // The descriptions /crew-map wrote are kept where they still cite only lines this scan cites; the rest are dropped.
256
377
  const carried = was ? carry(was, doc) : { doc, kept: 0, dropped: 0 };
378
+ // A rescan that would lose most of what the map held is far more often a wrong --root or a damaged checkout than a
379
+ // project that halved: /crew-map never commits, so the descriptions it drops exist nowhere else. Write nothing and
380
+ // say both counts; --force writes it anyway. Only descriptions whose NODE is gone count here: one whose cited line
381
+ // moved is dropped by design on an ordinary edit, and that rescan must still write.
382
+ const modsOf = (d) => (d?.nodes ?? []).filter((n) => n.kind === 'module').length;
383
+ const shrank = [];
384
+ if (was) {
385
+ const ids = new Set(doc.nodes.map((n) => n.id));
386
+ const described = (was.nodes ?? []).filter((n) => n.narrative != null);
387
+ const orphaned = described.filter((n) => !ids.has(n.id)).length;
388
+ if (orphaned * 2 > described.length) shrank.push(`${orphaned} of ${described.length} described node(s) would be gone, with their descriptions`);
389
+ if (modsOf(doc) * 2 < modsOf(was)) shrank.push(`modules would fall from ${modsOf(was)} to ${modsOf(doc)}`);
390
+ }
391
+ if (shrank.length && !force) {
392
+ console.error(`crew-map: nothing written — ${shrank.join('; ')}. A wrong --root or a damaged checkout does this; if the project really changed, run again with --force`);
393
+ return 1;
394
+ }
257
395
  const r = write(carried.doc, root, NAME);
258
396
  if (!r.ok) { for (const p of r.problems) console.error(` FAIL ${p.at}: ${p.code} — ${p.message}`); return 1; }
397
+ if (was) console.error(ledger(was, doc));
259
398
  console.error(summary(doc) + (carried.kept || carried.dropped ? ` · ${carried.kept} description(s) kept, ${carried.dropped} dropped (they cited lines this scan no longer reads)` : ''));
260
399
  console.log(r.file);
261
400
  return 0;
@@ -56,7 +56,17 @@
56
56
  "description": "The producer's own fields. Not interpreted here.",
57
57
  "type": "object"
58
58
  },
59
- "verification": { "$ref": "#/$defs/verification" }
59
+ "verification": { "$ref": "#/$defs/verification" },
60
+ "disposition": {
61
+ "description": "What became of the finding: fixed now, tracked elsewhere, accepted by the user, or dropped as disproved. A later review carries a tracked, accepted or dropped finding with the same id over instead of raising it again, unless the diff changes the lines it cites; a critical or high one is judged again every time.",
62
+ "type": "object",
63
+ "required": ["state"],
64
+ "additionalProperties": false,
65
+ "properties": {
66
+ "state": { "enum": ["fixed", "tracked", "accepted", "dropped"] },
67
+ "reason": { "type": "string", "maxLength": 500 }
68
+ }
69
+ }
60
70
  }
61
71
  },
62
72
  "verification": {
@@ -26,4 +26,27 @@ if [ -z "$NODE" ]; then
26
26
  echo "findings: no Node $FLOOR+ that runs was found, so nothing was checked (this is not a pass)" >&2
27
27
  exit 3
28
28
  fi
29
- exec "$NODE" "$d/findings.mjs" "$@"
29
+ [ "${1:-}" = write ] || exec "$NODE" "$d/findings.mjs" "$@"
30
+
31
+ # A write is also one REVIEW ROUND, and the round is logged here, not in findings.mjs (which reads no repository on
32
+ # purpose): one line per written document in the git directory, so it is never committed —
33
+ # <time> <branch> <name> <round> <critical> <high> <medium> <low> <info>
34
+ # The round is 1 + the lines already there for the same branch and name. It is what tells whether the late rounds of
35
+ # a review still find anything above low (/crew-review § Rounds). Outside a repository nothing is logged; a --root
36
+ # inside one logs there. Two writes at the same moment may share a round number: a log, not a lock.
37
+ "$NODE" "$d/findings.mjs" "$@"; rc=$?
38
+ [ "$rc" = 0 ] || exit "$rc"
39
+ root=.; name=""; shift
40
+ while [ $# -gt 0 ]; do case "$1" in --root) root="${2:-.}"; shift ;; --verified) ;; *) [ -z "$name" ] && name="$1" ;; esac; shift; done
41
+ log="$(git -C "$root" rev-parse --path-format=absolute --git-path crewforth-review-rounds.tsv 2>/dev/null)" || exit 0
42
+ # A git before 2.31 echoes the unknown --path-format back on a line of its own: that is no path to write to.
43
+ case "$log" in *$'\n'*|-*|'') exit 0 ;; esac
44
+ # symbolic-ref, not rev-parse: it names a branch with no commit yet too. Never empty: an empty tab-separated field
45
+ # collapses when the line is read back, and every round then counted as the first (found by the self-check).
46
+ br="$(git -C "$root" symbolic-ref --short -q HEAD 2>/dev/null)" || br=""; [ -n "$br" ] || br=-
47
+ # Any branch name git allows is logged; only a tab or a newline, which would split the line, is not.
48
+ case "$br" in *$'\t'*|*$'\n'*) exit 0 ;; esac
49
+ n=1; [ -f "$log" ] && while IFS=$'\t' read -r _ b nm _; do [ "$b" = "$br" ] && [ "$nm" = "$name" ] && n=$((n+1)); done < "$log"
50
+ sev="$("$NODE" -e 'const f=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).findings||[];const c={critical:0,high:0,medium:0,low:0,info:0};for(const x of f)if(x.severity in c)c[x.severity]++;process.stdout.write([c.critical,c.high,c.medium,c.low,c.info].join("\t"))' "$root/.crewforth/$name.json" 2>/dev/null)" || exit 0
51
+ printf '%s\t%s\t%s\t%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$br" "$name" "$n" "$sev" >> "$log" 2>/dev/null
52
+ exit 0
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env bash
2
+ # What the hooks wired on UserPromptSubmit and SessionStart put into the model's context, in bytes. One definition:
3
+ # doctor prints it (4c), smoke-test.sh pins a budget on it. Run from anywhere:
4
+ #
5
+ # bash .claude/eval/lib/hook-inject.sh [PROJECT_ROOT]
6
+ #
7
+ # Prints tab-separated lines, nothing else on stdout:
8
+ # run <event> <hook> <bytes> <rc> a hook that was run; bytes = what Claude Code would inject (0 when rc != 0)
9
+ # skip <event> <hook> <reason-key> a hook that was NOT run, and why (keys: see the table below)
10
+ # total <event> <bytes> <hooks run>
11
+ # prompt <the fixture prompt>
12
+ #
13
+ # WHY IT IS RUN AND NOT READ. A hook's injection depends on the project (route-hint matches the prompt against the
14
+ # installed agents, board-sync prints the cached board, context-usage the fill), so reading the scripts cannot give a
15
+ # number; running them on a fixture payload can. The number is therefore A FLOOR FOR THIS FIXTURE, not a maximum: a
16
+ # hook that speaks only for some prompts or states (an approval, an update, a compaction) prints nothing here.
17
+ #
18
+ # WHAT "INJECT" MEANS. For both events Claude Code adds a hook's stdout to the context on exit 0, or, when stdout is a
19
+ # JSON object, its hookSpecificOutput.additionalContext (a systemMessage goes to the user, not the model). That is
20
+ # what is counted: the decoded bytes of additionalContext, or the plain stdout. NOT counted (not measured here): the
21
+ # framing Claude Code puts around each injection.
22
+ #
23
+ # NO LASTING SIDE EFFECT. Every hook runs with a throwaway HOME, TMPDIR, XDG_CACHE_HOME and CLAUDE_CONFIG_DIR, a
24
+ # fixture transcript, and a session id no real session has (real ones are UUIDs). What each Crewforth hook writes was
25
+ # read from its source (grep for `> "`, `mkdir`, `.claude/state`) and each is placed in one of three rows:
26
+ # read-only for these payloads ............ run against the project (route-hint, session-rehydrate, skill-trust,
27
+ # turn-check: the fixture declares no fast check, so it writes no snapshot;
28
+ # branch-fresh with CREW_BRANCH_FRESH_FETCH=off: no fetch; its merge-tree check leaves only unreachable objects)
29
+ # writes only under TMPDIR ................ run against the project, TMPDIR thrown away (context-usage)
30
+ # board-sync: its detached half fetches over the network when the cache is stale; CREW_BOARD_MAX_AGE[_NOBOARD]
31
+ # set beyond any age keeps it on the cached read, which writes nothing
32
+ # prompt-approval: EVERY user message empties a recorded approval in the worktree's git directory and the
33
+ # question's state in .claude/state, so it runs with the project dir and cwd on a scratch folder outside any
34
+ # repository. For a prompt that is not an approval it prints nothing wherever it runs (read from its source).
35
+ # writes into the project, cannot be redirected -> not run, said so:
36
+ # agent-outcome (SessionStart rewrites .claude/state/crew-critical-paths.auto),
37
+ # session-update-check (writes .claude/.state/update-asked, which silences the real question for a day, and
38
+ # starts a network refresh)
39
+ # A hook not in this table (the project's own, or a Crewforth hook added later) is not run: nothing has checked
40
+ # what it writes. smoke-test.sh fails when a hook Crewforth wires on these events is missing from the table.
41
+ #
42
+ # Process cost: two awk per hook and a handful for the wiring, never one per line (Git Bash: 20-130 ms a process).
43
+ set -uo pipefail
44
+ export LC_ALL=C # bytes, not characters: length() in awk and the shell both count bytes under C
45
+
46
+ _hi_d="${BASH_SOURCE[0]//\\//}"; _hi_d="${_hi_d%/*}"; [ "$_hi_d" = "${BASH_SOURCE[0]//\\//}" ] && _hi_d=.
47
+ _hi_d="$(cd "$_hi_d" 2>/dev/null && pwd)" || _hi_d=. # absolute: the script cd's into ROOT below
48
+ SJ="$_hi_d/settings-json.awk"
49
+ ROOT="${1:-.}"
50
+ cd "$ROOT" 2>/dev/null || { echo "hook-inject: cannot enter '$ROOT'" >&2; exit 2; }
51
+ S=.claude/settings.json
52
+ [ -f "$SJ" ] || { echo "hook-inject: Crewforth's JSON reader is missing ($SJ)" >&2; exit 2; }
53
+ [ -f "$S" ] || { echo "hook-inject: no $S" >&2; exit 2; }
54
+ awk -v op=validate -f "$SJ" "$S" 2>/dev/null || { echo "hook-inject: $S is not valid JSON" >&2; exit 2; }
55
+
56
+ PROMPT="write unit tests for the payment service"
57
+ SID="crew-inject-probe-$$-${RANDOM:-0}"
58
+ GCG="${GIT_CONFIG_GLOBAL:-${HOME:-}/.gitconfig}"
59
+
60
+ # CREW_INJECT_SCRATCH: a folder to use and KEEP (smoke reads it back to prove the redirection happened). Otherwise a
61
+ # fresh one, removed on exit.
62
+ if [ -n "${CREW_INJECT_SCRATCH:-}" ]; then T="$CREW_INJECT_SCRATCH"; mkdir -p "$T" || exit 2
63
+ else T="$(mktemp -d 2>/dev/null)" || { echo "hook-inject: no scratch folder" >&2; exit 2; }; trap 'rm -rf "$T"' EXIT; fi
64
+ mkdir -p "$T/home/.claude" "$T/tmp" "$T/cache" "$T/proj" || exit 2
65
+
66
+ # A JSON string body for a path: backslash and quote escaped (a Windows path or an odd folder name).
67
+ _js(){ local s="${1//\\/\\\\}"; _JSV="${s//\"/\\\"}"; }
68
+ _js "$PWD"; PJ="$_JSV"; _js "$T/proj"; SCJ="$_JSV"; _js "$T/transcript.jsonl"; TJ="$_JSV"
69
+
70
+ # The fixture transcript: one assistant turn with usage, the record context-usage reads (2% of a 1M window).
71
+ printf '%s\n' '{"type":"assistant","message":{"role":"assistant","model":"claude-fixture","usage":{"input_tokens":100,"cache_read_input_tokens":20000,"cache_creation_input_tokens":0,"output_tokens":10}}}' > "$T/transcript.jsonl"
72
+ printf '{"session_id":"%s","transcript_path":"%s","cwd":"%s","permission_mode":"default","hook_event_name":"UserPromptSubmit","prompt":"%s"}\n' "$SID" "$TJ" "$PJ" "$PROMPT" > "$T/ups.json"
73
+ printf '{"session_id":"%s","transcript_path":"%s","cwd":"%s","permission_mode":"default","hook_event_name":"UserPromptSubmit","prompt":"%s"}\n' "$SID" "$TJ" "$SCJ" "$PROMPT" > "$T/ups-scratch.json"
74
+ printf '{"session_id":"%s","transcript_path":"%s","cwd":"%s","hook_event_name":"SessionStart","source":"startup"}\n' "$SID" "$TJ" "$PJ" > "$T/ss.json"
75
+
76
+ # prompt-approval's scratch folder must not sit inside a repository: its record lives in the enclosing git directory.
77
+ SCRATCH_OK=1; git -C "$T/proj" rev-parse --git-dir >/dev/null 2>&1 && SCRATCH_OK=0
78
+
79
+ # The decoded byte length of a JSON string token (quotes included in the input), or of plain text. One awk per hook.
80
+ # \\ and the one-letter escapes are one byte; \uXXXX is the UTF-8 length of that code point (a surrogate half 2,
81
+ # so a pair is 4). In C locale length() counts bytes, so raw UTF-8 in the string is counted as it will be sent.
82
+ DEC='
83
+ function hx(h, i, v) { v = 0; h = tolower(h); for (i = 1; i <= 4; i++) v = v * 16 + index("0123456789abcdef", substr(h, i, 1)) - 1; return v }
84
+ { buf = buf (NR > 1 ? "\n" : "") $0 }
85
+ END {
86
+ if (mode == "plain") { print length(buf) + (NR > 0 ? 1 : 0); exit }
87
+ s = buf; sub(/^[[:space:]]*"/, "", s); sub(/"[[:space:]]*$/, "", s)
88
+ gsub(/\\\\/, "B", s)
89
+ while (match(s, /\\u[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]/)) {
90
+ v = hx(substr(s, RSTART + 2, 4)); k = (v < 128 ? 1 : (v < 2048 ? 2 : ((v >= 55296 && v <= 57343) ? 2 : 3)))
91
+ r = substr("UUU", 1, k); s = substr(s, 1, RSTART - 1) r substr(s, RSTART + 6)
92
+ }
93
+ gsub(/\\./, "E", s)
94
+ print length(s)
95
+ }'
96
+
97
+ measure(){ # $1 = event, $2 = hook file name -> one run/skip line; adds to TOT_* and RUN_*
98
+ local ev="$1" h="$2" pl proj="$PWD" rc n ac
99
+ case "$ev" in UserPromptSubmit) pl="$T/ups.json" ;; *) pl="$T/ss.json" ;; esac
100
+ case "$h" in
101
+ context-usage.sh|route-hint.sh|session-rehydrate.sh|skill-trust.sh|board-sync.sh|turn-check.sh|branch-fresh.sh) ;;
102
+ prompt-approval.sh)
103
+ [ "$SCRATCH_OK" = 1 ] || { printf 'skip\t%s\t%s\t%s\n' "$ev" "$h" scratch-in-repo; return 0; }
104
+ proj="$T/proj"; pl="$T/ups-scratch.json" ;;
105
+ agent-outcome.sh) printf 'skip\t%s\t%s\t%s\n' "$ev" "$h" writes-state; return 0 ;;
106
+ session-update-check.sh) printf 'skip\t%s\t%s\t%s\n' "$ev" "$h" writes-update; return 0 ;;
107
+ *) printf 'skip\t%s\t%s\t%s\n' "$ev" "$h" unchecked; return 0 ;;
108
+ esac
109
+ [ -f ".claude/hooks/$h" ] || { printf 'skip\t%s\t%s\t%s\n' "$ev" "$h" missing; return 0; }
110
+ # Launched the way settings.json launches it (`bash .claude/hooks/<name>` from the project root): context-usage
111
+ # compares its own $0 with that spelling and warns about stale wiring when they differ. git keeps reading the
112
+ # user's own global config (GIT_CONFIG_GLOBAL, git 2.32+): session-rehydrate's answer depends on core.hooksPath.
113
+ GIT_CONFIG_GLOBAL="$GCG" HOME="$T/home" TMPDIR="$T/tmp" XDG_CACHE_HOME="$T/cache" CLAUDE_CONFIG_DIR="$T/home/.claude" \
114
+ CLAUDE_PROJECT_DIR="$proj" CLAUDE_PLUGIN_ROOT="" CREW_BOARD_MAX_AGE=999999999999 CREW_BOARD_MAX_AGE_NOBOARD=999999999999 CREW_BRANCH_FRESH_FETCH=off \
115
+ bash ".claude/hooks/$h" < "$pl" > "$T/out" 2>/dev/null
116
+ rc=$?
117
+ n=0
118
+ if [ "$rc" = 0 ] && [ -s "$T/out" ]; then
119
+ awk -v op=get -v path=hookSpecificOutput.additionalContext -f "$SJ" "$T/out" > "$T/ac" 2>/dev/null
120
+ case $? in
121
+ 0) n="$(awk -v mode=json "$DEC" "$T/ac")" ;;
122
+ 10) n="$(awk -v mode=plain "$DEC" "$T/out")" ;; # not a JSON object: Claude Code injects the text as it is
123
+ *) n=0 ;; # a JSON object with no additionalContext: nothing reaches the model
124
+ esac
125
+ fi
126
+ case "$n" in ''|*[!0-9]*) n=0 ;; esac
127
+ printf 'run\t%s\t%s\t%s\t%s\n' "$ev" "$h" "$n" "$rc"
128
+ case "$ev" in UserPromptSubmit) TOT_U=$((TOT_U + n)); RUN_U=$((RUN_U + 1)) ;; *) TOT_S=$((TOT_S + n)); RUN_S=$((RUN_S + 1)) ;; esac
129
+ }
130
+
131
+ TOT_U=0; RUN_U=0; TOT_S=0; RUN_S=0
132
+ printf 'prompt\t%s\n' "$PROMPT"
133
+ for ev in UserPromptSubmit SessionStart; do
134
+ ne="$(awk -v op=len -v path="hooks.$ev" -f "$SJ" "$S" 2>/dev/null)"; case "$ne" in ''|*[!0-9]*) ne=0 ;; esac
135
+ i=0
136
+ while [ "$i" -lt "$ne" ]; do
137
+ nh="$(awk -v op=len -v path="hooks.$ev.$i.hooks" -f "$SJ" "$S" 2>/dev/null)"; case "$nh" in ''|*[!0-9]*) nh=0 ;; esac
138
+ j=0
139
+ while [ "$j" -lt "$nh" ]; do
140
+ c="$(awk -v op=get -v path="hooks.$ev.$i.hooks.$j.command" -f "$SJ" "$S" 2>/dev/null)" || c=""
141
+ r="${c#*bash .claude/hooks/}"
142
+ if [ "$r" != "$c" ]; then
143
+ r="${r%\"}"
144
+ case "$r" in *.sh) case "$r" in *[!A-Za-z0-9._-]*) r="" ;; esac ;; *) r="" ;; esac
145
+ else r=""; fi
146
+ if [ -n "$r" ]; then measure "$ev" "$r"
147
+ else
148
+ # Not one of Crewforth's: named by its last word only (no path, no argument is printed).
149
+ f="${c%\"}"; f="${f//\\//}"; f="${f##*[ /]}"; f="${f//[!A-Za-z0-9._-]/}"
150
+ printf 'skip\t%s\t%s\t%s\n' "$ev" "${f:-hook}" unchecked
151
+ fi
152
+ j=$((j + 1))
153
+ done
154
+ i=$((i + 1))
155
+ done
156
+ done
157
+ printf 'total\tUserPromptSubmit\t%s\t%s\n' "$TOT_U" "$RUN_U"
158
+ printf 'total\tSessionStart\t%s\t%s\n' "$TOT_S" "$RUN_S"
159
+ exit 0