@dogfood-lab/study-swarm 1.2.0 → 2.0.0
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 +47 -0
- package/PROTOCOL.md +14 -2
- package/README.es.md +26 -3
- package/README.fr.md +26 -3
- package/README.hi.md +26 -3
- package/README.it.md +26 -3
- package/README.ja.md +26 -3
- package/README.md +26 -3
- package/README.pt-BR.md +26 -3
- package/README.zh.md +26 -3
- package/SECURITY.md +2 -2
- package/bin/study-swarm.mjs +529 -30
- package/examples/study-swarm-canon-rollback.dispatch.md +97 -0
- package/examples/study-swarm-canon-rollback.lock.json +78 -0
- package/examples/study-swarm-canon-rollback.orchestration.json +761 -0
- package/examples/study-swarm-ci.yml +3 -0
- package/examples/study-swarm-lock.dispatch.md +3 -3
- package/examples/study-swarm-lock.lock.json +15 -15
- package/package.json +1 -1
package/bin/study-swarm.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// study-swarm — thin CLI for the research-grounded design protocol.
|
|
3
3
|
// Zero runtime dependencies. Commands: protocol | new | lint | help | version.
|
|
4
|
-
import { readFileSync, writeFileSync, existsSync, statSync, readdirSync } from 'node:fs';
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, statSync, readdirSync, realpathSync } from 'node:fs';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { dirname, resolve, join } from 'node:path';
|
|
7
7
|
import { createHash } from 'node:crypto';
|
|
@@ -19,9 +19,15 @@ USAGE
|
|
|
19
19
|
COMMANDS
|
|
20
20
|
protocol Print the locked protocol (the five steps + halt rules).
|
|
21
21
|
new <slug> Scaffold a dispatch file <slug>.dispatch.md to fill in.
|
|
22
|
-
lint [--json] <path...>
|
|
22
|
+
lint [--json] [--strict] <path...>
|
|
23
|
+
Check dispatches' citations against the sourcing standard.
|
|
23
24
|
A <path> may be a file, a directory (linted recursively for
|
|
24
25
|
*.dispatch.md), or "-" to read one dispatch from stdin.
|
|
26
|
+
--strict also flags orphan citations (a finding no Step-5 choice
|
|
27
|
+
references by number or author — "citations without a connection
|
|
28
|
+
are noise"); opt-in, so the default CI gate is unchanged.
|
|
29
|
+
lock --init <dispatch> Scaffold <dispatch>.orchestration.json — a fill-in-the-blanks
|
|
30
|
+
harness record to feed to "lock <dispatch> --from".
|
|
25
31
|
lock <dispatch> --from <orchestration.json>
|
|
26
32
|
Emit <dispatch>.lock.json — pin (per Step-2 agent) the resolved
|
|
27
33
|
model + SHA-256 of the byte-exact prompt + SHA-256 of the tool
|
|
@@ -29,16 +35,35 @@ COMMANDS
|
|
|
29
35
|
lock --verify <dispatch> [--from <orchestration.json>]
|
|
30
36
|
Re-derive the deterministic hashes and assert they match the lock;
|
|
31
37
|
drift exits 1 (gates CI). Without --from, checks lock self-integrity.
|
|
38
|
+
withdraw <identifier> --reason <reason> [--detail <text>] [--from <dir>] [--receipt <path>]
|
|
39
|
+
Canon-rollback compensator. Flag every dispatch in the corpus whose
|
|
40
|
+
Research grounding cites <identifier> as "evidence-withdrawn" (a
|
|
41
|
+
tombstone sidecar <slug>.withdrawn.json — flag, never delete), and emit
|
|
42
|
+
a content-addressed withdrawal receipt. --reason is one of:
|
|
43
|
+
fabricated | misattributed | retracted | verifier-flipped | other.
|
|
44
|
+
requalify --check <corpus-dir>
|
|
45
|
+
Fail closed (exit 1) for any dispatch carrying an unresolved
|
|
46
|
+
evidence-withdrawn flag — the andon that HALTS a withdrawn finding's
|
|
47
|
+
dependents until it is removed or re-grounded. Gates CI.
|
|
48
|
+
requalify --status <corpus-dir> [--json]
|
|
49
|
+
Read-only evidence-health VIEW of a corpus: withdrawn vs resolved
|
|
50
|
+
counts, a breakdown by reason and resolution mode, per-dispatch
|
|
51
|
+
lines. Informational (exit 0), unlike the --check gate.
|
|
52
|
+
requalify --resolve <dispatch> <identifier> --mode removed|regrounded [--note <text>]
|
|
53
|
+
Clear a flag once the finding is removed (the citation is gone) or
|
|
54
|
+
re-grounded (re-verified clean by the sibling runner; --note records
|
|
55
|
+
the attestation). Idempotent; appends to the sidecar's audit trail.
|
|
32
56
|
help Show this help.
|
|
33
57
|
version Print the version.
|
|
34
58
|
|
|
35
59
|
EXIT CODES
|
|
36
|
-
0 ok / lint clean
|
|
37
|
-
1 lint
|
|
60
|
+
0 ok / lint clean / verify clean
|
|
61
|
+
1 a gate failed: a lint sourcing violation, lock --verify drift, or an
|
|
62
|
+
unresolved evidence-withdrawn flag (requalify --check)
|
|
38
63
|
2 usage or runtime error
|
|
39
64
|
|
|
40
65
|
NOTE
|
|
41
|
-
lint checks citation FORM (Step 3: author + year + a resolvable arXiv/DOI/URL,
|
|
66
|
+
lint checks citation FORM (Step 3: author + year + a resolvable arXiv/DOI/URL/RFC,
|
|
42
67
|
no "studies show…" gestures) — it does not judge whether a source is legitimate
|
|
43
68
|
or actually supports the claim. That is Step 4, below.
|
|
44
69
|
|
|
@@ -121,17 +146,63 @@ function cmdNew(slug) {
|
|
|
121
146
|
// --- lint core ------------------------------------------------------------
|
|
122
147
|
|
|
123
148
|
const YEAR = /\b(19|20)\d{2}\b/;
|
|
124
|
-
|
|
149
|
+
// A resolvable identifier: an arXiv id, a DOI, a direct URL, or a bare RFC number
|
|
150
|
+
// (RFC parity keeps `lint` in step with the sourcing standard and with normIdent, which
|
|
151
|
+
// already treats `RFC NNNN` as first-class in withdraw/requalify).
|
|
152
|
+
const ID = /(arxiv:\s*\d{4}\.\d{4,5}|10\.\d{4,9}\/\S+|https?:\/\/\S+|\brfc[\s/-]?\d{3,5}\b)/i;
|
|
125
153
|
const PLACEHOLDER = /arXiv:_{2,}|<finding>|<authors>|<year>|<implication>/i;
|
|
126
154
|
const BANNED = /\b(studies show|research suggests|it'?s well[- ]established|well[- ]established that)\b/i;
|
|
127
155
|
// An author cite: a capitalized name (Unicode-aware, so "Buçinca" counts), optionally
|
|
128
156
|
// followed by "et al.", "&", "and", or further surnames, immediately before the year.
|
|
129
|
-
// Accepts "Huang et al. 2023", "Walters & Wilder 2023", "Panickssery, Bowman & Feng 2024"
|
|
157
|
+
// Accepts "Huang et al. 2023", "Walters & Wilder 2023", "Panickssery, Bowman & Feng 2024",
|
|
158
|
+
// and space-separated org authors ("OASIS CSAF Technical Committee 2022");
|
|
130
159
|
// flags an author-less finding like "**Foo.** 2024 (arXiv:…)".
|
|
131
|
-
|
|
160
|
+
// The inner group requires a non-empty separator per iteration (`,?\s+`, never the old
|
|
161
|
+
// empty-matchable `\s*,?\s*`) and is bounded ({0,24}), so it is linear-time — the previous
|
|
162
|
+
// form had catastrophic backtracking (ReDoS) on a long capitalized/`and`-joined run with no
|
|
163
|
+
// trailing year, hanging the CI-gating `lint` command.
|
|
164
|
+
const AUTHOR = /\p{Lu}[\p{L}.'’-]+(?:,?\s+(?:&|and|et al\.?|\p{Lu}[\p{L}.'’-]+)){0,24}\s+\(?(?:19|20)\d{2}/u;
|
|
165
|
+
|
|
166
|
+
// --- strict mode: Step-5 connection / orphan-citation check (opt-in --strict) --------------
|
|
167
|
+
// Step 5 requires each finding to inform a design choice — "citations without a connection are
|
|
168
|
+
// noise" (PROTOCOL.md). This makes the protocol's one otherwise-unexecutable failure mode ("orphan
|
|
169
|
+
// citation") deterministic: a Step-3 finding whose number OR first-author token is never referenced
|
|
170
|
+
// in the "Step 5 / Architecture" section is flagged. Deliberately LIBERAL about what counts as a
|
|
171
|
+
// reference — a missed orphan is safer than a false orphan — so it never fails a dispatch that
|
|
172
|
+
// connected a finding in any reasonable numeric ("(findings 1, 3)") or prose ("Kim 2025") form.
|
|
173
|
+
const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
174
|
+
// The first-author surname token of a finding: the first Capitalized word after the bold **title**.
|
|
175
|
+
function authorTokenOf(findingText) {
|
|
176
|
+
const afterBold = String(findingText).replace(/^\s*\d+\.\s*/, '').replace(/^\s*\*\*[^*]*\*\*/, '');
|
|
177
|
+
const m = afterBold.match(/\p{Lu}[\p{L}.'’-]+/u);
|
|
178
|
+
return m ? m[0] : null;
|
|
179
|
+
}
|
|
180
|
+
// Finding numbers referenced in a Step-5 body: any "#N", plus every integer following the word
|
|
181
|
+
// "finding"/"findings" (covers "(findings 1, 3 and 5)"). Liberal.
|
|
182
|
+
function referencedNumbers(body) {
|
|
183
|
+
const nums = new Set();
|
|
184
|
+
for (const m of body.matchAll(/#\s*(\d+)/g)) nums.add(Number(m[1]));
|
|
185
|
+
for (const m of body.matchAll(/\bfindings?\b[\s:#-]*((?:\d+[\s,#&-]*(?:and\s+)?)+)/gi)) {
|
|
186
|
+
for (const d of m[1].match(/\d+/g) || []) nums.add(Number(d));
|
|
187
|
+
}
|
|
188
|
+
return nums;
|
|
189
|
+
}
|
|
190
|
+
// The Step-5 / Architecture section body (last matching heading → next heading/EOF), or null.
|
|
191
|
+
function step5Body(lines) {
|
|
192
|
+
let s = -1;
|
|
193
|
+
for (let i = 0; i < lines.length; i++) {
|
|
194
|
+
const h = lines[i].match(/^#{1,6}\s+(.*?)\s*$/);
|
|
195
|
+
if (h && /(step\s*5|architecture)/i.test(h[1])) s = i;
|
|
196
|
+
}
|
|
197
|
+
if (s === -1) return null;
|
|
198
|
+
let e = lines.length;
|
|
199
|
+
for (let i = s + 1; i < lines.length; i++) { if (/^#{1,6}\s/.test(lines[i])) { e = i; break; } }
|
|
200
|
+
return lines.slice(s + 1, e).join('\n');
|
|
201
|
+
}
|
|
132
202
|
|
|
133
|
-
// Check one dispatch's text. Returns a structured result; never exits.
|
|
134
|
-
|
|
203
|
+
// Check one dispatch's text. Returns a structured result; never exits. `strict` adds the Step-5
|
|
204
|
+
// orphan-citation check (opt-in, so the default CI gate stays stable).
|
|
205
|
+
function lintText(label, raw, strict) {
|
|
135
206
|
const lines = raw.split(/\r?\n/);
|
|
136
207
|
const problems = []; // { finding, line, rule, message }
|
|
137
208
|
const add = (rule, message, line = null, finding = null) => problems.push({ finding, line, rule, message });
|
|
@@ -172,13 +243,15 @@ function lintText(label, raw) {
|
|
|
172
243
|
findings.forEach((f, i) => {
|
|
173
244
|
const n = i + 1;
|
|
174
245
|
if (PLACEHOLDER.test(f.text)) add('placeholder', `finding ${n}: still has template placeholders — fill it in.`, f.line, n);
|
|
175
|
-
// Strip identifiers before the year check so
|
|
176
|
-
// (e.g. 2402 in arXiv:2402.01817)
|
|
177
|
-
|
|
246
|
+
// Strip identifiers before the year check so digits inside a citation can't masquerade
|
|
247
|
+
// as a publication year: an arXiv id's YYMM prefix (e.g. 2402 in arXiv:2402.01817), a DOI,
|
|
248
|
+
// or a year-like URL path segment (e.g. /2024/ in https://host/2024/paper). URLs are
|
|
249
|
+
// stripped first so a DOI-bearing URL is removed whole.
|
|
250
|
+
const fNoIds = f.text.replace(/https?:\/\/\S+/gi, '').replace(/arxiv:\s*\d{4}\.\d{4,5}/gi, '').replace(/10\.\d{4,9}\/\S+/g, '');
|
|
178
251
|
if (!YEAR.test(fNoIds)) add('missing-year', `finding ${n}: missing a year (spell it out, e.g. "2024" — an arXiv id alone is not a year).`, f.line, n);
|
|
179
252
|
if (!AUTHOR.test(f.text)) add('missing-author', `finding ${n}: missing an author before the year (e.g. "Huang et al. 2023").`, f.line, n);
|
|
180
253
|
const idm = f.text.match(ID);
|
|
181
|
-
if (!idm) add('missing-id', `finding ${n}: missing an identifier (arXiv:NNNN.NNNNN, DOI, or
|
|
254
|
+
if (!idm) add('missing-id', `finding ${n}: missing an identifier (arXiv:NNNN.NNNNN, DOI, URL, or RFC number).`, f.line, n);
|
|
182
255
|
const ym = fNoIds.match(YEAR);
|
|
183
256
|
const ident = idm ? idm[0].replace(/\s+/g, '').replace(/[).,;]+$/, '') : null;
|
|
184
257
|
parsed.push({ finding: n, year: ym ? ym[0] : null, identifier: ident });
|
|
@@ -194,30 +267,69 @@ function lintText(label, raw) {
|
|
|
194
267
|
}
|
|
195
268
|
});
|
|
196
269
|
|
|
270
|
+
// --strict: flag orphan citations — a finding no Step-5 choice connects to (Step 5 / orphan-citation).
|
|
271
|
+
if (strict) {
|
|
272
|
+
const body = step5Body(lines);
|
|
273
|
+
if (body === null) {
|
|
274
|
+
add('no-step5', 'strict: no "Step 5" / "Architecture" section found to check finding connections against.');
|
|
275
|
+
} else {
|
|
276
|
+
const refs = referencedNumbers(body);
|
|
277
|
+
findings.forEach((f, i) => {
|
|
278
|
+
const n = i + 1;
|
|
279
|
+
const tok = authorTokenOf(f.text);
|
|
280
|
+
// Match the author token OR (for a hyphenated surname like "Garcia-Molina") its first
|
|
281
|
+
// component, so a Step-5 reference to just "Garcia" still connects — the liberal direction.
|
|
282
|
+
const alts = tok ? [...new Set([tok, tok.split('-')[0]])].filter((t) => t.length >= 2) : [];
|
|
283
|
+
const tokHit = alts.some((t) => new RegExp('(?<![\\p{L}])' + escRe(t) + '(?![\\p{L}])', 'iu').test(body));
|
|
284
|
+
if (!refs.has(n) && !tokHit) {
|
|
285
|
+
add('orphan-citation', `finding ${n}: no Step-5 choice references it (by number or author) — a citation without a connection is noise (Step 5).`, f.line, n);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
197
291
|
return { file: label, ok: problems.length === 0, findingCount: findings.length, problems, findings: parsed };
|
|
198
292
|
}
|
|
199
293
|
|
|
200
|
-
// Recursively collect
|
|
201
|
-
|
|
294
|
+
// Recursively collect files whose name matches `re` under `dir`, sorted for determinism.
|
|
295
|
+
// Resilient by design:
|
|
296
|
+
// - an unreadable directory (EACCES/EPERM/ENOTDIR) is SKIPPED with a stderr note, never fatal, so a
|
|
297
|
+
// single bad node does not abort a whole-corpus lint / withdraw / requalify (PH-02);
|
|
298
|
+
// - symlinked entries are not followed, and a realpath `seen` set breaks directory-junction cycles
|
|
299
|
+
// (common on Windows) that would otherwise recurse until stack / path-length exhaustion (PH-03).
|
|
300
|
+
// Skips node_modules/.git.
|
|
301
|
+
function walkFiles(dir, re, seen) {
|
|
302
|
+
seen = seen || new Set();
|
|
303
|
+
let real; try { real = realpathSync(dir); } catch { real = dir; }
|
|
304
|
+
if (seen.has(real)) return []; // already visited via another path — junction/symlink cycle guard
|
|
305
|
+
seen.add(real);
|
|
306
|
+
let entries;
|
|
307
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); }
|
|
308
|
+
catch (err) { process.stderr.write(`study-swarm: skipping ${dir}: ${err && err.code ? err.code : err.message}\n`); return []; }
|
|
202
309
|
const out = [];
|
|
203
|
-
for (const entry of
|
|
310
|
+
for (const entry of entries) {
|
|
204
311
|
if (entry.name === 'node_modules' || entry.name === '.git') continue;
|
|
312
|
+
if (entry.isSymbolicLink()) continue; // don't follow symlinks (cycle + directory-escape safety)
|
|
205
313
|
const full = join(dir, entry.name);
|
|
206
|
-
if (entry.isDirectory()) out.push(...
|
|
207
|
-
else if (
|
|
314
|
+
if (entry.isDirectory()) out.push(...walkFiles(full, re, seen));
|
|
315
|
+
else if (re.test(entry.name)) out.push(full);
|
|
208
316
|
}
|
|
209
317
|
return out.sort();
|
|
210
318
|
}
|
|
319
|
+
function walkDispatches(dir) { return walkFiles(dir, /\.dispatch\.md$/i); }
|
|
211
320
|
|
|
212
321
|
function readTarget(p) {
|
|
213
322
|
try { return { label: p, raw: readFileSync(p, 'utf8') }; }
|
|
214
323
|
catch (err) { fail(2, `cannot read ${p}: ${err && err.code ? err.code : err.message}`); }
|
|
215
324
|
}
|
|
216
325
|
|
|
326
|
+
const LINT_SCHEMA = 'study-swarm.lint/v1'; // versioned handle for --json consumers (FG-03)
|
|
327
|
+
|
|
217
328
|
function cmdLint(args) {
|
|
218
329
|
const json = args.includes('--json');
|
|
219
|
-
const
|
|
220
|
-
|
|
330
|
+
const strict = args.includes('--strict'); // opt-in: also flag Step-5 orphan citations (FG-01)
|
|
331
|
+
const paths = args.filter((a) => a !== '--json' && a !== '--strict');
|
|
332
|
+
if (paths.length === 0) fail(2, 'usage: study-swarm lint [--json] [--strict] <file|dir|-> [more...]');
|
|
221
333
|
|
|
222
334
|
const targets = [];
|
|
223
335
|
for (const p of paths) {
|
|
@@ -238,20 +350,23 @@ function cmdLint(args) {
|
|
|
238
350
|
}
|
|
239
351
|
}
|
|
240
352
|
|
|
241
|
-
const results = targets.map((t) => lintText(t.label, t.raw));
|
|
353
|
+
const results = targets.map((t) => lintText(t.label, t.raw, strict));
|
|
242
354
|
const anyFail = results.some((r) => !r.ok);
|
|
243
355
|
|
|
244
356
|
if (json) {
|
|
245
|
-
|
|
357
|
+
// A versioned envelope so a CI/roleos consumer can detect a shape change (FG-03), matching the
|
|
358
|
+
// schema/version pattern the lock, sidecar, and receipt objects already carry.
|
|
359
|
+
const meta = { schema: LINT_SCHEMA, study_swarm_version: VERSION };
|
|
360
|
+
const payload = results.length === 1 ? { ...meta, ...results[0] } : { ...meta, ok: !anyFail, files: results };
|
|
246
361
|
process.stdout.write(JSON.stringify(payload) + '\n');
|
|
247
362
|
process.exit(anyFail ? 1 : 0);
|
|
248
363
|
}
|
|
249
364
|
|
|
250
365
|
for (const r of results) {
|
|
251
366
|
if (r.ok) {
|
|
252
|
-
process.stdout.write(`ok ${r.file}: ${r.findingCount} finding(s), all sourced.\n`);
|
|
367
|
+
process.stdout.write(`ok ${r.file}: ${r.findingCount} finding(s), all sourced${strict ? ' and connected' : ''}.\n`);
|
|
253
368
|
} else {
|
|
254
|
-
process.stderr.write(`x ${r.file}: ${r.problems.length} sourcing issue(s)\n`);
|
|
369
|
+
process.stderr.write(`x ${r.file}: ${r.problems.length} ${strict ? 'issue(s)' : 'sourcing issue(s)'}\n`);
|
|
255
370
|
for (const pr of r.problems) process.stderr.write(` - ${pr.message}\n`);
|
|
256
371
|
}
|
|
257
372
|
}
|
|
@@ -260,6 +375,9 @@ function cmdLint(args) {
|
|
|
260
375
|
`\nStep 3 (sourcing FORM) is satisfied — this does NOT confirm the citations exist or support the claim.\n` +
|
|
261
376
|
`Run Step 4 (existence + groundedness, a different model family): roleos verify-citations <file>\n`,
|
|
262
377
|
);
|
|
378
|
+
} else {
|
|
379
|
+
// Symmetry with the clean-path nudge: tell the user what to do next (H1).
|
|
380
|
+
process.stderr.write(`\nFix the issue(s) above, then re-run study-swarm lint. (This checks Step 3 sourcing FORM${strict ? ' + Step 5 connections' : ''} only.)\n`);
|
|
263
381
|
}
|
|
264
382
|
process.exit(anyFail ? 1 : 0);
|
|
265
383
|
}
|
|
@@ -270,17 +388,26 @@ function cmdLint(args) {
|
|
|
270
388
|
// (resolved models + byte-exact prompts + tool schemas + verifier receipt); the CLI only
|
|
271
389
|
// canonicalizes + hashes + validates it. No network, no model calls (L2).
|
|
272
390
|
|
|
273
|
-
const LOCK_SCHEMA = 'dispatch.lock/
|
|
391
|
+
const LOCK_SCHEMA = 'dispatch.lock/v2';
|
|
274
392
|
|
|
275
393
|
// Self-describing digest "sha256-<base64>" — the W3C Subresource Integrity form: algorithm-
|
|
276
394
|
// prefixed (so it's algorithm-agile) and used fail-closed on mismatch (L9; lock dispatch finding 38).
|
|
277
395
|
function sriBytes(buf) { return 'sha256-' + createHash('sha256').update(buf).digest('base64'); }
|
|
396
|
+
// Domain-separation tags (v2): a TEXT preimage and a structured-JSON (JCS) preimage are hashed in
|
|
397
|
+
// DISJOINT spaces, so a prompt whose literal text happens to equal some tool schema's canonical JSON
|
|
398
|
+
// can never produce the same digest as that schema (the tagged-hash / DSSE "hash known bytes with a
|
|
399
|
+
// context" rule the lock dispatch cites — TUF/Rekor/CT). Without a tag, jcsDigest({}) === sriText('{}').
|
|
400
|
+
// The tag carries the schema major, so bumping it is itself a lock-format change — hence the v1 -> v2
|
|
401
|
+
// bump on LOCK_SCHEMA / WITHDRAWN_SCHEMA / RECEIPT_SCHEMA, and the schema gate on read that turns a
|
|
402
|
+
// stale-format lock into a clear "regenerate" message instead of a confusing hash mismatch.
|
|
403
|
+
const DOMAIN_TEXT = 'study-swarm/v2/text\n';
|
|
404
|
+
const DOMAIN_JCS = 'study-swarm/v2/jcs\n';
|
|
278
405
|
// Normalize TEXT before hashing so the same content hashes identically across platforms — strip a
|
|
279
406
|
// BOM, fold CRLF/CR -> LF, NFC-normalize. Without this, a CRLF working tree (Windows) and an LF
|
|
280
407
|
// checkout (git/CI) produce different hashes — the exact cross-platform drift our Q2 findings warn
|
|
281
408
|
// about (RFC 8259 BOM, UAX #15 NFC, and CRLF/LF). Applied to every text input that gets hashed.
|
|
282
409
|
function normText(s) { s = String(s); if (s.charCodeAt(0) === 0xFEFF) s = s.slice(1); return s.replace(/\r\n?/g, '\n').normalize('NFC'); }
|
|
283
|
-
function sriText(str) { return sriBytes(Buffer.from(normText(str), 'utf8')); }
|
|
410
|
+
function sriText(str) { return sriBytes(Buffer.from(DOMAIN_TEXT + normText(str), 'utf8')); }
|
|
284
411
|
|
|
285
412
|
// RFC 8785 (JCS) canonical JSON, for the structured JSON the CLI assembles ITSELF (the tool
|
|
286
413
|
// surface and the lock body): NFC-normalize strings, sort object keys by UTF-16 code unit (JS
|
|
@@ -307,7 +434,7 @@ function jcs(value) {
|
|
|
307
434
|
};
|
|
308
435
|
return ser(value);
|
|
309
436
|
}
|
|
310
|
-
function jcsDigest(value) { return sriBytes(Buffer.from(jcs(value), 'utf8')); }
|
|
437
|
+
function jcsDigest(value) { return sriBytes(Buffer.from(DOMAIN_JCS + jcs(value), 'utf8')); }
|
|
311
438
|
|
|
312
439
|
// The lock sits beside its dispatch: <dir>/<stem>.lock.json (stem strips a trailing .dispatch.md).
|
|
313
440
|
function lockPathFor(dispatch) {
|
|
@@ -337,8 +464,14 @@ function buildLockObject(dispatchPath, orchestration) {
|
|
|
337
464
|
if (s.params && typeof s.params === 'object') rec.params = s.params;
|
|
338
465
|
// L7 — output hash for DRIFT DETECTION only (not determinism). The harness may ship the raw
|
|
339
466
|
// output (the CLI hashes it) OR a pre-computed output_sha256 (large outputs needn't be shipped).
|
|
340
|
-
|
|
341
|
-
|
|
467
|
+
// A caller-supplied digest is validated to the SRI sha256- shape here, so a malformed hash is
|
|
468
|
+
// rejected where it enters rather than mis-surfacing as "drift" on a later verify (PH-05).
|
|
469
|
+
if (typeof s.output_sha256 === 'string') {
|
|
470
|
+
if (!/^sha256-[A-Za-z0-9+/]+=*$/.test(s.output_sha256)) {
|
|
471
|
+
fail(2, `orchestration step ${i + 1} output_sha256 is not an "sha256-<base64>" digest: "${s.output_sha256}"`);
|
|
472
|
+
}
|
|
473
|
+
rec.output_sha256 = s.output_sha256;
|
|
474
|
+
} else if (s.output !== undefined) rec.output_sha256 = typeof s.output === 'string' ? sriText(s.output) : jcsDigest(s.output);
|
|
342
475
|
return rec;
|
|
343
476
|
});
|
|
344
477
|
const lock = {
|
|
@@ -358,12 +491,27 @@ function buildLockObject(dispatchPath, orchestration) {
|
|
|
358
491
|
return lock;
|
|
359
492
|
}
|
|
360
493
|
|
|
494
|
+
// A forward-compat guard (PH-04): an artifact whose `schema` string names a version this CLI does
|
|
495
|
+
// not write is reported as "regenerate", not as a confusing hash/integrity mismatch — because the
|
|
496
|
+
// hash preimage (the domain tag) changes with the schema major, a v1 artifact read by a v2 CLI would
|
|
497
|
+
// otherwise fail self-integrity with a misleading "the body was edited" accusation. Absent schema =
|
|
498
|
+
// no gate (the self-integrity check still applies). Returns a message string, or null when in-version.
|
|
499
|
+
function staleSchema(stored, expected, kind, path) {
|
|
500
|
+
if (stored && typeof stored === 'object' && typeof stored.schema === 'string' && stored.schema !== expected) {
|
|
501
|
+
return `${path}: ${kind} is schema "${stored.schema}"; this study-swarm v${VERSION} understands "${expected}" — regenerate it (the hash format changed between schema versions).`;
|
|
502
|
+
}
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
|
|
361
506
|
// Verify a lock: self-integrity always; source-drift too when an orchestration record is supplied.
|
|
362
507
|
// Strict-match, fail-closed (L8): returns a list of problems (empty = clean).
|
|
363
508
|
function verifyLockObject(dispatchPath, lockPath, orchestration) {
|
|
364
509
|
let stored;
|
|
365
510
|
try { stored = JSON.parse(readFileSync(lockPath, 'utf8')); }
|
|
366
511
|
catch (err) { fail(2, `cannot read lock ${lockPath}: ${err && err.code ? err.code : err.message}`); }
|
|
512
|
+
// 0) Stale-format gate — a wrong-schema lock is "regenerate", not a hash mismatch (PH-04).
|
|
513
|
+
const stale = staleSchema(stored, LOCK_SCHEMA, 'lock', lockPath);
|
|
514
|
+
if (stale) return [stale];
|
|
367
515
|
const problems = [];
|
|
368
516
|
// 1) Self-integrity — recompute lock_sha256 over the stored body (detects a hand-edited lock).
|
|
369
517
|
if (!stored || typeof stored !== 'object' || typeof stored.lock_sha256 !== 'string') {
|
|
@@ -394,7 +542,46 @@ function verifyLockObject(dispatchPath, lockPath, orchestration) {
|
|
|
394
542
|
return problems;
|
|
395
543
|
}
|
|
396
544
|
|
|
545
|
+
// FG-05 — scaffold the orchestration.json the harness must supply, mirroring what `new` does for a
|
|
546
|
+
// dispatch. A deterministic Write of a template (no network, no models); refuses to overwrite. The
|
|
547
|
+
// orchestration record is the harder artifact to hand-author, and its shape was documented only in
|
|
548
|
+
// the large worked examples — this gives a fill-in-the-blanks starting point.
|
|
549
|
+
const orchTemplate = () => JSON.stringify({
|
|
550
|
+
_note: 'study-swarm orchestration record — the harness-emitted input to `study-swarm lock <dispatch> --from <this file>`. One steps[] entry per Step-2 research agent; replace every <...> placeholder. A full worked record: examples/study-swarm-lock.orchestration.json. Optional per step: params, schema_dialect, output_sha256 (an "sha256-<base64>" digest for drift detection).',
|
|
551
|
+
steps: [
|
|
552
|
+
{
|
|
553
|
+
question_id: '<Q1-short-slug>',
|
|
554
|
+
resolved_model: '<resolved model id, e.g. claude-opus-4-8 — never a floating alias>',
|
|
555
|
+
prompt: '<the byte-exact prompt string this research agent was given>',
|
|
556
|
+
tool_schema: { type: 'object', properties: {} },
|
|
557
|
+
schema_dialect: 'https://json-schema.org/draft/2020-12/schema',
|
|
558
|
+
},
|
|
559
|
+
],
|
|
560
|
+
verification: {
|
|
561
|
+
runner: 'roleos verify-citations',
|
|
562
|
+
tool: 'prism verify --type citations',
|
|
563
|
+
verifier_family: '<a DIFFERENT model family than the synthesizer>',
|
|
564
|
+
receipt_id: '<prism-receipt-id>',
|
|
565
|
+
receipt_chain_sha256: '<the verifier receipt chain hash>',
|
|
566
|
+
},
|
|
567
|
+
}, null, 2) + '\n';
|
|
568
|
+
|
|
569
|
+
function orchPathFor(dispatch) {
|
|
570
|
+
const base = dispatch.split(/[\\/]/).pop().replace(/(\.dispatch)?\.md$/i, '');
|
|
571
|
+
return join(dirname(dispatch), `${base}.orchestration.json`);
|
|
572
|
+
}
|
|
573
|
+
|
|
397
574
|
function cmdLock(args) {
|
|
575
|
+
if (args.includes('--init')) { // FG-05 — scaffold the orchestration record for a dispatch
|
|
576
|
+
const dispatch = args.filter((a) => a !== '--init')[0];
|
|
577
|
+
if (!dispatch) fail(2, 'usage: study-swarm lock --init <dispatch>');
|
|
578
|
+
if (!existsSync(dispatch)) fail(2, `dispatch not found: ${dispatch}`);
|
|
579
|
+
const out = orchPathFor(dispatch);
|
|
580
|
+
if (existsSync(out)) fail(2, `refusing to overwrite existing ${out}`);
|
|
581
|
+
writeFileSync(out, orchTemplate(), 'utf8');
|
|
582
|
+
process.stdout.write(`Created ${out}\nFill in each step (one per Step-2 research agent), then: study-swarm lock ${dispatch} --from ${out}\n`);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
398
585
|
const verify = args.includes('--verify');
|
|
399
586
|
const rest = args.filter((a) => a !== '--verify');
|
|
400
587
|
let orchPath = null;
|
|
@@ -438,6 +625,316 @@ function cmdLock(args) {
|
|
|
438
625
|
process.stdout.write(`Created ${lockPath}\nlock_sha256: ${lock.lock_sha256}\nVerify with: study-swarm lock --verify ${dispatch} --from ${orchPath}\n`);
|
|
439
626
|
}
|
|
440
627
|
|
|
628
|
+
// --- canon-rollback core (the requalify_dependent_slices compensator) -----------
|
|
629
|
+
// Design + research grounding: examples/study-swarm-canon-rollback.dispatch.md (choices C1-C12).
|
|
630
|
+
// The compensator operates on the VOLATILE evidence layer (a per-dispatch tombstone sidecar),
|
|
631
|
+
// never the STABLE protocol/lock shape — `lock --verify` is unaffected by withdraw/resolve (C11,
|
|
632
|
+
// the Parnas boundary). Like `lock`, the CLI is a PURE FUNCTION of bytes: it flags, gates, and
|
|
633
|
+
// receipts deterministically (file reads, JSON I/O, SHA-256); the actual re-verification of a
|
|
634
|
+
// re-grounded finding defers to the sibling runner (C12, honest ceiling). No network, no models.
|
|
635
|
+
|
|
636
|
+
const WITHDRAWN_SCHEMA = 'dispatch.withdrawn/v2';
|
|
637
|
+
const RECEIPT_SCHEMA = 'withdrawal-receipt/v2';
|
|
638
|
+
// A CLOSED, machine-readable reason enum — never free text (C3; OpenVEX/CSAF/CycloneDX: a status
|
|
639
|
+
// must carry a structured justification, a bare flag is non-conformant).
|
|
640
|
+
const WITHDRAW_REASONS = ['fabricated', 'misattributed', 'retracted', 'verifier-flipped', 'other'];
|
|
641
|
+
|
|
642
|
+
// Normalize a citation identifier to ONE canonical key so the SAME source matches across the forms
|
|
643
|
+
// a finding might cite it in: arXiv (bare / arxiv.org URL / version suffix), DOI (bare / doi.org
|
|
644
|
+
// URL), RFC (RFC NNNN / rfc-editor / datatracker), else a trimmed lowercased URL. Used on BOTH the
|
|
645
|
+
// dispatch's extracted identifier and the user's <identifier> argument so `withdraw arXiv:2402.15089`
|
|
646
|
+
// flags a finding citing `https://arxiv.org/abs/2402.15089v2` (C2).
|
|
647
|
+
function normIdent(raw) {
|
|
648
|
+
let s = String(raw || '').trim().toLowerCase().replace(/[).,;]+$/, '');
|
|
649
|
+
let m = s.match(/arxiv\.org\/(?:abs|pdf)\/(\d{4}\.\d{4,5})/) || s.match(/arxiv:\s*(\d{4}\.\d{4,5})/);
|
|
650
|
+
if (m) return 'arxiv:' + m[1];
|
|
651
|
+
m = s.match(/(?:doi\.org\/|dx\.doi\.org\/|doi:\s*)?(10\.\d{4,9}\/\S+)/);
|
|
652
|
+
if (m) return 'doi:' + m[1].replace(/[).,;]+$/, '');
|
|
653
|
+
m = s.match(/rfc[\s/-]?(\d{3,5})/);
|
|
654
|
+
if (m) return 'rfc:' + m[1];
|
|
655
|
+
return s.replace(/\/+$/, '');
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// The tombstone sits beside its dispatch: <dir>/<stem>.withdrawn.json (C4 — status travels WITH
|
|
659
|
+
// the artifact, the OCSP-stapling property; stem strips a trailing .dispatch.md).
|
|
660
|
+
function withdrawnPathFor(dispatch) {
|
|
661
|
+
const base = dispatch.split(/[\\/]/).pop().replace(/(\.dispatch)?\.md$/i, '');
|
|
662
|
+
return join(dirname(dispatch), `${base}.withdrawn.json`);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Recursively collect files matching a regex (delegates to the resilient shared walker).
|
|
666
|
+
function walkByExt(dir, re) { return walkFiles(dir, re); }
|
|
667
|
+
|
|
668
|
+
// The finding numbers in one dispatch whose citation normalizes to `want` (reuses the lint parser,
|
|
669
|
+
// so Step 3 and the compensator agree on what a citation is).
|
|
670
|
+
function findingsCiting(dispatchPath, want) {
|
|
671
|
+
const res = lintText(dispatchPath, readFileSync(dispatchPath, 'utf8'));
|
|
672
|
+
return (res.findings || []).filter((f) => f.identifier && normIdent(f.identifier) === want).map((f) => f.finding);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Every dispatch in the corpus citing `target`, with the finding numbers + a content hash each.
|
|
676
|
+
function findDependents(corpus, target) {
|
|
677
|
+
const want = normIdent(target);
|
|
678
|
+
const files = statSync(corpus).isDirectory() ? walkDispatches(corpus) : [corpus];
|
|
679
|
+
const deps = [];
|
|
680
|
+
for (const f of files) {
|
|
681
|
+
const hits = findingsCiting(f, want);
|
|
682
|
+
if (hits.length) deps.push({ path: f, dispatch_sha256: sriText(readFileSync(f, 'utf8')), findings: hits });
|
|
683
|
+
}
|
|
684
|
+
return deps;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Content-address the sidecar/receipt exactly like the lock: jcsDigest over the body with the hash
|
|
688
|
+
// field omitted (C8/C9 — TUF/Rekor/CT/Git content-addressing; self-integrity catches a hand-edit).
|
|
689
|
+
function withSha(body, key) {
|
|
690
|
+
const { [key]: _omit, ...rest } = body;
|
|
691
|
+
return { ...rest, [key]: jcsDigest(rest) };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Load an existing tombstone sidecar, or seed a fresh one for `dispatch`.
|
|
695
|
+
function loadSidecar(dispatchPath) {
|
|
696
|
+
const p = withdrawnPathFor(dispatchPath);
|
|
697
|
+
if (existsSync(p)) {
|
|
698
|
+
try { return JSON.parse(readFileSync(p, 'utf8')); }
|
|
699
|
+
catch (err) { fail(2, `cannot read sidecar ${p}: ${err && err.code ? err.code : err.message}`); }
|
|
700
|
+
}
|
|
701
|
+
return {
|
|
702
|
+
schema: WITHDRAWN_SCHEMA,
|
|
703
|
+
study_swarm_version: VERSION,
|
|
704
|
+
dispatch: dispatchPath.split(/[\\/]/).pop(),
|
|
705
|
+
dispatch_sha256: sriText(readFileSync(dispatchPath, 'utf8')),
|
|
706
|
+
version: 0,
|
|
707
|
+
withdrawals: [],
|
|
708
|
+
audit_trail: [],
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Recompute the rolled-up hash and write the sidecar; returns the finalized object.
|
|
713
|
+
function writeSidecar(dispatchPath, body) {
|
|
714
|
+
body.dispatch_sha256 = sriText(readFileSync(dispatchPath, 'utf8')); // reconcile to current content
|
|
715
|
+
const finalized = withSha(body, 'withdrawn_sha256');
|
|
716
|
+
writeFileSync(withdrawnPathFor(dispatchPath), JSON.stringify(finalized, null, 2) + '\n', 'utf8');
|
|
717
|
+
return finalized;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function parseFlags(args, withValue) {
|
|
721
|
+
const out = { _: [] };
|
|
722
|
+
for (let i = 0; i < args.length; i++) {
|
|
723
|
+
const a = args[i];
|
|
724
|
+
if (a.startsWith('--')) {
|
|
725
|
+
const k = a.slice(2);
|
|
726
|
+
if (withValue.has(k)) { out[k] = args[++i]; }
|
|
727
|
+
else out[k] = true;
|
|
728
|
+
} else out._.push(a);
|
|
729
|
+
}
|
|
730
|
+
return out;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function cmdWithdraw(args) {
|
|
734
|
+
const f = parseFlags(args, new Set(['reason', 'detail', 'from', 'receipt']));
|
|
735
|
+
const identifier = f._[0];
|
|
736
|
+
if (!identifier) fail(2, 'usage: study-swarm withdraw <identifier> --reason <reason> [--detail <text>] [--from <corpus-dir>] [--receipt <path>] [--json]');
|
|
737
|
+
if (!f.reason) fail(2, `withdraw requires --reason <${WITHDRAW_REASONS.join('|')}> — a withdrawal with no machine-readable cause is not allowed`);
|
|
738
|
+
if (!WITHDRAW_REASONS.includes(String(f.reason))) fail(2, `invalid --reason "${f.reason}" — use one of: ${WITHDRAW_REASONS.join(', ')}`);
|
|
739
|
+
const corpus = f.from || '.';
|
|
740
|
+
if (!existsSync(corpus)) fail(2, `corpus not found: ${corpus}`);
|
|
741
|
+
const want = normIdent(identifier);
|
|
742
|
+
const detail = f.detail ? String(f.detail) : '';
|
|
743
|
+
|
|
744
|
+
const deps = findDependents(corpus, identifier);
|
|
745
|
+
if (deps.length === 0) fail(2, `no dispatch in ${corpus} cites ${identifier} (normalized: ${want}) — nothing to withdraw. Check the identifier spelling and the --from directory; "study-swarm lint ${corpus}" lists the citations the tool can see.`);
|
|
746
|
+
|
|
747
|
+
const dependents = [];
|
|
748
|
+
for (const d of deps) {
|
|
749
|
+
const body = loadSidecar(d.path);
|
|
750
|
+
const existing = body.withdrawals.find((w) => w.identifier === want);
|
|
751
|
+
// Idempotent: an identical withdrawal (same id + reason + detail + finding numbers, still
|
|
752
|
+
// withdrawn) is a no-op. The findings array is part of the identity so that re-withdrawing
|
|
753
|
+
// after the dispatch was edited (a citation moved to a different finding #) refreshes the
|
|
754
|
+
// stale numbers instead of being skipped as "identical".
|
|
755
|
+
const sameFindings = existing && Array.isArray(existing.findings) &&
|
|
756
|
+
existing.findings.length === d.findings.length && existing.findings.every((v, i) => v === d.findings[i]);
|
|
757
|
+
const identical = existing && existing.status === 'withdrawn' && existing.reason === String(f.reason) && (existing.detail || '') === detail && sameFindings;
|
|
758
|
+
if (!identical) {
|
|
759
|
+
if (existing) {
|
|
760
|
+
existing.reason = String(f.reason); existing.detail = detail; existing.status = 'withdrawn'; existing.resolution = null; existing.findings = d.findings;
|
|
761
|
+
} else {
|
|
762
|
+
body.withdrawals.push({ identifier: want, reason: String(f.reason), detail, findings: d.findings, status: 'withdrawn', resolution: null });
|
|
763
|
+
}
|
|
764
|
+
body.version += 1;
|
|
765
|
+
body.audit_trail.push({ seq: body.audit_trail.length + 1, event: 'withdraw', identifier: want, reason: String(f.reason), findings: d.findings });
|
|
766
|
+
}
|
|
767
|
+
const finalized = writeSidecar(d.path, body);
|
|
768
|
+
dependents.push({ dispatch: finalized.dispatch, dispatch_sha256: finalized.dispatch_sha256, findings: d.findings, sidecar: withdrawnPathFor(d.path).split(/[\\/]/).pop() });
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const receipt = withSha({
|
|
772
|
+
schema: RECEIPT_SCHEMA,
|
|
773
|
+
study_swarm_version: VERSION,
|
|
774
|
+
identifier: want,
|
|
775
|
+
reason: String(f.reason),
|
|
776
|
+
detail,
|
|
777
|
+
corpus: corpus.split(/[\\/]/).pop() || corpus,
|
|
778
|
+
dependents,
|
|
779
|
+
post_rollback_state: `${dependents.length} dependent(s) flagged evidence-withdrawn; "study-swarm requalify --check" fails closed until each is removed or re-grounded.`,
|
|
780
|
+
}, 'receipt_sha256');
|
|
781
|
+
|
|
782
|
+
if (f.receipt) writeFileSync(String(f.receipt), JSON.stringify(receipt, null, 2) + '\n', 'utf8');
|
|
783
|
+
|
|
784
|
+
if (f.json) {
|
|
785
|
+
process.stdout.write(JSON.stringify(receipt) + '\n');
|
|
786
|
+
process.exit(0);
|
|
787
|
+
}
|
|
788
|
+
// Contrastive surfacing — never a silent drop (C10; Buçinca 2024, Bansal 2021).
|
|
789
|
+
process.stdout.write(`Withdrew ${want} (reason: ${f.reason}). ${dependents.length} dependent(s) flagged evidence-withdrawn:\n`);
|
|
790
|
+
for (const d of dependents) process.stdout.write(` - ${d.dispatch} (findings ${d.findings.map((n) => '#' + n).join(', ')})\n`);
|
|
791
|
+
process.stdout.write(
|
|
792
|
+
`\nYou may have relied on this finding. Each flagged dispatch now HALTS "study-swarm requalify --check"\n` +
|
|
793
|
+
`until the finding is removed or re-grounded — re-ground or override.\n` +
|
|
794
|
+
`${f.receipt ? `Receipt written to ${String(f.receipt)}` : 'No receipt file written — re-run with --receipt <path> or --json to capture it'} — receipt_sha256 ${receipt.receipt_sha256}\n`);
|
|
795
|
+
process.exit(0);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function cmdRequalify(args) {
|
|
799
|
+
if (args.includes('--check')) return requalifyCheck(args.filter((a) => a !== '--check'));
|
|
800
|
+
if (args.includes('--resolve')) return requalifyResolve(args.filter((a) => a !== '--resolve'));
|
|
801
|
+
if (args.includes('--status')) return requalifyStatus(args.filter((a) => a !== '--status'));
|
|
802
|
+
fail(2, 'usage: study-swarm requalify --check <corpus-dir> | study-swarm requalify --status <corpus-dir> [--json] | study-swarm requalify --resolve <dispatch> <identifier> --mode removed|regrounded [--note <text>]');
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const STATUS_SCHEMA = 'study-swarm.status/v1';
|
|
806
|
+
|
|
807
|
+
// FG-04 — the read-only evidence-health VIEW of a corpus (distinct from --check, the CI gate). Walks
|
|
808
|
+
// the .withdrawn.json sidecars and aggregates: withdrawn vs resolved counts, a breakdown by reason
|
|
809
|
+
// and by resolution mode, and a per-dispatch line. Informational — it never fails closed (exit 0),
|
|
810
|
+
// so it composes in a report without gating a build the way --check does.
|
|
811
|
+
function requalifyStatus(args) {
|
|
812
|
+
const f = parseFlags(args, new Set());
|
|
813
|
+
const corpus = f._[0];
|
|
814
|
+
if (!corpus) fail(2, 'usage: study-swarm requalify --status <corpus-dir> [--json]');
|
|
815
|
+
if (!existsSync(corpus)) fail(2, `corpus not found: ${corpus}`);
|
|
816
|
+
const sidecars = statSync(corpus).isDirectory() ? walkByExt(corpus, /\.withdrawn\.json$/i) : [corpus];
|
|
817
|
+
const totals = { withdrawn: 0, resolved: 0 };
|
|
818
|
+
const by_reason = {};
|
|
819
|
+
const by_mode = {};
|
|
820
|
+
const dispatches = [];
|
|
821
|
+
const problems = [];
|
|
822
|
+
for (const sc of sidecars) {
|
|
823
|
+
let stored;
|
|
824
|
+
try { stored = JSON.parse(readFileSync(sc, 'utf8')); }
|
|
825
|
+
catch (err) { problems.push(`${sc}: not valid JSON (${err.message})`); continue; }
|
|
826
|
+
if (!stored || typeof stored !== 'object' || Array.isArray(stored)) { problems.push(`${sc}: sidecar is not a JSON object`); continue; }
|
|
827
|
+
const entries = [];
|
|
828
|
+
for (const w of stored.withdrawals || []) {
|
|
829
|
+
const status = w.status === 'resolved' ? 'resolved' : 'withdrawn';
|
|
830
|
+
totals[status] += 1;
|
|
831
|
+
if (w.reason) by_reason[w.reason] = (by_reason[w.reason] || 0) + 1;
|
|
832
|
+
const mode = status === 'resolved' && w.resolution ? w.resolution.mode : null;
|
|
833
|
+
if (mode) by_mode[mode] = (by_mode[mode] || 0) + 1;
|
|
834
|
+
entries.push({ identifier: w.identifier, reason: w.reason, status, mode, findings: w.findings || [] });
|
|
835
|
+
}
|
|
836
|
+
dispatches.push({ dispatch: stored.dispatch, sidecar: sc.split(/[\\/]/).pop(), withdrawals: entries });
|
|
837
|
+
}
|
|
838
|
+
const kv = (o) => Object.keys(o).sort().map((k) => `${k}=${o[k]}`).join(', ') || '(none)';
|
|
839
|
+
if (f.json) {
|
|
840
|
+
process.stdout.write(JSON.stringify({ schema: STATUS_SCHEMA, study_swarm_version: VERSION, corpus, totals, by_reason, by_mode, dispatches, problems }) + '\n');
|
|
841
|
+
process.exit(0);
|
|
842
|
+
}
|
|
843
|
+
process.stdout.write(`study-swarm requalify --status ${corpus}: ${dispatches.length} sidecar(s), ${totals.withdrawn + totals.resolved} withdrawal(s)\n`);
|
|
844
|
+
process.stdout.write(` ${totals.withdrawn} unresolved (evidence-withdrawn), ${totals.resolved} resolved\n`);
|
|
845
|
+
process.stdout.write(` by reason: ${kv(by_reason)}\n`);
|
|
846
|
+
process.stdout.write(` by resolution mode: ${kv(by_mode)}\n`);
|
|
847
|
+
for (const d of dispatches) {
|
|
848
|
+
for (const w of d.withdrawals) {
|
|
849
|
+
const tag = w.status === 'resolved' ? `resolved: ${w.mode}` : 'withdrawn';
|
|
850
|
+
process.stdout.write(` - ${d.dispatch}: ${w.identifier} (${w.reason}) [${tag}] findings ${(w.findings || []).map((n) => '#' + n).join(', ')}\n`);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
for (const p of problems) process.stderr.write(` ! ${p}\n`);
|
|
854
|
+
process.exit(0);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function requalifyCheck(args) {
|
|
858
|
+
const f = parseFlags(args, new Set());
|
|
859
|
+
const corpus = f._[0];
|
|
860
|
+
if (!corpus) fail(2, 'usage: study-swarm requalify --check <corpus-dir> [--json]');
|
|
861
|
+
if (!existsSync(corpus)) fail(2, `corpus not found: ${corpus}`);
|
|
862
|
+
const sidecars = statSync(corpus).isDirectory() ? walkByExt(corpus, /\.withdrawn\.json$/i) : [corpus];
|
|
863
|
+
const halts = []; // { sidecar, dispatch, identifier, reason, findings }
|
|
864
|
+
const problems = [];
|
|
865
|
+
let resolvedCount = 0;
|
|
866
|
+
for (const sc of sidecars) {
|
|
867
|
+
let stored;
|
|
868
|
+
try { stored = JSON.parse(readFileSync(sc, 'utf8')); }
|
|
869
|
+
catch (err) { problems.push(`${sc}: not valid JSON (${err.message})`); continue; }
|
|
870
|
+
// A non-object sidecar (null / array / scalar) is a reportable problem, not a crash — the per-file
|
|
871
|
+
// loop keeps checking the rest of the corpus and the offending file is named (PH-01).
|
|
872
|
+
if (!stored || typeof stored !== 'object' || Array.isArray(stored)) {
|
|
873
|
+
problems.push(`${sc}: sidecar is not a JSON object`); continue;
|
|
874
|
+
}
|
|
875
|
+
// Stale-format gate — a wrong-schema sidecar is "regenerate", not a self-integrity failure (PH-04).
|
|
876
|
+
const stale = staleSchema(stored, WITHDRAWN_SCHEMA, 'sidecar', sc);
|
|
877
|
+
if (stale) { problems.push(stale); continue; }
|
|
878
|
+
// Self-integrity: a hand-edited sidecar (e.g. a status forged to "resolved") fails closed.
|
|
879
|
+
if (typeof stored.withdrawn_sha256 !== 'string' || stored.withdrawn_sha256 !== withSha(stored, 'withdrawn_sha256').withdrawn_sha256) {
|
|
880
|
+
problems.push(`${sc}: withdrawn_sha256 self-integrity mismatch (the sidecar was hand-edited)`);
|
|
881
|
+
}
|
|
882
|
+
for (const w of stored.withdrawals || []) {
|
|
883
|
+
if (w.status === 'withdrawn') halts.push({ sidecar: sc.split(/[\\/]/).pop(), dispatch: stored.dispatch, identifier: w.identifier, reason: w.reason, findings: w.findings });
|
|
884
|
+
else if (w.status === 'resolved') resolvedCount += 1;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
const red = halts.length > 0 || problems.length > 0;
|
|
888
|
+
if (f.json) {
|
|
889
|
+
process.stdout.write(JSON.stringify({ ok: !red, halts, resolved: resolvedCount, problems }) + '\n');
|
|
890
|
+
process.exit(red ? 1 : 0);
|
|
891
|
+
}
|
|
892
|
+
if (!red) {
|
|
893
|
+
process.stdout.write(`ok ${corpus}: no unresolved evidence-withdrawn flags (${resolvedCount} resolved).\n`);
|
|
894
|
+
process.exit(0);
|
|
895
|
+
}
|
|
896
|
+
process.stderr.write(`x requalify --check ${corpus}: ${halts.length} unresolved evidence-withdrawn flag(s) — HALT\n`);
|
|
897
|
+
for (const h of halts) process.stderr.write(` - ${h.dispatch}: ${h.identifier} withdrawn (reason: ${h.reason}) — findings ${(h.findings || []).map((n) => '#' + n).join(', ')}. You may have relied on it; re-ground or override.\n`);
|
|
898
|
+
for (const p of problems) process.stderr.write(` - ${p}\n`);
|
|
899
|
+
process.exit(1);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function requalifyResolve(args) {
|
|
903
|
+
const f = parseFlags(args, new Set(['mode', 'note']));
|
|
904
|
+
const dispatch = f._[0];
|
|
905
|
+
const identifier = f._[1];
|
|
906
|
+
if (!dispatch || !identifier) fail(2, 'usage: study-swarm requalify --resolve <dispatch> <identifier> --mode removed|regrounded [--note <text>]');
|
|
907
|
+
if (!existsSync(dispatch)) fail(2, `dispatch not found: ${dispatch}`);
|
|
908
|
+
const scPath = withdrawnPathFor(dispatch);
|
|
909
|
+
if (!existsSync(scPath)) fail(2, `no tombstone sidecar at ${scPath} — nothing to resolve`);
|
|
910
|
+
const mode = f.mode ? String(f.mode) : null;
|
|
911
|
+
if (mode !== 'removed' && mode !== 'regrounded') fail(2, 'requalify --resolve requires --mode removed|regrounded');
|
|
912
|
+
const want = normIdent(identifier);
|
|
913
|
+
let body;
|
|
914
|
+
try { body = JSON.parse(readFileSync(scPath, 'utf8')); }
|
|
915
|
+
catch (err) { fail(2, `cannot read sidecar ${scPath}: ${err && err.code ? err.code : err.message}`); }
|
|
916
|
+
const entry = (body.withdrawals || []).find((w) => w.identifier === want);
|
|
917
|
+
if (!entry) fail(2, `no evidence-withdrawn flag for ${identifier} (normalized: ${want}) on ${dispatch}`);
|
|
918
|
+
|
|
919
|
+
if (entry.status === 'resolved') { // Idempotent: re-resolving is a no-op, no new audit entry (C7).
|
|
920
|
+
process.stdout.write(`ok ${scPath}: ${want} already resolved (mode: ${entry.resolution && entry.resolution.mode}) — no-op.\n`);
|
|
921
|
+
process.exit(0);
|
|
922
|
+
}
|
|
923
|
+
if (mode === 'removed') {
|
|
924
|
+
const still = findingsCiting(dispatch, want);
|
|
925
|
+
if (still.length) fail(1, `${dispatch} still cites ${want} (findings ${still.map((n) => '#' + n).join(', ')}) — cannot resolve --mode removed while the citation is present.\n Two ways forward:\n • remove the citation from the dispatch, then re-run --mode removed; or\n • if it was re-verified in place, use --mode regrounded --note "<attestation>".`);
|
|
926
|
+
} else if (mode === 'regrounded' && !f.note) {
|
|
927
|
+
fail(2, '--mode regrounded requires --note <attestation> — the CLI records that the sibling runner re-verified the finding, it does not itself re-verify');
|
|
928
|
+
}
|
|
929
|
+
entry.status = 'resolved';
|
|
930
|
+
entry.resolution = { mode, note: f.note ? String(f.note) : '' };
|
|
931
|
+
body.version += 1;
|
|
932
|
+
body.audit_trail.push({ seq: (body.audit_trail || []).length + 1, event: 'resolve', identifier: want, mode, note: f.note ? String(f.note) : '' });
|
|
933
|
+
const finalized = writeSidecar(dispatch, body);
|
|
934
|
+
process.stdout.write(`Resolved ${want} on ${finalized.dispatch} (mode: ${mode}). Sidecar version ${finalized.version}; withdrawn_sha256 ${finalized.withdrawn_sha256}\n`);
|
|
935
|
+
process.exit(0);
|
|
936
|
+
}
|
|
937
|
+
|
|
441
938
|
function main(argv) {
|
|
442
939
|
const [cmd, ...rest] = argv;
|
|
443
940
|
switch (cmd) {
|
|
@@ -445,6 +942,8 @@ function main(argv) {
|
|
|
445
942
|
case 'new': return cmdNew(rest[0]);
|
|
446
943
|
case 'lint': return cmdLint(rest);
|
|
447
944
|
case 'lock': return cmdLock(rest);
|
|
945
|
+
case 'withdraw': return cmdWithdraw(rest);
|
|
946
|
+
case 'requalify': return cmdRequalify(rest);
|
|
448
947
|
case 'version': case '--version': case '-v':
|
|
449
948
|
return void process.stdout.write(VERSION + '\n');
|
|
450
949
|
case 'help': case '--help': case '-h': case undefined:
|