crewforth 3.1.1 → 3.2.0-rc.10

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 (110) hide show
  1. package/CHANGELOG.md +323 -0
  2. package/README.md +6 -3
  3. package/VERSION +1 -1
  4. package/adopt.sh +66 -6
  5. package/bin/cli.js +3 -2
  6. package/kit/AGENT_TEMPLATE.md +2 -1
  7. package/kit/CLAUDE.md +15 -14
  8. package/kit/agents/crew-commit-agent.md +11 -4
  9. package/kit/agents/crew-docs-agent.md +71 -0
  10. package/kit/agents/crew-planner.md +2 -0
  11. package/kit/agents/crew-refuter.md +77 -0
  12. package/kit/agents/crew-review-agent.md +2 -0
  13. package/kit/agents/crew-security-expert.md +3 -1
  14. package/kit/agents/crew-session-manager.md +11 -9
  15. package/kit/eval/doctor.sh +128 -6
  16. package/kit/eval/gate-report.sh +2 -0
  17. package/kit/eval/golden-routing.txt +9 -0
  18. package/kit/eval/lib/crew-map/crew-map.sh +26 -0
  19. package/kit/eval/lib/crew-map/detect.mjs +238 -0
  20. package/kit/eval/lib/crew-map/narrative.mjs +132 -0
  21. package/kit/eval/lib/crew-map/parse.mjs +305 -0
  22. package/kit/eval/lib/crew-map/project.mjs +164 -0
  23. package/kit/eval/lib/crew-map/rules/names.json +38 -0
  24. package/kit/eval/lib/crew-map/rules/packages.json +142 -0
  25. package/kit/eval/lib/crew-map/rules/python-stdlib.json +197 -0
  26. package/kit/eval/lib/crew-map/rules/tech.json +35 -0
  27. package/kit/eval/lib/crew-map/scan.mjs +266 -0
  28. package/kit/eval/lib/crew-map/stacks.mjs +547 -0
  29. package/kit/eval/lib/crew-map/surface.mjs +535 -0
  30. package/kit/eval/lib/findings/findings.mjs +410 -0
  31. package/kit/eval/lib/findings/findings.schema.json +122 -0
  32. package/kit/eval/lib/findings/findings.sh +29 -0
  33. package/kit/eval/lib/gate-log.sh +52 -0
  34. package/kit/eval/lib/json-string.awk +27 -0
  35. package/kit/eval/lib/preset.sh +109 -0
  36. package/kit/eval/lib/presets/default.json +7 -0
  37. package/kit/eval/lib/presets/solo.json +7 -0
  38. package/kit/eval/lib/presets/strict.json +11 -0
  39. package/kit/eval/lib/preview.sh +150 -0
  40. package/kit/eval/lib/review-bench/bench.mjs +273 -0
  41. package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
  42. package/kit/eval/lib/review-prepass/prepass.sh +23 -0
  43. package/kit/eval/lib/settings-default.sh +36 -0
  44. package/kit/eval/lib/settings-json.awk +19 -4
  45. package/kit/eval/lib/star.sh +23 -7
  46. package/kit/eval/lib/statusline.settings.json +6 -0
  47. package/kit/eval/lib/statusline.sh +100 -0
  48. package/kit/eval/lib/verify-cmd.sh +17 -0
  49. package/kit/eval/lib/version.sh +41 -0
  50. package/kit/eval/review-bench.sh +26 -0
  51. package/kit/eval/smoke-test.sh +878 -53
  52. package/kit/hooks/agent-outcome.sh +44 -12
  53. package/kit/hooks/board-sync.sh +2 -23
  54. package/kit/hooks/guard-agent-model.sh +177 -24
  55. package/kit/hooks/guard-bash.sh +295 -204
  56. package/kit/hooks/guard-commit-scan.sh +10 -2
  57. package/kit/hooks/guard-question.sh +73 -0
  58. package/kit/hooks/guard-write.sh +24 -7
  59. package/kit/hooks/precompact-snapshot.sh +75 -0
  60. package/kit/hooks/prompt-approval.sh +389 -103
  61. package/kit/hooks/refuter-ledger.sh +80 -0
  62. package/kit/hooks/session-rehydrate.sh +25 -2
  63. package/kit/hooks/session-update-check.sh +9 -8
  64. package/kit/hooks/trace-blocklist.txt +9 -0
  65. package/kit/owned-blobs.tsv +4 -4
  66. package/kit/settings.json +51 -0
  67. package/kit/skills/crew-approve/SKILL.md +7 -0
  68. package/kit/skills/crew-code-review/SKILL.md +16 -0
  69. package/kit/skills/crew-doctor/SKILL.md +5 -1
  70. package/kit/skills/crew-handoff/SKILL.md +3 -2
  71. package/kit/skills/crew-map/SKILL.md +55 -0
  72. package/kit/skills/crew-review/SKILL.md +4 -1
  73. package/kit/skills/crew-ship/SKILL.md +3 -1
  74. package/kit/skills/crew-update/SKILL.md +1 -1
  75. package/kit/skills/incident-runbook/SKILL.md +2 -2
  76. package/kit/skills/security-scan/references/verify.md +6 -3
  77. package/kit/studio/README.md +25 -2
  78. package/kit/studio/server/index.js +187 -21
  79. package/kit/studio/server/lib/code-stamp.js +35 -0
  80. package/kit/studio/server/lib/crew-map.js +113 -0
  81. package/kit/studio/server/lib/graph.js +9 -1
  82. package/kit/studio/server/lib/kit-telemetry.js +30 -5
  83. package/kit/studio/server/lib/lan.js +87 -0
  84. package/kit/studio/server/lib/model-outcomes.js +130 -15
  85. package/kit/studio/server/lib/owned-stream.js +56 -0
  86. package/kit/studio/server/lib/pricing.js +3 -0
  87. package/kit/studio/server/lib/projects.js +11 -4
  88. package/kit/studio/server/lib/redact.js +60 -0
  89. package/kit/studio/server/lib/remote.js +185 -0
  90. package/kit/studio/server/lib/session.js +1 -1
  91. package/kit/studio/server/lib/terminal.js +51 -13
  92. package/kit/studio/server/lib/transcript.js +32 -0
  93. package/kit/studio/server/lib/usage.js +74 -14
  94. package/kit/studio/web/app.js +287 -17
  95. package/kit/studio/web/canvas.js +58 -122
  96. package/kit/studio/web/chat.js +114 -24
  97. package/kit/studio/web/graph-plan.js +13 -6
  98. package/kit/studio/web/index.html +10 -5
  99. package/kit/studio/web/liveness.js +14 -0
  100. package/kit/studio/web/map-plan.js +410 -0
  101. package/kit/studio/web/map.js +596 -0
  102. package/kit/studio/web/models-plan.js +61 -7
  103. package/kit/studio/web/models.js +19 -3
  104. package/kit/studio/web/qr.js +200 -0
  105. package/kit/studio/web/style.css +165 -0
  106. package/kit/studio/web/tokens.css +6 -0
  107. package/kit/studio/web/usage.js +12 -0
  108. package/kit/studio/web/viewport.js +160 -0
  109. package/package.json +6 -5
  110. package/start.sh +24 -1
