@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.
- package/CHANGELOG.md +34 -0
- package/PROTOCOL.md +3 -3
- package/README.es.md +12 -11
- package/README.fr.md +12 -11
- package/README.hi.md +13 -12
- package/README.it.md +13 -12
- package/README.ja.md +12 -11
- package/README.md +8 -7
- package/README.pt-BR.md +12 -11
- package/README.zh.md +13 -12
- package/SECURITY.md +4 -3
- package/bin/study-swarm.mjs +383 -69
- package/examples/study-swarm-canon-rollback.dispatch.md +8 -9
- package/examples/study-swarm-canon-rollback.lock.json +4 -4
- package/examples/study-swarm-ci.yml +3 -2
- package/examples/study-swarm-lock.dispatch.md +10 -10
- package/examples/study-swarm-lock.lock.json +4 -4
- package/examples/study-swarm-v1_1.dispatch.md +1 -1
- package/package.json +1 -1
package/bin/study-swarm.mjs
CHANGED
|
@@ -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
|
|
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
|
|
62
|
-
unresolved evidence-withdrawn flag
|
|
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
|
-
|
|
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}\
|
|
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
|
-
|
|
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 &&
|
|
233
|
+
if (h && isStep5Heading(h[1])) s = i;
|
|
196
234
|
}
|
|
197
235
|
if (s === -1) return null;
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
296
|
-
// - an unreadable directory
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
// Skips node_modules/.git.
|
|
301
|
-
function
|
|
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 [];
|
|
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) {
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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 +
|
|
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:
|
|
459
|
-
resolved_model:
|
|
460
|
-
prompt_sha256: sriText(
|
|
461
|
-
tool_schema_sha256:
|
|
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
|
|
464
|
-
|
|
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 (
|
|
471
|
-
fail(2, `orchestration step ${i + 1} output_sha256 is not
|
|
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 (
|
|
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
|
|
485
|
-
|
|
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
|
|
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 +
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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);
|