@dogfood-lab/study-swarm 1.3.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
@@ -39,6 +45,10 @@ COMMANDS
39
45
  Fail closed (exit 1) for any dispatch carrying an unresolved
40
46
  evidence-withdrawn flag — the andon that HALTS a withdrawn finding's
41
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.
42
52
  requalify --resolve <dispatch> <identifier> --mode removed|regrounded [--note <text>]
43
53
  Clear a flag once the finding is removed (the citation is gone) or
44
54
  re-grounded (re-verified clean by the sibling runner; --note records
@@ -47,12 +57,13 @@ COMMANDS
47
57
  version Print the version.
48
58
 
49
59
  EXIT CODES
50
- 0 ok / lint clean
51
- 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)
52
63
  2 usage or runtime error
53
64
 
54
65
  NOTE
55
- 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,
56
67
  no "studies show…" gestures) — it does not judge whether a source is legitimate
57
68
  or actually supports the claim. That is Step 4, below.
58
69
 
@@ -135,17 +146,63 @@ function cmdNew(slug) {
135
146
  // --- lint core ------------------------------------------------------------
136
147
 
137
148
  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;
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;
139
153
  const PLACEHOLDER = /arXiv:_{2,}|<finding>|<authors>|<year>|<implication>/i;
140
154
  const BANNED = /\b(studies show|research suggests|it'?s well[- ]established|well[- ]established that)\b/i;
141
155
  // An author cite: a capitalized name (Unicode-aware, so "Buçinca" counts), optionally
142
156
  // 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";
157
+ // Accepts "Huang et al. 2023", "Walters & Wilder 2023", "Panickssery, Bowman & Feng 2024",
158
+ // and space-separated org authors ("OASIS CSAF Technical Committee 2022");
144
159
  // 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;
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
+ }
146
202
 
147
- // Check one dispatch's text. Returns a structured result; never exits.
148
- 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) {
149
206
  const lines = raw.split(/\r?\n/);
150
207
  const problems = []; // { finding, line, rule, message }
151
208
  const add = (rule, message, line = null, finding = null) => problems.push({ finding, line, rule, message });
@@ -186,13 +243,15 @@ function lintText(label, raw) {
186
243
  findings.forEach((f, i) => {
187
244
  const n = i + 1;
188
245
  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, '');
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, '');
192
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);
193
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);
194
253
  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);
254
+ if (!idm) add('missing-id', `finding ${n}: missing an identifier (arXiv:NNNN.NNNNN, DOI, URL, or RFC number).`, f.line, n);
196
255
  const ym = fNoIds.match(YEAR);
197
256
  const ident = idm ? idm[0].replace(/\s+/g, '').replace(/[).,;]+$/, '') : null;
198
257
  parsed.push({ finding: n, year: ym ? ym[0] : null, identifier: ident });
@@ -208,30 +267,69 @@ function lintText(label, raw) {
208
267
  }
209
268
  });
210
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
+
211
291
  return { file: label, ok: problems.length === 0, findingCount: findings.length, problems, findings: parsed };
212
292
  }
213
293
 
214
- // Recursively collect *.dispatch.md files under a directory (skips node_modules/.git).
215
- 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 []; }
216
309
  const out = [];
217
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
310
+ for (const entry of entries) {
218
311
  if (entry.name === 'node_modules' || entry.name === '.git') continue;
312
+ if (entry.isSymbolicLink()) continue; // don't follow symlinks (cycle + directory-escape safety)
219
313
  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);
314
+ if (entry.isDirectory()) out.push(...walkFiles(full, re, seen));
315
+ else if (re.test(entry.name)) out.push(full);
222
316
  }
223
317
  return out.sort();
224
318
  }
319
+ function walkDispatches(dir) { return walkFiles(dir, /\.dispatch\.md$/i); }
225
320
 
226
321
  function readTarget(p) {
227
322
  try { return { label: p, raw: readFileSync(p, 'utf8') }; }
228
323
  catch (err) { fail(2, `cannot read ${p}: ${err && err.code ? err.code : err.message}`); }
229
324
  }
230
325
 
326
+ const LINT_SCHEMA = 'study-swarm.lint/v1'; // versioned handle for --json consumers (FG-03)
327
+
231
328
  function cmdLint(args) {
232
329
  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...]');
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...]');
235
333
 
