crewforth 3.4.0 → 3.5.0-rc.1
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.
- package/CHANGELOG.md +241 -0
- package/README.md +3 -2
- package/VERSION +1 -1
- package/adopt.sh +40 -5
- package/bin/cli.js +138 -2
- package/kit/AGENT_TEMPLATE.md +15 -0
- package/kit/CLAUDE.md +49 -17
- package/kit/agents/crew-backend-expert.md +4 -3
- package/kit/agents/crew-commit-agent.md +2 -0
- package/kit/agents/crew-database-expert.md +2 -1
- package/kit/agents/crew-devops-expert.md +1 -0
- package/kit/agents/crew-docs-agent.md +3 -1
- package/kit/agents/crew-frontend-expert.md +2 -0
- package/kit/agents/crew-performance-expert.md +7 -2
- package/kit/agents/crew-planner.md +15 -3
- package/kit/agents/crew-privacy-agent.md +2 -0
- package/kit/agents/crew-review-agent.md +8 -1
- package/kit/agents/crew-security-expert.md +4 -3
- package/kit/agents/crew-test-expert.md +6 -2
- package/kit/eval/doctor.sh +201 -1
- package/kit/eval/gate-report.sh +28 -0
- package/kit/eval/golden-holdout.txt +2 -0
- package/kit/eval/golden-routing.txt +17 -0
- package/kit/eval/lib/crew-map/contracts.mjs +77 -0
- package/kit/eval/lib/crew-map/crew-map.sh +2 -1
- package/kit/eval/lib/crew-map/detect.mjs +2 -1
- package/kit/eval/lib/crew-map/narrative.mjs +14 -3
- package/kit/eval/lib/crew-map/project.mjs +45 -6
- package/kit/eval/lib/crew-map/scan.mjs +153 -14
- package/kit/eval/lib/findings/findings.schema.json +11 -1
- package/kit/eval/lib/findings/findings.sh +24 -1
- package/kit/eval/lib/hook-inject.sh +159 -0
- package/kit/eval/lib/lockfiles.sh +71 -0
- package/kit/eval/lib/payload-check.sh +29 -0
- package/kit/eval/lib/preview.sh +3 -3
- package/kit/eval/lib/review-bench/bench.mjs +14 -2
- package/kit/eval/lib/review-prepass/prepass.mjs +32 -3
- package/kit/eval/lib/settings-json.awk +18 -0
- package/kit/eval/lib/untrusted-text.awk +38 -0
- package/kit/eval/scan-skill.sh +67 -20
- package/kit/eval/smoke-test.sh +1700 -40
- package/kit/eval/utilization.sh +77 -3
- package/kit/hooks/agent-outcome.sh +9 -1
- package/kit/hooks/board-sync.sh +49 -3
- package/kit/hooks/board.sh +8 -8
- package/kit/hooks/branch-fresh.sh +101 -0
- package/kit/hooks/floor-blocklist.txt +19 -1
- package/kit/hooks/guard-bash.sh +92 -8
- package/kit/hooks/guard-question.sh +2 -1
- package/kit/hooks/loop-guard.sh +445 -0
- package/kit/hooks/map-stale.sh +437 -0
- package/kit/hooks/pre-commit +48 -7
- package/kit/hooks/precompact-snapshot.sh +102 -6
- package/kit/hooks/prompt-approval.sh +45 -9
- package/kit/hooks/route-hint.sh +36 -7
- package/kit/hooks/secret-blocklist.txt +55 -1
- package/kit/hooks/session-guard.sh +45 -15
- package/kit/hooks/session-rehydrate.sh +34 -11
- package/kit/hooks/session-stats.sh +169 -8
- package/kit/hooks/session-update-check.sh +5 -1
- package/kit/hooks/skill-trust.sh +23 -14
- package/kit/hooks/turn-check.sh +190 -0
- package/kit/owned-blobs.tsv +4 -4
- package/kit/settings.json +64 -0
- package/kit/skills/a11y/SKILL.md +50 -3
- package/kit/skills/a11y/references/recipes.md +41 -0
- package/kit/skills/adr/SKILL.md +14 -2
- package/kit/skills/api-design/SKILL.md +6 -1
- package/kit/skills/api-design/references/idempotency.md +39 -0
- package/kit/skills/brainstorm/SKILL.md +4 -2
- package/kit/skills/brainstorm/references/asking.md +47 -0
- package/kit/skills/ci-pipeline/SKILL.md +11 -1
- package/kit/skills/ci-pipeline/references/agent-job.md +33 -0
- package/kit/skills/commit-message/SKILL.md +9 -3
- package/kit/skills/confidence-check/SKILL.md +9 -7
- package/kit/skills/confidence-check/references/source-check.md +37 -0
- package/kit/skills/crew-approve/SKILL.md +3 -0
- package/kit/skills/crew-audit/SKILL.md +63 -0
- package/kit/skills/crew-board/SKILL.md +2 -2
- package/kit/skills/crew-code-review/SKILL.md +27 -4
- package/kit/skills/crew-code-review/references/panel-mode.md +9 -9
- package/kit/skills/crew-code-review/references/simplify.md +28 -0
- package/kit/skills/crew-doctor/SKILL.md +3 -2
- package/kit/skills/crew-gates/SKILL.md +3 -0
- package/kit/skills/crew-gates/references/enforcers.md +30 -0
- package/kit/skills/crew-interview/SKILL.md +42 -0
- package/kit/skills/crew-map/SKILL.md +26 -6
- package/kit/skills/crew-plan/SKILL.md +9 -2
- package/kit/skills/crew-pr/SKILL.md +57 -0
- package/kit/skills/crew-pr/references/checks.md +35 -0
- package/kit/skills/crew-pr/references/threads.md +23 -0
- package/kit/skills/crew-review/SKILL.md +34 -1
- package/kit/skills/crew-ship/SKILL.md +5 -2
- package/kit/skills/crew-skill/SKILL.md +26 -1
- package/kit/skills/crew-update/SKILL.md +4 -1
- package/kit/skills/db-migration/SKILL.md +15 -0
- package/kit/skills/db-migration/references/expand-contract.md +52 -0
- package/kit/skills/dependency-audit/SKILL.md +34 -1
- package/kit/skills/dependency-audit/scripts/record.sh +37 -0
- package/kit/skills/dependency-upgrade/SKILL.md +29 -1
- package/kit/skills/deploy/SKILL.md +18 -0
- package/kit/skills/deploy/references/topology-a.md +7 -0
- package/kit/skills/deploy/references/topology-b.md +2 -2
- package/kit/skills/docs-writer/SKILL.md +37 -2
- package/kit/skills/eval-grader/SKILL.md +9 -0
- package/kit/skills/eval-grader/references/method.md +26 -0
- package/kit/skills/frontend/SKILL.md +5 -2
- package/kit/skills/frontend/references/from-design.md +29 -0
- package/kit/skills/frontend/references/page-metadata.md +17 -0
- package/kit/skills/frontend/references/verify-contract.md +18 -1
- package/kit/skills/frontend/references/worst-data.md +35 -0
- package/kit/skills/frontend-design/SKILL.md +20 -4
- package/kit/skills/frontend-design/references/design-review.md +21 -0
- package/kit/skills/frontend-design/references/detection.md +44 -0
- package/kit/skills/frontend-design/references/motion.md +54 -0
- package/kit/skills/frontend-design/references/ui-copy.md +35 -0
- package/kit/skills/frontend-rn-expo/SKILL.md +3 -0
- package/kit/skills/handoff/SKILL.md +28 -3
- package/kit/skills/handoff/references/cold-reader.md +13 -6
- package/kit/skills/i18n-integrity/SKILL.md +3 -0
- package/kit/skills/iterate/SKILL.md +7 -2
- package/kit/skills/mcp-builder/SKILL.md +21 -4
- package/kit/skills/mcp-builder/references/building.md +74 -13
- package/kit/skills/mcp-builder/references/cli-for-agents.md +26 -0
- package/kit/skills/observability/SKILL.md +13 -1
- package/kit/skills/performance/SKILL.md +25 -1
- package/kit/skills/performance/references/bottlenecks.md +11 -0
- package/kit/skills/performance/references/web-vitals.md +31 -0
- package/kit/skills/red-team/SKILL.md +25 -3
- package/kit/skills/reflect/SKILL.md +7 -2
- package/kit/skills/release/SKILL.md +13 -0
- package/kit/skills/security-scan/SKILL.md +6 -2
- package/kit/skills/security-scan/references/ai-agents.md +16 -0
- package/kit/skills/security-scan/references/fronts.md +59 -2
- package/kit/skills/security-scan/references/reporting.md +13 -0
- package/kit/skills/security-scan/references/verify.md +9 -0
- package/kit/skills/sonarqube-check/SKILL.md +32 -2
- package/kit/skills/sonarqube-check/references/ratchet.md +32 -0
- package/kit/skills/spec-planning/SKILL.md +25 -8
- package/kit/skills/systematic-debugging/SKILL.md +15 -7
- package/kit/skills/systematic-debugging/references/techniques.md +5 -0
- package/kit/skills/teamboard/SKILL.md +3 -3
- package/kit/skills/testing/SKILL.md +18 -3
- package/kit/skills/testing/references/browser-headless.md +21 -0
- package/kit/skills/token-budget/SKILL.md +26 -5
- package/kit/skills/worktree/SKILL.md +13 -5
- package/kit/studio/server/lib/session.js +23 -2
- package/kit/studio/server/lib/worktree-changes.js +64 -0
- package/kit/studio/web/convo.js +14 -0
- package/kit/studio/web/map-plan.js +54 -8
- package/kit/studio/web/map.js +15 -3
- package/package.json +5 -4
- 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
|
-
|
|
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 (
|
|
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
|
|
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,
|
|
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 === '--
|
|
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
|
|
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
|
-
|
|
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
|