crewforth 3.1.1 → 3.2.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +171 -0
- package/README.md +4 -3
- package/VERSION +1 -1
- package/adopt.sh +56 -5
- package/kit/AGENT_TEMPLATE.md +2 -1
- package/kit/CLAUDE.md +14 -13
- package/kit/agents/crew-commit-agent.md +7 -2
- package/kit/agents/crew-docs-agent.md +71 -0
- package/kit/agents/crew-planner.md +2 -0
- package/kit/agents/crew-refuter.md +77 -0
- package/kit/agents/crew-security-expert.md +3 -1
- package/kit/agents/crew-session-manager.md +11 -9
- package/kit/eval/doctor.sh +117 -6
- package/kit/eval/golden-routing.txt +9 -0
- package/kit/eval/lib/crew-map/crew-map.sh +26 -0
- package/kit/eval/lib/crew-map/detect.mjs +238 -0
- package/kit/eval/lib/crew-map/narrative.mjs +98 -0
- package/kit/eval/lib/crew-map/parse.mjs +305 -0
- package/kit/eval/lib/crew-map/project.mjs +163 -0
- package/kit/eval/lib/crew-map/rules/names.json +38 -0
- package/kit/eval/lib/crew-map/rules/packages.json +142 -0
- package/kit/eval/lib/crew-map/rules/python-stdlib.json +197 -0
- package/kit/eval/lib/crew-map/scan.mjs +214 -0
- package/kit/eval/lib/crew-map/stacks.mjs +441 -0
- package/kit/eval/lib/crew-map/surface.mjs +457 -0
- package/kit/eval/lib/findings/findings.mjs +410 -0
- package/kit/eval/lib/findings/findings.schema.json +122 -0
- package/kit/eval/lib/findings/findings.sh +29 -0
- package/kit/eval/lib/settings-default.sh +36 -0
- package/kit/eval/lib/settings-json.awk +15 -4
- package/kit/eval/lib/star.sh +23 -7
- package/kit/eval/lib/statusline.settings.json +6 -0
- package/kit/eval/lib/statusline.sh +100 -0
- package/kit/eval/lib/version.sh +41 -0
- package/kit/eval/smoke-test.sh +576 -41
- package/kit/hooks/agent-outcome.sh +26 -12
- package/kit/hooks/guard-agent-model.sh +109 -9
- package/kit/hooks/guard-bash.sh +240 -182
- package/kit/hooks/guard-question.sh +73 -0
- package/kit/hooks/guard-write.sh +21 -0
- package/kit/hooks/prompt-approval.sh +320 -103
- package/kit/hooks/refuter-ledger.sh +80 -0
- package/kit/hooks/session-update-check.sh +9 -8
- package/kit/owned-blobs.tsv +4 -4
- package/kit/settings.json +33 -0
- package/kit/skills/crew-approve/SKILL.md +7 -0
- package/kit/skills/crew-doctor/SKILL.md +5 -1
- package/kit/skills/crew-handoff/SKILL.md +3 -2
- package/kit/skills/crew-map/SKILL.md +44 -0
- package/kit/skills/crew-review/SKILL.md +2 -0
- package/kit/skills/crew-update/SKILL.md +1 -1
- package/kit/skills/incident-runbook/SKILL.md +2 -2
- package/kit/skills/security-scan/references/verify.md +6 -3
- package/kit/studio/README.md +12 -0
- package/kit/studio/server/index.js +125 -11
- package/kit/studio/server/lib/code-stamp.js +35 -0
- package/kit/studio/server/lib/crew-map.js +113 -0
- package/kit/studio/server/lib/model-outcomes.js +130 -15
- package/kit/studio/server/lib/owned-stream.js +56 -0
- package/kit/studio/server/lib/pricing.js +3 -0
- package/kit/studio/server/lib/projects.js +11 -4
- package/kit/studio/server/lib/remote.js +99 -0
- package/kit/studio/server/lib/usage.js +74 -14
- package/kit/studio/web/app.js +223 -15
- package/kit/studio/web/canvas.js +44 -120
- package/kit/studio/web/chat.js +82 -17
- package/kit/studio/web/graph-plan.js +10 -4
- package/kit/studio/web/index.html +10 -5
- package/kit/studio/web/liveness.js +14 -0
- package/kit/studio/web/map-plan.js +341 -0
- package/kit/studio/web/map.js +503 -0
- package/kit/studio/web/models-plan.js +61 -7
- package/kit/studio/web/models.js +19 -3
- package/kit/studio/web/style.css +131 -0
- package/kit/studio/web/usage.js +12 -0
- package/kit/studio/web/viewport.js +160 -0
- package/package.json +6 -5
- package/start.sh +10 -0
|
@@ -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,36 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# settings-default.sh — put one of Crewforth's defaults into the project's .claude/settings.json, only where nothing
|
|
3
|
+
# sets it yet. The project's settings win over the user's, so writing a default over a user's own choice would
|
|
4
|
+
# silently replace it: a value set in the project's settings, its local settings or the user's own is kept.
|
|
5
|
+
#
|
|
6
|
+
# bash settings-default.sh --install <path> <defaults.json> [ENV_VAR] from the project root
|
|
7
|
+
# <path> the setting, as a dot path: statusLine, env.CLAUDE_CODE_SUBAGENT_MODEL
|
|
8
|
+
# <defaults.json> a JSON object holding just that setting (data beside the caller: one definition)
|
|
9
|
+
# ENV_VAR also kept when this variable is set in the environment (a value exported in the shell)
|
|
10
|
+
#
|
|
11
|
+
# Prints one word: installed · kept (it is set) · none (no settings.json) · failed (the file is left as it was).
|
|
12
|
+
# A managed settings layer is not read. The merge is Crewforth's own JSON reader.
|
|
13
|
+
set -u
|
|
14
|
+
_here="${BASH_SOURCE%[/\\]*}"; [ "$_here" = "${BASH_SOURCE}" ] && _here=.
|
|
15
|
+
|
|
16
|
+
[ "${1:-}" = --install ] && [ -n "${2:-}" ] && [ -n "${3:-}" ] || { echo "usage: settings-default.sh --install <path> <defaults.json> [ENV_VAR]" >&2; exit 2; }
|
|
17
|
+
KEY="$2"; K="$3"; VAR="${4:-}"
|
|
18
|
+
P=".claude/settings.json"; SJ="$_here/settings-json.awk"
|
|
19
|
+
[ -f "$P" ] && [ -f "$SJ" ] || { echo none; exit 0; }
|
|
20
|
+
case "$VAR" in '') ;; [A-Za-z_]*) case "$VAR" in *[!A-Za-z0-9_]*) echo failed; exit 0 ;; esac; [ -n "${!VAR:-}" ] && { echo kept; exit 0; } ;; *) echo failed; exit 0 ;; esac
|
|
21
|
+
for f in "$P" ".claude/settings.local.json" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json"; do
|
|
22
|
+
[ -f "$f" ] && awk -v op=get -v path="$KEY" -f "$SJ" "$f" >/dev/null 2>&1 && { echo kept; exit 0; }
|
|
23
|
+
done
|
|
24
|
+
[ -f "$K" ] || { echo failed; exit 0; }
|
|
25
|
+
t="$P.default.$$"
|
|
26
|
+
r=failed
|
|
27
|
+
# The project's file goes FIRST: the merge refreshes the first file's .claude/hooks/ entries from the first file,
|
|
28
|
+
# so put second it would lose every Crewforth hook (measured: the installed project's own smoke failed 30 checks).
|
|
29
|
+
# The second file holds only the default, which the first lacks: that is the one change (an object such as env is
|
|
30
|
+
# merged member by member, the project's own members kept). Written through the file, so a symlink and its mode
|
|
31
|
+
# survive.
|
|
32
|
+
if awk -v op=merge -v retired= -f "$SJ" "$P" "$K" > "$t" 2>/dev/null && [ -s "$t" ] \
|
|
33
|
+
&& awk -v op=validate -f "$SJ" "$t" 2>/dev/null && awk -v op=get -v path="$KEY" -f "$SJ" "$t" >/dev/null 2>&1; then
|
|
34
|
+
cat "$t" > "$P" && r=installed
|
|
35
|
+
fi
|
|
36
|
+
rm -f "$t"; echo "$r"
|
|
@@ -4,10 +4,14 @@
|
|
|
4
4
|
# awk -v op=validate -f settings-json.awk FILE rc 0 = FILE is a JSON object, 1 = it is not
|
|
5
5
|
# awk -v op=get -v path=hooks.Stop.0 -f settings-json.awk FILE the value there, as the merge would print it
|
|
6
6
|
# awk -v op=len -v path=hooks.Stop -f settings-json.awk FILE how many entries an array/object holds
|
|
7
|
+
# awk -v op=gets -v path='a.b|c.d' -f settings-json.awk FILE several values in one read, tab-separated, "" if absent
|
|
7
8
|
# awk -v op=strings -v path=permissions.deny -f settings-json.awk FILE each string element of that array, one per
|
|
8
9
|
# line, quotes off, escapes as written (rc 1 = not an array)
|
|
9
10
|
# path = dot-separated keys, a number indexes an array (0-based), empty = the root. rc 1 = no such path
|
|
10
11
|
# (or, for len, a scalar there), 10 = FILE is not a JSON object.
|
|
12
|
+
# awk -v op=get -v path=P -v setk=M -v setv=RAWJSON -f settings-json.awk FILE the object at P with member M set
|
|
13
|
+
# to RAWJSON (added when absent): a payload rewritten in one field, every other field and escape kept
|
|
14
|
+
# (rc 1 = nothing at P, or not an object)
|
|
11
15
|
# awk -v op=overlay -v key=K [-v setk=M -v setv=RAWJSON] -f settings-json.awk FILE1 FILE2
|
|
12
16
|
# FILE1 with FILE1.K shallow-merged from FILE2.K (FILE2 wins, FILE1's other members and order kept), then
|
|
13
17
|
# member M set to RAWJSON. rc 10 = FILE1 (or its K) is not an object, 11 = FILE2 has no object at K.
|
|
@@ -69,6 +73,10 @@ function at(id,path, n,sg,i){ n=split(path,sg,".")
|
|
|
69
73
|
return id }
|
|
70
74
|
function mk(t){ NN++; T[NN]=t; N[NN]=0; return NN }
|
|
71
75
|
function add(o,k,c){ N[o]++; K[o,N[o]]=k; C[o,N[o]]=c }
|
|
76
|
+
# Member k of object o set to the raw JSON v, added at the end when o has none (overlay and get share it).
|
|
77
|
+
function setm(o,k,v, sn,i,x){ sn=mk("s"); V[sn]=v; x=0
|
|
78
|
+
for(i=1;i<=N[o];i++) if(K[o,i]=="\"" k "\""){ C[o,i]=sn; x=1 }
|
|
79
|
+
if(!x) add(o,"\"" k "\"",sn) }
|
|
72
80
|
function canon(id, i,j,n,s,t,ord){
|
|
73
81
|
if(T[id]=="s") return V[id]
|
|
74
82
|
if(T[id]=="a"){ s="["; for(i=1;i<=N[id];i++) s=s (i>1 ? "," : "") canon(C[id,i]); return s "]" }
|
|
@@ -124,8 +132,13 @@ END{
|
|
|
124
132
|
print (it && !isnull(it) ? V[it] : 0) + V[cr] + (cc && !isnull(cc) ? V[cc] : 0); exit 0 }
|
|
125
133
|
exit 0 }
|
|
126
134
|
if(op=="validate") exit !doc(1)
|
|
135
|
+
# gets: several paths in one read (path = "a.b|c.d"), their values on one line, tab-separated; an absent one is
|
|
136
|
+
# empty. One process for what a hot caller (the status line) would otherwise read with one awk per field.
|
|
137
|
+
if(op=="gets"){ if(!(r=doc(1))) exit 10; n=split(path,pp,"|"); o=""
|
|
138
|
+
for(i=1;i<=n;i++){ x=at(r,pp[i]); o=o (i>1?"\t":"") (x ? put(x,"") : "") } print o; exit 0 }
|
|
127
139
|
if(op=="get" || op=="len"){ if(!(r=doc(1))) exit 10; if(!(r=at(r,path))) exit 1
|
|
128
|
-
if(op=="get"){
|
|
140
|
+
if(op=="get"){ if(setk!=""){ if(T[r]!="o") exit 1; setm(r,setk,setv) } print put(r,""); exit 0 }
|
|
141
|
+
if(T[r]=="s") exit 1; print N[r]; exit 0 }
|
|
129
142
|
if(op=="strings"){ if(!(r=doc(1))) exit 10; if(!(r=at(r,path))) exit 1; if(T[r]!="a") exit 1
|
|
130
143
|
for(i=1;i<=N[r];i++){ c=C[r,i]; if(T[c]=="s" && substr(V[c],1,1)=="\"") print substr(V[c],2,length(V[c])-2) }
|
|
131
144
|
exit 0 }
|
|
@@ -133,9 +146,7 @@ END{
|
|
|
133
146
|
qv=get(q,kk); if(T[qv]!="o") exit 11; tv=get(t,kk); if(tv && T[tv]!="o") exit 10
|
|
134
147
|
nv=mk("o"); for(i=1;i<=N[tv];i++){ x=get(qv,K[tv,i]); add(nv,K[tv,i], x ? x : C[tv,i]) }
|
|
135
148
|
for(i=1;i<=N[qv];i++) if(!get(nv,K[qv,i])) add(nv,K[qv,i],C[qv,i])
|
|
136
|
-
if(setk!="")
|
|
137
|
-
for(i=1;i<=N[nv];i++) if(K[nv,i]=="\"" setk "\""){ C[nv,i]=sn; x=1 }
|
|
138
|
-
if(!x) add(nv,"\"" setk "\"",sn) }
|
|
149
|
+
if(setk!="") setm(nv,setk,setv)
|
|
139
150
|
x=0; for(i=1;i<=N[t];i++) if(K[t,i]==kk){ C[t,i]=nv; x=1 }
|
|
140
151
|
if(!x) add(t,kk,nv)
|
|
141
152
|
print put(t,""); exit 0 }
|