@dogfood-lab/study-swarm 1.1.0 → 1.3.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/README.zh.md CHANGED
@@ -76,6 +76,11 @@ npm i -g @dogfood-lab/study-swarm # or run ad-hoc: npx @dogfood-lab/study-sw
76
76
  | `study-swarm protocol` | 打印完整的协议——五个步骤、停止表以及来源标准。 |
77
77
  | `study-swarm new <slug>` | 创建一个`<slug>.dispatch.md`文件,其中包含五步流程的框架,以便进行填充。 |
78
78
  | `study-swarm lint [--json] <path…>` | 根据来源标准检查工作流程的*研究扎实性*——每条研究结果都需要作者、年份和一个可解析的标识符(arXiv / DOI / URL);“研究表明……”这种含糊其辞的方式将被拒绝。如果存在违规行为,则退出代码为`1`,以便在CI中进行筛选。`<path>`可以是文件、目录(递归地检查所有`.dispatch.md`文件),或者`-`表示标准输入;`--json`会输出机器可读的报告。 |
79
+ | `study-swarm lock <dispatch> --from <orchestration.json>` | 将一个调度固定下来以便重放——编写 `<dispatch>.lock.json`,其中包含基于内容的哈希值,按照步骤 2 中的代理进行操作,包括**已解析的模型 ID** + **字节级精确提示的 SHA-256 值** + **工具模式的 SHA-256 值**,以及步骤 4 中的**验证者凭证**,并将它们组合成一个 `lock_sha256`。 |
80
+ | `study-swarm lock --verify <dispatch> [--from …]` | 重新计算这些哈希值并确认它们与锁匹配;如果出现任何偏差,则退出并返回 1,因此它就像软件包的 lock 文件一样,可以控制 CI 流程。如果不使用 `--from` 参数,则会检查锁自身的完整性。 |
81
+ | `study-swarm withdraw <id> --reason <reason> [--from <dir>] [--receipt <path>]` | **规范回滚补偿器。** 标记语料库中每个引用 `<id>` 作为“证据已撤回”(一个墓碑侧文件 `<slug>.withdrawn.json`——标记,永不删除)的文档,并生成基于内容的撤回凭证。 `--reason` ∈ `fabricated · misattributed · retracted · verifier-flipped · other`。 |
82
+ | `study-swarm requalify --check <corpus-dir>` | 对于任何带有未解决的“证据已撤回”标志的文档,执行失败安全机制(退出代码为 `1`)——这是一种“andon”(警报),它会**阻止**已撤回结论的依赖项,直到该结论被删除或重新验证。用于门控 CI。 |
83
+ | `study-swarm requalify --resolve <dispatch> <id> --mode removed\ | regrounded [--note …]` | 一旦该结论被删除(引用消失)或重新验证(由辅助运行器重新验证;`--note` 记录证明),则清除标志。幂等性;附加到侧文件的审计跟踪中。 |
79
84
 