@@ -0,0 +1,410 @@
1
+ #!/usr/bin/env node
2
+ // findings.mjs — the evidence-backed findings core: one document shape, and a validator that holds every
3
+ // finding to the lines it cites.
4
+ //
5
+ // node findings.mjs validate [--root DIR] [--verified] [--json] FILE|- rc 0 = every entry verified · 1 = at least one problem
6
+ // node findings.mjs stamp [--root DIR] [--verified] [--redact] FILE|- the document on stdout with a sha256 on each verified entry
7
+ // node findings.mjs write [--root DIR] [--verified] NAME FILE|- validate, then write DIR/.crewforth/NAME.json
8
+ // node findings.mjs schema the schema, as shipped
9
+ // rc 2 = usage, or a document that could not be read as a JSON object. Nothing is written on a non-zero rc.
10
+ //
11
+ // Host-independent on purpose: plain JSON in, plain JSON and an exit code out, Node's standard library only. It
12
+ // reads no hook payload, no settings file and no environment variable, so the same file serves a scanner, a CI
13
+ // job and an assistant; whatever is specific to one host belongs in the caller.
14
+ //
15
+ // The shape lives in findings.schema.json beside this file and is READ from there, so there is one definition.
16
+ // What a schema cannot say is checked here, against the project at --root (default: the working directory):
17
+ //
18
+ // path the cited file is a relative, forward-slash path with no '.' or '..' segment, spelled exactly as
19
+ // the directory lists it (a case-insensitive disk would otherwise verify a path that another
20
+ // machine cannot open), and it resolves inside the project after symlinks
21
+ // missing the file is there and is a regular file
22
+ // range line..endLine exist in it
23
+ // stale the anchor still matches: sha256 of the cited lines, and/or the quoted text is in them
24
+ // machine-path no string in the document carries this machine's project or home directory
25
+ // verification a finding's verdict is the one its verifiers' verdicts give, its verifiers are distinct agents on
26
+ // distinct lenses, and only a TRUE_POSITIVE keeps a severity
27
+ // --verified every finding of a severity in the schema's x-verifiers table has that many verifiers, and every
28
+ // verifier is a line of the refuter ledger, .claude/state/crew-refuter.tsv under --root, which only
29
+ // Crewforth's SubagentStop hook writes. The path is fixed on purpose: a ledger the caller could name
30
+ // is one the caller could write.
31
+ //
32
+ // A document is either standalone ("schema": "crewforth.findings/1", held to the whole schema) or a host
33
+ // document with its own "schema": "crewforth.<name>/<n>" and a "findings" array in the same shape. In both,
34
+ // EVERY "evidence" array is verified wherever it sits, so a host document can put evidence on its own objects
35
+ // (an edge, a module) and have it held to the same standard.
36
+ //
37
+ // Not checked: that the cited lines actually support the finding's claim. That is a judgement; this proves only
38
+ // that the lines exist and are the ones the producer read.
39
+
40
+ import fs from 'node:fs';
41
+ import os from 'node:os';
42
+ import path from 'node:path';
43
+ import crypto from 'node:crypto';
44
+ import { fileURLToPath } from 'node:url';
45
+
46
+ const SELF = fileURLToPath(import.meta.url);
47
+ export const SCHEMA_ID = 'crewforth.findings/1';
48
+ export const OUT_DIR = '.crewforth';
49
+ const HOST_SCHEMA = /^crewforth\.[a-z][a-z0-9-]*\/[1-9][0-9]*$/;
50
+ const OUT_NAME = /^[a-z0-9][a-z0-9-]*$/;
51
+ const MAX_FILE_BYTES = 32 * 1024 * 1024;
52
+
53
+ export function loadSchema() {
54
+ return JSON.parse(fs.readFileSync(path.join(path.dirname(SELF), 'findings.schema.json'), 'utf8'));
55
+ }
56
+
57
+ // ---- shape: the subset of JSON Schema that findings.schema.json uses --------------------------------------
58
+ // $ref into $defs, type, const, enum, pattern, min/maxLength, minimum, minItems, items, required, properties,
59
+ // additionalProperties:false, anyOf. A keyword outside this list in the schema is a bug the selfcheck pins.
60
+ export const SHAPE_KEYWORDS = ['$ref', 'type', 'const', 'enum', 'pattern', 'minLength', 'maxLength', 'minimum',
61
+ 'minItems', 'items', 'required', 'properties', 'additionalProperties', 'anyOf'];
62
+
63
+ const kind = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array'
64
+ : Number.isInteger(v) ? 'integer' : typeof v);
65
+ const show = (v) => { const s = JSON.stringify(v); return s === undefined ? String(v) : s.length > 80 ? s.slice(0, 77) + '...' : s; };
66
+
67
+ function shape(v, s, at, schema, out) {
68
+ if (s.$ref) s = s.$ref.replace(/^#\//, '').split('/').reduce((o, k) => o[k], schema);
69
+ const bad = (message) => out.push({ at, code: 'shape', message });
70
+ if ('const' in s && v !== s.const) return bad(`must be ${show(s.const)}, is ${show(v)}`);
71
+ if (s.enum && !s.enum.includes(v)) return bad(`must be one of ${s.enum.join(' | ')}, is ${show(v)}`);
72
+ if (s.type && kind(v) !== s.type) return bad(`must be of type ${s.type}, is ${show(v)}`);
73
+ if (typeof v === 'string') {
74
+ if (s.minLength !== undefined && v.length < s.minLength) bad(`is shorter than ${s.minLength}`);
75
+ if (s.maxLength !== undefined && v.length > s.maxLength) bad(`is longer than ${s.maxLength} (${v.length})`);
76
+ if (s.pattern && !new RegExp(s.pattern).test(v)) bad(`must match ${s.pattern}, is ${show(v)}`);
77
+ }
78
+ if (typeof v === 'number' && s.minimum !== undefined && v < s.minimum) bad(`must be at least ${s.minimum}, is ${v}`);
79
+ if (Array.isArray(v)) {
80
+ if (s.minItems !== undefined && v.length < s.minItems) bad(`needs at least ${s.minItems} entr${s.minItems === 1 ? 'y' : 'ies'}, has ${v.length}`);
81
+ if (s.items) v.forEach((x, i) => shape(x, s.items, `${at}[${i}]`, schema, out));
82
+ }
83
+ if (kind(v) === 'object') {
84
+ for (const k of s.required || []) if (!(k in v)) bad(`"${k}" is required`);
85
+ for (const k of Object.keys(v)) {
86
+ if (s.properties && k in s.properties) shape(v[k], s.properties[k], at ? `${at}.${k}` : k, schema, out);
87
+ else if (s.additionalProperties === false) bad(`"${k}" is not a field of this object`);
88
+ }
89
+ if (s.anyOf && !s.anyOf.some((alt) => { const o = []; shape(v, alt, at, schema, o); return o.length === 0; })) {
90
+ bad(`needs one of: ${s.anyOf.map((alt) => (alt.required || []).join('+')).join(' | ')}`);
91
+ }
92
+ }
93
+ }
94
+
95
+ // ---- evidence: a cited range, held against the project ----------------------------------------------------
96
+ const squash = (t) => t.replace(/\s+/g, ' ').trim();
97
+
98
+ function unsafePath(file) {
99
+ if (file.includes('\0')) return 'contains a NUL';
100
+ if (file.includes('\\')) return 'uses a backslash; write forward slashes';
101
+ if (file.startsWith('/') || /^[A-Za-z]:/.test(file)) return 'is absolute; write it relative to the project root';
102
+ for (const seg of file.split('/')) {
103
+ if (seg === '' || seg === '.' || seg === '..') return `has ${seg === '' ? 'an empty' : `a "${seg}"`} segment`;
104
+ }
105
+ return '';
106
+ }
107
+
108
+ // One project, opened once: directory listings and file lines are cached for the run.
109
+ export function openProject(root) {
110
+ const real = fs.realpathSync(root);
111
+ const dirs = new Map();
112
+ const files = new Map();
113
+ const list = (dir) => {
114
+ if (!dirs.has(dir)) { let names = null; try { names = new Set(fs.readdirSync(dir)); } catch { /* not a directory */ } dirs.set(dir, names); }
115
+ return dirs.get(dir);
116
+ };
117
+ // { lines: Buffer[] } or { code, message }
118
+ const read = (file) => {
119
+ if (files.has(file)) return files.get(file);
120
+ const done = (r) => { files.set(file, r); return r; };
121
+ const why = unsafePath(file);
122
+ if (why) return done({ code: 'path', message: `${show(file)} ${why}` });
123
+ let abs = real;
124
+ for (const seg of file.split('/')) {
125
+ const names = list(abs);
126
+ if (!names || !names.has(seg)) return done({ code: 'missing', message: `${file}: no "${seg}" with that exact spelling in the project` });
127
+ abs = path.join(abs, seg);
128
+ }
129
+ let target;
130
+ try { target = fs.realpathSync(abs); } catch { return done({ code: 'missing', message: `${file}: a link that leads nowhere` }); }
131
+ if (target !== real && !target.startsWith(real + path.sep)) return done({ code: 'path', message: `${file} resolves outside the project` });
132
+ const st = fs.statSync(target);
133
+ if (!st.isFile()) return done({ code: 'missing', message: `${file} is not a regular file` });
134
+ if (st.size > MAX_FILE_BYTES) return done({ code: 'unreadable', message: `${file} is ${st.size} bytes; the limit is ${MAX_FILE_BYTES}` });
135
+ const buf = fs.readFileSync(target);
136
+ const lines = [];
137
+ for (let from = 0; from < buf.length;) {
138
+ let nl = buf.indexOf(0x0a, from);
139
+ if (nl === -1) nl = buf.length;
140
+ let to = nl;
141
+ if (to > from && buf[to - 1] === 0x0d) to--;
142
+ lines.push(buf.subarray(from, to));
143
+ from = nl + 1;
144
+ }
145
+ return done({ lines });
146
+ };
147
+ return { root: real, read };
148
+ }
149
+
150
+ // The anchor's bytes: the cited lines joined by LF, CR at line ends already removed, no trailing newline.
151
+ function rangeOf(project, ev) {
152
+ const f = project.read(ev.file);
153
+ if (!f.lines) return f;
154
+ const end = ev.endLine === undefined ? ev.line : ev.endLine;
155
+ if (end < ev.line) return { code: 'range', message: `${ev.file}: endLine ${end} is before line ${ev.line}` };
156
+ if (end > f.lines.length) return { code: 'range', message: `${ev.file}:${ev.line}${end > ev.line ? '-' + end : ''} is past the end; the file has ${f.lines.length} line(s)` };
157
+ const parts = [];
158
+ for (let i = ev.line; i <= end; i++) { if (i > ev.line) parts.push(Buffer.from('\n')); parts.push(f.lines[i - 1]); }
159
+ const bytes = Buffer.concat(parts);
160
+ return { bytes, sha256: crypto.createHash('sha256').update(bytes).digest('hex'), where: `${ev.file}:${ev.line}${end > ev.line ? '-' + end : ''}` };
161
+ }
162
+
163
+ // null when the entry holds; otherwise { code, message }. `ev` has already passed the shape check.
164
+ export function checkEvidence(project, ev) {
165
+ const r = rangeOf(project, ev);
166
+ if (!r.bytes) return r;
167
+ if (ev.sha256 !== undefined && ev.sha256 !== r.sha256) {
168
+ return { code: 'stale', message: `${r.where} sha256 is ${r.sha256}, the document says ${ev.sha256}` };
169
+ }
170
+ if (ev.text !== undefined) {
171
+ const want = squash(ev.text);
172
+ if (want === '') return { code: 'shape', message: `${r.where} "text" is only whitespace` };
173
+ if (!squash(r.bytes.toString('utf8')).includes(want)) return { code: 'stale', message: `${r.where} does not contain the quoted text ${show(want)}` };
174
+ }
175
+ return null;
176
+ }
177
+
178
+ // Every "evidence" member in the document, with its JSON path. The key is the contract: a host document that
179
+ // puts evidence anywhere gets it verified, and one that names the member differently gets nothing — which the
180
+ // counts in the report make visible.
181
+ function evidenceArrays(v, at, out) {
182
+ if (Array.isArray(v)) v.forEach((x, i) => evidenceArrays(x, `${at}[${i}]`, out));
183
+ else if (v && typeof v === 'object') {
184
+ for (const k of Object.keys(v)) {
185
+ const here = at ? `${at}.${k}` : k;
186
+ if (k === 'evidence') out.push({ at: here, value: v[k] });
187
+ else evidenceArrays(v[k], here, out);
188
+ }
189
+ }
190
+ return out;
191
+ }
192
+
193
+ function strings(v, at, out) {
194
+ if (typeof v === 'string') out.push({ at, value: v });
195
+ else if (Array.isArray(v)) v.forEach((x, i) => strings(x, `${at}[${i}]`, out));
196
+ else if (v && typeof v === 'object') for (const k of Object.keys(v)) strings(v[k], at ? `${at}.${k}` : k, out);
197
+ return out;
198
+ }
199
+
200
+ // The spellings of this machine's project and home directory that must not travel in a document. A root or a
201
+ // home of three characters or fewer ("/", "C:\") is skipped: it would match every path there is.
202
+ function machinePaths(rootGiven, rootReal) {
203
+ const set = new Set();
204
+ for (const p of [path.resolve(rootGiven), rootReal, os.homedir()]) {
205
+ if (!p || p.length <= 3) continue;
206
+ set.add(p); set.add(p.split(path.sep).join('/'));
207
+ }
208
+ return [...set];
209
+ }
210
+
211
+ // { ok, schema, counts: { findings, evidence, verified }, problems: [{ at, code, message }], held: [entry…] }
212
+ export function validate(doc, root = '.', opts = {}) {
213
+ const problems = [];
214
+ const counts = { findings: 0, evidence: 0, verified: 0 };
215
+ const held = [];
216
+ const report = () => ({ ok: problems.length === 0, schema: doc && doc.schema, counts, problems, held });
217
+ if (kind(doc) !== 'object') { problems.push({ at: '', code: 'shape', message: `the document must be a JSON object, is ${show(doc)}` }); return report(); }
218
+ const schema = loadSchema();
219
+ const FINDING = { $ref: '#/$defs/finding' }; const EVIDENCE = { $ref: '#/$defs/evidence' };
220
+ if (doc.schema === SCHEMA_ID) shape(doc, schema, '', schema, problems);
221
+ else {
222
+ if (typeof doc.schema !== 'string' || !HOST_SCHEMA.test(doc.schema)) problems.push({ at: 'schema', code: 'shape', message: `must be "${SCHEMA_ID}" or a host document's "crewforth.<name>/<n>", is ${show(doc.schema)}` });
223
+ if (!Array.isArray(doc.findings)) problems.push({ at: 'findings', code: 'shape', message: `must be an array, is ${show(doc.findings)}` });
224
+ else doc.findings.forEach((f, i) => shape(f, FINDING, `findings[${i}]`, schema, problems));
225
+ }
226
+ if (Array.isArray(doc.findings)) {
227
+ counts.findings = doc.findings.length;
228
+ const seen = new Map();
229
+ doc.findings.forEach((f, i) => {
230
+ if (!f || typeof f.id !== 'string') return;
231
+ if (seen.has(f.id)) problems.push({ at: `findings[${i}].id`, code: 'duplicate-id', message: `${show(f.id)} is also findings[${seen.get(f.id)}]` });
232
+ else seen.set(f.id, i);
233
+ });
234
+ }
235
+ const project = openProject(root);
236
+ for (const m of machinePaths(root, project.root)) {
237
+ for (const s of strings(doc, '', [])) {
238
+ if (s.value.includes(m)) problems.push({ at: s.at, code: 'machine-path', message: `carries this machine's directory ${show(m)}` });
239
+ }
240
+ }
241
+ const inFinding = /^findings\[\d+\]\.evidence$/;
242
+ for (const arr of evidenceArrays(doc, '', [])) {
243
+ if (!Array.isArray(arr.value)) { problems.push({ at: arr.at, code: 'shape', message: `must be an array, is ${show(arr.value)}` }); continue; }
244
+ arr.value.forEach((ev, i) => {
245
+ const at = `${arr.at}[${i}]`;
246
+ counts.evidence++;
247
+ // A finding's evidence was already shaped, and reported, with the finding; evidence anywhere else is
248
+ // reported here. Either way an entry that is not well-formed is not taken to the project.
249
+ const o = []; shape(ev, EVIDENCE, at, schema, o);
250
+ if (o.length) { if (!inFinding.test(arr.at)) problems.push(...o); return; }
251
+ const p = checkEvidence(project, ev);
252
+ if (p) problems.push({ at, ...p }); else { counts.verified++; held.push(ev); }
253
+ });
254
+ }
255
+ if (Array.isArray(doc.findings)) verification(doc.findings, schema, project, opts, problems);
256
+ return report();
257
+ }
258
+
259
+ // ---- verification: what refuters concluded, and the ledger it must come from -------------------------------
260
+ export const LEDGER = path.join('.claude', 'state', 'crew-refuter.tsv');
261
+ // The verdict a set of verifiers gives: one that shows the claim wrong outweighs the rest, an open question
262
+ // outweighs agreement. The producer writes it down; it does not get to choose it.
263
+ export function verdictOf(verdicts) {
264
+ if (verdicts.includes('FALSE_POSITIVE')) return 'FALSE_POSITIVE';
265
+ if (verdicts.includes('CANNOT_VERIFY')) return 'CANNOT_VERIFY';
266
+ return 'TRUE_POSITIVE';
267
+ }
268
+ // { rows: [{ agent, finding, lens, verdict }] } or { error }
269
+ export function readLedger(file) {
270
+ let text;
271
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return { error: `no refuter ledger at ${LEDGER.split(path.sep).join('/')}` }; }
272
+ const rows = [];
273
+ for (const line of text.split('\n')) {
274
+ const c = line.replace(/\r$/, '').split('\t');
275
+ if (c.length < 6 || c[0] === 'ts') continue;
276
+ rows.push({ agent: c[2], finding: c[3], lens: c[4], verdict: c[5] });
277
+ }
278
+ return { rows };
279
+ }
280
+ function verification(findings, schema, project, opts, problems) {
281
+ const table = opts.verified ? (schema['x-verifiers'] || {}) : {};
282
+ let ledger = null;
283
+ findings.forEach((f, i) => {
284
+ if (!f || typeof f !== 'object') return;
285
+ const at = `findings[${i}]`;
286
+ const v = f.verification;
287
+ const ok = v && typeof v === 'object' && Array.isArray(v.verifiers) && v.verifiers.every((x) => x && typeof x === 'object');
288
+ if (ok) {
289
+ const want = verdictOf(v.verifiers.map((x) => x.verdict));
290
+ if (v.verdict !== want) problems.push({ at: `${at}.verification.verdict`, code: 'verification', message: `is ${show(v.verdict)}; its verifiers give ${want}` });
291
+ const agents = v.verifiers.map((x) => x.agent); const lenses = v.verifiers.map((x) => x.lens);
292
+ if (new Set(agents).size !== agents.length) problems.push({ at: `${at}.verification.verifiers`, code: 'verification', message: 'names one agent twice: one context counted as two verifiers' });
293
+ if (new Set(lenses).size !== lenses.length) problems.push({ at: `${at}.verification.verifiers`, code: 'verification', message: 'repeats a lens: the same method twice is repetition, not confirmation' });
294
+ if (f.severity !== undefined && v.verdict !== 'TRUE_POSITIVE') problems.push({ at: `${at}.severity`, code: 'verification', message: `a finding judged ${v.verdict} carries no severity` });
295
+ }
296
+ if (!opts.verified) return;
297
+ const need = typeof f.severity === 'string' ? table[f.severity] : undefined;
298
+ if (typeof need === 'number' && need > 0) {
299
+ if (!ok) { problems.push({ at, code: 'unverified', message: `a ${f.severity} finding needs ${need} verifier(s) from the refuter ledger, and has none` }); return; }
300
+ if (v.verifiers.length < need) problems.push({ at: `${at}.verification.verifiers`, code: 'unverified', message: `a ${f.severity} finding needs ${need} verifier(s), has ${v.verifiers.length}` });
301
+ }
302
+ if (!ok) return;
303
+ if (ledger === null) ledger = readLedger(opts.ledger || path.join(project.root, LEDGER));
304
+ if (ledger.error) { problems.push({ at: `${at}.verification`, code: 'ledger', message: ledger.error }); return; }
305
+ v.verifiers.forEach((x, k) => {
306
+ const hit = ledger.rows.some((r) => r.agent === x.agent && r.finding === f.id && r.lens === x.lens && r.verdict === x.verdict);
307
+ if (!hit) problems.push({ at: `${at}.verification.verifiers[${k}]`, code: 'ledger', message: `no ledger line says agent ${show(x.agent)} judged ${show(f.id)} on ${show(x.lens)} as ${show(x.verdict)}` });
308
+ });
309
+ });
310
+ }
311
+
312
+ // The document with the cited lines' sha256 written onto every entry that verifies. An entry that does not
313
+ // verify is never given a hash: stamping would turn an unchecked claim into one that looks checked.
314
+ // `redact` then drops "text" from those entries, for lines that should not be quoted (a secret, personal data).
315
+ export function stamp(doc, root = '.', { redact = false, ...opts } = {}) {
316
+ const r = validate(doc, root, opts);
317
+ const project = openProject(root);
318
+ for (const ev of r.held) {
319
+ ev.sha256 = rangeOf(project, ev).sha256;
320
+ if (redact) delete ev.text;
321
+ }
322
+ return r;
323
+ }
324
+
325
+ // The bytes a document is stored as: findings in id order, two-space indent, LF, one trailing newline. Members
326
+ // keep the order the producer gave them; a producer that wants a byte-stable file emits them in a fixed order.
327
+ export function serialize(doc) {
328
+ const out = { ...doc };
329
+ if (Array.isArray(out.findings)) out.findings = [...out.findings].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
330
+ return JSON.stringify(out, null, 2) + '\n';
331
+ }
332
+
333
+ // Validate, then write <root>/.crewforth/<name>.json. An unverified document is never written. Returns the
334
+ // validation report with `file` (relative to the root) when it was.
335
+ export function write(doc, root, name, opts = {}) {
336
+ if (!OUT_NAME.test(name)) throw new Error(`the name must match ${OUT_NAME}, is ${show(name)}`);
337
+ const r = validate(doc, root, opts);
338
+ if (!r.ok) return r;
339
+ const real = fs.realpathSync(root);
340
+ const dir = path.join(real, OUT_DIR);
341
+ fs.mkdirSync(dir, { recursive: true });
342
+ if (!fs.lstatSync(dir).isDirectory()) throw new Error(`${OUT_DIR} is not a directory of this project`);
343
+ const tmp = path.join(dir, `.${name}.${process.pid}.tmp`);
344
+ fs.writeFileSync(tmp, serialize(doc));
345
+ fs.renameSync(tmp, path.join(dir, `${name}.json`));
346
+ return { ...r, file: `${OUT_DIR}/${name}.json` };
347
+ }
348
+
349
+ // ---- command line -----------------------------------------------------------------------------------------
350
+ function usage(msg) {
351
+ if (msg) console.error(`findings: ${msg}`);
352
+ console.error('usage: findings.mjs validate [--root DIR] [--verified] [--json] FILE|-\n'
353
+ + ' findings.mjs stamp [--root DIR] [--verified] [--redact] FILE|-\n'
354
+ + ' findings.mjs write [--root DIR] [--verified] NAME FILE|-\n'
355
+ + ' findings.mjs schema');
356
+ return 2;
357
+ }
358
+
359
+ function summary(r, log) {
360
+ for (const p of r.problems) log(` FAIL ${p.at || '(document)'}: ${p.code} — ${p.message}`);
361
+ log(`findings: ${r.counts.findings} finding(s) · ${r.counts.evidence} evidence entr${r.counts.evidence === 1 ? 'y' : 'ies'} checked · ${r.counts.verified} verified · ${r.problems.length} problem(s)`);
362
+ }
363
+
364
+ export function main(argv) {
365
+ const [op, ...rest] = argv;
366
+ if (op === 'schema' && rest.length === 0) { process.stdout.write(JSON.stringify(loadSchema(), null, 2) + '\n'); return 0; }
367
+ if (!['validate', 'stamp', 'write'].includes(op)) return usage(op ? `no such operation: ${op}` : '');
368
+ let root = '.'; let json = false; let redact = false; let verified = false; const pos = [];
369
+ for (let i = 0; i < rest.length; i++) {
370
+ const a = rest[i];
371
+ if (a === '--root') { root = rest[++i]; if (root === undefined) return usage('--root needs a directory'); }
372
+ else if (a === '--json' && op === 'validate') json = true;
373
+ else if (a === '--redact' && op === 'stamp') redact = true;
374
+ else if (a === '--verified') verified = true;
375
+ else if (a !== '-' && a.startsWith('-')) return usage(`${op} has no option ${a}`);
376
+ else pos.push(a);
377
+ }
378
+ if (pos.length !== (op === 'write' ? 2 : 1)) return usage(`${op} takes ${op === 'write' ? 'a name and a document' : 'one document'}`);
379
+ const src = pos[pos.length - 1];
380
+ let doc;
381
+ try {
382
+ if (!fs.statSync(root).isDirectory()) throw new Error('not a directory');
383
+ } catch (e) { console.error(`findings: --root ${root}: ${e.message}`); return 2; }
384
+ try { doc = JSON.parse(fs.readFileSync(src === '-' ? 0 : src, 'utf8')); }
385
+ catch (e) { console.error(`findings: ${src === '-' ? 'stdin' : src} could not be read as JSON: ${e.message}`); return 2; }
386
+
387
+ if (op === 'validate') {
388
+ const r = validate(doc, root, { verified });
389
+ if (json) { const { held, ...pub } = r; process.stdout.write(JSON.stringify(pub, null, 2) + '\n'); }
390
+ else summary(r, (l) => console.log(l));
391
+ return r.ok ? 0 : 1;
392
+ }
393
+ if (op === 'stamp') {
394
+ const r = stamp(doc, root, { redact, verified });
395
+ summary(r, (l) => console.error(l));
396
+ if (!r.ok) return 1;
397
+ process.stdout.write(serialize(doc));
398
+ return 0;
399
+ }
400
+ let r;
401
+ try { r = write(doc, root, pos[0], { verified }); } catch (e) { return usage(e.message); }
402
+ summary(r, (l) => console.error(l));
403
+ if (!r.ok) { console.error('findings: nothing was written'); return 1; }
404
+ console.log(r.file);
405
+ return 0;
406
+ }
407
+
408
+ let direct = false;
409
+ try { direct = !!process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(SELF); } catch { /* imported */ }
410
+ if (direct) process.exitCode = main(process.argv.slice(2));
@@ -0,0 +1,122 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "crewforth.findings/1",
4
+ "title": "Crewforth findings document",
5
+ "description": "A list of findings, each one tied to the lines of the project it was read from. This file is the one definition of the shape: findings.mjs reads it at run time, so a field cannot be accepted by the validator and absent here, or the reverse. Whether an evidence entry is TRUE (the file is there, the lines are there, the anchor still matches) is not expressible in JSON Schema; findings.mjs checks that against the project and its header says how.",
6
+ "type": "object",
7
+ "required": ["schema", "findings"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "schema": { "const": "crewforth.findings/1" },
11
+ "producer": {
12
+ "type": "object",
13
+ "required": ["name"],
14
+ "additionalProperties": false,
15
+ "properties": {
16
+ "name": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" },
17
+ "version": { "type": "string", "minLength": 1, "maxLength": 64 }
18
+ }
19
+ },
20
+ "source": {
21
+ "type": "object",
22
+ "additionalProperties": false,
23
+ "properties": {
24
+ "commit": { "type": "string", "pattern": "^[0-9a-f]{7,64}$" }
25
+ }
26
+ },
27
+ "findings": { "type": "array", "items": { "$ref": "#/$defs/finding" } },
28
+ "notMeasured": { "type": "array", "items": { "$ref": "#/$defs/notMeasured" } }
29
+ },
30
+ "x-verifiers": {
31
+ "description": "Under --verified: a finding of one of these severities needs this many verifiers, each its own agent on its own lens, and keeps the severity only when the verdict is TRUE_POSITIVE. A severity not listed needs none.",
32
+ "medium": 1, "high": 3, "critical": 3
33
+ },
34
+ "$defs": {
35
+ "finding": {
36
+ "type": "object",
37
+ "required": ["id", "rule", "title", "confidence", "evidence"],
38
+ "additionalProperties": false,
39
+ "properties": {
40
+ "id": {
41
+ "description": "Stable across runs and unique in the document.",
42
+ "type": "string",
43
+ "pattern": "^[a-z0-9][a-z0-9._:-]*$",
44
+ "maxLength": 200
45
+ },
46
+ "rule": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$", "maxLength": 80 },
47
+ "title": { "type": "string", "minLength": 1, "maxLength": 200 },
48
+ "confidence": {
49
+ "description": "proven: the cited lines show it on their own. inferred: the cited lines are what the conclusion was drawn from.",
50
+ "enum": ["proven", "inferred"]
51
+ },
52
+ "severity": { "enum": ["info", "low", "medium", "high", "critical"] },
53
+ "detail": { "type": "string", "maxLength": 4000 },
54
+ "evidence": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/evidence" } },
55
+ "data": {
56
+ "description": "The producer's own fields. Not interpreted here.",
57
+ "type": "object"
58
+ },
59
+ "verification": { "$ref": "#/$defs/verification" }
60
+ }
61
+ },
62
+ "verification": {
63
+ "description": "What independent refuters concluded. verdict is not chosen by the producer: it is FALSE_POSITIVE when any verifier said so, else CANNOT_VERIFY when any did, else TRUE_POSITIVE, and the validator checks it. Under --verified every verifier must be a line of the refuter ledger.",
64
+ "type": "object",
65
+ "required": ["verdict", "verifiers"],
66
+ "additionalProperties": false,
67
+ "properties": {
68
+ "verdict": { "$ref": "#/$defs/verdict" },
69
+ "verifiers": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/verifier" } }
70
+ }
71
+ },
72
+ "verifier": {
73
+ "type": "object",
74
+ "required": ["agent", "lens", "verdict"],
75
+ "additionalProperties": false,
76
+ "properties": {
77
+ "agent": { "description": "The refuter agent's id, as the ledger records it.", "type": "string", "pattern": "^[A-Za-z0-9._-]{1,128}$" },
78
+ "lens": { "enum": ["reachability", "protection", "reproduction", "exclusion", "blast-radius", "claim"] },
79
+ "verdict": { "$ref": "#/$defs/verdict" }
80
+ }
81
+ },
82
+ "verdict": { "enum": ["TRUE_POSITIVE", "FALSE_POSITIVE", "CANNOT_VERIFY"] },
83
+ "evidence": {
84
+ "description": "One cited range of one file. An entry needs an anchor, text or sha256, because a line number alone only says the file is that long.",
85
+ "type": "object",
86
+ "required": ["file", "line"],
87
+ "additionalProperties": false,
88
+ "anyOf": [{ "required": ["text"] }, { "required": ["sha256"] }],
89
+ "properties": {
90
+ "file": {
91
+ "description": "Relative to the project root, forward slashes, no '.' or '..' segment.",
92
+ "type": "string",
93
+ "minLength": 1,
94
+ "maxLength": 1024
95
+ },
96
+ "line": { "type": "integer", "minimum": 1 },
97
+ "endLine": { "type": "integer", "minimum": 1 },
98
+ "text": {
99
+ "description": "A quote from the cited lines. Compared with runs of whitespace collapsed.",
100
+ "type": "string",
101
+ "minLength": 1,
102
+ "maxLength": 2000
103
+ },
104
+ "sha256": {
105
+ "description": "SHA-256 of the cited lines joined by LF, CR at line ends removed, no trailing newline.",
106
+ "type": "string",
107
+ "pattern": "^[0-9a-f]{64}$"
108
+ },
109
+ "note": { "type": "string", "maxLength": 400 }
110
+ }
111
+ },
112
+ "notMeasured": {
113
+ "type": "object",
114
+ "required": ["what", "reason"],
115
+ "additionalProperties": false,
116
+ "properties": {
117
+ "what": { "type": "string", "minLength": 1, "maxLength": 400 },
118
+ "reason": { "type": "string", "minLength": 1, "maxLength": 400 }
119
+ }
120
+ }
121
+ }
122
+ }
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env bash
2
+ # findings.sh — the shell entry to findings.mjs. It does one thing: find a Node that RUNS and hand over.
3
+ #
4
+ # bash findings.sh validate|stamp|write|schema … the arguments and exit codes of findings.mjs (0, 1, 2)
5
+ # rc 3 = no usable Node here, so nothing was checked. Not a pass: a caller must not read 3 as verified.
6
+ #
7
+ # A name that resolves is not a runtime that works: the Windows Store's stub satisfies `command -v` and then
8
+ # exits 49. So node is asked for its version and the answer is read. When PATH has none, the panel's own
9
+ # resolver is asked (it knows the places PATH does not reach, and the runtime Crewforth may have fetched). This
10
+ # file never installs anything.
11
+ set -u
12
+ # A caller on Windows may name this file with backslashes (Node's path.join does); cut at either separator,
13
+ # or the directory falls back to "." and findings.mjs is looked for in the caller's cwd.
14
+ d="${BASH_SOURCE%[/\\]*}"; [ "$d" = "${BASH_SOURCE}" ] && d=.
15
+ FLOOR=18
16
+
17
+ usable(){ local v; v="$("$1" -p 'process.versions.node.split(".")[0]' 2>/dev/null)" || return 1
18
+ case "$v" in ''|*[!0-9]*) return 1 ;; esac; [ "$v" -ge "$FLOOR" ]; }
19
+
20
+ NODE=""
21
+ if usable node; then NODE=node
22
+ elif [ -f "$d/../../../studio/ensure-node.sh" ]; then
23
+ n="$(bash "$d/../../../studio/ensure-node.sh" 2>/dev/null)" && [ -n "$n" ] && usable "$n" && NODE="$n"
24
+ fi
25
+ if [ -z "$NODE" ]; then
26
+ echo "findings: no Node $FLOOR+ that runs was found, so nothing was checked (this is not a pass)" >&2
27
+ exit 3
28
+ fi
29
+ exec "$NODE" "$d/findings.mjs" "$@"
@@ -0,0 +1,52 @@
1
+ # Sourced, not run. The ONE rule for where the gate log is, for the gates that write it and everything that reads it
2
+ # (the status line, gate-report, doctor). Studio's reader (server/lib/kit-telemetry.js) follows the same order.
3
+ #
4
+ # 1. CREW_GATE_LOG, when set: used as given (/dev/null turns recording off).
5
+ # 2. .claude/gate-log.tsv, outside a git repository or where git ignores it (a full install ignores .claude/).
6
+ # 3. Otherwise <git dir>/crewforth-gate-log.tsv: inside the repository's own .git, which is never committed.
7
+ # Before 3.2.0 the log was simply not written there, so a project that tracks .claude/ had no record of a
8
+ # refusal, no 🛡 on its status line and no note of a model the gate lowered (seen on a real project).
9
+ #
10
+ # Builtins only on the reading side, which the status line runs every few seconds. The writing side asks git
11
+ # (one or two processes), and only when a decision is being logged.
12
+
13
+ # The repository's git directory, from the project root up, without a process: _CREW_GD, or rc 1.
14
+ crew_git_dir(){
15
+ local d="${1:-.}" n=0 l
16
+ _CREW_GD=""
17
+ while [ "$n" -lt 12 ]; do
18
+ if [ -d "$d/.git" ]; then _CREW_GD="$d/.git"; return 0; fi
19
+ if [ -f "$d/.git" ]; then
20
+ IFS= read -r l < "$d/.git" 2>/dev/null || l=""
21
+ case "$l" in "gitdir: "*) l="${l#gitdir: }"; l="${l%$'\r'}"
22
+ case "$l" in /*|[A-Za-z]:*) ;; *) l="$d/$l" ;; esac
23
+ [ -d "$l" ] && { _CREW_GD="$l"; return 0; } ;; esac
24
+ return 1
25
+ fi
26
+ [ "$d" = / ] && return 1
27
+ d="$d/.."; n=$((n+1))
28
+ done
29
+ return 1
30
+ }
31
+
32
+ # Where a gate writes: _CREW_GL ("" = nowhere). Run from the project root.
33
+ crew_gate_log_write(){
34
+ _CREW_GL=""
35
+ if [ -n "${CREW_GATE_LOG:-}" ]; then _CREW_GL="$CREW_GATE_LOG"; return 0; fi
36
+ [ -d .claude ] || return 0
37
+ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1 || git check-ignore -q .claude/gate-log.tsv 2>/dev/null; then
38
+ _CREW_GL=.claude/gate-log.tsv; return 0
39
+ fi
40
+ local g; g="$(git rev-parse --absolute-git-dir 2>/dev/null)" || g=""
41
+ [ -n "$g" ] && [ -d "$g" ] && _CREW_GL="$g/crewforth-gate-log.tsv"
42
+ return 0
43
+ }
44
+
45
+ # Where a reader finds it: _CREW_GL ("" = none yet). No process.
46
+ crew_gate_log_read(){
47
+ _CREW_GL=""
48
+ if [ -n "${CREW_GATE_LOG:-}" ]; then _CREW_GL="$CREW_GATE_LOG"; return 0; fi
49
+ if [ -f .claude/gate-log.tsv ]; then _CREW_GL=.claude/gate-log.tsv; return 0; fi
50
+ crew_git_dir . && [ -f "$_CREW_GD/crewforth-gate-log.tsv" ] && _CREW_GL="$_CREW_GD/crewforth-gate-log.tsv"
51
+ return 0
52
+ }
@@ -0,0 +1,27 @@
1
+ # json-string.awk — the text on stdin as the inside of a JSON string, printed with no quotes around it and no newline
2
+ # after it. One escaper for every hook that puts dynamic text into its JSON (board-sync.sh, session-rehydrate.sh).
3
+ #
4
+ # printf '%s\n' "$MSG" | LC_ALL=C awk -f json-string.awk
5
+ #
6
+ # jq-identical, measured on 15 inputs (quote, backslash, tab, CR, C0 controls, DEL, UTF-8, leading/trailing
7
+ # newlines, 5000 chars): 15/15 equal to `jq -cn --arg m`. The one it replaced escaped only the quote, the backslash
8
+ # and the newline, so a tab in a board item produced JSON the CLI cannot parse (jq rc=5) and the board silently
9
+ # vanished from the session. The trailing newline the caller prints closes the last record, so a value ending in a
10
+ # newline keeps it. A CR that ENDS a line is dropped on purpose: it is a CRLF line ending, not content, and MSYS gawk
11
+ # already drops it on read (text mode) while BSD awk keeps it, measured on Windows; stripping it here makes every OS
12
+ # emit the same bytes. A CR inside a line is still escaped as \r.
13
+ BEGIN { ORS = ""
14
+ for (i = 1; i < 32; i++) ctl[i] = sprintf("%c", i)
15
+ nm[8] = "\\b"; nm[9] = "\\t"; nm[12] = "\\f"; nm[13] = "\\r" }
16
+ NR > 1 { print "\\n" }
17
+ { s = $0; sub(/\r$/, "", s)
18
+ gsub(/\\/, "\\\\", s); gsub(/"/, "\\\"", s)
19
+ for (i = 1; i < 32; i++) {
20
+ if (i == 10 || !index(s, ctl[i])) continue
21
+ r = (i in nm) ? nm[i] : sprintf("\\u%04x", i)
22
+ out = ""; while ((p = index(s, ctl[i])) > 0) { out = out substr(s, 1, p - 1) r; s = substr(s, p + 1) }
23
+ s = out s
24
+ }
25
+ d = sprintf("%c", 127)
26
+ if (index(s, d)) { out = ""; while ((p = index(s, d)) > 0) { out = out substr(s, 1, p - 1) "\\u007f"; s = substr(s, p + 1) }; s = out s }
27
+ print s }