@dogfood-lab/study-swarm 2.0.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.
@@ -19,6 +19,11 @@ 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
+ 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.
22
27
  lint [--json] [--strict] <path...>
23
28
  Check dispatches' citations against the sourcing standard.
24
29
  A <path> may be a file, a directory (linted recursively for
@@ -30,7 +35,7 @@ COMMANDS
30
35
  harness record to feed to "lock <dispatch> --from".
31
36
  lock <dispatch> --from <orchestration.json>
32
37
  Emit <dispatch>.lock.json — pin (per Step-2 agent) the resolved
33
- 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
34
39
  schema, plus the verifier receipt, rolled into one lock_sha256.
35
40
  lock --verify <dispatch> [--from <orchestration.json>]
36
41
  Re-derive the deterministic hashes and assert they match the lock;
@@ -58,8 +63,9 @@ COMMANDS
58
63
 
59
64
  EXIT CODES
60
65
  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)
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)
63
69
  2 usage or runtime error
64
70
 
65
71
  NOTE
@@ -114,7 +120,7 @@ const template = (slug, stamp) => `<!-- ${stamp} -->
114
120
 
115
121
  ## Step 4 — External verification
116
122
  <!-- Different model family, reasoning-stripped. Run: roleos verify-citations ${slug}.dispatch.md
117
- 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. -->
118
124
  - [ ] every citation resolved by retrieval (arXiv/DOI), not model memory
119
125
  - [ ] every finding matches what its source actually claims (groundedness)
120
126
  - [ ] >= 3 decorrelated lenses (retrieval oracle + >= 2 different model families)
@@ -161,7 +167,21 @@ const BANNED = /\b(studies show|research suggests|it'?s well[- ]established|well
161
167
  // empty-matchable `\s*,?\s*`) and is bounded ({0,24}), so it is linear-time — the previous
162
168
  // form had catastrophic backtracking (ReDoS) on a long capitalized/`and`-joined run with no
163
169
  // 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;
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
+ }
165
185
 
166
186
  // --- strict mode: Step-5 connection / orphan-citation check (opt-in --strict) --------------
167
187
  // Step 5 requires each finding to inform a design choice — "citations without a connection are
@@ -187,17 +207,33 @@ function referencedNumbers(body) {
187
207
  }
188
208
  return nums;
189
209
  }
190
- // The Step-5 / Architecture section body (last matching heading → next heading/EOF), or null.
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.
191
229
  function step5Body(lines) {
192
230
  let s = -1;
193
231
  for (let i = 0; i < lines.length; i++) {
194
232
  const h = lines[i].match(/^#{1,6}\s+(.*?)\s*$/);
195
- if (h && /(step\s*5|architecture)/i.test(h[1])) s = i;
233
+ if (h && isStep5Heading(h[1])) s = i;
196
234
  }
197
235
  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');
236
+ return lines.slice(s + 1, sectionEnd(lines, s)).join('\n');
201
237
  }
202
238
 
203
239
  // Check one dispatch's text. Returns a structured result; never exits. `strict` adds the Step-5
@@ -218,10 +254,7 @@ function lintText(label, raw, strict) {
218
254
  add('no-section', 'no "Research grounding" section found — every dispatch needs one (Step 3).');
219
255
  return { file: label, ok: false, findingCount: 0, problems, findings: [] };
220
256
  }
221
- let end = lines.length;
222
- for (let i = start + 1; i < lines.length; i++) {
223
- if (/^#{1,6}\s/.test(lines[i])) { end = i; break; }
224
- }
257
+ const end = sectionEnd(lines, start);
225
258
  const section = lines.slice(start + 1, end);
226
259
 
227
260
  // Split into findings (numbered items + continuation lines), ignoring fenced code blocks
@@ -236,12 +269,19 @@ function lintText(label, raw, strict) {
236
269
  else if (cur && l.trim()) cur.text += ' ' + l.trim();
237
270
  });
238
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);
239
273
 
240
274
  if (findings.length === 0) add('no-findings', 'Research grounding has no numbered findings.');
241
275
 
242
276
  const parsed = [];
277
+ const seenNumbers = new Set();
243
278
  findings.forEach((f, i) => {
244
- 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);
245
285
  if (PLACEHOLDER.test(f.text)) add('placeholder', `finding ${n}: still has template placeholders — fill it in.`, f.line, n);
246
286
  // Strip identifiers before the year check so digits inside a citation can't masquerade
247
287
  // as a publication year: an arXiv id's YYMM prefix (e.g. 2402 in arXiv:2402.01817), a DOI,
@@ -249,12 +289,16 @@ function lintText(label, raw, strict) {
249
289
  // stripped first so a DOI-bearing URL is removed whole.
250
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, '');
251
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);
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);
253
- const idm = f.text.match(ID);
254
- if (!idm) add('missing-id', `finding ${n}: missing an identifier (arXiv:NNNN.NNNNN, DOI, URL, or RFC number).`, 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);
255
300
  const ym = fNoIds.match(YEAR);
256
- const ident = idm ? idm[0].replace(/\s+/g, '').replace(/[).,;]+$/, '') : null;
257
- 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 });
258
302
  });
259
303
 
260
304
  // Banned gesture anywhere in the section (outside fences): a finding STATES its result,
@@ -275,7 +319,7 @@ function lintText(label, raw, strict) {
275
319
  } else {
276
320
  const refs = referencedNumbers(body);
277
321
  findings.forEach((f, i) => {
278
- const n = i + 1;
322
+ const n = parsed[i].finding;
279
323
  const tok = authorTokenOf(f.text);
280
324
  // Match the author token OR (for a hyphenated surname like "Garcia-Molina") its first
281
325
  // component, so a Step-5 reference to just "Garcia" still connects — the liberal direction.
@@ -292,31 +336,59 @@ function lintText(label, raw, strict) {
292
336
  }
293
337
 
294
338
  // 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) {
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) {
302
349
  seen = seen || new Set();
350
+ report = report || newWalkReport();
303
351
  let real; try { real = realpathSync(dir); } catch { real = dir; }
304
- if (seen.has(real)) return []; // already visited via another path — junction/symlink cycle guard
352
+ if (seen.has(real)) return [];
305
353
  seen.add(real);
306
354
  let entries;
307
355
  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 []; }
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
+ }
309
362
  const out = [];
310
363
  for (const entry of entries) {
311
364
  if (entry.name === 'node_modules' || entry.name === '.git') continue;
312
- if (entry.isSymbolicLink()) continue; // don't follow symlinks (cycle + directory-escape safety)
313
365
  const full = join(dir, entry.name);
314
- if (entry.isDirectory()) out.push(...walkFiles(full, re, seen));
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));
315
376
  else if (re.test(entry.name)) out.push(full);
316
377
  }
317
378
  return out.sort();
318
379
  }
319
- function walkDispatches(dir) { return walkFiles(dir, /\.dispatch\.md$/i); }
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); }
320
392
 
321
393
  function readTarget(p) {
322
394
  try { return { label: p, raw: readFileSync(p, 'utf8') }; }
@@ -342,7 +414,10 @@ function cmdLint(args) {
342
414
  }
343
415
  if (!existsSync(p)) fail(2, `path not found: ${p}`);
344
416
  if (statSync(p).isDirectory()) {
345
- 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);
346
421
  if (files.length === 0) fail(2, `no .dispatch.md files found under ${p}`);
347
422
  for (const f of files) targets.push(readTarget(f));
348
423
  } else {
@@ -377,7 +452,8 @@ function cmdLint(args) {
377
452
  );
378
453
  } else {
379
454
  // 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`);
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`);
381
457
  }
382
458
  process.exit(anyFail ? 1 : 0);
383
459
  }
@@ -385,7 +461,7 @@ function cmdLint(args) {
385
461
  // --- lock core (dispatch.lock.json — the PIN_PER_STEP feature) ------------------
386
462
  // Design + research grounding: examples/study-swarm-lock.dispatch.md (choices L1-L11).
387
463
  // The CLI is a PURE FUNCTION of provided bytes: the orchestration harness emits the record
388
- // (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
389
465
  // canonicalizes + hashes + validates it. No network, no model calls (L2).
390
466
 
391
467
  const LOCK_SCHEMA = 'dispatch.lock/v2';
@@ -393,6 +469,11 @@ const LOCK_SCHEMA = 'dispatch.lock/v2';
393
469
  // Self-describing digest "sha256-<base64>" — the W3C Subresource Integrity form: algorithm-
394
470
  // prefixed (so it's algorithm-agile) and used fail-closed on mismatch (L9; lock dispatch finding 38).
395
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
+ }
396
477
  // Domain-separation tags (v2): a TEXT preimage and a structured-JSON (JCS) preimage are hashed in
397
478
  // DISJOINT spaces, so a prompt whose literal text happens to equal some tool schema's canonical JSON
398
479
  // can never produce the same digest as that schema (the tagged-hash / DSSE "hash known bytes with a
@@ -454,24 +535,52 @@ function buildLockObject(dispatchPath, orchestration) {
454
535
  if (s == null || s[k] === undefined || s[k] === null) fail(2, `orchestration step ${i + 1} is missing "${k}"`);
455
536
  return s[k];
456
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`);
457
544
  const rec = {
458
- question_id: String(need('question_id')),
459
- resolved_model: String(need('resolved_model')), // L6 — the resolved id, never an alias
460
- prompt_sha256: sriText(String(need('prompt'))), // L3 — text-normalized (LF/NFC/BOM), not JCS-restructured
461
- 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
+ })(),
462
555
  };