80
85
  `lint`是确定性的——不调用任何模型——因此可以在CI中安全使用。它在本地强制执行**第3步的来源标准**;基于模型的**第4步**验证仍然依赖于[`roleos verify-citations`](https://github.com/mcp-tool-shop-org/role-os) → prism。
81
86
 
@@ -88,7 +93,7 @@ study-swarm lint my-decision.dispatch.md # enforce the sourcing standard
88
93
  roleos verify-citations my-decision.dispatch.md # model-based Step 4 (different family, via prism)
89
94
  ```
90
95
 
91
- 两个完整的、符合“lint”规范的工作流程示例以供参考:[`examples/study-swarm-self.dispatch.md`](examples/study-swarm-self.dispatch.md)(协议的核心决策,简洁)和[`examples/study-swarm-v1_1.dispatch.md`](examples/study-swarm-v1_1.dispatch.md)(完整的v1.1设计流程——27条引用,每一条都经过外部验证)。
96
+ 四个完整的、经过代码检查的文档作为参考发布:[`examples/study-swarm-self.dispatch.md`](examples/study-swarm-self.dispatch.md)(协议的核心决策,简洁),[`examples/study-swarm-v1_1.dispatch.md`](examples/study-swarm-v1_1.dispatch.md)(完整的 v1.1 设计版本——27 个引用,每个引用都经过外部验证),[`examples/study-swarm-lock.dispatch.md`](examples/study-swarm-lock.dispatch.md)(v1.2 锁定设计——39 个引用,通过运行器进行门控,并且是第一个发布其自身锁定的文档),以及 [`examples/study-swarm-canon-rollback.dispatch.md`](examples/study-swarm-canon-rollback.dispatch.md)(v1.3 规范回滚设计——27 个引用,涵盖撤销、撤稿、连续事件和构建失效,并且是第一个被撤回然后重新验证的文档)。
92
97
 
93
98
  ### 在CI中进行筛选
94
99
 
@@ -114,6 +119,26 @@ jobs:
114
119
  - run: npx @dogfood-lab/study-swarm@latest lint dispatches/
115
120
  ```
116
121
 
122
+ ### 将一个调度固定下来以便重放 (`dispatch.lock.json`)
123
+
124
+ 只有当你能够说明*是什么产生了它*时,才能对经过验证的调度进行审计。`study-swarm lock` 编写一个配套的锁文件,该文件基于内容进行哈希处理,按照研究代理进行操作,包括**已解析的模型 ID(绝不使用浮动别名)**、**字节级精确提示的 SHA-256 值**以及**工具模式的 SHA-256 值**,以及外部**验证者凭证**——所有这些都组合成一个 `lock_sha256`。`study-swarm lock --verify` 重新计算这些哈希值,并且如果出现任何偏差,则会失败并停止,因此,如果提示、模型或工具发生更改,系统都会检测到——这是 [PIN_PER_STEP](https://github.com/dogfood-lab/study-swarm) 可重复性标准的可执行版本。该框架会输出记录;CLI 保持零依赖和无网络状态,仅进行规范化(RFC 8785)、哈希处理和验证。
125
+
126
+ **它固定输入,而不是输出。** 固定模型 + 提示 + 温度并不能使 LLM 的输出完全相同——批处理不变性、浮点数非结合律、混合专家路由以及无声提供者漂移都超出了离线工具的控制范围。因此,该锁为您提供**可重放的输入和可检测偏差的输出**,而不是“确定性重放”。该设计基于 [`examples/study-swarm-lock.dispatch.md`](examples/study-swarm-lock.dispatch.md) 中的每一处引用,并且是第一个发布其自身锁([`examples/study-swarm-lock.lock.json`](examples/study-swarm-lock.lock.json))的调度文件。
127
+
128
+ ### 回滚已撤回的结论 (`withdraw` / `requalify`)
129
+
130
+ 经过验证的结论成为**规范**——它会影响下游决策。那么,如果稍后该结论被**撤回**(在重新运行时发现引用是捏造/错误归因,引用的论文被撤稿,或者门控机制将其标记),会发生什么?简单的 `git revert` 并不足以解决问题,因为该结论已经传播开来。规范回滚补偿器使清理过程可执行:
131
+
132
+ ```bash
133
+ study-swarm withdraw arXiv:2402.15089 --reason misattributed --from dispatches/ --receipt rollback.json
134
+ # → flags every dispatch citing it `evidence-withdrawn` (a tombstone sidecar — flag, never delete)
135
+ # and writes a content-addressed withdrawal receipt naming every dependent.
136
+ study-swarm requalify --check dispatches/ # exit 1 while any flag is unresolved — the andon HALT
137
+ study-swarm requalify --resolve d.dispatch.md arXiv:2402.15089 --mode removed # or: --mode regrounded --note "<attestation>"
138
+ ```
139
+
140
+ `requalify --check` 在每个带有标志的结论被删除或**重新验证**(由辅助运行器重新验证——CLI 记录证明,它本身不会重新验证)之前,将**失败安全**。撤回以**对比方式**呈现,而不是默默地删除。所有内容——墓碑和凭证——都基于内容进行寻址且可检测漂移,并且仅对*证据*层进行操作:`lock --verify` 不受撤回的影响。该设计基于 [`examples/study-swarm-canon-rollback.dispatch.md`](examples/study-swarm-canon-rollback.dispatch.md),并且 [PROTOCOL.md](PROTOCOL.md) §“补偿已撤回的结论”是可执行的形式。这是**NAMED_COMPENSATORS** 标准的可执行版本:一种命名的、幂等的撤销操作,它会留下一个已知的后期状态和一个凭证。
141
+
117
142
  ## 用一句话概括其工作原理
118
143
 
119
144
  **及时性**——该领域发展迅速;要求提供具体的带有年份的研究,可以防止设计落后18个月。**功能性**——证据表明哪些*方法失败*,而不仅仅是哪些有效(解释可能会增加对*错误*人工智能的过度依赖——Bansal等人,2021年,[arXiv:2006.14779](https://arxiv.org/abs/2006.14779))。**安全性**——受验证器保护的范围是证据支持的架构,并且协议对其自身的输出进行强制执行。来源不是学术上的形式主义;它是证据链。
@@ -124,7 +149,7 @@ jobs:
124
149
 
125
150
  ## 状态
126
151
 
127
- 一个可用的协议,其自身的机制对其进行了外部验证——不同的模型系列检查其引用(参见上面的证明)。**v1.1**改进了验证器,弥补了第一个版本中存在的不足:分解/三元扎实性、生成时扎实性、由预言机控制的级联机制以及经过校准的弃权——每项都基于经过验证的v1.1工作流程。此仓库是公共参考;[PROTOCOL.md](PROTOCOL.md)是可执行的形式。它是[dogfood-lab](https://github.com/dogfood-lab)系列的一部分——用于在人工智能时代构建方法和示例。
152
+ 一个可工作的协议,由其自身的机制进行外部验证——不同的模型系列检查其引用(参见上面的证明)。**v1.1** 改进了验证器,而第一个版本是静默的:分解/三元验证、生成时验证、用于组合透镜的基于 oracle 的级联以及校准后的弃权——每个都基于经过验证的 v1.1 文档。**v1.2** 使文档可重放:`study-swarm lock` 为每个步骤固定已解决的模型、提示和工具模式,以及验证器凭证,并且 `lock --verify` 在检测到漂移时会失败安全。**v1.3** 使回滚操作可执行:当已经成为规范的结论被撤回时,`study-swarm withdraw` 会标记所有依赖项,并且 `requalify --check` 会阻止它们,直到它们被删除或重新验证——这是一种命名的、带有凭证的、幂等的补偿器。此仓库是公共参考;[PROTOCOL.md](PROTOCOL.md) 是可执行的形式。它是 [dogfood-lab](https://github.com/dogfood-lab) 系列的一部分——用于在人工智能时代构建的方法和示例。
128
153
 
129
154
  采用MIT许可证。
130
155
 
@@ -22,6 +22,27 @@ COMMANDS
22
22
  lint [--json] <path...> Check dispatches' citations against the sourcing standard.
23
23
  A <path> may be a file, a directory (linted recursively for
24
24
  *.dispatch.md), or "-" to read one dispatch from stdin.
25
+ lock <dispatch> --from <orchestration.json>
26
+ Emit <dispatch>.lock.json — pin (per Step-2 agent) the resolved
27
+ model + SHA-256 of the byte-exact prompt + SHA-256 of the tool
28
+ schema, plus the verifier receipt, rolled into one lock_sha256.
29
+ lock --verify <dispatch> [--from <orchestration.json>]
30
+ Re-derive the deterministic hashes and assert they match the lock;
31
+ drift exits 1 (gates CI). Without --from, checks lock self-integrity.
32
+ withdraw <identifier> --reason <reason> [--detail <text>] [--from <dir>] [--receipt <path>]
33
+ Canon-rollback compensator. Flag every dispatch in the corpus whose
34
+ Research grounding cites <identifier> as "evidence-withdrawn" (a
35
+ tombstone sidecar <slug>.withdrawn.json — flag, never delete), and emit
36
+ a content-addressed withdrawal receipt. --reason is one of:
37
+ fabricated | misattributed | retracted | verifier-flipped | other.
38
+ requalify --check <corpus-dir>
39
+ Fail closed (exit 1) for any dispatch carrying an unresolved
40
+ evidence-withdrawn flag — the andon that HALTS a withdrawn finding's
41
+ dependents until it is removed or re-grounded. Gates CI.
42
+ requalify --resolve <dispatch> <identifier> --mode removed|regrounded [--note <text>]
43
+ Clear a flag once the finding is removed (the citation is gone) or
44
+ re-grounded (re-verified clean by the sibling runner; --note records
45
+ the attestation). Idempotent; appends to the sidecar's audit trail.
25
46
  help Show this help.
26
47
  version Print the version.
27
48
 
@@ -47,7 +68,7 @@ function fail(code, msg) {
47
68
  // Short hash of the vendored PROTOCOL.md, so a scaffolded dispatch records the exact
48
69
  // methodology version it was authored against (the package vendors PROTOCOL.md for this).
49
70
  function protocolHash() {
50
- try { return createHash('sha256').update(readFileSync(PROTOCOL_PATH)).digest('hex').slice(0, 16); }
71
+ try { return createHash('sha256').update(normText(readFileSync(PROTOCOL_PATH, 'utf8'))).digest('hex').slice(0, 16); }
51
72
  catch { return 'unknown'; }
52
73
  }
53
74
 
@@ -257,12 +278,442 @@ function cmdLint(args) {
257
278
  process.exit(anyFail ? 1 : 0);
258
279
  }
259
280
 
281
+ // --- lock core (dispatch.lock.json — the PIN_PER_STEP feature) ------------------
282
+ // Design + research grounding: examples/study-swarm-lock.dispatch.md (choices L1-L11).
283
+ // The CLI is a PURE FUNCTION of provided bytes: the orchestration harness emits the record
284
+ // (resolved models + byte-exact prompts + tool schemas + verifier receipt); the CLI only
285
+ // canonicalizes + hashes + validates it. No network, no model calls (L2).
286
+
287
+ const LOCK_SCHEMA = 'dispatch.lock/v1';
288
+
289
+ // Self-describing digest "sha256-<base64>" — the W3C Subresource Integrity form: algorithm-
290
+ // prefixed (so it's algorithm-agile) and used fail-closed on mismatch (L9; lock dispatch finding 38).
291
+ function sriBytes(buf) { return 'sha256-' + createHash('sha256').update(buf).digest('base64'); }
292
+ // Normalize TEXT before hashing so the same content hashes identically across platforms — strip a
293
+ // BOM, fold CRLF/CR -> LF, NFC-normalize. Without this, a CRLF working tree (Windows) and an LF
294
+ // checkout (git/CI) produce different hashes — the exact cross-platform drift our Q2 findings warn
295
+ // about (RFC 8259 BOM, UAX #15 NFC, and CRLF/LF). Applied to every text input that gets hashed.
296
+ 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')); }
298
+
299
+ // RFC 8785 (JCS) canonical JSON, for the structured JSON the CLI assembles ITSELF (the tool
300
+ // surface and the lock body): NFC-normalize strings, sort object keys by UTF-16 code unit (JS
301
+ // default string sort), no inter-token whitespace, ECMAScript-shortest numbers, UTF-8 (L4).
302
+ // The PROMPT is NOT JCS-restructured — it is the literal string the model conditioned on, so it is
303
+ // hashed as text (L3; JWS/DSSE hash-known-bytes rule, findings 12/23) under the SAME newline/BOM/NFC
304
+ // normalization (normText) so the same prompt hashes identically across platforms (findings 10/11).
305
+ function jcs(value) {
306
+ const ser = (v) => {
307
+ if (v === null) return 'null';
308
+ const t = typeof v;
309
+ if (t === 'boolean') return v ? 'true' : 'false';
310
+ if (t === 'number') {
311
+ if (!Number.isFinite(v)) throw new Error('JCS: non-finite number not allowed');
312
+ return JSON.stringify(v); // ECMAScript Number->String shortest round-trip
313
+ }
314
+ if (t === 'string') return JSON.stringify(normText(v));
315
+ if (Array.isArray(v)) return '[' + v.map(ser).join(',') + ']';
316
+ if (t === 'object') {
317
+ return '{' + Object.keys(v).sort()
318
+ .map((k) => JSON.stringify(normText(k)) + ':' + ser(v[k])).join(',') + '}';
319
+ }
320
+ throw new Error(`JCS: unsupported type ${t}`);
321
+ };
322
+ return ser(value);
323
+ }
324
+ function jcsDigest(value) { return sriBytes(Buffer.from(jcs(value), 'utf8')); }
325
+
326
+ // The lock sits beside its dispatch: <dir>/<stem>.lock.json (stem strips a trailing .dispatch.md).
327
+ function lockPathFor(dispatch) {
328
+ const base = dispatch.split(/[\\/]/).pop().replace(/(\.dispatch)?\.md$/i, '');
329
+ return join(dirname(dispatch), `${base}.lock.json`);
330
+ }
331
+
332
+ // Build the lock object from the dispatch bytes + the harness-emitted orchestration record.
333
+ function buildLockObject(dispatchPath, orchestration) {
334
+ const dispatchText = readFileSync(dispatchPath, 'utf8');
335
+ const protocolText = readFileSync(PROTOCOL_PATH, 'utf8');
336
+ if (!orchestration || !Array.isArray(orchestration.steps) || orchestration.steps.length === 0) {
337
+ fail(2, 'orchestration record has no non-empty "steps" array');
338
+ }
339
+ const steps = orchestration.steps.map((s, i) => {
340
+ const need = (k) => {
341
+ if (s == null || s[k] === undefined || s[k] === null) fail(2, `orchestration step ${i + 1} is missing "${k}"`);
342
+ return s[k];
343
+ };
344
+ const rec = {
345
+ question_id: String(need('question_id')),
346
+ resolved_model: String(need('resolved_model')), // L6 — the resolved id, never an alias
347
+ prompt_sha256: sriText(String(need('prompt'))), // L3 — text-normalized (LF/NFC/BOM), not JCS-restructured
348
+ tool_schema_sha256: jcsDigest(need('tool_schema')), // L5 — canonicalized tool surface
349
+ };
350
+ if (s.schema_dialect) rec.schema_dialect = String(s.schema_dialect); // L5 — dialect is contract
351
+ if (s.params && typeof s.params === 'object') rec.params = s.params;
352
+ // L7 — output hash for DRIFT DETECTION only (not determinism). The harness may ship the raw
353
+ // output (the CLI hashes it) OR a pre-computed output_sha256 (large outputs needn't be shipped).
354
+ if (typeof s.output_sha256 === 'string') rec.output_sha256 = s.output_sha256;
355
+ else if (s.output !== undefined) rec.output_sha256 = typeof s.output === 'string' ? sriText(s.output) : jcsDigest(s.output);
356
+ return rec;
357
+ });
358
+ const lock = {
359
+ schema: LOCK_SCHEMA,
360
+ study_swarm_version: VERSION,
361
+ protocol_sha256: sriText(protocolText), // pins the methodology version (text-normalized)
362
+ dispatch_sha256: sriText(dispatchText), // pins the dispatch text (text-normalized)
363
+ steps,
364
+ };
365
+ if (orchestration.verification && typeof orchestration.verification === 'object') {
366
+ lock.verification = orchestration.verification; // L10 — the external-verifier receipt
367
+ }
368
+ // L1/L9 — rollup over the whole body (this object, before lock_sha256 is added) as ONE flat
369
+ // canonical object: distinct keys give domain separation, the steps array's explicit length
370
+ // commits to exactly N steps (no odd-leaf duplication).
371
+ lock.lock_sha256 = jcsDigest(lock);
372
+ return lock;
373
+ }
374
+
375
+ // Verify a lock: self-integrity always; source-drift too when an orchestration record is supplied.
376
+ // Strict-match, fail-closed (L8): returns a list of problems (empty = clean).
377
+ function verifyLockObject(dispatchPath, lockPath, orchestration) {
378
+ let stored;
379
+ try { stored = JSON.parse(readFileSync(lockPath, 'utf8')); }
380
+ catch (err) { fail(2, `cannot read lock ${lockPath}: ${err && err.code ? err.code : err.message}`); }
381
+ const problems = [];
382
+ // 1) Self-integrity — recompute lock_sha256 over the stored body (detects a hand-edited lock).
383
+ if (!stored || typeof stored !== 'object' || typeof stored.lock_sha256 !== 'string') {
384
+ problems.push('lock has no lock_sha256 string');
385
+ } else {
386
+ const { lock_sha256, ...body } = stored;
387
+ const recomputed = jcsDigest(body);
388
+ if (lock_sha256 !== recomputed) {
389
+ problems.push(`lock_sha256 mismatch (the lock body was edited): stored ${lock_sha256} != recomputed ${recomputed}`);
390
+ }
391
+ }
392
+ // 2) Source drift — re-derive the deterministic hashes from the live inputs and strict-compare.
393
+ if (orchestration) {
394
+ const fresh = buildLockObject(dispatchPath, orchestration);
395
+ const cmp = (label, a, b) => { if (a !== b) problems.push(`${label} drift: lock ${b} != re-derived ${a}`); };
396
+ cmp('lock_sha256', fresh.lock_sha256, stored.lock_sha256); // the authoritative rollup guard
397
+ cmp('protocol_sha256', fresh.protocol_sha256, stored.protocol_sha256);
398
+ cmp('dispatch_sha256', fresh.dispatch_sha256, stored.dispatch_sha256);
399
+ const a = fresh.steps || [], b = Array.isArray(stored.steps) ? stored.steps : [];
400
+ if (a.length !== b.length) problems.push(`step count drift: re-derived ${a.length} != lock ${b.length}`);
401
+ for (let i = 0; i < Math.min(a.length, b.length); i++) {
402
+ for (const k of ['question_id', 'resolved_model', 'prompt_sha256', 'tool_schema_sha256']) {
403
+ cmp(`steps[${i}].${k}`, a[i][k], b[i][k]);
404
+ }
405
+ if (a[i].output_sha256 || b[i].output_sha256) cmp(`steps[${i}].output_sha256`, a[i].output_sha256, b[i].output_sha256);
406
+ }
407
+ }
408
+ return problems;
409
+ }
410
+
411
+ function cmdLock(args) {
412
+ const verify = args.includes('--verify');
413
+ const rest = args.filter((a) => a !== '--verify');
414
+ let orchPath = null;
415
+ const fromIdx = rest.indexOf('--from');
416
+ if (fromIdx !== -1) {
417
+ orchPath = rest[fromIdx + 1];
418
+ if (!orchPath) fail(2, 'usage: --from <orchestration.json>');
419
+ rest.splice(fromIdx, 2);
420
+ }
421
+ const dispatch = rest[0];
422
+ if (!dispatch) {
423
+ fail(2, 'usage: study-swarm lock <dispatch> --from <orchestration.json> | study-swarm lock --verify <dispatch> [--from <orchestration.json>]');
424
+ }
425
+ if (!existsSync(dispatch)) fail(2, `dispatch not found: ${dispatch}`);
426
+ let orchestration = null;
427
+ if (orchPath) {
428
+ if (!existsSync(orchPath)) fail(2, `orchestration record not found: ${orchPath}`);
429
+ try { orchestration = JSON.parse(readFileSync(orchPath, 'utf8')); }
430
+ catch (err) { fail(2, `orchestration record is not valid JSON: ${err.message}`); }
431
+ }
432
+ const lockPath = lockPathFor(dispatch);
433
+
434
+ if (verify) {
435
+ if (!existsSync(lockPath)) fail(2, `no lock at ${lockPath} — create it with: study-swarm lock ${dispatch} --from <orchestration.json>`);
436
+ const problems = verifyLockObject(dispatch, lockPath, orchestration);
437
+ if (problems.length === 0) {
438
+ const scope = orchestration ? 'lock integrity verified + no source drift' : 'lock self-integrity verified (pass --from to also check source drift)';
439
+ process.stdout.write(`ok ${lockPath}: ${scope}.\n`);
440
+ process.exit(0);
441
+ }
442
+ process.stderr.write(`x ${lockPath}: ${problems.length} drift/integrity issue(s)\n`);
443
+ for (const p of problems) process.stderr.write(` - ${p}\n`);
444
+ process.exit(1);
445
+ }
446
+
447
+ if (!orchestration) {
448
+ fail(2, 'study-swarm lock <dispatch> requires --from <orchestration.json> — the harness-emitted record of resolved models + byte-exact prompts + tool schemas + the verifier receipt');
449
+ }
450
+ const lock = buildLockObject(dispatch, orchestration);
451
+ writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf8');
452
+ process.stdout.write(`Created ${lockPath}\nlock_sha256: ${lock.lock_sha256}\nVerify with: study-swarm lock --verify ${dispatch} --from ${orchPath}\n`);
453
+ }
454
+
455
+ // --- canon-rollback core (the requalify_dependent_slices compensator) -----------
456
+ // Design + research grounding: examples/study-swarm-canon-rollback.dispatch.md (choices C1-C12).
457
+ // The compensator operates on the VOLATILE evidence layer (a per-dispatch tombstone sidecar),
458
+ // never the STABLE protocol/lock shape — `lock --verify` is unaffected by withdraw/resolve (C11,
459
+ // the Parnas boundary). Like `lock`, the CLI is a PURE FUNCTION of bytes: it flags, gates, and
460
+ // receipts deterministically (file reads, JSON I/O, SHA-256); the actual re-verification of a
461
+ // re-grounded finding defers to the sibling runner (C12, honest ceiling). No network, no models.
462
+
463
+ const WITHDRAWN_SCHEMA = 'dispatch.withdrawn/v1';
464
+ const RECEIPT_SCHEMA = 'withdrawal-receipt/v1';
465
+ // A CLOSED, machine-readable reason enum — never free text (C3; OpenVEX/CSAF/CycloneDX: a status
466
+ // must carry a structured justification, a bare flag is non-conformant).
467
+ const WITHDRAW_REASONS = ['fabricated', 'misattributed', 'retracted', 'verifier-flipped', 'other'];
468
+
469
+ // Normalize a citation identifier to ONE canonical key so the SAME source matches across the forms
470
+ // a finding might cite it in: arXiv (bare / arxiv.org URL / version suffix), DOI (bare / doi.org
471
+ // URL), RFC (RFC NNNN / rfc-editor / datatracker), else a trimmed lowercased URL. Used on BOTH the
472
+ // dispatch's extracted identifier and the user's <identifier> argument so `withdraw arXiv:2402.15089`
473
+ // flags a finding citing `https://arxiv.org/abs/2402.15089v2` (C2).
474
+ function normIdent(raw) {
475
+ let s = String(raw || '').trim().toLowerCase().replace(/[).,;]+$/, '');
476
+ let m = s.match(/arxiv\.org\/(?:abs|pdf)\/(\d{4}\.\d{4,5})/) || s.match(/arxiv:\s*(\d{4}\.\d{4,5})/);
477
+ if (m) return 'arxiv:' + m[1];
478
+ m = s.match(/(?:doi\.org\/|dx\.doi\.org\/|doi:\s*)?(10\.\d{4,9}\/\S+)/);
479
+ if (m) return 'doi:' + m[1].replace(/[).,;]+$/, '');
480
+ m = s.match(/rfc[\s/-]?(\d{3,5})/);
481
+ if (m) return 'rfc:' + m[1];
482
+ return s.replace(/\/+$/, '');
483
+ }
484
+
485
+ // The tombstone sits beside its dispatch: <dir>/<stem>.withdrawn.json (C4 — status travels WITH
486
+ // the artifact, the OCSP-stapling property; stem strips a trailing .dispatch.md).
487
+ function withdrawnPathFor(dispatch) {
488
+ const base = dispatch.split(/[\\/]/).pop().replace(/(\.dispatch)?\.md$/i, '');
489
+ return join(dirname(dispatch), `${base}.withdrawn.json`);
490
+ }
491
+
492
+ // Recursively collect files matching a regex (skips node_modules/.git), sorted for determinism.
493
+ function walkByExt(dir, re) {
494
+ const out = [];
495
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
496
+ if (entry.name === 'node_modules' || entry.name === '.git') continue;
497
+ const full = join(dir, entry.name);
498
+ if (entry.isDirectory()) out.push(...walkByExt(full, re));
499
+ else if (re.test(entry.name)) out.push(full);
500
+ }
501
+ return out.sort();
502
+ }
503
+
504
+ // The finding numbers in one dispatch whose citation normalizes to `want` (reuses the lint parser,
505
+ // so Step 3 and the compensator agree on what a citation is).
506
+ function findingsCiting(dispatchPath, want) {
507
+ const res = lintText(dispatchPath, readFileSync(dispatchPath, 'utf8'));
508
+ return (res.findings || []).filter((f) => f.identifier && normIdent(f.identifier) === want).map((f) => f.finding);
509
+ }
510
+
511
+ // Every dispatch in the corpus citing `target`, with the finding numbers + a content hash each.
512
+ function findDependents(corpus, target) {
513
+ const want = normIdent(target);
514
+ const files = statSync(corpus).isDirectory() ? walkDispatches(corpus) : [corpus];
515
+ const deps = [];
516
+ for (const f of files) {
517
+ const hits = findingsCiting(f, want);
518
+ if (hits.length) deps.push({ path: f, dispatch_sha256: sriText(readFileSync(f, 'utf8')), findings: hits });
519
+ }
520
+ return deps;
521
+ }
522
+
523
+ // Content-address the sidecar/receipt exactly like the lock: jcsDigest over the body with the hash
524
+ // field omitted (C8/C9 — TUF/Rekor/CT/Git content-addressing; self-integrity catches a hand-edit).
525
+ function withSha(body, key) {
526
+ const { [key]: _omit, ...rest } = body;
527
+ return { ...rest, [key]: jcsDigest(rest) };
528
+ }
529
+
530
+ // Load an existing tombstone sidecar, or seed a fresh one for `dispatch`.
531
+ function loadSidecar(dispatchPath) {
532
+ const p = withdrawnPathFor(dispatchPath);
533
+ if (existsSync(p)) {
534
+ try { return JSON.parse(readFileSync(p, 'utf8')); }
535
+ catch (err) { fail(2, `cannot read sidecar ${p}: ${err && err.code ? err.code : err.message}`); }
536
+ }
537
+ return {
538
+ schema: WITHDRAWN_SCHEMA,
539
+ study_swarm_version: VERSION,
540
+ dispatch: dispatchPath.split(/[\\/]/).pop(),
541
+ dispatch_sha256: sriText(readFileSync(dispatchPath, 'utf8')),
542
+ version: 0,
543
+ withdrawals: [],
544
+ audit_trail: [],
545
+ };
546
+ }
547
+
548
+ // Recompute the rolled-up hash and write the sidecar; returns the finalized object.
549
+ function writeSidecar(dispatchPath, body) {
550
+ body.dispatch_sha256 = sriText(readFileSync(dispatchPath, 'utf8')); // reconcile to current content
551
+ const finalized = withSha(body, 'withdrawn_sha256');
552
+ writeFileSync(withdrawnPathFor(dispatchPath), JSON.stringify(finalized, null, 2) + '\n', 'utf8');
553
+ return finalized;
554
+ }
555
+
556
+ function parseFlags(args, withValue) {
557
+ const out = { _: [] };
558
+ for (let i = 0; i < args.length; i++) {
559
+ const a = args[i];
560
+ if (a.startsWith('--')) {
561
+ const k = a.slice(2);
562
+ if (withValue.has(k)) { out[k] = args[++i]; }
563
+ else out[k] = true;
564
+ } else out._.push(a);
565
+ }
566
+ return out;
567
+ }
568
+
569
+ function cmdWithdraw(args) {
570
+ const f = parseFlags(args, new Set(['reason', 'detail', 'from', 'receipt']));
571
+ const identifier = f._[0];
572
+ if (!identifier) fail(2, 'usage: study-swarm withdraw <identifier> --reason <reason> [--detail <text>] [--from <corpus-dir>] [--receipt <path>] [--json]');
573
+ if (!f.reason) fail(2, `withdraw requires --reason <${WITHDRAW_REASONS.join('|')}> — a withdrawal with no machine-readable cause is not allowed`);
574
+ if (!WITHDRAW_REASONS.includes(String(f.reason))) fail(2, `invalid --reason "${f.reason}" — use one of: ${WITHDRAW_REASONS.join(', ')}`);
575
+ const corpus = f.from || '.';
576
+ if (!existsSync(corpus)) fail(2, `corpus not found: ${corpus}`);
577
+ const want = normIdent(identifier);
578
+ const detail = f.detail ? String(f.detail) : '';
579
+
580
+ const deps = findDependents(corpus, identifier);
581
+ if (deps.length === 0) fail(2, `no dispatch in ${corpus} cites ${identifier} (normalized: ${want}) — nothing to withdraw`);
582
+
583
+ const dependents = [];
584
+ for (const d of deps) {
585
+ const body = loadSidecar(d.path);
586
+ const existing = body.withdrawals.find((w) => w.identifier === want);
587
+ // Idempotent: an identical withdrawal (same id + reason + detail, still withdrawn) is a no-op.
588
+ const identical = existing && existing.status === 'withdrawn' && existing.reason === String(f.reason) && (existing.detail || '') === detail;
589
+ if (!identical) {
590
+ if (existing) {
591
+ existing.reason = String(f.reason); existing.detail = detail; existing.status = 'withdrawn'; existing.resolution = null; existing.findings = d.findings;
592
+ } else {
593
+ body.withdrawals.push({ identifier: want, reason: String(f.reason), detail, findings: d.findings, status: 'withdrawn', resolution: null });
594
+ }
595
+ body.version += 1;
596
+ body.audit_trail.push({ seq: body.audit_trail.length + 1, event: 'withdraw', identifier: want, reason: String(f.reason), findings: d.findings });
597
+ }
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
+ }
601
+
602
+ const receipt = withSha({
603
+ schema: RECEIPT_SCHEMA,
604
+ study_swarm_version: VERSION,
605
+ identifier: want,
606
+ reason: String(f.reason),
607
+ detail,
608
+ corpus: corpus.split(/[\\/]/).pop() || corpus,
609
+ dependents,
610
+ post_rollback_state: `${dependents.length} dependent(s) flagged evidence-withdrawn; "study-swarm requalify --check" fails closed until each is removed or re-grounded.`,
611
+ }, 'receipt_sha256');
612
+
613
+ if (f.receipt) writeFileSync(String(f.receipt), JSON.stringify(receipt, null, 2) + '\n', 'utf8');
614
+
615
+ if (f.json) {
616
+ process.stdout.write(JSON.stringify(receipt) + '\n');
617
+ process.exit(0);
618
+ }
619
+ // Contrastive surfacing — never a silent drop (C10; Buçinca 2024, Bansal 2021).
620
+ process.stdout.write(`Withdrew ${want} (reason: ${f.reason}). ${dependents.length} dependent(s) flagged evidence-withdrawn:\n`);
621
+ for (const d of dependents) process.stdout.write(` - ${d.dispatch} (findings ${d.findings.map((n) => '#' + n).join(', ')})\n`);
622
+ process.stdout.write(
623
+ `\nYou may have relied on this finding. Each flagged dispatch now HALTS "study-swarm requalify --check"\n` +
624
+ `until the finding is removed or re-grounded — re-ground or override.\n` +
625
+ `Receipt ${f.receipt ? String(f.receipt) : '(stdout: pass --json)'} — receipt_sha256 ${receipt.receipt_sha256}\n`);
626
+ process.exit(0);
627
+ }
628
+
629
+ function cmdRequalify(args) {
630
+ if (args.includes('--check')) return requalifyCheck(args.filter((a) => a !== '--check'));
631
+ if (args.includes('--resolve')) return requalifyResolve(args.filter((a) => a !== '--resolve'));
632
+ fail(2, 'usage: study-swarm requalify --check <corpus-dir> | study-swarm requalify --resolve <dispatch> <identifier> --mode removed|regrounded [--note <text>]');
633
+ }
634
+
635
+ function requalifyCheck(args) {
636
+ const f = parseFlags(args, new Set());
637
+ const corpus = f._[0];
638
+ if (!corpus) fail(2, 'usage: study-swarm requalify --check <corpus-dir> [--json]');
639
+ if (!existsSync(corpus)) fail(2, `corpus not found: ${corpus}`);
640
+ const sidecars = statSync(corpus).isDirectory() ? walkByExt(corpus, /\.withdrawn\.json$/i) : [corpus];
641
+ const halts = []; // { sidecar, dispatch, identifier, reason, findings }
642
+ const problems = [];
643
+ let resolvedCount = 0;
644
+ for (const sc of sidecars) {
645
+ let stored;
646
+ try { stored = JSON.parse(readFileSync(sc, 'utf8')); }
647
+ catch (err) { problems.push(`${sc}: not valid JSON (${err.message})`); continue; }
648
+ // Self-integrity: a hand-edited sidecar (e.g. a status forged to "resolved") fails closed.
649
+ if (typeof stored.withdrawn_sha256 !== 'string' || stored.withdrawn_sha256 !== withSha(stored, 'withdrawn_sha256').withdrawn_sha256) {
650
+ problems.push(`${sc}: withdrawn_sha256 self-integrity mismatch (the sidecar was hand-edited)`);
651
+ }
652
+ for (const w of stored.withdrawals || []) {
653
+ if (w.status === 'withdrawn') halts.push({ sidecar: sc.split(/[\\/]/).pop(), dispatch: stored.dispatch, identifier: w.identifier, reason: w.reason, findings: w.findings });
654
+ else if (w.status === 'resolved') resolvedCount += 1;
655
+ }
656
+ }
657
+ const red = halts.length > 0 || problems.length > 0;
658
+ if (f.json) {
659
+ process.stdout.write(JSON.stringify({ ok: !red, halts, resolved: resolvedCount, problems }) + '\n');
660
+ process.exit(red ? 1 : 0);
661
+ }
662
+ if (!red) {
663
+ process.stdout.write(`ok ${corpus}: no unresolved evidence-withdrawn flags (${resolvedCount} resolved).\n`);
664
+ process.exit(0);
665
+ }
666
+ process.stderr.write(`x requalify --check ${corpus}: ${halts.length} unresolved evidence-withdrawn flag(s) — HALT\n`);
667
+ for (const h of halts) process.stderr.write(` - ${h.dispatch}: ${h.identifier} withdrawn (reason: ${h.reason}) — findings ${(h.findings || []).map((n) => '#' + n).join(', ')}. You may have relied on it; re-ground or override.\n`);
668
+ for (const p of problems) process.stderr.write(` - ${p}\n`);
669
+ process.exit(1);
670
+ }
671
+
672
+ function requalifyResolve(args) {
673
+ const f = parseFlags(args, new Set(['mode', 'note']));
674
+ const dispatch = f._[0];
675
+ const identifier = f._[1];
676
+ if (!dispatch || !identifier) fail(2, 'usage: study-swarm requalify --resolve <dispatch> <identifier> --mode removed|regrounded [--note <text>]');
677
+ if (!existsSync(dispatch)) fail(2, `dispatch not found: ${dispatch}`);
678
+ const scPath = withdrawnPathFor(dispatch);
679
+ if (!existsSync(scPath)) fail(2, `no tombstone sidecar at ${scPath} — nothing to resolve`);
680
+ const mode = f.mode ? String(f.mode) : null;
681
+ if (mode !== 'removed' && mode !== 'regrounded') fail(2, 'requalify --resolve requires --mode removed|regrounded');
682
+ const want = normIdent(identifier);
683
+ let body;
684
+ try { body = JSON.parse(readFileSync(scPath, 'utf8')); }
685
+ catch (err) { fail(2, `cannot read sidecar ${scPath}: ${err && err.code ? err.code : err.message}`); }
686
+ const entry = (body.withdrawals || []).find((w) => w.identifier === want);
687
+ if (!entry) fail(2, `no evidence-withdrawn flag for ${identifier} (normalized: ${want}) on ${dispatch}`);
688
+
689
+ if (entry.status === 'resolved') { // Idempotent: re-resolving is a no-op, no new audit entry (C7).
690
+ process.stdout.write(`ok ${scPath}: ${want} already resolved (mode: ${entry.resolution && entry.resolution.mode}) — no-op.\n`);
691
+ process.exit(0);
692
+ }
693
+ if (mode === 'removed') {
694
+ const still = findingsCiting(dispatch, want);
695
+ if (still.length) fail(1, `${dispatch} still cites ${want} (findings ${still.map((n) => '#' + n).join(', ')}) — cannot resolve --mode removed until the finding is removed; use --mode regrounded with --note <attestation> if it was re-verified in place`);
696
+ } else if (mode === 'regrounded' && !f.note) {
697
+ 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
+ }
699
+ entry.status = 'resolved';
700
+ entry.resolution = { mode, note: f.note ? String(f.note) : '' };
701
+ body.version += 1;
702
+ body.audit_trail.push({ seq: (body.audit_trail || []).length + 1, event: 'resolve', identifier: want, mode, note: f.note ? String(f.note) : '' });
703
+ const finalized = writeSidecar(dispatch, body);
704
+ process.stdout.write(`Resolved ${want} on ${finalized.dispatch} (mode: ${mode}). Sidecar version ${finalized.version}; withdrawn_sha256 ${finalized.withdrawn_sha256}\n`);
705
+ process.exit(0);
706
+ }
707
+
260
708
  function main(argv) {
261
709
  const [cmd, ...rest] = argv;
262
710
  switch (cmd) {
263
711
  case 'protocol': return cmdProtocol();
264
712
  case 'new': return cmdNew(rest[0]);
265
713
  case 'lint': return cmdLint(rest);
714
+ case 'lock': return cmdLock(rest);
715
+ case 'withdraw': return cmdWithdraw(rest);
716
+ case 'requalify': return cmdRequalify(rest);
266
717
  case 'version': case '--version': case '-v':
267
718
  return void process.stdout.write(VERSION + '\n');
268
719
  case 'help': case '--help': case '-h': case undefined: