@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.
@@ -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...> Check dispatches' citations against the sourcing standard.
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 found sourcing violations
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
- const ID = /(arxiv:\s*\d{4}\.\d{4,5}|10\.\d{4,9}\/\S+|https?:\/\/\S+)/i;
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
- const AUTHOR = /\p{Lu}[\p{L}.'’-]+(?:\s*,?\s*(?:&|and|et al\.?|\p{Lu}[\p{L}.'’-]+))*\s+\(?(?:19|20)\d{2}/u;
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
- function lintText(label, raw) {
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 an arXiv id's YYMM prefix
176
- // (e.g. 2402 in arXiv:2402.01817) can't masquerade as a publication year.
177
- const fNoIds = f.text.replace(/arxiv:\s*\d{4}\.\d{4,5}/gi, '').replace(/10\.\d{4,9}\/\S+/g, '');
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 URL).`, f.line, n);
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 *.dispatch.md files under a directory (skips node_modules/.git).
201
- function walkDispatches(dir) {
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 readdirSync(dir, { withFileTypes: true })) {
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(...walkDispatches(full));
207
- else if (/\.dispatch\.md$/i.test(entry.name)) out.push(full);
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 paths = args.filter((a) => a !== '--json');
220
- if (paths.length === 0) fail(2, 'usage: study-swarm lint [--json] <file|dir|-> [more...]');
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
- const payload = results.length === 1 ? results[0] : { ok: !anyFail, files: results };
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/v1';
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
- if (typeof s.output_sha256 === 'string') rec.output_sha256 = s.output_sha256;
341
- else if (s.output !== undefined) rec.output_sha256 = typeof s.output === 'string' ? sriText(s.output) : jcsDigest(s.output);
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: