@dogfood-lab/study-swarm 1.3.0 → 2.1.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,12 +19,23 @@ 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
+ return <dispatch> [--check]
23
+ Write the results of a dispatch: a sheet you can hand someone
24
+ (<stem>.results.md) and the same facts kept beside it
25
+ (<stem>.results.json) for the next run to open. --check fails
26
+ if either copy has drifted from the dispatch.
27
+ lint [--json] [--strict] <path...>
28
+ Check dispatches' citations against the sourcing standard.
23
29
  A <path> may be a file, a directory (linted recursively for
24
30
  *.dispatch.md), or "-" to read one dispatch from stdin.
31
+ --strict also flags orphan citations (a finding no Step-5 choice
32
+ references by number or author — "citations without a connection
33
+ are noise"); opt-in, so the default CI gate is unchanged.
34
+ lock --init <dispatch> Scaffold <dispatch>.orchestration.json — a fill-in-the-blanks
35
+ harness record to feed to "lock <dispatch> --from".
25
36
  lock <dispatch> --from <orchestration.json>
26
37
  Emit <dispatch>.lock.json — pin (per Step-2 agent) the resolved
27
- model + SHA-256 of the byte-exact prompt + SHA-256 of the tool
38
+ model + SHA-256 of the text-normalized prompt + SHA-256 of the tool
28
39
  schema, plus the verifier receipt, rolled into one lock_sha256.
29
40
  lock --verify <dispatch> [--from <orchestration.json>]
30
41
  Re-derive the deterministic hashes and assert they match the lock;
@@ -39,6 +50,10 @@ COMMANDS
39
50
  Fail closed (exit 1) for any dispatch carrying an unresolved
40
51
  evidence-withdrawn flag — the andon that HALTS a withdrawn finding's
41
52
  dependents until it is removed or re-grounded. Gates CI.
53
+ requalify --status <corpus-dir> [--json]
54
+ Read-only evidence-health VIEW of a corpus: withdrawn vs resolved
55
+ counts, a breakdown by reason and resolution mode, per-dispatch
56
+ lines. Informational (exit 0), unlike the --check gate.
42
57
  requalify --resolve <dispatch> <identifier> --mode removed|regrounded [--note <text>]
43
58
  Clear a flag once the finding is removed (the citation is gone) or
44
59
  re-grounded (re-verified clean by the sibling runner; --note records
@@ -47,12 +62,14 @@ COMMANDS
47
62
  version Print the version.
48
63
 
49
64
  EXIT CODES
50
- 0 ok / lint clean
51
- 1 lint found sourcing violations
65
+ 0 ok / lint clean / verify clean
66
+ 1 a gate failed: a lint sourcing violation, lock --verify drift, or
67
+ requalify --check (an unresolved evidence-withdrawn flag, or a sidecar
68
+ the check could not trust)
52
69
  2 usage or runtime error
53
70
 
54
71
  NOTE
55
- lint checks citation FORM (Step 3: author + year + a resolvable arXiv/DOI/URL,
72
+ lint checks citation FORM (Step 3: author + year + a resolvable arXiv/DOI/URL/RFC,
56
73
  no "studies show…" gestures) — it does not judge whether a source is legitimate
57
74
  or actually supports the claim. That is Step 4, below.
58
75
 
@@ -103,7 +120,7 @@ const template = (slug, stamp) => `<!-- ${stamp} -->
103
120
 
104
121
  ## Step 4 — External verification
105
122
  <!-- Different model family, reasoning-stripped. Run: roleos verify-citations ${slug}.dispatch.md
106
- HALT on fabricated/misattributed; halt-and-escalate if the verifier or oracle is unavailable. -->
123
+ Drop a fabricated citation; correct a misattribution once and re-verify; halt-and-escalate only if the verifier or oracle is unavailable. -->
107
124
  - [ ] every citation resolved by retrieval (arXiv/DOI), not model memory
108
125
  - [ ] every finding matches what its source actually claims (groundedness)
109
126
  - [ ] >= 3 decorrelated lenses (retrieval oracle + >= 2 different model families)
@@ -135,17 +152,93 @@ function cmdNew(slug) {
135
152
  // --- lint core ------------------------------------------------------------
136
153
 
137
154
  const YEAR = /\b(19|20)\d{2}\b/;
138
- const ID = /(arxiv:\s*\d{4}\.\d{4,5}|10\.\d{4,9}\/\S+|https?:\/\/\S+)/i;
155
+ // A resolvable identifier: an arXiv id, a DOI, a direct URL, or a bare RFC number
156
+ // (RFC parity keeps `lint` in step with the sourcing standard and with normIdent, which
157
+ // already treats `RFC NNNN` as first-class in withdraw/requalify).
158
+ const ID = /(arxiv:\s*\d{4}\.\d{4,5}|10\.\d{4,9}\/\S+|https?:\/\/\S+|\brfc[\s/-]?\d{3,5}\b)/i;
139
159
  const PLACEHOLDER = /arXiv:_{2,}|<finding>|<authors>|<year>|<implication>/i;
140
160
  const BANNED = /\b(studies show|research suggests|it'?s well[- ]established|well[- ]established that)\b/i;
141
161
  // An author cite: a capitalized name (Unicode-aware, so "Buçinca" counts), optionally
142
162
  // followed by "et al.", "&", "and", or further surnames, immediately before the year.
143
- // Accepts "Huang et al. 2023", "Walters & Wilder 2023", "Panickssery, Bowman & Feng 2024";
163
+ // Accepts "Huang et al. 2023", "Walters & Wilder 2023", "Panickssery, Bowman & Feng 2024",
164
+ // and space-separated org authors ("OASIS CSAF Technical Committee 2022");
144
165
  // flags an author-less finding like "**Foo.** 2024 (arXiv:…)".
145
- const AUTHOR = /\p{Lu}[\p{L}.'’-]+(?:\s*,?\s*(?:&|and|et al\.?|\p{Lu}[\p{L}.'’-]+))*\s+\(?(?:19|20)\d{2}/u;
166
+ // The inner group requires a non-empty separator per iteration (`,?\s+`, never the old
167
+ // empty-matchable `\s*,?\s*`) and is bounded ({0,24}), so it is linear-time — the previous
168
+ // form had catastrophic backtracking (ReDoS) on a long capitalized/`and`-joined run with no
169
+ // trailing year, hanging the CI-gating `lint` command.
170
+ const AUTHOR = /\p{Lu}[\p{L}.'’-]+(?:,?\s+(?:&|and|et al\.?|\p{Lu}[\p{L}.'’-]+)){0,24}(?:,\s*|\s+)\(?(?:19|20)\d{2}/u;
171
+ // A single function word before a year is not an author ("The 2024"). A name, "et al.", or a
172
+ // multi-word organization still is. Checked after the regex so the stop-list cannot reintroduce
173
+ // the empty-match backtracking the quantified group above was rewritten to avoid.
174
+ const AUTHOR_STOP = new Set('the a an this that these those it its in on for with from and but or of to as at by we our'.split(' '));
175
+ function authorOk(text) {
176
+ const re = new RegExp(AUTHOR.source, 'gu');
177
+ for (const m of text.matchAll(re)) {
178
+ const phrase = m[0].replace(/(?:,\s*|\s+)\(?((?:19|20)\d{2})\s*$/, '').trim();
179
+ const words = phrase.split(/\s+/).map((w) => w.replace(/[.,]+$/u, '').replace(/(?:['’]s)$/iu, ''));
180
+ const bareStop = words.length === 1 && AUTHOR_STOP.has(words[0].toLowerCase());
181
+ if (!bareStop) return true;
182
+ }
183
+ return false;
184
+ }
185
+
186
+ // --- strict mode: Step-5 connection / orphan-citation check (opt-in --strict) --------------
187
+ // Step 5 requires each finding to inform a design choice — "citations without a connection are
188
+ // noise" (PROTOCOL.md). This makes the protocol's one otherwise-unexecutable failure mode ("orphan
189
+ // citation") deterministic: a Step-3 finding whose number OR first-author token is never referenced
190
+ // in the "Step 5 / Architecture" section is flagged. Deliberately LIBERAL about what counts as a
191
+ // reference — a missed orphan is safer than a false orphan — so it never fails a dispatch that
192
+ // connected a finding in any reasonable numeric ("(findings 1, 3)") or prose ("Kim 2025") form.
193
+ const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
194
+ // The first-author surname token of a finding: the first Capitalized word after the bold **title**.
195
+ function authorTokenOf(findingText) {
196
+ const afterBold = String(findingText).replace(/^\s*\d+\.\s*/, '').replace(/^\s*\*\*[^*]*\*\*/, '');
197
+ const m = afterBold.match(/\p{Lu}[\p{L}.'’-]+/u);
198
+ return m ? m[0] : null;
199
+ }
200
+ // Finding numbers referenced in a Step-5 body: any "#N", plus every integer following the word
201
+ // "finding"/"findings" (covers "(findings 1, 3 and 5)"). Liberal.
202
+ function referencedNumbers(body) {
203
+ const nums = new Set();
204
+ for (const m of body.matchAll(/#\s*(\d+)/g)) nums.add(Number(m[1]));
205
+ for (const m of body.matchAll(/\bfindings?\b[\s:#-]*((?:\d+[\s,#&-]*(?:and\s+)?)+)/gi)) {
206
+ for (const d of m[1].match(/\d+/g) || []) nums.add(Number(d));
207
+ }
208
+ return nums;
209
+ }
210
+ function headingLevel(line) {
211
+ const m = /^(#{1,6})\s/.exec(line);
212
+ return m ? m[1].length : 0;
213
+ }
214
+ // A section runs until the next heading of the same or higher level. A ### inside ## stays inside.
215
+ function sectionEnd(lines, start) {
216
+ const level = headingLevel(lines[start]) || 6;
217
+ for (let i = start + 1; i < lines.length; i++) {
218
+ const lv = headingLevel(lines[i]);
219
+ if (lv && lv <= level) return i;
220
+ }
221
+ return lines.length;
222
+ }
223
+ function isStep5Heading(text) {
224
+ const t = String(text || '').trim();
225
+ // The section title, not a later note that mentions the words.
226
+ return /^step\s*5\b/i.test(t) || /^architecture$/i.test(t);
227
+ }
228
+ // The Step-5 / Architecture section body (last real Step 5 heading → next same-or-higher heading), or null.
229
+ function step5Body(lines) {
230
+ let s = -1;
231
+ for (let i = 0; i < lines.length; i++) {
232
+ const h = lines[i].match(/^#{1,6}\s+(.*?)\s*$/);
233
+ if (h && isStep5Heading(h[1])) s = i;
234
+ }
235
+ if (s === -1) return null;
236
+ return lines.slice(s + 1, sectionEnd(lines, s)).join('\n');
237
+ }
146
238
 
147
- // Check one dispatch's text. Returns a structured result; never exits.
148
- function lintText(label, raw) {
239
+ // Check one dispatch's text. Returns a structured result; never exits. `strict` adds the Step-5
240
+ // orphan-citation check (opt-in, so the default CI gate stays stable).
241
+ function lintText(label, raw, strict) {
149
242
  const lines = raw.split(/\r?\n/);
150
243
  const problems = []; // { finding, line, rule, message }
151
244
  const add = (rule, message, line = null, finding = null) => problems.push({ finding, line, rule, message });
@@ -161,10 +254,7 @@ function lintText(label, raw) {
161
254
  add('no-section', 'no "Research grounding" section found — every dispatch needs one (Step 3).');
162
255
  return { file: label, ok: false, findingCount: 0, problems, findings: [] };
163
256
  }
164
- let end = lines.length;
165
- for (let i = start + 1; i < lines.length; i++) {
166
- if (/^#{1,6}\s/.test(lines[i])) { end = i; break; }
167
- }
257
+ const end = sectionEnd(lines, start);
168
258
  const section = lines.slice(start + 1, end);
169
259
 
170
260
  // Split into findings (numbered items + continuation lines), ignoring fenced code blocks
@@ -179,23 +269,36 @@ function lintText(label, raw) {
179
269
  else if (cur && l.trim()) cur.text += ' ' + l.trim();
180
270
  });
181
271
  if (cur) findings.push(cur);
272
+ if (inFence) add('unclosed-fence', 'Research grounding has an unclosed code fence, so the lines after it were not checked.', start + 1 + section.length);
182
273
 
183
274
  if (findings.length === 0) add('no-findings', 'Research grounding has no numbered findings.');
184
275
 
185
276
  const parsed = [];
277
+ const seenNumbers = new Set();
186
278
  findings.forEach((f, i) => {
187
- const n = i + 1;
279
+ // The citation number is the integer the author wrote, not this array's index.
280
+ // "2." then "4." are findings 2 and 4. Step 5 and requalify both use that number.
281
+ const declared = /^(\d+)\.\s/.exec(f.text.trim());
282
+ const n = declared ? Number(declared[1]) : i + 1;
283
+ if (seenNumbers.has(n)) add('duplicate-finding-number', `finding number ${n} is used more than once.`, f.line, n);
284
+ seenNumbers.add(n);
188
285
  if (PLACEHOLDER.test(f.text)) add('placeholder', `finding ${n}: still has template placeholders — fill it in.`, f.line, n);
189
- // Strip identifiers before the year check so an arXiv id's YYMM prefix
190
- // (e.g. 2402 in arXiv:2402.01817) can't masquerade as a publication year.
191
- const fNoIds = f.text.replace(/arxiv:\s*\d{4}\.\d{4,5}/gi, '').replace(/10\.\d{4,9}\/\S+/g, '');
286
+ // Strip identifiers before the year check so digits inside a citation can't masquerade
287
+ // as a publication year: an arXiv id's YYMM prefix (e.g. 2402 in arXiv:2402.01817), a DOI,
288
+ // or a year-like URL path segment (e.g. /2024/ in https://host/2024/paper). URLs are
289
+ // stripped first so a DOI-bearing URL is removed whole.
290
+ const fNoIds = f.text.replace(/https?:\/\/\S+/gi, '').replace(/arxiv:\s*\d{4}\.\d{4,5}/gi, '').replace(/10\.\d{4,9}\/\S+/g, '');
192
291
  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);
193
- 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);
194
- const idm = f.text.match(ID);
195
- if (!idm) add('missing-id', `finding ${n}: missing an identifier (arXiv:NNNN.NNNNN, DOI, or URL).`, f.line, n);
292
+ if (!authorOk(f.text)) add('missing-author', `finding ${n}: missing an author before the year (e.g. "Huang et al. 2023").`, f.line, n);
293
+ // Every identifier, not the first. A URL earlier in the sentence must not hide a later arXiv
294
+ // or DOI from withdraw / requalify --resolve --mode removed.
295
+ const identifiers = [];
296
+ for (const m of f.text.matchAll(new RegExp(ID.source, 'gi'))) {
297
+ identifiers.push(cleanIdent(m[0]));
298
+ }
299
+ if (identifiers.length === 0) add('missing-id', `finding ${n}: missing an identifier (arXiv:NNNN.NNNNN, DOI, URL, or RFC number).`, f.line, n);
196
300
  const ym = fNoIds.match(YEAR);
197
- const ident = idm ? idm[0].replace(/\s+/g, '').replace(/[).,;]+$/, '') : null;
198
- parsed.push({ finding: n, year: ym ? ym[0] : null, identifier: ident });
301
+ parsed.push({ finding: n, year: ym ? ym[0] : null, identifier: identifiers[0] || null, identifiers });
199
302
  });
200
303
 
201
304
  // Banned gesture anywhere in the section (outside fences): a finding STATES its result,
@@ -208,30 +311,97 @@ function lintText(label, raw) {
208
311
  }
209
312
  });
210
313
 
314
+ // --strict: flag orphan citations — a finding no Step-5 choice connects to (Step 5 / orphan-citation).
315
+ if (strict) {
316
+ const body = step5Body(lines);
317
+ if (body === null) {
318
+ add('no-step5', 'strict: no "Step 5" / "Architecture" section found to check finding connections against.');
319
+ } else {
320
+ const refs = referencedNumbers(body);
321
+ findings.forEach((f, i) => {
322
+ const n = parsed[i].finding;
323
+ const tok = authorTokenOf(f.text);
324
+ // Match the author token OR (for a hyphenated surname like "Garcia-Molina") its first
325
+ // component, so a Step-5 reference to just "Garcia" still connects — the liberal direction.
326
+ const alts = tok ? [...new Set([tok, tok.split('-')[0]])].filter((t) => t.length >= 2) : [];
327
+ const tokHit = alts.some((t) => new RegExp('(?<![\\p{L}])' + escRe(t) + '(?![\\p{L}])', 'iu').test(body));
328
+ if (!refs.has(n) && !tokHit) {
329
+ 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);
330
+ }
331
+ });
332
+ }
333
+ }
334
+
211
335
  return { file: label, ok: problems.length === 0, findingCount: findings.length, problems, findings: parsed };
212
336
  }
213
337
 
214
- // Recursively collect *.dispatch.md files under a directory (skips node_modules/.git).
215
- function walkDispatches(dir) {
338
+ // Recursively collect files whose name matches `re` under `dir`, sorted for determinism.
339
+ // A gate that could not read part of the tree must not report the rest as clean:
340
+ // - an unreadable directory is recorded on `report.unreadable` (the caller fails the gate);
341
+ // - a symlink or junction whose name matches `re`, or that points at a directory, is not followed
342
+ // and is recorded on `report.skippedLinks`. A link to an ordinary non-matching file is ignored.
343
+ // A realpath `seen` set still breaks a cycle if a real directory is reached twice (PH-03).
344
+ // Skips node_modules/.git by name, before the link check.
345
+ function newWalkReport() {
346
+ return { unreadable: [], skippedLinks: [] };
347
+ }
348
+ function walkFiles(dir, re, seen, report) {
349
+ seen = seen || new Set();
350
+ report = report || newWalkReport();
351
+ let real; try { real = realpathSync(dir); } catch { real = dir; }
352
+ if (seen.has(real)) return [];
353
+ seen.add(real);
354
+ let entries;
355
+ try { entries = readdirSync(dir, { withFileTypes: true }); }
356
+ catch (err) {
357
+ const code = err && err.code ? err.code : String(err && err.message || err);
358
+ report.unreadable.push({ dir, code });
359
+ process.stderr.write(`study-swarm: cannot list ${dir}: ${code}\n`);
360
+ return [];
361
+ }
216
362
  const out = [];
217
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
363
+ for (const entry of entries) {
218
364
  if (entry.name === 'node_modules' || entry.name === '.git') continue;
219
365
  const full = join(dir, entry.name);
220
- if (entry.isDirectory()) out.push(...walkDispatches(full));
221
- else if (/\.dispatch\.md$/i.test(entry.name)) out.push(full);
366
+ if (entry.isSymbolicLink()) {
367
+ let followed = null;
368
+ try { followed = statSync(full); } catch { followed = null; }
369
+ if (re.test(entry.name) || !followed || followed.isDirectory()) {
370
+ report.skippedLinks.push(full);
371
+ process.stderr.write(`study-swarm: not following ${full}\n`);
372
+ }
373
+ continue;
374
+ }
375
+ if (entry.isDirectory()) out.push(...walkFiles(full, re, seen, report));
376
+ else if (re.test(entry.name)) out.push(full);
222
377
  }
223
378
  return out.sort();
224
379
  }
380
+ function walkBlockedMessage(report) {
381
+ if (!report) return null;
382
+ const parts = [];
383
+ if (report.unreadable.length) {
384
+ parts.push(`${report.unreadable.length} unlistable director${report.unreadable.length === 1 ? 'y' : 'ies'} (${report.unreadable.map((u) => u.dir).join(', ')})`);
385
+ }
386
+ if (report.skippedLinks.length) {
387
+ parts.push(`${report.skippedLinks.length} skipped symlink(s) (${report.skippedLinks.join(', ')})`);
388
+ }
389
+ return parts.length ? `refusing a clean result — the walk could not read everything under the path you passed: ${parts.join('; ')}` : null;
390
+ }
391
+ function walkDispatches(dir, report) { return walkFiles(dir, /\.dispatch\.md$/i, undefined, report); }
225
392
 
226
393
  function readTarget(p) {
227
394
  try { return { label: p, raw: readFileSync(p, 'utf8') }; }
228
395
  catch (err) { fail(2, `cannot read ${p}: ${err && err.code ? err.code : err.message}`); }
229
396
  }
230
397
 
398
+ const LINT_SCHEMA = 'study-swarm.lint/v1'; // versioned handle for --json consumers (FG-03)
399
+
231
400
  function cmdLint(args) {
232
401
  const json = args.includes('--json');
233
- const paths = args.filter((a) => a !== '--json');
234
- if (paths.length === 0) fail(2, 'usage: study-swarm lint [--json] <file|dir|-> [more...]');
402
+ const strict = args.includes('--strict'); // opt-in: also flag Step-5 orphan citations (FG-01)
403
+ const paths = args.filter((a) => a !== '--json' && a !== '--strict');
404
+ if (paths.length === 0) fail(2, 'usage: study-swarm lint [--json] [--strict] <file|dir|-> [more...]');
235
405
 
236
406
  const targets = [];
237
407
  for (const p of paths) {
@@ -244,7 +414,10 @@ function cmdLint(args) {
244
414
  }
245
415
  if (!existsSync(p)) fail(2, `path not found: ${p}`);
246
416
  if (statSync(p).isDirectory()) {
247
- const files = walkDispatches(p);
417
+ const report = newWalkReport();
418
+ const files = walkDispatches(p, report);
419
+ const blocked = walkBlockedMessage(report);
420
+ if (blocked) fail(1, blocked);
248
421
  if (files.length === 0) fail(2, `no .dispatch.md files found under ${p}`);
249
422
  for (const f of files) targets.push(readTarget(f));
250
423
  } else {
@@ -252,20 +425,23 @@ function cmdLint(args) {
252
425
  }
253
426
  }
254
427
 
255
- const results = targets.map((t) => lintText(t.label, t.raw));
428
+ const results = targets.map((t) => lintText(t.label, t.raw, strict));
256
429
  const anyFail = results.some((r) => !r.ok);
257
430
 
258
431
  if (json) {
259
- const payload = results.length === 1 ? results[0] : { ok: !anyFail, files: results };
432
+ // A versioned envelope so a CI/roleos consumer can detect a shape change (FG-03), matching the
433
+ // schema/version pattern the lock, sidecar, and receipt objects already carry.
434
+ const meta = { schema: LINT_SCHEMA, study_swarm_version: VERSION };
435
+ const payload = results.length === 1 ? { ...meta, ...results[0] } : { ...meta, ok: !anyFail, files: results };
260
436
  process.stdout.write(JSON.stringify(payload) + '\n');
261
437
  process.exit(anyFail ? 1 : 0);
262
438
  }
263
439
 
264
440
  for (const r of results) {
265
441
  if (r.ok) {
266
- process.stdout.write(`ok ${r.file}: ${r.findingCount} finding(s), all sourced.\n`);
442
+ process.stdout.write(`ok ${r.file}: ${r.findingCount} finding(s), all sourced${strict ? ' and connected' : ''}.\n`);
267
443
  } else {
268
- process.stderr.write(`x ${r.file}: ${r.problems.length} sourcing issue(s)\n`);
444
+ process.stderr.write(`x ${r.file}: ${r.problems.length} ${strict ? 'issue(s)' : 'sourcing issue(s)'}\n`);
269
445
  for (const pr of r.problems) process.stderr.write(` - ${pr.message}\n`);
270
446
  }
271
447
  }
@@ -274,6 +450,10 @@ function cmdLint(args) {
274
450
  `\nStep 3 (sourcing FORM) is satisfied — this does NOT confirm the citations exist or support the claim.\n` +
275
451
  `Run Step 4 (existence + groundedness, a different model family): roleos verify-citations <file>\n`,
276
452
  );
453
+ } else {
454
+ // Symmetry with the clean-path nudge: tell the user what to do next (H1).
455
+ const again = ['study-swarm', 'lint', ...(strict ? ['--strict'] : []), ...paths].join(' ');
456
+ process.stderr.write(`\nFix the issue(s) above, then re-run ${again}.\n(This checks Step 3 sourcing FORM${strict ? ' + Step 5 connections' : ''} only.)\n`);
277
457
  }
278
458
  process.exit(anyFail ? 1 : 0);
279
459
  }
@@ -281,20 +461,34 @@ function cmdLint(args) {
281
461
  // --- lock core (dispatch.lock.json — the PIN_PER_STEP feature) ------------------
282
462
  // Design + research grounding: examples/study-swarm-lock.dispatch.md (choices L1-L11).
283
463
  // The CLI is a PURE FUNCTION of provided bytes: the orchestration harness emits the record
284
- // (resolved models + byte-exact prompts + tool schemas + verifier receipt); the CLI only
464
+ // (resolved models + text-normalized prompts + tool schemas + verifier receipt); the CLI only
285
465
  // canonicalizes + hashes + validates it. No network, no model calls (L2).
286
466
 
287
- const LOCK_SCHEMA = 'dispatch.lock/v1';
467
+ const LOCK_SCHEMA = 'dispatch.lock/v2';
288
468
 
289
469
  // Self-describing digest "sha256-<base64>" — the W3C Subresource Integrity form: algorithm-
290
470
  // prefixed (so it's algorithm-agile) and used fail-closed on mismatch (L9; lock dispatch finding 38).
291
471
  function sriBytes(buf) { return 'sha256-' + createHash('sha256').update(buf).digest('base64'); }
472
+ function sha256DigestOk(value) {
473
+ const m = /^sha256-([A-Za-z0-9+/]+)=*$/.exec(value);
474
+ if (!m) return false;
475
+ return Buffer.from(m[1], 'base64').length === 32;
476
+ }
477
+ // Domain-separation tags (v2): a TEXT preimage and a structured-JSON (JCS) preimage are hashed in
478
+ // DISJOINT spaces, so a prompt whose literal text happens to equal some tool schema's canonical JSON
479
+ // can never produce the same digest as that schema (the tagged-hash / DSSE "hash known bytes with a
480
+ // context" rule the lock dispatch cites — TUF/Rekor/CT). Without a tag, jcsDigest({}) === sriText('{}').
481
+ // The tag carries the schema major, so bumping it is itself a lock-format change — hence the v1 -> v2
482
+ // bump on LOCK_SCHEMA / WITHDRAWN_SCHEMA / RECEIPT_SCHEMA, and the schema gate on read that turns a
483
+ // stale-format lock into a clear "regenerate" message instead of a confusing hash mismatch.
484
+ const DOMAIN_TEXT = 'study-swarm/v2/text\n';
485
+ const DOMAIN_JCS = 'study-swarm/v2/jcs\n';
292
486
  // Normalize TEXT before hashing so the same content hashes identically across platforms — strip a
293
487
  // BOM, fold CRLF/CR -> LF, NFC-normalize. Without this, a CRLF working tree (Windows) and an LF
294
488
  // checkout (git/CI) produce different hashes — the exact cross-platform drift our Q2 findings warn
295
489
  // about (RFC 8259 BOM, UAX #15 NFC, and CRLF/LF). Applied to every text input that gets hashed.
296
490
  function normText(s) { s = String(s); if (s.charCodeAt(0) === 0xFEFF) s = s.slice(1); return s.replace(/\r\n?/g, '\n').normalize('NFC'); }
297
- function sriText(str) { return sriBytes(Buffer.from(normText(str), 'utf8')); }
491
+ function sriText(str) { return sriBytes(Buffer.from(DOMAIN_TEXT + normText(str), 'utf8')); }
298
492
 
299
493
  // RFC 8785 (JCS) canonical JSON, for the structured JSON the CLI assembles ITSELF (the tool
300
494
  // surface and the lock body): NFC-normalize strings, sort object keys by UTF-16 code unit (JS
@@ -321,7 +515,7 @@ function jcs(value) {
321
515
  };
322
516
  return ser(value);
323
517
  }
324
- function jcsDigest(value) { return sriBytes(Buffer.from(jcs(value), 'utf8')); }
518
+ function jcsDigest(value) { return sriBytes(Buffer.from(DOMAIN_JCS + jcs(value), 'utf8')); }
325
519
 
326
520
  // The lock sits beside its dispatch: <dir>/<stem>.lock.json (stem strips a trailing .dispatch.md).
327
521
  function lockPathFor(dispatch) {
@@ -341,18 +535,52 @@ function buildLockObject(dispatchPath, orchestration) {
341
535
  if (s == null || s[k] === undefined || s[k] === null) fail(2, `orchestration step ${i + 1} is missing "${k}"`);
342
536
  return s[k];
343
537
  };
538
+ const model = need('resolved_model');
539
+ const prompt = need('prompt');
540
+ if (typeof model !== 'string' || !model.trim()) fail(2, `orchestration step ${i + 1} resolved_model must be a non-empty string`);
541
+ if (typeof prompt !== 'string') fail(2, `orchestration step ${i + 1} prompt must be a string`);
542
+ const qid = need('question_id');
543
+ if (typeof qid !== 'string' || !qid.trim()) fail(2, `orchestration step ${i + 1} question_id must be a non-empty string`);
344
544
  const rec = {
345
- question_id: String(need('question_id')),
346
- resolved_model: String(need('resolved_model')), // L6 — the resolved id, never an alias
347
- prompt_sha256: sriText(String(need('prompt'))), // L3 — text-normalized (LF/NFC/BOM), not JCS-restructured
348
- tool_schema_sha256: jcsDigest(need('tool_schema')), // L5 — canonicalized tool surface
545
+ question_id: qid,
546
+ resolved_model: model, // L6 — the resolved id, never an alias
547
+ prompt_sha256: sriText(prompt), // L3 — text-normalized (LF/NFC/BOM), not JCS-restructured
548
+ tool_schema_sha256: (() => {
549
+ const schema = need('tool_schema');
550
+ if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
551
+ fail(2, `orchestration step ${i + 1} tool_schema must be a JSON object`);
552
+ }
553
+ return jcsDigest(schema);
554
+ })(),
349
555
  };
350
- if (s.schema_dialect) rec.schema_dialect = String(s.schema_dialect); // L5 — dialect is contract
351
- if (s.params && typeof s.params === 'object') rec.params = s.params;
556
+ if (s.schema_dialect !== undefined) {
557
+ if (typeof s.schema_dialect !== 'string' || !s.schema_dialect.trim()) fail(2, `orchestration step ${i + 1} schema_dialect must be a non-empty string`);
558
+ rec.schema_dialect = s.schema_dialect;
559
+ }
560
+ if (s.params !== undefined) {
561
+ if (!s.params || typeof s.params !== 'object' || Array.isArray(s.params)) fail(2, `orchestration step ${i + 1} params must be a JSON object`);
562
+ rec.params = s.params;
563
+ }
352
564
  // L7 — output hash for DRIFT DETECTION only (not determinism). The harness may ship the raw
353
565
  // output (the CLI hashes it) OR a pre-computed output_sha256 (large outputs needn't be shipped).
354
- if (typeof s.output_sha256 === 'string') rec.output_sha256 = s.output_sha256;
355
- else if (s.output !== undefined) rec.output_sha256 = typeof s.output === 'string' ? sriText(s.output) : jcsDigest(s.output);
566
+ // A caller-supplied digest is validated to the SRI sha256- shape here, so a malformed hash is
567
+ // rejected where it enters rather than mis-surfacing as "drift" on a later verify (PH-05).
568
+ const hashed = s.output !== undefined ? (typeof s.output === 'string' ? sriText(s.output) : jcsDigest(s.output)) : null;
569
+ if (s.output_sha256 !== undefined && typeof s.output_sha256 !== 'string') {
570
+ fail(2, `orchestration step ${i + 1} output_sha256 must be a string`);
571
+ }
572
+ if (typeof s.output_sha256 === 'string') {
573
+ if (!sha256DigestOk(s.output_sha256)) {
574
+ fail(2, `orchestration step ${i + 1} output_sha256 is not a sha256 digest of 32 bytes: "${s.output_sha256}"`);
575
+ }
576
+ if (hashed !== null && hashed !== s.output_sha256) {
577
+ const preimage = typeof s.output === 'string'
578
+ ? 'the text-normalized digest under study-swarm/v2/text (BOM stripped, newlines folded to LF, NFC), not a raw SHA-256 of the output bytes'
579
+ : 'the canonical-JSON digest under study-swarm/v2/jcs';
580
+ fail(2, `orchestration step ${i + 1} output_sha256 does not match ${preimage}. Recomputed ${hashed}.`);
581
+ }
582
+ rec.output_sha256 = s.output_sha256;
583
+ } else if (hashed !== null) rec.output_sha256 = hashed;
356
584
  return rec;
357
585
  });
358
586
  const lock = {
@@ -362,8 +590,10 @@ function buildLockObject(dispatchPath, orchestration) {
362
590
  dispatch_sha256: sriText(dispatchText), // pins the dispatch text (text-normalized)
363
591
  steps,
364
592
  };
365
- if (orchestration.verification && typeof orchestration.verification === 'object') {
366
- lock.verification = orchestration.verification; // L10 — the external-verifier receipt
593
+ if (orchestration.verification !== undefined) {
594
+ const v = orchestration.verification;
595
+ if (!v || typeof v !== 'object' || Array.isArray(v)) fail(2, 'orchestration verification must be a JSON object');
596
+ lock.verification = v; // L10 — the external-verifier receipt
367
597
  }
368
598
  // L1/L9 — rollup over the whole body (this object, before lock_sha256 is added) as ONE flat
369
599
  // canonical object: distinct keys give domain separation, the steps array's explicit length
@@ -372,12 +602,27 @@ function buildLockObject(dispatchPath, orchestration) {
372
602
  return lock;
373
603
  }
374
604
 
605
+ // A forward-compat guard (PH-04): an artifact whose `schema` string names a version this CLI does
606
+ // not write is reported as "regenerate", not as a confusing hash/integrity mismatch — because the
607
+ // hash preimage (the domain tag) changes with the schema major, a v1 artifact read by a v2 CLI would
608
+ // otherwise fail self-integrity with a misleading "the body was edited" accusation. Absent schema =
609
+ // no gate (the self-integrity check still applies). Returns a message string, or null when in-version.
610
+ function staleSchema(stored, expected, kind, path) {
611
+ if (stored && typeof stored === 'object' && typeof stored.schema === 'string' && stored.schema !== expected) {
612
+ return `${path}: ${kind} is schema "${stored.schema}"; this study-swarm v${VERSION} understands "${expected}" — regenerate it (the hash format changed between schema versions).`;
613
+ }
614
+ return null;
615
+ }
616
+
375
617
  // Verify a lock: self-integrity always; source-drift too when an orchestration record is supplied.
376
618
  // Strict-match, fail-closed (L8): returns a list of problems (empty = clean).
377
619
  function verifyLockObject(dispatchPath, lockPath, orchestration) {
378
620
  let stored;
379
621
  try { stored = JSON.parse(readFileSync(lockPath, 'utf8')); }
380
622
  catch (err) { fail(2, `cannot read lock ${lockPath}: ${err && err.code ? err.code : err.message}`); }
623
+ // 0) Stale-format gate — a wrong-schema lock is "regenerate", not a hash mismatch (PH-04).
624
+ const stale = staleSchema(stored, LOCK_SCHEMA, 'lock', lockPath);
625
+ if (stale) return [stale];
381
626
  const problems = [];
382
627
  // 1) Self-integrity — recompute lock_sha256 over the stored body (detects a hand-edited lock).
383
628
  if (!stored || typeof stored !== 'object' || typeof stored.lock_sha256 !== 'string') {
@@ -408,7 +653,46 @@ function verifyLockObject(dispatchPath, lockPath, orchestration) {
408
653
  return problems;
409
654
  }
410
655
 
656
+ // FG-05 — scaffold the orchestration.json the harness must supply, mirroring what `new` does for a
657
+ // dispatch. A deterministic Write of a template (no network, no models); refuses to overwrite. The
658
+ // orchestration record is the harder artifact to hand-author, and its shape was documented only in
659
+ // the large worked examples — this gives a fill-in-the-blanks starting point.
660
+ const orchTemplate = () => JSON.stringify({
661
+ _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).',
662
+ steps: [
663
+ {
664
+ question_id: '<Q1-short-slug>',
665
+ resolved_model: '<resolved model id, e.g. claude-opus-4-8 — never a floating alias>',
666
+ prompt: '<the prompt string this research agent was given; the lock hashes it text-normalized: BOM stripped, CR/CRLF folded to LF, NFC>',
667
+ tool_schema: { type: 'object', properties: {} },
668
+ schema_dialect: 'https://json-schema.org/draft/2020-12/schema',
669
+ },
670
+ ],
671
+ verification: {
672
+ runner: 'roleos verify-citations',
673
+ tool: 'prism verify --type citations',
674
+ verifier_family: '<a DIFFERENT model family than the synthesizer>',
675
+ receipt_id: '<prism-receipt-id>',
676
+ receipt_chain_sha256: '<the verifier receipt chain hash>',
677
+ },
678
+ }, null, 2) + '\n';
679
+
680
+ function orchPathFor(dispatch) {
681
+ const base = dispatch.split(/[\\/]/).pop().replace(/(\.dispatch)?\.md$/i, '');
682
+ return join(dirname(dispatch), `${base}.orchestration.json`);
683
+ }
684
+
411
685
  function cmdLock(args) {
686
+ if (args.includes('--init')) { // FG-05 — scaffold the orchestration record for a dispatch
687
+ const dispatch = args.filter((a) => a !== '--init')[0];
688
+ if (!dispatch) fail(2, 'usage: study-swarm lock --init <dispatch>');
689
+ if (!existsSync(dispatch)) fail(2, `dispatch not found: ${dispatch}`);
690
+ const out = orchPathFor(dispatch);
691
+ if (existsSync(out)) fail(2, `refusing to overwrite existing ${out}`);
692
+ writeFileSync(out, orchTemplate(), 'utf8');
693
+ process.stdout.write(`Created ${out}\nFill in each step (one per Step-2 research agent), then: study-swarm lock ${dispatch} --from ${out}\n`);
694
+ return;
695
+ }
412
696
  const verify = args.includes('--verify');
413
697
  const rest = args.filter((a) => a !== '--verify');
414
698
  let orchPath = null;
@@ -445,7 +729,7 @@ function cmdLock(args) {
445
729
  }
446
730
 
447
731
  if (!orchestration) {
448
- fail(2, 'study-swarm lock <dispatch> requires --from <orchestration.json> — the harness-emitted record of resolved models + byte-exact prompts + tool schemas + the verifier receipt');
732
+ fail(2, 'study-swarm lock <dispatch> requires --from <orchestration.json> — the harness-emitted record of resolved models + text-normalized prompts + tool schemas + the verifier receipt');
449
733
  }
450
734
  const lock = buildLockObject(dispatch, orchestration);
451
735
  writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf8');
@@ -460,8 +744,8 @@ function cmdLock(args) {
460
744
  // receipts deterministically (file reads, JSON I/O, SHA-256); the actual re-verification of a
461
745
  // re-grounded finding defers to the sibling runner (C12, honest ceiling). No network, no models.
462
746
 
463
- const WITHDRAWN_SCHEMA = 'dispatch.withdrawn/v1';
464
- const RECEIPT_SCHEMA = 'withdrawal-receipt/v1';
747
+ const WITHDRAWN_SCHEMA = 'dispatch.withdrawn/v2';
748
+ const RECEIPT_SCHEMA = 'withdrawal-receipt/v2';
465
749
  // A CLOSED, machine-readable reason enum — never free text (C3; OpenVEX/CSAF/CycloneDX: a status
466
750
  // must carry a structured justification, a bare flag is non-conformant).
467
751
  const WITHDRAW_REASONS = ['fabricated', 'misattributed', 'retracted', 'verifier-flipped', 'other'];
@@ -471,12 +755,23 @@ const WITHDRAW_REASONS = ['fabricated', 'misattributed', 'retracted', 'verifier-
471
755
  // URL), RFC (RFC NNNN / rfc-editor / datatracker), else a trimmed lowercased URL. Used on BOTH the
472
756
  // dispatch's extracted identifier and the user's <identifier> argument so `withdraw arXiv:2402.15089`
473
757
  // flags a finding citing `https://arxiv.org/abs/2402.15089v2` (C2).
758
+ // Peel markdown wrappers off an identifier's edges so `<https://…>` and `**10.x/y**` match the
759
+ // bare DOI or URL a caller passes to withdraw. Loops because wrappers nest (`**<url>**`).
760
+ function cleanIdent(raw) {
761
+ let s = String(raw || '').replace(/\s+/g, '');
762
+ let prev;
763
+ do {
764
+ prev = s;
765
+ s = s.replace(/^[<*_`"'\u201c\u2018\[]+/, '').replace(/[>*_`"'\u201d\u2019\]).,;]+$/, '');
766
+ } while (s !== prev && s.length);
767
+ return s;
768
+ }
474
769
  function normIdent(raw) {
475
- let s = String(raw || '').trim().toLowerCase().replace(/[).,;]+$/, '');
476
- let m = s.match(/arxiv\.org\/(?:abs|pdf)\/(\d{4}\.\d{4,5})/) || s.match(/arxiv:\s*(\d{4}\.\d{4,5})/);
770
+ let s = cleanIdent(raw).toLowerCase();
771
+ let m = s.match(/arxiv\.org\/(?:abs|pdf|html)\/(\d{4}\.\d{4,5})/) || s.match(/arxiv:\s*(\d{4}\.\d{4,5})/);
477
772
  if (m) return 'arxiv:' + m[1];
478
773
  m = s.match(/(?:doi\.org\/|dx\.doi\.org\/|doi:\s*)?(10\.\d{4,9}\/\S+)/);
479
- if (m) return 'doi:' + m[1].replace(/[).,;]+$/, '');
774
+ if (m) return 'doi:' + cleanIdent(m[1]).toLowerCase().replace(/\/+$/, '');
480
775
  m = s.match(/rfc[\s/-]?(\d{3,5})/);
481
776
  if (m) return 'rfc:' + m[1];
482
777
  return s.replace(/\/+$/, '');
@@ -484,34 +779,52 @@ function normIdent(raw) {
484
779
 
485
780
  // The tombstone sits beside its dispatch: <dir>/<stem>.withdrawn.json (C4 — status travels WITH
486
781
  // the artifact, the OCSP-stapling property; stem strips a trailing .dispatch.md).
782
+ function shQuote(s) {
783
+ const t = String(s);
784
+ return /[\s"]/.test(t) ? `"${t.replace(/"/g, '\\"')}"` : t;
785
+ }
786
+ function resolveCommands(dispatchPath, identifier) {
787
+ const d = shQuote(dispatchPath);
788
+ const id = shQuote(identifier);
789
+ return [
790
+ `study-swarm requalify --resolve ${d} ${id} --mode removed`,
791
+ `study-swarm requalify --resolve ${d} ${id} --mode regrounded --note "<attestation>"`,
792
+ ];
793
+ }
794
+ function openableDispatch(sidecarPath, storedName) {
795
+ const name = String(storedName || '');
796
+ if (!name) return sidecarPath;
797
+ if (name.includes('/') || name.includes('\\')) return name;
798
+ return join(dirname(sidecarPath), name);
799
+ }
487
800
  function withdrawnPathFor(dispatch) {
488
801
  const base = dispatch.split(/[\\/]/).pop().replace(/(\.dispatch)?\.md$/i, '');
489
802
  return join(dirname(dispatch), `${base}.withdrawn.json`);
490
803
  }
491
804
 
492
- // Recursively collect files matching a regex (skips node_modules/.git), sorted for determinism.
493
- function walkByExt(dir, re) {
494
- const out = [];
495
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
496
- if (entry.name === 'node_modules' || entry.name === '.git') continue;
497
- const full = join(dir, entry.name);
498
- if (entry.isDirectory()) out.push(...walkByExt(full, re));
499
- else if (re.test(entry.name)) out.push(full);
500
- }
501
- return out.sort();
502
- }
805
+ // Recursively collect files matching a regex (delegates to the resilient shared walker).
806
+ function walkByExt(dir, re, report) { return walkFiles(dir, re, undefined, report); }
503
807
 
504
808
  // The finding numbers in one dispatch whose citation normalizes to `want` (reuses the lint parser,
505
809
  // so Step 3 and the compensator agree on what a citation is).
506
810
  function findingsCiting(dispatchPath, want) {
507
811
  const res = lintText(dispatchPath, readFileSync(dispatchPath, 'utf8'));
508
- return (res.findings || []).filter((f) => f.identifier && normIdent(f.identifier) === want).map((f) => f.finding);
812
+ if ((res.problems || []).some((p) => p.rule === 'unclosed-fence')) {
813
+ fail(1, `${dispatchPath}: Research grounding has an unclosed code fence, so citations after it were not scanned. Refusing to treat them as absent.`);
814
+ }
815
+ return (res.findings || []).filter((f) => {
816
+ const ids = Array.isArray(f.identifiers) && f.identifiers.length ? f.identifiers : (f.identifier ? [f.identifier] : []);
817
+ return ids.some((id) => normIdent(id) === want);
818
+ }).map((f) => f.finding);
509
819
  }
510
820
 
511
821
  // Every dispatch in the corpus citing `target`, with the finding numbers + a content hash each.
512
822
  function findDependents(corpus, target) {
513
823
  const want = normIdent(target);
514
- const files = statSync(corpus).isDirectory() ? walkDispatches(corpus) : [corpus];
824
+ const report = newWalkReport();
825
+ const files = statSync(corpus).isDirectory() ? walkDispatches(corpus, report) : [corpus];
826
+ const blocked = walkBlockedMessage(report);
827
+ if (blocked) fail(1, blocked);
515
828
  const deps = [];
516
829
  for (const f of files) {
517
830
  const hits = findingsCiting(f, want);
@@ -547,6 +860,8 @@ function loadSidecar(dispatchPath) {
547
860
 
548
861
  // Recompute the rolled-up hash and write the sidecar; returns the finalized object.
549
862
  function writeSidecar(dispatchPath, body) {
863
+ body.schema = WITHDRAWN_SCHEMA;
864
+ body.study_swarm_version = VERSION;
550
865
  body.dispatch_sha256 = sriText(readFileSync(dispatchPath, 'utf8')); // reconcile to current content
551
866
  const finalized = withSha(body, 'withdrawn_sha256');
552
867
  writeFileSync(withdrawnPathFor(dispatchPath), JSON.stringify(finalized, null, 2) + '\n', 'utf8');
@@ -578,14 +893,26 @@ function cmdWithdraw(args) {
578
893
  const detail = f.detail ? String(f.detail) : '';
579
894
 
580
895
  const deps = findDependents(corpus, identifier);
581
- if (deps.length === 0) fail(2, `no dispatch in ${corpus} cites ${identifier} (normalized: ${want}) — nothing to withdraw`);
896
+ 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 --json ${corpus}" lists the identifiers the parser saw (findings[].identifiers).`);
582
897
 
583
898
  const dependents = [];
584
899
  for (const d of deps) {
585
900
  const body = loadSidecar(d.path);
586
- const existing = body.withdrawals.find((w) => w.identifier === want);
587
- // Idempotent: an identical withdrawal (same id + reason + detail, still withdrawn) is a no-op.
588
- const identical = existing && existing.status === 'withdrawn' && existing.reason === String(f.reason) && (existing.detail || '') === detail;
901
+ const sidePath = withdrawnPathFor(d.path);
902
+ if (existsSync(sidePath)) {
903
+ if (!body || typeof body !== 'object' || Array.isArray(body)) fail(2, `sidecar is not a JSON object: ${sidePath}`);
904
+ if (typeof body.withdrawn_sha256 !== 'string' || body.withdrawn_sha256 !== withSha(body, 'withdrawn_sha256').withdrawn_sha256) {
905
+ fail(1, `${sidePath}: withdrawn_sha256 self-integrity mismatch (the sidecar was hand-edited). Refusing to withdraw over it.`);
906
+ }
907
+ }
908
+ const existing = (body.withdrawals || []).find((w) => w && w.identifier === want);
909
+ // Idempotent: an identical withdrawal (same id + reason + detail + finding numbers, still
910
+ // withdrawn) is a no-op. The findings array is part of the identity so that re-withdrawing
911
+ // after the dispatch was edited (a citation moved to a different finding #) refreshes the
912
+ // stale numbers instead of being skipped as "identical".
913
+ const sameFindings = existing && Array.isArray(existing.findings) &&
914
+ existing.findings.length === d.findings.length && existing.findings.every((v, i) => v === d.findings[i]);
915
+ const identical = existing && existing.status === 'withdrawn' && existing.reason === String(f.reason) && (existing.detail || '') === detail && sameFindings;
589
916
  if (!identical) {
590
917
  if (existing) {
591
918
  existing.reason = String(f.reason); existing.detail = detail; existing.status = 'withdrawn'; existing.resolution = null; existing.findings = d.findings;
@@ -594,9 +921,11 @@ function cmdWithdraw(args) {
594
921
  }
595
922
  body.version += 1;
596
923
  body.audit_trail.push({ seq: body.audit_trail.length + 1, event: 'withdraw', identifier: want, reason: String(f.reason), findings: d.findings });
924
+ const finalized = writeSidecar(d.path, body);
925
+ dependents.push({ dispatch: finalized.dispatch, path: d.path, dispatch_sha256: finalized.dispatch_sha256, findings: d.findings, sidecar: withdrawnPathFor(d.path).split(/[\\/]/).pop() });
926
+ } else {
927
+ dependents.push({ dispatch: body.dispatch, path: d.path, dispatch_sha256: body.dispatch_sha256, findings: d.findings, sidecar: sidePath.split(/[\\/]/).pop() });
597
928
  }
598
- const finalized = writeSidecar(d.path, body);
599
- dependents.push({ dispatch: finalized.dispatch, dispatch_sha256: finalized.dispatch_sha256, findings: d.findings, sidecar: withdrawnPathFor(d.path).split(/[\\/]/).pop() });
600
929
  }
601
930
 
602
931
  const receipt = withSha({
@@ -606,7 +935,7 @@ function cmdWithdraw(args) {
606
935
  reason: String(f.reason),
607
936
  detail,
608
937
  corpus: corpus.split(/[\\/]/).pop() || corpus,
609
- dependents,
938
+ dependents: dependents.map(({ path: _path, ...rest }) => rest),
610
939
  post_rollback_state: `${dependents.length} dependent(s) flagged evidence-withdrawn; "study-swarm requalify --check" fails closed until each is removed or re-grounded.`,
611
940
  }, 'receipt_sha256');
612
941
 
@@ -618,18 +947,79 @@ function cmdWithdraw(args) {
618
947
  }
619
948
  // Contrastive surfacing — never a silent drop (C10; Buçinca 2024, Bansal 2021).
620
949
  process.stdout.write(`Withdrew ${want} (reason: ${f.reason}). ${dependents.length} dependent(s) flagged evidence-withdrawn:\n`);
621
- for (const d of dependents) process.stdout.write(` - ${d.dispatch} (findings ${d.findings.map((n) => '#' + n).join(', ')})\n`);
950
+ for (const d of dependents) {
951
+ const where = d.path || d.dispatch;
952
+ process.stdout.write(` - ${where} (findings ${d.findings.map((n) => '#' + n).join(', ')})\n`);
953
+ for (const cmd of resolveCommands(where, want)) process.stdout.write(` ${cmd}\n`);
954
+ }
622
955
  process.stdout.write(
623
956
  `\nYou may have relied on this finding. Each flagged dispatch now HALTS "study-swarm requalify --check"\n` +
624
- `until the finding is removed or re-grounded — re-ground or override.\n` +
625
- `Receipt ${f.receipt ? String(f.receipt) : '(stdout: pass --json)'} — receipt_sha256 ${receipt.receipt_sha256}\n`);
957
+ `until you run one of the commands above. Delete the citation before --mode removed.\n` +
958
+ `${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`);
626
959
  process.exit(0);
627
960
  }
628
961
 
629
962
  function cmdRequalify(args) {
630
963
  if (args.includes('--check')) return requalifyCheck(args.filter((a) => a !== '--check'));
631
964
  if (args.includes('--resolve')) return requalifyResolve(args.filter((a) => a !== '--resolve'));
632
- fail(2, 'usage: study-swarm requalify --check <corpus-dir> | study-swarm requalify --resolve <dispatch> <identifier> --mode removed|regrounded [--note <text>]');
965
+ if (args.includes('--status')) return requalifyStatus(args.filter((a) => a !== '--status'));
966
+ 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>]');
967
+ }
968
+
969
+ const STATUS_SCHEMA = 'study-swarm.status/v1';
970
+
971
+ // FG-04 — the read-only evidence-health VIEW of a corpus (distinct from --check, the CI gate). Walks
972
+ // the .withdrawn.json sidecars and aggregates: withdrawn vs resolved counts, a breakdown by reason
973
+ // and by resolution mode, and a per-dispatch line. Informational — it never fails closed (exit 0),
974
+ // so it composes in a report without gating a build the way --check does.
975
+ function requalifyStatus(args) {
976
+ const f = parseFlags(args, new Set());
977
+ const corpus = f._[0];
978
+ if (!corpus) fail(2, 'usage: study-swarm requalify --status <corpus-dir> [--json]');
979
+ if (!existsSync(corpus)) fail(2, `corpus not found: ${corpus}`);
980
+ const walk = newWalkReport();
981
+ const sidecars = statSync(corpus).isDirectory() ? walkByExt(corpus, /\.withdrawn\.json$/i, walk) : [corpus];
982
+ const totals = { withdrawn: 0, resolved: 0 };
983
+ const by_reason = {};
984
+ const by_mode = {};
985
+ const dispatches = [];
986
+ const problems = [];
987
+ const statusBlocked = walkBlockedMessage(walk);
988
+ if (statusBlocked) problems.push(statusBlocked);
989
+ for (const sc of sidecars) {
990
+ let stored;
991
+ try { stored = JSON.parse(readFileSync(sc, 'utf8')); }
992
+ catch (err) { problems.push(`${sc}: not valid JSON (${err.message})`); continue; }
993
+ if (!stored || typeof stored !== 'object' || Array.isArray(stored)) { problems.push(`${sc}: sidecar is not a JSON object`); continue; }
994
+ const entries = [];
995
+ for (const w of stored.withdrawals || []) {
996
+ if (!w || typeof w !== 'object') { problems.push(`${sc}: a withdrawals entry is not an object`); continue; }
997
+ const status = w.status === 'resolved' ? 'resolved' : 'withdrawn';
998
+ totals[status] += 1;
999
+ if (w.reason) by_reason[w.reason] = (by_reason[w.reason] || 0) + 1;
1000
+ const mode = status === 'resolved' && w.resolution ? w.resolution.mode : null;
1001
+ if (mode) by_mode[mode] = (by_mode[mode] || 0) + 1;
1002
+ entries.push({ identifier: w.identifier, reason: w.reason, status, mode, findings: w.findings || [] });
1003
+ }
1004
+ dispatches.push({ dispatch: openableDispatch(sc, stored.dispatch), sidecar: sc, withdrawals: entries });
1005
+ }
1006
+ const kv = (o) => Object.keys(o).sort().map((k) => `${k}=${o[k]}`).join(', ') || '(none)';
1007
+ if (f.json) {
1008
+ process.stdout.write(JSON.stringify({ schema: STATUS_SCHEMA, study_swarm_version: VERSION, corpus, totals, by_reason, by_mode, dispatches, problems }) + '\n');
1009
+ process.exit(0);
1010
+ }
1011
+ process.stdout.write(`study-swarm requalify --status ${corpus}: ${dispatches.length} sidecar(s), ${totals.withdrawn + totals.resolved} withdrawal(s)\n`);
1012
+ process.stdout.write(` ${totals.withdrawn} unresolved (evidence-withdrawn), ${totals.resolved} resolved\n`);
1013
+ process.stdout.write(` by reason: ${kv(by_reason)}\n`);
1014
+ process.stdout.write(` by resolution mode: ${kv(by_mode)}\n`);
1015
+ for (const d of dispatches) {
1016
+ for (const w of d.withdrawals) {
1017
+ const tag = w.status === 'resolved' ? `resolved: ${w.mode}` : 'withdrawn';
1018
+ process.stdout.write(` - ${d.dispatch}: ${w.identifier} (${w.reason}) [${tag}] findings ${(w.findings || []).map((n) => '#' + n).join(', ')}\n`);
1019
+ }
1020
+ }
1021
+ for (const p of problems) process.stderr.write(` ! ${p}\n`);
1022
+ process.exit(0);
633
1023
  }
634
1024
 
635
1025
  function requalifyCheck(args) {
@@ -637,20 +1027,32 @@ function requalifyCheck(args) {
637
1027
  const corpus = f._[0];
638
1028
  if (!corpus) fail(2, 'usage: study-swarm requalify --check <corpus-dir> [--json]');
639
1029
  if (!existsSync(corpus)) fail(2, `corpus not found: ${corpus}`);
640
- const sidecars = statSync(corpus).isDirectory() ? walkByExt(corpus, /\.withdrawn\.json$/i) : [corpus];
1030
+ const walk = newWalkReport();
1031
+ const sidecars = statSync(corpus).isDirectory() ? walkByExt(corpus, /\.withdrawn\.json$/i, walk) : [corpus];
641
1032
  const halts = []; // { sidecar, dispatch, identifier, reason, findings }
642
1033
  const problems = [];
1034
+ const checkBlocked = walkBlockedMessage(walk);
1035
+ if (checkBlocked) problems.push(checkBlocked);
643
1036
  let resolvedCount = 0;
644
1037
  for (const sc of sidecars) {
645
1038
  let stored;
646
1039
  try { stored = JSON.parse(readFileSync(sc, 'utf8')); }
647
1040
  catch (err) { problems.push(`${sc}: not valid JSON (${err.message})`); continue; }
1041
+ // A non-object sidecar (null / array / scalar) is a reportable problem, not a crash — the per-file
1042
+ // loop keeps checking the rest of the corpus and the offending file is named (PH-01).
1043
+ if (!stored || typeof stored !== 'object' || Array.isArray(stored)) {
1044
+ problems.push(`${sc}: sidecar is not a JSON object`); continue;
1045
+ }
1046
+ // Stale-format gate — a wrong-schema sidecar is "regenerate", not a self-integrity failure (PH-04).
1047
+ const stale = staleSchema(stored, WITHDRAWN_SCHEMA, 'sidecar', sc);
1048
+ if (stale) { problems.push(stale); continue; }
648
1049
  // Self-integrity: a hand-edited sidecar (e.g. a status forged to "resolved") fails closed.
649
1050
  if (typeof stored.withdrawn_sha256 !== 'string' || stored.withdrawn_sha256 !== withSha(stored, 'withdrawn_sha256').withdrawn_sha256) {
650
1051
  problems.push(`${sc}: withdrawn_sha256 self-integrity mismatch (the sidecar was hand-edited)`);
651
1052
  }
652
1053
  for (const w of stored.withdrawals || []) {
653
- if (w.status === 'withdrawn') halts.push({ sidecar: sc.split(/[\\/]/).pop(), dispatch: stored.dispatch, identifier: w.identifier, reason: w.reason, findings: w.findings });
1054
+ if (!w || typeof w !== 'object') { problems.push(`${sc}: a withdrawals entry is not an object`); continue; }
1055
+ if (w.status === 'withdrawn') halts.push({ sidecar: sc, dispatch: openableDispatch(sc, stored.dispatch), identifier: w.identifier, reason: w.reason, findings: w.findings });
654
1056
  else if (w.status === 'resolved') resolvedCount += 1;
655
1057
  }
656
1058
  }
@@ -663,8 +1065,12 @@ function requalifyCheck(args) {
663
1065
  process.stdout.write(`ok ${corpus}: no unresolved evidence-withdrawn flags (${resolvedCount} resolved).\n`);
664
1066
  process.exit(0);
665
1067
  }
666
- process.stderr.write(`x requalify --check ${corpus}: ${halts.length} unresolved evidence-withdrawn flag(s) — HALT\n`);
667
- 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`);
1068
+ if (halts.length) process.stderr.write(`x requalify --check ${corpus}: ${halts.length} unresolved evidence-withdrawn flag(s) — HALT\n`);
1069
+ else process.stderr.write(`x requalify --check ${corpus}: ${problems.length} problem(s) — the check could not trust the corpus\n`);
1070
+ for (const h of halts) {
1071
+ process.stderr.write(` - ${h.dispatch}: ${h.identifier} withdrawn (reason: ${h.reason}) — findings ${(h.findings || []).map((n) => '#' + n).join(', ')}. You may have relied on it.\n`);
1072
+ for (const cmd of resolveCommands(h.dispatch, h.identifier)) process.stderr.write(` ${cmd}\n`);
1073
+ }
668
1074
  for (const p of problems) process.stderr.write(` - ${p}\n`);
669
1075
  process.exit(1);
670
1076
  }
@@ -683,7 +1089,11 @@ function requalifyResolve(args) {
683
1089
  let body;
684
1090
  try { body = JSON.parse(readFileSync(scPath, 'utf8')); }
685
1091
  catch (err) { fail(2, `cannot read sidecar ${scPath}: ${err && err.code ? err.code : err.message}`); }
686
- const entry = (body.withdrawals || []).find((w) => w.identifier === want);
1092
+ if (!body || typeof body !== 'object' || Array.isArray(body)) fail(2, `sidecar is not a JSON object: ${scPath}`);
1093
+ if (typeof body.withdrawn_sha256 !== 'string' || body.withdrawn_sha256 !== withSha(body, 'withdrawn_sha256').withdrawn_sha256) {
1094
+ fail(1, `${scPath}: withdrawn_sha256 self-integrity mismatch (the sidecar was hand-edited). Refusing to resolve it.`);
1095
+ }
1096
+ const entry = (body.withdrawals || []).find((w) => w && w.identifier === want);
687
1097
  if (!entry) fail(2, `no evidence-withdrawn flag for ${identifier} (normalized: ${want}) on ${dispatch}`);
688
1098
 
689
1099
  if (entry.status === 'resolved') { // Idempotent: re-resolving is a no-op, no new audit entry (C7).
@@ -692,7 +1102,7 @@ function requalifyResolve(args) {
692
1102
  }
693
1103
  if (mode === 'removed') {
694
1104
  const still = findingsCiting(dispatch, want);
695
- if (still.length) fail(1, `${dispatch} still cites ${want} (findings ${still.map((n) => '#' + n).join(', ')}) — cannot resolve --mode removed until the finding is removed; use --mode regrounded with --note <attestation> if it was re-verified in place`);
1105
+ 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>".`);
696
1106
  } else if (mode === 'regrounded' && !f.note) {
697
1107
  fail(2, '--mode regrounded requires --note <attestation> — the CLI records that the sibling runner re-verified the finding, it does not itself re-verify');
698
1108
  }
@@ -705,12 +1115,146 @@ function requalifyResolve(args) {
705
1115
  process.exit(0);
706
1116
  }
707
1117
 
1118
+ const RESULTS_SCHEMA = 'study-swarm.results/v1';
1119
+
1120
+ function resultsPaths(dispatch) {
1121
+ const base = dispatch.split(/[\\/]/).pop().replace(/(\.dispatch)?\.md$/i, '');
1122
+ return {
1123
+ md: join(dirname(dispatch), `${base}.results.md`),
1124
+ json: join(dirname(dispatch), `${base}.results.json`),
1125
+ };
1126
+ }
1127
+
1128
+ function firstHeading(raw) {
1129
+ const m = raw.match(/^#{1,6}\s+(.*?)\s*$/m);
1130
+ return m ? m[1].replace(/\*\*/g, '').trim() : '';
1131
+ }
1132
+
1133
+ function sentenceOf(raw, n) {
1134
+ for (const line of raw.split(/\r?\n/)) {
1135
+ const m = new RegExp('^\\s*' + n + '\\.\\s+(.*)$').exec(line);
1136
+ if (m) return m[1].replace(/\*\*/g, '').replace(/\s+/g, ' ').trim().slice(0, 280);
1137
+ }
1138
+ return '';
1139
+ }
1140
+
1141
+ function buildResults(dispatchPath) {
1142
+ const raw = readFileSync(dispatchPath, 'utf8');
1143
+ const lint = lintText(dispatchPath, raw, false);
1144
+ const lines = raw.split(/\r?\n/);
1145
+ const body = step5Body(lines);
1146
+ const connected = body === null ? [] : [...referencedNumbers(body)].sort((a, b) => a - b);
1147
+ let lock = { present: false };
1148
+ const lp = lockPathFor(dispatchPath);
1149
+ if (existsSync(lp)) {
1150
+ try {
1151
+ const obj = JSON.parse(readFileSync(lp, 'utf8'));
1152
+ lock = { present: true, schema: obj.schema || null, lock_sha256: obj.lock_sha256 || null };
1153
+ } catch { lock = { present: true, unreadable: true }; }
1154
+ }
1155
+ let withdrawn = [];
1156
+ const sp = withdrawnPathFor(dispatchPath);
1157
+ if (existsSync(sp)) {
1158
+ try {
1159
+ const side = JSON.parse(readFileSync(sp, 'utf8'));
1160
+ withdrawn = (side.withdrawals || []).filter((w) => w && typeof w === 'object').map((w) => ({
1161
+ identifier: w.identifier || null,
1162
+ status: w.status || null,
1163
+ reason: w.reason || null,
1164
+ }));
1165
+ } catch { withdrawn = [{ identifier: null, status: 'unreadable', reason: null }]; }
1166
+ }
1167
+ const record = {
1168
+ schema: RESULTS_SCHEMA,
1169
+ study_swarm_version: VERSION,
1170
+ dispatch: dispatchPath.split(/[\\/]/).pop(),
1171
+ dispatch_sha256: sriText(raw),
1172
+ title: firstHeading(raw),
1173
+ lint: {
1174
+ ok: lint.ok,
1175
+ finding_count: lint.findingCount,
1176
+ problems: (lint.problems || []).map((p) => ({ rule: p.rule, message: p.message })),
1177
+ },
1178
+ findings: (lint.findings || []).map((f) => ({
1179
+ finding: f.finding,
1180
+ year: f.year,
1181
+ identifier: f.identifier,
1182
+ identifiers: f.identifiers || [],
1183
+ line: sentenceOf(raw, f.finding),
1184
+ })),
1185
+ step5_finding_numbers: connected,
1186
+ lock,
1187
+ withdrawn,
1188
+ };
1189
+ return withSha(record, 'results_sha256');
1190
+ }
1191
+
1192
+ function renderResultsMd(rec, jsonName) {
1193
+ const out = [];
1194
+ out.push(`# ${rec.title || rec.dispatch}`);
1195
+ out.push('');
1196
+ out.push(`Dispatch \`${rec.dispatch}\` (\`${rec.dispatch_sha256}\`). Lint ${rec.lint.ok ? 'clean' : 'failed'}, ${rec.lint.finding_count} finding(s).`);
1197
+ out.push(rec.lock.present && rec.lock.lock_sha256 ? `Lock \`${rec.lock.lock_sha256}\`.` : 'No lock beside this dispatch.');
1198
+ if (rec.withdrawn.length) {
1199
+ out.push('Withdrawn:');
1200
+ for (const w of rec.withdrawn) out.push(`- ${w.identifier} — ${w.status} (${w.reason})`);
1201
+ } else out.push('No withdrawal flags.');
1202
+ out.push('');
1203
+ out.push('## Findings');
1204
+ out.push('');
1205
+ if (!rec.findings.length) out.push('No numbered findings.');
1206
+ for (const f of rec.findings) {
1207
+ out.push(`${f.finding}. ${f.year || 'no year'} — ${f.identifier || 'no identifier'}`);
1208
+ if (f.line) out.push(` ${f.line}`);
1209
+ out.push('');
1210
+ }
1211
+ if (rec.lint.problems.length) {
1212
+ out.push('## Lint problems');
1213
+ out.push('');
1214
+ for (const p of rec.lint.problems) out.push(`- ${p.rule}: ${p.message}`);
1215
+ out.push('');
1216
+ }
1217
+ out.push('## Kept record');
1218
+ out.push('');
1219
+ out.push(`This sheet is the copy to hand someone. The same facts are kept in \`${jsonName}\` (\`${rec.results_sha256}\`). A later run opens that file. \`study-swarm return --check ${rec.dispatch}\` fails if either copy has drifted from the dispatch.`);
1220
+ out.push('');
1221
+ return out.join('\n');
1222
+ }
1223
+
1224
+ function cmdReturn(args) {
1225
+ const check = args.includes('--check');
1226
+ const dispatch = args.filter((a) => a !== '--check')[0];
1227
+ if (!dispatch) fail(2, 'usage: study-swarm return <dispatch> [--check]');
1228
+ if (!existsSync(dispatch)) fail(2, `dispatch not found: ${dispatch}`);
1229
+ const rec = buildResults(dispatch);
1230
+ const paths = resultsPaths(dispatch);
1231
+ const jsonName = paths.json.split(/[\\/]/).pop();
1232
+ const md = renderResultsMd(rec, jsonName);
1233
+ if (!check) {
1234
+ writeFileSync(paths.json, JSON.stringify(rec, null, 2) + '\n', 'utf8');
1235
+ writeFileSync(paths.md, md, 'utf8');
1236
+ process.stdout.write(`Results for ${rec.dispatch}: ${paths.md}\nKept record: ${paths.json} (${rec.results_sha256})\n`);
1237
+ process.exit(0);
1238
+ }
1239
+ if (!existsSync(paths.json) || !existsSync(paths.md)) fail(2, `no results beside ${dispatch} — run study-swarm return ${dispatch}`);
1240
+ let kept;
1241
+ try { kept = JSON.parse(readFileSync(paths.json, 'utf8')); }
1242
+ catch (err) { fail(2, `cannot read ${paths.json}: ${err.message}`); }
1243
+ const sheet = readFileSync(paths.md, 'utf8');
1244
+ if (kept.results_sha256 !== rec.results_sha256 || !sheet.includes(rec.results_sha256)) {
1245
+ fail(1, `${jsonName}: results have drifted from ${dispatch}. Re-run study-swarm return ${dispatch}.`);
1246
+ }
1247
+ process.stdout.write(`ok ${jsonName}: ${rec.results_sha256}\n`);
1248
+ process.exit(0);
1249
+ }
1250
+
708
1251
  function main(argv) {
709
1252
  const [cmd, ...rest] = argv;
710
1253
  switch (cmd) {
711
1254
  case 'protocol': return cmdProtocol();
712
1255
  case 'new': return cmdNew(rest[0]);
713
1256
  case 'lint': return cmdLint(rest);
1257
+ case 'return': return cmdReturn(rest);
714
1258
  case 'lock': return cmdLock(rest);
715
1259
  case 'withdraw': return cmdWithdraw(rest);
716
1260
  case 'requalify': return cmdRequalify(rest);