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