236
334
  const targets = [];
237
335
  for (const p of paths) {
@@ -252,20 +350,23 @@ function cmdLint(args) {
252
350
  }
253
351
  }
254
352
 
255
- const results = targets.map((t) => lintText(t.label, t.raw));
353
+ const results = targets.map((t) => lintText(t.label, t.raw, strict));
256
354
  const anyFail = results.some((r) => !r.ok);
257
355
 
258
356
  if (json) {
259
- 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 };
260
361
  process.stdout.write(JSON.stringify(payload) + '\n');
261
362
  process.exit(anyFail ? 1 : 0);
262
363
  }
263
364
 
264
365
  for (const r of results) {
265
366
  if (r.ok) {
266
- 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`);
267
368
  } else {
268
- 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`);
269
370
  for (const pr of r.problems) process.stderr.write(` - ${pr.message}\n`);
270
371
  }
271
372
  }
@@ -274,6 +375,9 @@ function cmdLint(args) {
274
375
  `\nStep 3 (sourcing FORM) is satisfied — this does NOT confirm the citations exist or support the claim.\n` +
275
376
  `Run Step 4 (existence + groundedness, a different model family): roleos verify-citations <file>\n`,
276
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`);
277
381
  }
278
382
  process.exit(anyFail ? 1 : 0);
279
383
  }
@@ -284,17 +388,26 @@ function cmdLint(args) {
284
388
  // (resolved models + byte-exact prompts + tool schemas + verifier receipt); the CLI only
285
389
  // canonicalizes + hashes + validates it. No network, no model calls (L2).
286
390
 
287
- const LOCK_SCHEMA = 'dispatch.lock/v1';
391
+ const LOCK_SCHEMA = 'dispatch.lock/v2';
288
392
 
289
393
  // Self-describing digest "sha256-<base64>" — the W3C Subresource Integrity form: algorithm-
290
394
  // prefixed (so it's algorithm-agile) and used fail-closed on mismatch (L9; lock dispatch finding 38).
291
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';
292
405
  // Normalize TEXT before hashing so the same content hashes identically across platforms — strip a
293
406
  // BOM, fold CRLF/CR -> LF, NFC-normalize. Without this, a CRLF working tree (Windows) and an LF
294
407
  // checkout (git/CI) produce different hashes — the exact cross-platform drift our Q2 findings warn
295
408
  // about (RFC 8259 BOM, UAX #15 NFC, and CRLF/LF). Applied to every text input that gets hashed.
296
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'); }
297
- function sriText(str) { return sriBytes(Buffer.from(normText(str), 'utf8')); }
410
+ function sriText(str) { return sriBytes(Buffer.from(DOMAIN_TEXT + normText(str), 'utf8')); }
298
411
 
299
412
  // RFC 8785 (JCS) canonical JSON, for the structured JSON the CLI assembles ITSELF (the tool
300
413
  // surface and the lock body): NFC-normalize strings, sort object keys by UTF-16 code unit (JS
@@ -321,7 +434,7 @@ function jcs(value) {
321
434
  };
322
435
  return ser(value);
323
436
  }
324
- function jcsDigest(value) { return sriBytes(Buffer.from(jcs(value), 'utf8')); }
437
+ function jcsDigest(value) { return sriBytes(Buffer.from(DOMAIN_JCS + jcs(value), 'utf8')); }
325
438
 
326
439
  // The lock sits beside its dispatch: <dir>/<stem>.lock.json (stem strips a trailing .dispatch.md).
327
440
  function lockPathFor(dispatch) {
@@ -351,8 +464,14 @@ function buildLockObject(dispatchPath, orchestration) {
351
464
  if (s.params && typeof s.params === 'object') rec.params = s.params;
352
465
  // L7 — output hash for DRIFT DETECTION only (not determinism). The harness may ship the raw
353
466
  // 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);
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);
356
475
  return rec;
357
476
  });
358
477
  const lock = {
@@ -372,12 +491,27 @@ function buildLockObject(dispatchPath, orchestration) {
372
491
  return lock;
373
492
  }
374
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
+
375
506
  // Verify a lock: self-integrity always; source-drift too when an orchestration record is supplied.
376
507
  // Strict-match, fail-closed (L8): returns a list of problems (empty = clean).
377
508
  function verifyLockObject(dispatchPath, lockPath, orchestration) {
378
509
  let stored;
379
510
  try { stored = JSON.parse(readFileSync(lockPath, 'utf8')); }
380
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];
381
515
  const problems = [];
382
516
  // 1) Self-integrity — recompute lock_sha256 over the stored body (detects a hand-edited lock).
383
517
  if (!stored || typeof stored !== 'object' || typeof stored.lock_sha256 !== 'string') {
@@ -408,7 +542,46 @@ function verifyLockObject(dispatchPath, lockPath, orchestration) {
408
542
  return problems;
409
543
  }
410
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
+
411
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
+ }
412
585
  const verify = args.includes('--verify');
413
586
  const rest = args.filter((a) => a !== '--verify');
414
587
  let orchPath = null;
@@ -460,8 +633,8 @@ function cmdLock(args) {
460
633
  // receipts deterministically (file reads, JSON I/O, SHA-256); the actual re-verification of a
461
634
  // re-grounded finding defers to the sibling runner (C12, honest ceiling). No network, no models.
462
635
 
463
- const WITHDRAWN_SCHEMA = 'dispatch.withdrawn/v1';
464
- const RECEIPT_SCHEMA = 'withdrawal-receipt/v1';
636
+ const WITHDRAWN_SCHEMA = 'dispatch.withdrawn/v2';
637
+ const RECEIPT_SCHEMA = 'withdrawal-receipt/v2';
465
638
  // A CLOSED, machine-readable reason enum — never free text (C3; OpenVEX/CSAF/CycloneDX: a status
466
639
  // must carry a structured justification, a bare flag is non-conformant).
467
640
  const WITHDRAW_REASONS = ['fabricated', 'misattributed', 'retracted', 'verifier-flipped', 'other'];
@@ -489,17 +662,8 @@ function withdrawnPathFor(dispatch) {
489
662
  return join(dirname(dispatch), `${base}.withdrawn.json`);
490
663
  }
491
664
 
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
- }
665
+ // Recursively collect files matching a regex (delegates to the resilient shared walker).
666
+ function walkByExt(dir, re) { return walkFiles(dir, re); }
503
667
 
504
668
  // The finding numbers in one dispatch whose citation normalizes to `want` (reuses the lint parser,
505
669
  // so Step 3 and the compensator agree on what a citation is).
@@ -578,14 +742,19 @@ function cmdWithdraw(args) {
578
742
  const detail = f.detail ? String(f.detail) : '';
579
743
 
580
744
  const deps = findDependents(corpus, identifier);
581
- if (deps.length === 0) fail(2, `no dispatch in ${corpus} cites ${identifier} (normalized: ${want}) — nothing to withdraw`);
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.`);
582
746
 
583
747
  const dependents = [];
584
748
  for (const d of deps) {
585
749
  const body = loadSidecar(d.path);
586
750
  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;
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;
589
758
  if (!identical) {
590
759
  if (existing) {
591
760
  existing.reason = String(f.reason); existing.detail = detail; existing.status = 'withdrawn'; existing.resolution = null; existing.findings = d.findings;
@@ -622,14 +791,67 @@ function cmdWithdraw(args) {
622
791
  process.stdout.write(
623
792
  `\nYou may have relied on this finding. Each flagged dispatch now HALTS "study-swarm requalify --check"\n` +
624
793
  `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`);
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`);
626
795
  process.exit(0);
627
796
  }
628
797
 
629
798
  function cmdRequalify(args) {
630
799
  if (args.includes('--check')) return requalifyCheck(args.filter((a) => a !== '--check'));
631
800
  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>]');
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);
633
855
  }
634
856
 
635
857
  function requalifyCheck(args) {
@@ -645,6 +867,14 @@ function requalifyCheck(args) {
645
867
  let stored;
646
868
  try { stored = JSON.parse(readFileSync(sc, 'utf8')); }
647
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; }
648
878
  // Self-integrity: a hand-edited sidecar (e.g. a status forged to "resolved") fails closed.
649
879
  if (typeof stored.withdrawn_sha256 !== 'string' || stored.withdrawn_sha256 !== withSha(stored, 'withdrawn_sha256').withdrawn_sha256) {
650
880
  problems.push(`${sc}: withdrawn_sha256 self-integrity mismatch (the sidecar was hand-edited)`);
@@ -692,7 +922,7 @@ function requalifyResolve(args) {
692
922
  }
693
923
  if (mode === 'removed') {
694
924
  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`);
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>".`);
696
926
  } else if (mode === 'regrounded' && !f.note) {
697
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');
698
928
  }
@@ -1,63 +1,63 @@
1
1
  {
2
- "schema": "dispatch.lock/v1",
3
- "study_swarm_version": "1.3.0",
4
- "protocol_sha256": "sha256-seELxwXtj+xVxa3mas2cP0J0iMTTpq4vHqFMiUGqmuU=",
5
- "dispatch_sha256": "sha256-V7b/nROpdDfH6TBUDrBwcyxFwR99yihkfIaNFE5yGkk=",
2
+ "schema": "dispatch.lock/v2",
3
+ "study_swarm_version": "2.0.0",
4
+ "protocol_sha256": "sha256-9tVI9RrD1qKHB/j/Ec9+//Q6y5coRtOvVcfDyiH46eQ=",
5
+ "dispatch_sha256": "sha256-O6rba+NrJhL4s6qSzaMPgQ5hhWB7mQWD1lEIQJjOJ9I=",
6
6
  "steps": [
7
7
  {
8
8
  "question_id": "Q1-revocation-propagation",
9
9
  "resolved_model": "claude-opus-4-8",
10
- "prompt_sha256": "sha256-UM5MzdUiL88wbj+GaUsrlwiAwSffNYD6G5KcIRSFcn0=",
11
- "tool_schema_sha256": "sha256-Tuyydf1fteUasUVBy+VzlGijelrqPfvHjI7wHvE9Omk=",
10
+ "prompt_sha256": "sha256-itBKUD1vfwbDaQFlItEbIrWRx4auszx3/MeDUggQRI4=",
11
+ "tool_schema_sha256": "sha256-rAn5uvRldR9ctw1Sb8ZkXIuVRzA8spJBRmFJqkAqUO0=",
12
12
  "schema_dialect": "https://json-schema.org/draft/2020-12/schema",
13
13
  "params": {
14
14
  "effort": "high"
15
15
  },
16
- "output_sha256": "sha256-utlcmvknCo6mvVE0CJSptTHKAWjAbyAccCUXlV4cuyk="
16
+ "output_sha256": "sha256-nIWvzFM74zz5s6DddRBXeoZXUUrn5W5YB7L9symaFXo="
17
17
  },
18
18
  {
19
19
  "question_id": "Q2-status-propagation-states",
20
20
  "resolved_model": "claude-opus-4-8",
21
- "prompt_sha256": "sha256-AY9HKlNQhdAw8Woxni4903GeG3GwItWaHLucloitZlQ=",
22
- "tool_schema_sha256": "sha256-Tuyydf1fteUasUVBy+VzlGijelrqPfvHjI7wHvE9Omk=",
21
+ "prompt_sha256": "sha256-rLMa1hQeYobjvEeCWjmhhwjx0ZV7A/JG4xN+Gn/Taus=",
22
+ "tool_schema_sha256": "sha256-rAn5uvRldR9ctw1Sb8ZkXIuVRzA8spJBRmFJqkAqUO0=",
23
23
  "schema_dialect": "https://json-schema.org/draft/2020-12/schema",
24
24
  "params": {
25
25
  "effort": "high"
26
26
  },
27
- "output_sha256": "sha256-yU0hMIEGjOL7imQbLJWzXhJQze1onwHDVmPY0dKR8ow="
27
+ "output_sha256": "sha256-PY9+TX8amO4HEnSj2m5667eCPzSxQoJLLlRifWNjSAE="
28
28
  },
29
29
  {
30
30
  "question_id": "Q3-scholarly-retraction",
31
31
  "resolved_model": "claude-opus-4-8",
32
- "prompt_sha256": "sha256-TEeHZdUC26paRW9TSYHuiDx0Xn6olBysMIkPDDw4jXo=",
33
- "tool_schema_sha256": "sha256-Tuyydf1fteUasUVBy+VzlGijelrqPfvHjI7wHvE9Omk=",
32
+ "prompt_sha256": "sha256-edU5+lj80xAt/lICOwERznjxy7Xavrt4zkRbHAL4lL8=",
33
+ "tool_schema_sha256": "sha256-rAn5uvRldR9ctw1Sb8ZkXIuVRzA8spJBRmFJqkAqUO0=",
34
34
  "schema_dialect": "https://json-schema.org/draft/2020-12/schema",
35
35
  "params": {
36
36
  "effort": "high"
37
37
  },
38
- "output_sha256": "sha256-gWAS2GsMJmkDgODKPL3tR0bX1qqc7pIh9MVevzVUkHQ="
38
+ "output_sha256": "sha256-ffW4pz8IyjoeRqgNHe6xTfnvo8zs9wKSCZJczG4qBTc="
39
39
  },
40
40
  {
41
41
  "question_id": "Q4-sound-compensators",
42
42
  "resolved_model": "claude-opus-4-8",
43
- "prompt_sha256": "sha256-aavKw3GRZWQPAQf3UbsptfbiOjQSLfqOoQJ9zqvQeJM=",
44
- "tool_schema_sha256": "sha256-Tuyydf1fteUasUVBy+VzlGijelrqPfvHjI7wHvE9Omk=",
43
+ "prompt_sha256": "sha256-f2549e25ZsyA+QgebqdIeNsh3a+u/iAer+t439zh+J4=",
44
+ "tool_schema_sha256": "sha256-rAn5uvRldR9ctw1Sb8ZkXIuVRzA8spJBRmFJqkAqUO0=",
45
45
  "schema_dialect": "https://json-schema.org/draft/2020-12/schema",
46
46
  "params": {
47
47
  "effort": "high"
48
48
  },
49
- "output_sha256": "sha256-5ui1SfolNP+Dj4wqut/XysFeXlRD2wtlAd9cUzFgkSU="
49
+ "output_sha256": "sha256-e8mm2j3uOX0pUvdgMpck8OMqtvs+NG3FuVMn4LMQhDU="
50
50
  },
51
51
  {
52
52
  "question_id": "Q5-stale-tombstone-contrastive",
53
53
  "resolved_model": "claude-opus-4-8",
54
- "prompt_sha256": "sha256-4K8vxSk4rRNpH76T+dBc5pmMRqdP1nCPKahSB650cKc=",
55
- "tool_schema_sha256": "sha256-Tuyydf1fteUasUVBy+VzlGijelrqPfvHjI7wHvE9Omk=",
54
+ "prompt_sha256": "sha256-Ml4IoYnSajRyAjaWyjrA44kdzpmDGbrSFGAPOfYUPVo=",
55
+ "tool_schema_sha256": "sha256-rAn5uvRldR9ctw1Sb8ZkXIuVRzA8spJBRmFJqkAqUO0=",
56
56
  "schema_dialect": "https://json-schema.org/draft/2020-12/schema",
57
57
  "params": {
58
58
  "effort": "high"
59
59
  },
60
- "output_sha256": "sha256-0UKzfcvegCvkXik9Dq/xSW2e2kDW1LNgKaOt+omoMq8="
60
+ "output_sha256": "sha256-wpEjen2OeX44jBIkf50iRfY4VLQMWCcJyoiyBYq33lI="
61
61
  }
62
62
  ],
63
63
  "verification": {
@@ -74,5 +74,5 @@
74
74
  "citations_sha256": "70a0d84650477090be49925bf4b53ab52b8fdb88ebf5d2487f3000ea96fc810a",
75
75
  "receipt_chain_sha256": "dcd57fc2ec8d66992e9762e1e551aee75829dda2bf9cc0837bc3106f48cece94"
76
76
  },
77
- "lock_sha256": "sha256-ux8eG1feaHkbKoSd4qxcoK/UE7jgXe1jd21LBfxviNc="
77
+ "lock_sha256": "sha256-soRuGNSctKGPLxebfbZtCx+nA/oWtDTeFaf0TRIwYhg="
78
78
  }