463
- if (s.schema_dialect) rec.schema_dialect = String(s.schema_dialect); // L5 — dialect is contract
464
- 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
+ }
465
564
  // L7 — output hash for DRIFT DETECTION only (not determinism). The harness may ship the raw
466
565
  // output (the CLI hashes it) OR a pre-computed output_sha256 (large outputs needn't be shipped).
467
566
  // A caller-supplied digest is validated to the SRI sha256- shape here, so a malformed hash is
468
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
+ }
469
572
  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}"`);
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}.`);
472
581
  }
473
582
  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);
583
+ } else if (hashed !== null) rec.output_sha256 = hashed;
475
584
  return rec;
476
585
  });
477
586
  const lock = {
@@ -481,8 +590,10 @@ function buildLockObject(dispatchPath, orchestration) {
481
590
  dispatch_sha256: sriText(dispatchText), // pins the dispatch text (text-normalized)
482
591
  steps,
483
592
  };
484
- if (orchestration.verification && typeof orchestration.verification === 'object') {
485
- 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
486
597
  }
487
598
  // L1/L9 — rollup over the whole body (this object, before lock_sha256 is added) as ONE flat
488
599
  // canonical object: distinct keys give domain separation, the steps array's explicit length
@@ -552,7 +663,7 @@ const orchTemplate = () => JSON.stringify({
552
663
  {
553
664
  question_id: '<Q1-short-slug>',
554
665
  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>',
666
+ prompt: '<the prompt string this research agent was given; the lock hashes it text-normalized: BOM stripped, CR/CRLF folded to LF, NFC>',
556
667
  tool_schema: { type: 'object', properties: {} },
557
668
  schema_dialect: 'https://json-schema.org/draft/2020-12/schema',
558
669
  },
@@ -618,7 +729,7 @@ function cmdLock(args) {
618
729
  }
619
730
 
620
731
  if (!orchestration) {
621
- 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');
622
733
  }
623
734
  const lock = buildLockObject(dispatch, orchestration);
624
735
  writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf8');
@@ -644,12 +755,23 @@ const WITHDRAW_REASONS = ['fabricated', 'misattributed', 'retracted', 'verifier-
644
755
  // URL), RFC (RFC NNNN / rfc-editor / datatracker), else a trimmed lowercased URL. Used on BOTH the
645
756
  // dispatch's extracted identifier and the user's <identifier> argument so `withdraw arXiv:2402.15089`
646
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
+ }
647
769
  function normIdent(raw) {
648
- let s = String(raw || '').trim().toLowerCase().replace(/[).,;]+$/, '');
649
- let m = s.match(/arxiv\.org\/(?:abs|pdf)\/(\d{4}\.\d{4,5})/) || s.match(/arxiv:\s*(\d{4}\.\d{4,5})/);
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})/);
650
772
  if (m) return 'arxiv:' + m[1];
651
773
  m = s.match(/(?:doi\.org\/|dx\.doi\.org\/|doi:\s*)?(10\.\d{4,9}\/\S+)/);
652
- if (m) return 'doi:' + m[1].replace(/[).,;]+$/, '');
774
+ if (m) return 'doi:' + cleanIdent(m[1]).toLowerCase().replace(/\/+$/, '');
653
775
  m = s.match(/rfc[\s/-]?(\d{3,5})/);
654
776
  if (m) return 'rfc:' + m[1];
655
777
  return s.replace(/\/+$/, '');
@@ -657,25 +779,52 @@ function normIdent(raw) {
657
779
 
658
780
  // The tombstone sits beside its dispatch: <dir>/<stem>.withdrawn.json (C4 — status travels WITH
659
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
+ }
660
800
  function withdrawnPathFor(dispatch) {
661
801
  const base = dispatch.split(/[\\/]/).pop().replace(/(\.dispatch)?\.md$/i, '');
662
802
  return join(dirname(dispatch), `${base}.withdrawn.json`);
663
803
  }
664
804
 
665
805
  // Recursively collect files matching a regex (delegates to the resilient shared walker).
666
- function walkByExt(dir, re) { return walkFiles(dir, re); }
806
+ function walkByExt(dir, re, report) { return walkFiles(dir, re, undefined, report); }
667
807
 
668
808
  // The finding numbers in one dispatch whose citation normalizes to `want` (reuses the lint parser,
669
809
  // so Step 3 and the compensator agree on what a citation is).
670
810
  function findingsCiting(dispatchPath, want) {
671
811
  const res = lintText(dispatchPath, readFileSync(dispatchPath, 'utf8'));
672
- 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);
673
819
  }
674
820
 
675
821
  // Every dispatch in the corpus citing `target`, with the finding numbers + a content hash each.
676
822
  function findDependents(corpus, target) {
677
823
  const want = normIdent(target);
678
- 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);
679
828
  const deps = [];
680
829
  for (const f of files) {
681
830
  const hits = findingsCiting(f, want);
@@ -711,6 +860,8 @@ function loadSidecar(dispatchPath) {
711
860
 
712
861
  // Recompute the rolled-up hash and write the sidecar; returns the finalized object.
713
862
  function writeSidecar(dispatchPath, body) {
863
+ body.schema = WITHDRAWN_SCHEMA;
864
+ body.study_swarm_version = VERSION;
714
865
  body.dispatch_sha256 = sriText(readFileSync(dispatchPath, 'utf8')); // reconcile to current content
715
866
  const finalized = withSha(body, 'withdrawn_sha256');
716
867
  writeFileSync(withdrawnPathFor(dispatchPath), JSON.stringify(finalized, null, 2) + '\n', 'utf8');
@@ -742,12 +893,19 @@ function cmdWithdraw(args) {
742
893
  const detail = f.detail ? String(f.detail) : '';
743
894
 
744
895
  const deps = findDependents(corpus, identifier);
745
- if (deps.length === 0) fail(2, `no dispatch in ${corpus} cites ${identifier} (normalized: ${want}) — nothing to withdraw. Check the identifier spelling and the --from directory; "study-swarm lint ${corpus}" lists the citations the tool can see.`);
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).`);
746
897
 
747
898
  const dependents = [];
748
899
  for (const d of deps) {
749
900
  const body = loadSidecar(d.path);
750
- const existing = body.withdrawals.find((w) => w.identifier === want);
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);
751
909
  // Idempotent: an identical withdrawal (same id + reason + detail + finding numbers, still
752
910
  // withdrawn) is a no-op. The findings array is part of the identity so that re-withdrawing
753
911
  // after the dispatch was edited (a citation moved to a different finding #) refreshes the
@@ -763,9 +921,11 @@ function cmdWithdraw(args) {
763
921
  }
764
922
  body.version += 1;
765
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() });
766
928
  }
767
- const finalized = writeSidecar(d.path, body);
768
- dependents.push({ dispatch: finalized.dispatch, dispatch_sha256: finalized.dispatch_sha256, findings: d.findings, sidecar: withdrawnPathFor(d.path).split(/[\\/]/).pop() });
769
929
  }
770
930
 
771
931
  const receipt = withSha({
@@ -775,7 +935,7 @@ function cmdWithdraw(args) {
775
935
  reason: String(f.reason),
776
936
  detail,
777
937
  corpus: corpus.split(/[\\/]/).pop() || corpus,
778
- dependents,
938
+ dependents: dependents.map(({ path: _path, ...rest }) => rest),
779
939
  post_rollback_state: `${dependents.length} dependent(s) flagged evidence-withdrawn; "study-swarm requalify --check" fails closed until each is removed or re-grounded.`,
780
940
  }, 'receipt_sha256');
781
941
 
@@ -787,10 +947,14 @@ function cmdWithdraw(args) {
787
947
  }
788
948
  // Contrastive surfacing — never a silent drop (C10; Buçinca 2024, Bansal 2021).
789
949
  process.stdout.write(`Withdrew ${want} (reason: ${f.reason}). ${dependents.length} dependent(s) flagged evidence-withdrawn:\n`);
790
- for (const d of dependents) process.stdout.write(` - ${d.dispatch} (findings ${d.findings.map((n) => '#' + n).join(', ')})\n`);
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
+ }
791
955
  process.stdout.write(
792
956
  `\nYou may have relied on this finding. Each flagged dispatch now HALTS "study-swarm requalify --check"\n` +
793
- `until the finding is removed or re-grounded — re-ground or override.\n` +
957
+ `until you run one of the commands above. Delete the citation before --mode removed.\n` +
794
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`);
795
959
  process.exit(0);
796
960
  }
@@ -813,12 +977,15 @@ function requalifyStatus(args) {
813
977
  const corpus = f._[0];
814
978
  if (!corpus) fail(2, 'usage: study-swarm requalify --status <corpus-dir> [--json]');
815
979
  if (!existsSync(corpus)) fail(2, `corpus not found: ${corpus}`);
816
- const sidecars = statSync(corpus).isDirectory() ? walkByExt(corpus, /\.withdrawn\.json$/i) : [corpus];
980
+ const walk = newWalkReport();
981
+ const sidecars = statSync(corpus).isDirectory() ? walkByExt(corpus, /\.withdrawn\.json$/i, walk) : [corpus];
817
982
  const totals = { withdrawn: 0, resolved: 0 };
818
983
  const by_reason = {};
819
984
  const by_mode = {};
820
985
  const dispatches = [];
821
986
  const problems = [];
987
+ const statusBlocked = walkBlockedMessage(walk);
988
+ if (statusBlocked) problems.push(statusBlocked);
822
989
  for (const sc of sidecars) {
823
990
  let stored;
824
991
  try { stored = JSON.parse(readFileSync(sc, 'utf8')); }
@@ -826,6 +993,7 @@ function requalifyStatus(args) {
826
993
  if (!stored || typeof stored !== 'object' || Array.isArray(stored)) { problems.push(`${sc}: sidecar is not a JSON object`); continue; }
827
994
  const entries = [];
828
995
  for (const w of stored.withdrawals || []) {
996
+ if (!w || typeof w !== 'object') { problems.push(`${sc}: a withdrawals entry is not an object`); continue; }
829
997
  const status = w.status === 'resolved' ? 'resolved' : 'withdrawn';
830
998
  totals[status] += 1;
831
999
  if (w.reason) by_reason[w.reason] = (by_reason[w.reason] || 0) + 1;
@@ -833,7 +1001,7 @@ function requalifyStatus(args) {
833
1001
  if (mode) by_mode[mode] = (by_mode[mode] || 0) + 1;
834
1002
  entries.push({ identifier: w.identifier, reason: w.reason, status, mode, findings: w.findings || [] });
835
1003
  }
836
- dispatches.push({ dispatch: stored.dispatch, sidecar: sc.split(/[\\/]/).pop(), withdrawals: entries });
1004
+ dispatches.push({ dispatch: openableDispatch(sc, stored.dispatch), sidecar: sc, withdrawals: entries });
837
1005
  }
838
1006
  const kv = (o) => Object.keys(o).sort().map((k) => `${k}=${o[k]}`).join(', ') || '(none)';
839
1007
  if (f.json) {
@@ -859,9 +1027,12 @@ function requalifyCheck(args) {
859
1027
  const corpus = f._[0];
860
1028
  if (!corpus) fail(2, 'usage: study-swarm requalify --check <corpus-dir> [--json]');
861
1029
  if (!existsSync(corpus)) fail(2, `corpus not found: ${corpus}`);
862
- 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];
863
1032
  const halts = []; // { sidecar, dispatch, identifier, reason, findings }
864
1033
  const problems = [];
1034
+ const checkBlocked = walkBlockedMessage(walk);
1035
+ if (checkBlocked) problems.push(checkBlocked);
865
1036
  let resolvedCount = 0;
866
1037
  for (const sc of sidecars) {
867
1038
  let stored;
@@ -880,7 +1051,8 @@ function requalifyCheck(args) {
880
1051
  problems.push(`${sc}: withdrawn_sha256 self-integrity mismatch (the sidecar was hand-edited)`);
881
1052
  }
882
1053
  for (const w of stored.withdrawals || []) {
883
- if (w.status === 'withdrawn') halts.push({ sidecar: sc.split(/[\\/]/).pop(), dispatch: stored.dispatch, identifier: w.identifier, reason: w.reason, findings: w.findings });
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 });
884
1056
  else if (w.status === 'resolved') resolvedCount += 1;
885
1057
  }
886
1058
  }
@@ -893,8 +1065,12 @@ function requalifyCheck(args) {
893
1065
  process.stdout.write(`ok ${corpus}: no unresolved evidence-withdrawn flags (${resolvedCount} resolved).\n`);
894
1066
  process.exit(0);
895
1067
  }
896
- process.stderr.write(`x requalify --check ${corpus}: ${halts.length} unresolved evidence-withdrawn flag(s) — HALT\n`);
897
- for (const h of halts) process.stderr.write(` - ${h.dispatch}: ${h.identifier} withdrawn (reason: ${h.reason}) — findings ${(h.findings || []).map((n) => '#' + n).join(', ')}. You may have relied on it; re-ground or override.\n`);
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
+ }
898
1074
  for (const p of problems) process.stderr.write(` - ${p}\n`);
899
1075
  process.exit(1);
900
1076
  }
@@ -913,7 +1089,11 @@ function requalifyResolve(args) {
913
1089
  let body;
914
1090
  try { body = JSON.parse(readFileSync(scPath, 'utf8')); }
915
1091
  catch (err) { fail(2, `cannot read sidecar ${scPath}: ${err && err.code ? err.code : err.message}`); }
916
- const entry = (body.withdrawals || []).find((w) => w.identifier === want);
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);
917
1097
  if (!entry) fail(2, `no evidence-withdrawn flag for ${identifier} (normalized: ${want}) on ${dispatch}`);
918
1098
 
919
1099
  if (entry.status === 'resolved') { // Idempotent: re-resolving is a no-op, no new audit entry (C7).
@@ -935,12 +1115,146 @@ function requalifyResolve(args) {
935
1115
  process.exit(0);
936
1116
  }
937
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
+
938
1251
  function main(argv) {
939
1252
  const [cmd, ...rest] = argv;
940
1253
  switch (cmd) {
941
1254
  case 'protocol': return cmdProtocol();
942
1255
  case 'new': return cmdNew(rest[0]);
943
1256
  case 'lint': return cmdLint(rest);
1257
+ case 'return': return cmdReturn(rest);
944
1258
  case 'lock': return cmdLock(rest);
945
1259
  case 'withdraw': return cmdWithdraw(rest);
946
1260
  case 'requalify': return cmdRequalify(rest);