agentic-flow-kit 1.7.15 → 1.8.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.md +50 -8
- package/modules/hosts/claude/commands/wf-build.md +1 -1
- package/modules/hosts/claude/commands/wf-design.md +5 -4
- package/modules/hosts/claude/commands/wf-maintain.md +1 -1
- package/modules/hosts/claude/commands/wf-new-task.md +47 -25
- package/modules/hosts/claude/commands/wf-plan.md +1 -1
- package/modules/hosts/claude/commands/wf-review.md +2 -2
- package/modules/hosts/claude/commands/wf-sync-hosts.md +7 -13
- package/modules/hosts/claude/commands/wf-test.md +2 -2
- package/modules/hosts/codex/commands/wf-build.md +1 -1
- package/modules/hosts/codex/commands/wf-design.md +5 -4
- package/modules/hosts/codex/commands/wf-maintain.md +1 -1
- package/modules/hosts/codex/commands/wf-new-task.md +47 -25
- package/modules/hosts/codex/commands/wf-plan.md +1 -1
- package/modules/hosts/codex/commands/wf-review.md +2 -2
- package/modules/hosts/codex/commands/wf-sync-hosts.md +7 -13
- package/modules/hosts/codex/commands/wf-test.md +2 -2
- package/modules/hosts/cursor/commands/wf-build.md +1 -1
- package/modules/hosts/cursor/commands/wf-design.md +5 -4
- package/modules/hosts/cursor/commands/wf-maintain.md +1 -1
- package/modules/hosts/cursor/commands/wf-new-task.md +47 -25
- package/modules/hosts/cursor/commands/wf-plan.md +1 -1
- package/modules/hosts/cursor/commands/wf-review.md +2 -2
- package/modules/hosts/cursor/commands/wf-sync-hosts.md +7 -13
- package/modules/hosts/cursor/commands/wf-test.md +2 -2
- package/modules/hosts/opencode/commands/wf-build.md +1 -1
- package/modules/hosts/opencode/commands/wf-design.md +5 -4
- package/modules/hosts/opencode/commands/wf-maintain.md +1 -1
- package/modules/hosts/opencode/commands/wf-new-task.md +47 -25
- package/modules/hosts/opencode/commands/wf-plan.md +1 -1
- package/modules/hosts/opencode/commands/wf-review.md +2 -2
- package/modules/hosts/opencode/commands/wf-sync-hosts.md +7 -13
- package/modules/hosts/opencode/commands/wf-test.md +2 -2
- package/modules/hosts/trae/commands/wf-build.md +1 -1
- package/modules/hosts/trae/commands/wf-design.md +5 -4
- package/modules/hosts/trae/commands/wf-maintain.md +1 -1
- package/modules/hosts/trae/commands/wf-new-task.md +47 -25
- package/modules/hosts/trae/commands/wf-plan.md +1 -1
- package/modules/hosts/trae/commands/wf-review.md +2 -2
- package/modules/hosts/trae/commands/wf-sync-hosts.md +7 -13
- package/modules/hosts/trae/commands/wf-test.md +2 -2
- package/package.json +1 -1
- package/src/cli.mjs +47 -4
- package/src/doctor.mjs +48 -3
- package/src/executor.mjs +262 -0
- package/src/executor.test.mjs +295 -0
- package/src/fresh-init.test.mjs +1 -1
- package/src/init.mjs +27 -3
- package/src/profiles.mjs +10 -3
- package/src/render.mjs +3 -3
- package/src/rule-budget.test.mjs +2 -2
- package/src/stack-profile.test.mjs +6 -0
- package/src/sync-hosts.mjs +37 -43
- package/src/sync-hosts.test.mjs +67 -56
- package/src/validate.mjs +6 -1
- package/src/validate.test.mjs +1 -1
- package/templates/.gitattributes +12 -1
- package/templates/AGENTS.md +3 -3
- package/templates/_agents/commands/build.md +1 -1
- package/templates/_agents/commands/design.md +5 -4
- package/templates/_agents/commands/maintain.md +1 -1
- package/templates/_agents/commands/new-task.md +47 -25
- package/templates/_agents/commands/plan.md +1 -1
- package/templates/_agents/commands/review.md +2 -2
- package/templates/_agents/commands/sync-hosts.md +7 -13
- package/templates/_agents/commands/test.md +2 -2
- package/templates/_agents/control-surfaces.txt +1 -1
- package/templates/_agents/engine-lock.json +334 -0
- package/templates/_agents/hooks/check-index-incremental.mjs +48 -0
- package/templates/_agents/hooks/check-pairing-incremental.mjs +66 -0
- package/templates/_agents/hooks/check-wiki-ledger.mjs +30 -0
- package/templates/_agents/lane-surfaces.txt +5 -2
- package/templates/_agents/level-hints.txt +30 -0
- package/templates/_agents/loop/papercut-debt/DESIGN.md +112 -0
- package/templates/_agents/mechanism-registry.md +27 -0
- package/templates/_agents/rule-budgets.txt +2 -2
- package/templates/_agents/scripts/agg-delegations.cjs +21 -3
- package/templates/_agents/scripts/agg-delegations.test.mjs +5 -3
- package/templates/_agents/scripts/agg-gate-stats.mjs +3 -3
- package/templates/_agents/scripts/agg-judgments.mjs +80 -0
- package/templates/_agents/scripts/agg-judgments.test.mjs +45 -0
- package/templates/_agents/scripts/archive-sweep.mjs +233 -0
- package/templates/_agents/scripts/archive-sweep.test.mjs +129 -0
- package/templates/_agents/scripts/check-attribution.mjs +37 -0
- package/templates/_agents/scripts/check-convergence.mjs +119 -0
- package/templates/_agents/scripts/check-engine-integrity.mjs +59 -127
- package/templates/_agents/scripts/check-engine-integrity.test.mjs +33 -45
- package/templates/_agents/scripts/check-funnel.mjs +56 -0
- package/templates/_agents/scripts/check-hygiene.mjs +5 -5
- package/templates/_agents/scripts/check-index-incremental.test.mjs +73 -0
- package/templates/_agents/scripts/check-lane-surface.mjs +74 -25
- package/templates/_agents/scripts/check-lane-surface.test.mjs +71 -5
- package/templates/_agents/scripts/check-ledger-invariant.mjs +427 -78
- package/templates/_agents/scripts/check-ledger-invariant.test.mjs +438 -4
- package/templates/_agents/scripts/check-loop-rev.test.mjs +13 -8
- package/templates/_agents/scripts/check-loop-unit.test.mjs +8 -0
- package/templates/_agents/scripts/check-loop.mjs +264 -75
- package/templates/_agents/scripts/check-loop.test.mjs +587 -61
- package/templates/_agents/scripts/check-metric-claims.mjs +4 -2
- package/templates/_agents/scripts/check-prejudgment.mjs +59 -0
- package/templates/_agents/scripts/check-registry.mjs +45 -0
- package/templates/_agents/scripts/check-scope.mjs +96 -0
- package/templates/_agents/scripts/check-scope.test.mjs +96 -0
- package/templates/_agents/scripts/check-scripts-syntax.mjs +61 -0
- package/templates/_agents/scripts/check-scripts-syntax.test.mjs +72 -0
- package/templates/_agents/scripts/check-spec-verif.mjs +47 -0
- package/templates/_agents/scripts/compact-ledgers.mjs +97 -0
- package/templates/_agents/scripts/compact-ledgers.test.mjs +162 -0
- package/templates/_agents/scripts/confirm-doc.mjs +136 -31
- package/templates/_agents/scripts/confirm-doc.test.mjs +180 -4
- package/templates/_agents/scripts/doctor.test.mjs +44 -1
- package/templates/_agents/scripts/edit-face-check.mjs +5 -3
- package/templates/_agents/scripts/edit-face-check.test.mjs +5 -4
- package/templates/_agents/scripts/fill-intent.mjs +7 -5
- package/templates/_agents/scripts/fill-intent.test.mjs +19 -1
- package/templates/_agents/scripts/fill-plan.mjs +10 -6
- package/templates/_agents/scripts/fill-plan.test.mjs +19 -3
- package/templates/_agents/scripts/fill-spec.mjs +6 -3
- package/templates/_agents/scripts/fill-spec.test.mjs +12 -4
- package/templates/_agents/scripts/fixture-scripts.mjs +153 -0
- package/templates/_agents/scripts/fixture-scripts.test.mjs +112 -0
- package/templates/_agents/scripts/gate-checklist.mjs +10 -9
- package/templates/_agents/scripts/gate-checklist.test.mjs +2 -11
- package/templates/_agents/scripts/gate-seg.mjs +1 -1
- package/templates/_agents/scripts/gen-effective-policy.mjs +107 -0
- package/templates/_agents/scripts/gen-effective-policy.test.mjs +100 -0
- package/templates/_agents/scripts/gen-verify-evidence.mjs +107 -0
- package/templates/_agents/scripts/gen-verify-evidence.test.mjs +84 -0
- package/templates/_agents/scripts/gen-workflow-dashboard.mjs +16 -14
- package/templates/_agents/scripts/gen-workflow-dashboard.test.mjs +2 -1
- package/templates/_agents/scripts/gen-workflow-metrics.mjs +12 -36
- package/templates/_agents/scripts/git-core.mjs +45 -0
- package/templates/_agents/scripts/git-merge-jsonl.mjs +63 -0
- package/templates/_agents/scripts/git-merge-jsonl.test.mjs +125 -0
- package/templates/_agents/scripts/judgment-ledger.mjs +65 -0
- package/templates/_agents/scripts/judgment-ledger.test.mjs +129 -0
- package/templates/_agents/scripts/kb-search.mjs +19 -4
- package/templates/_agents/scripts/kb-search.test.mjs +33 -0
- package/templates/_agents/scripts/lane-surface-audit.mjs +143 -0
- package/templates/_agents/scripts/lane-surface-audit.test.mjs +93 -0
- package/templates/_agents/scripts/ledger-doc-chain.test.mjs +166 -0
- package/templates/_agents/scripts/ledger-loader.mjs +108 -0
- package/templates/_agents/scripts/ledger-loader.test.mjs +131 -0
- package/templates/_agents/scripts/ledger-prune.mjs +96 -0
- package/templates/_agents/scripts/ledger-prune.test.mjs +120 -0
- package/templates/_agents/scripts/ledger-shards.mjs +307 -0
- package/templates/_agents/scripts/ledger-shards.test.mjs +339 -0
- package/templates/_agents/scripts/ledger-ts-monotonic.test.mjs +135 -0
- package/templates/_agents/scripts/level-criteria.mjs +231 -0
- package/templates/_agents/scripts/level-criteria.test.mjs +281 -0
- package/templates/_agents/scripts/papercut-debt-controller.mjs +141 -0
- package/templates/_agents/scripts/papercut-debt-controller.test.mjs +127 -0
- package/templates/_agents/scripts/papercut-debt-sensor.mjs +170 -0
- package/templates/_agents/scripts/papercut-debt-sensor.test.mjs +146 -0
- package/templates/_agents/scripts/pipeline-run.mjs +10 -24
- package/templates/_agents/scripts/pipeline-run.test.mjs +22 -42
- package/templates/_agents/scripts/policy.mjs +75 -0
- package/templates/_agents/scripts/resolve-merge-conflicts.mjs +110 -0
- package/templates/_agents/scripts/resolve-merge-conflicts.test.mjs +87 -0
- package/templates/_agents/scripts/rewrite-file.mjs +133 -0
- package/templates/_agents/scripts/rewrite-file.test.mjs +148 -0
- package/templates/_agents/scripts/rule-budget.mjs +83 -0
- package/templates/_agents/scripts/run-tests.mjs +99 -0
- package/templates/_agents/scripts/run-tests.test.mjs +105 -0
- package/templates/_agents/scripts/solidify-task.test.mjs +1 -1
- package/templates/_agents/scripts/stage-gates.mjs +38 -21
- package/templates/_agents/scripts/trust-mode.mjs +4 -4
- package/templates/_agents/scripts/trust-mode.test.mjs +1 -1
- package/templates/_agents/scripts/verify.mjs +135 -58
- package/templates/_agents/scripts/verify.test.mjs +156 -47
- package/templates/_agents/scripts/wf-journal.mjs +8 -4
- package/templates/_agents/scripts/wf-journal.test.mjs +1 -1
- package/templates/_agents/scripts/wiki-search.test.mjs +5 -6
- package/templates/_agents/scripts/workflow-board-server.test.mjs +2 -2
- package/templates/_agents/scripts/workflow-enums.test.mjs +14 -5
- package/templates/_agents/scripts/workflows-check.mjs +1 -1
- package/templates/_agents/scripts-test-exempt.txt +1 -0
- package/templates/_agents/skills/papercut-fix/SKILL.md +60 -0
- package/templates/_agents/skills/papercut-fix/references/response-template.md +81 -0
- package/templates/_agents/workflow-enums.txt +8 -1
- package/templates/_agents/workflows/pipeline-closing.md +4 -5
- package/templates/_githooks/pre-commit +109 -120
- package/templates/_githooks/pre-merge-commit +27 -23
- package/templates/_githooks/pre-push +7 -6
- package/templates/_github/workflows/kit-ci.yml +4 -3
- package/templates/workflow/README.md +23 -5
- package/templates/workflow/incidents/_TEMPLATE.md +4 -1
- package/templates/workflow/intents/_TEMPLATE.md +4 -2
- package/templates/workflow/papercuts.md +2 -0
- package/templates/workflow/plans/_TEMPLATE.md +10 -5
- package/templates/workflow/regression-checklist.md +1 -0
- package/templates/workflow/specs/_TEMPLATE.md +17 -1
- package/modules/hosts/claude/commands/wf-source-sync-check.md +0 -89
- package/modules/hosts/codex/commands/wf-source-sync-check.md +0 -89
- package/modules/hosts/cursor/commands/wf-source-sync-check.md +0 -89
- package/modules/hosts/opencode/commands/wf-source-sync-check.md +0 -89
- package/modules/hosts/trae/commands/wf-source-sync-check.md +0 -90
- package/templates/_agents/commands/source-sync-check.md +0 -103
- package/templates/_agents/hooks/check-pairing-incremental.sh +0 -43
- package/templates/_agents/hooks/check-wiki-ledger.sh +0 -18
- package/templates/_agents/scripts/check-ledger.mjs +0 -109
- package/templates/_agents/scripts/check-ledger.test.mjs +0 -199
- package/templates/_agents/scripts/rule-budget.sh +0 -74
- package/templates/_agents/scripts/source-sync-check.mjs +0 -156
- package/templates/_agents/scripts/source-sync-check.test.mjs +0 -286
- package/templates/_agents/workflows/source-sync-repair.md +0 -30
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ledger-doc-chain.test.mjs — 台账哈希链「链域改造」fixture 套件(2026-10-12 ledger-doc-chain)
|
|
3
|
+
// 核心命题:把链域从「文件物理序」改成「按 doc 的逻辑序」,在**不削弱篡改检出**的前提下让三种
|
|
4
|
+
// 正常协作(并发 append / git 并集合并 / 分片归并)不再撞 hard 门。
|
|
5
|
+
// 覆盖面:锚缺键=旧行为 / 锚前行维持物理链 / 锚后行按 doc 链 / 跨域衔接 / 三类篡改仍被抓 /
|
|
6
|
+
// 写入侧与校验侧口径一致(双方单源函数同源)。
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const { ledgerChainHash, ledgerDocChainActive, ledgerDocChainPrev } =
|
|
12
|
+
await import(pathToFileURL(path.join(SCRIPT_DIR, 'policy.mjs')).href);
|
|
13
|
+
|
|
14
|
+
let pass = 0, fail = 0;
|
|
15
|
+
const check = (name, ok, detail = '') => {
|
|
16
|
+
if (ok) { pass++; console.log(` ok ${name}`); }
|
|
17
|
+
else { fail++; console.error(`FAIL ${name}${detail ? ' — ' + detail : ''}`); }
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const ANCHOR = '2026-10-12T07:20:00Z';
|
|
21
|
+
const OLD = '2026-10-01T00:00:00Z'; // 锚前(旧物理链口径)
|
|
22
|
+
const NEW = '2026-10-13T00:00:00Z'; // 锚后(新 doc 链口径)
|
|
23
|
+
|
|
24
|
+
// mkRow:造一条链行(prevHash 由调用方给)
|
|
25
|
+
const mkRow = (ts, doc, stage, prevHash) => {
|
|
26
|
+
const fields = { ts, doc, stage, fingerprint: 'f'.repeat(64), source: 'tty' };
|
|
27
|
+
return { ...fields, prevHash, hash: ledgerChainHash(fields, prevHash) };
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// ---- 校验侧判据的可执行镜像(必须与 check-loop.mjs 检查 15 同判据;此处用于单测,生产判据在 check-loop)----
|
|
31
|
+
function verifyChain(rows, policy) {
|
|
32
|
+
const on = ledgerDocChainActive(policy);
|
|
33
|
+
const since = on ? policy.ledgerDocChainSince : '';
|
|
34
|
+
let prevHash = '', brokenAt = 0, brokenCount = 0;
|
|
35
|
+
const byDoc = new Map();
|
|
36
|
+
rows.forEach((row, idx) => {
|
|
37
|
+
if (!row || typeof row.hash !== 'string') { prevHash = ''; return; }
|
|
38
|
+
const { hash, prevHash: p, ...rest } = row;
|
|
39
|
+
const inDoc = on && typeof row.ts === 'string' && row.ts >= since;
|
|
40
|
+
const expected = inDoc ? (byDoc.get(row.doc) ?? '') : prevHash;
|
|
41
|
+
const ok = (p ?? '') === expected && ledgerChainHash(rest, p ?? '') === hash;
|
|
42
|
+
if (!ok) { if (!brokenAt) brokenAt = idx + 1; brokenCount++; }
|
|
43
|
+
prevHash = hash;
|
|
44
|
+
if (typeof row.doc === 'string') byDoc.set(row.doc, hash);
|
|
45
|
+
});
|
|
46
|
+
return { brokenAt, brokenCount };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ================= 单元:锚开关 =================
|
|
50
|
+
{
|
|
51
|
+
check('U1 缺键(v1-v7)→ 新口径不生效', ledgerDocChainActive({}) === false);
|
|
52
|
+
check('U2 非法锚值 → 不生效(fail-safe 回旧口径)', ledgerDocChainActive({ ledgerDocChainSince: '2026-10-12' }) === false);
|
|
53
|
+
check('U3 合法 ISO 时刻锚 → 生效', ledgerDocChainActive({ ledgerDocChainSince: ANCHOR }) === true);
|
|
54
|
+
check('U4 v8 锚值合法', ledgerDocChainActive({ ledgerDocChainSince: ANCHOR }) === true);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ================= 单元:前驱取法 =================
|
|
58
|
+
{
|
|
59
|
+
const rA1 = mkRow(OLD, 'a.md', 'approved', '');
|
|
60
|
+
const rA2 = mkRow(OLD, 'b.md', 'approved', rA1.hash);
|
|
61
|
+
const rA3 = mkRow(OLD, 'a.md', 'done', rA2.hash);
|
|
62
|
+
const rows = [rA1, rA2, rA3];
|
|
63
|
+
check('U5 doc 链前驱取同行末行(非物理末行)', ledgerDocChainPrev(rows, 'a.md') === rA3.hash);
|
|
64
|
+
check('U6 另一 doc 的前驱独立', ledgerDocChainPrev(rows, 'b.md') === rA2.hash);
|
|
65
|
+
check('U7 该 doc 无链行 → 链段重启返回空', ledgerDocChainPrev(rows, 'zz.md') === '');
|
|
66
|
+
check('U8 空表 → 返回空', ledgerDocChainPrev([], 'a.md') === '' && ledgerDocChainPrev(null, 'a.md') === '');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ================= 关键:缺锚 = 旧行为(存量零变化)=====================
|
|
70
|
+
{
|
|
71
|
+
const rA1 = mkRow(OLD, 'a.md', 'approved', '');
|
|
72
|
+
const rA2 = mkRow(OLD, 'b.md', 'approved', rA1.hash);
|
|
73
|
+
const rA3 = mkRow(OLD, 'a.md', 'done', rA2.hash);
|
|
74
|
+
// 物理链下这三行是自洽的(旧口径判定)
|
|
75
|
+
check('K1 缺锚:物理链自洽行不报断(= 存量零新增告警)',
|
|
76
|
+
verifyChain([rA1, rA2, rA3], {}).brokenCount === 0);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ================= 关键:锚前行维持物理链 =================
|
|
80
|
+
{
|
|
81
|
+
const pol = { ledgerDocChainSince: ANCHOR };
|
|
82
|
+
const rA1 = mkRow(OLD, 'a.md', 'approved', '');
|
|
83
|
+
const rA2 = mkRow(OLD, 'b.md', 'approved', rA1.hash); // 物理接续(doc 链口径下这行会断)
|
|
84
|
+
const rA3 = mkRow(OLD, 'a.md', 'done', rA2.hash);
|
|
85
|
+
check('K2 锚前:物理链接续的三行不误报(存量不受改造影响)',
|
|
86
|
+
verifyChain([rA1, rA2, rA3], pol).brokenCount === 0, JSON.stringify(verifyChain([rA1, rA2, rA3], pol)));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ================= 关键:锚后 doc 链 + 跨域衔接 =================
|
|
90
|
+
{
|
|
91
|
+
const pol = { ledgerDocChainSince: ANCHOR };
|
|
92
|
+
// 锚前:物理链两行(a 起点、b 接物理)
|
|
93
|
+
const rA1 = mkRow(OLD, 'a.md', 'approved', '');
|
|
94
|
+
const rB1 = mkRow(OLD, 'b.md', 'approved', rA1.hash);
|
|
95
|
+
// 锚后:doc 链接续(a 的新行接 rA1,b 的新行接 rB1——**不是**物理末行 rA1)
|
|
96
|
+
const rA2 = mkRow(NEW, 'a.md', 'done', rA1.hash);
|
|
97
|
+
const rB2 = mkRow(NEW, 'b.md', 'done', rB1.hash);
|
|
98
|
+
check('K3 锚后:doc 链接续自洽(跨域衔接成功)',
|
|
99
|
+
verifyChain([rA1, rB1, rA2, rB2], pol).brokenCount === 0, JSON.stringify(verifyChain([rA1, rB1, rA2, rB2], pol)));
|
|
100
|
+
check('K4 锚后 b 的前驱是 b 自己上一行(不是物理末行)',
|
|
101
|
+
rB2.prevHash === rB1.hash && rB2.prevHash !== rA2.hash);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ================= 关键:三种正常协作不再误报 =================
|
|
105
|
+
// 注意夹具语义:doc 链下某 doc 的**首个链行 prevHash 恒为 ''**(该 doc 链段重启),第二行才接前一行。
|
|
106
|
+
// 用物理前驱填夹具会造出「本就不该自洽」的样本——那是夹具错,不是判据错(K5/K6/K7 首版即栽在这里)。
|
|
107
|
+
{
|
|
108
|
+
const pol = { ledgerDocChainSince: ANCHOR };
|
|
109
|
+
const rA1 = mkRow(NEW, 'a.md', 'approved', '');
|
|
110
|
+
const rA2 = mkRow(NEW, 'a.md', 'done', rA1.hash);
|
|
111
|
+
const rB1 = mkRow(NEW, 'b.md', 'approved', '');
|
|
112
|
+
const rB2 = mkRow(NEW, 'b.md', 'done', rB1.hash);
|
|
113
|
+
// 协作①:两分支各自追加后并集合并 —— 行序可能被打乱(b 行被挪到 a 行之间)
|
|
114
|
+
check('K5 协作①并集合并(跨 doc 行序与物理链不同)→ doc 链仍自洽',
|
|
115
|
+
verifyChain([rA1, rB1, rA2, rB2], pol).brokenCount === 0, JSON.stringify(verifyChain([rA1, rB1, rA2, rB2], pol)));
|
|
116
|
+
// 协作②:分片归并把别的 doc 的行插进本 doc 链的中段
|
|
117
|
+
const rC1 = mkRow(NEW, 'c.md', 'approved', '');
|
|
118
|
+
check('K6 协作②分片归并插行 → doc 链仍自洽',
|
|
119
|
+
verifyChain([rA1, rC1, rA2, rB1, rB2], pol).brokenCount === 0, JSON.stringify(verifyChain([rA1, rC1, rA2, rB1, rB2], pol)));
|
|
120
|
+
// 基线:供下方篡改用例复用
|
|
121
|
+
const base = [rA1, rB1, rA2, rB2];
|
|
122
|
+
check('K7 基线:四行自洽', verifyChain(base, pol).brokenCount === 0, JSON.stringify(verifyChain(base, pol)));
|
|
123
|
+
|
|
124
|
+
// 篡改①:就地改中间行内容(不动其 hash)→ 重算必失配
|
|
125
|
+
const tampered1 = base.map((r) => (r === rB1 ? { ...r, stage: 'cancelled' } : r));
|
|
126
|
+
check('K8 篡改①就地改行 → 抓到', verifyChain(tampered1, pol).brokenCount > 0);
|
|
127
|
+
|
|
128
|
+
// 篡改②:删该 doc 的**中间行**(a 的首行)→ 其后继行 prevHash 失配。
|
|
129
|
+
// 诚实边界:**删某 doc 的末行在两种链域下都抓不到**(无后继可比),这是链式防伪的固有上限,
|
|
130
|
+
// 物理链同样如此;兜底仍是 check-ledger-invariant 的 append-only 历史全扫。
|
|
131
|
+
const deleted = base.filter((r) => r !== rA1);
|
|
132
|
+
check('K9 篡改②删中间行 → 抓到', verifyChain(deleted, pol).brokenCount > 0);
|
|
133
|
+
|
|
134
|
+
// 篡改③:插一行(自洽重算但前驱为空)→ 后继行前驱失配
|
|
135
|
+
const fFields = { ts: NEW, doc: 'a.md', stage: 'reverted', fingerprint: 'x'.repeat(64), source: 'tty' };
|
|
136
|
+
const forged = { ...fFields, prevHash: '', hash: ledgerChainHash(fFields, '') };
|
|
137
|
+
const inserted = [rA1, forged, rA2, rB1, rB2];
|
|
138
|
+
check('K10 篡改③插行(自洽重算但前驱为空)→ 抓到', verifyChain(inserted, pol).brokenCount > 0);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ================= 关键:写入侧与校验侧口径一致 =================
|
|
142
|
+
{
|
|
143
|
+
// 写入侧取前驱(ledgerDocChainPrev)与校验侧期望(byDoc 末值)必须是同一个值——否则新行一落账即断
|
|
144
|
+
const rA1 = mkRow(OLD, 'a.md', 'approved', '');
|
|
145
|
+
const rB1 = mkRow(OLD, 'b.md', 'approved', rA1.hash);
|
|
146
|
+
const rows = [rA1, rB1];
|
|
147
|
+
const writeSidePrev = ledgerDocChainPrev(rows, 'a.md');
|
|
148
|
+
// 校验侧:走一遍同样两行后,byDoc.get('a.md') 应等于写入侧取到的值
|
|
149
|
+
const byDoc = new Map();
|
|
150
|
+
for (const row of rows) byDoc.set(row.doc, row.hash);
|
|
151
|
+
check('K11 写入侧取前驱 == 校验侧期望(两侧单源一致)',
|
|
152
|
+
writeSidePrev === rA1.hash && byDoc.get('a.md') === writeSidePrev);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ================= 边界:无 ts 的链行 fail-safe 回旧口径 =================
|
|
156
|
+
{
|
|
157
|
+
const pol = { ledgerDocChainSince: ANCHOR };
|
|
158
|
+
const noTs = { doc: 'a.md', stage: 'approved', fingerprint: 'z'.repeat(64), prevHash: '', hash: '' };
|
|
159
|
+
const fields = { doc: 'a.md', stage: 'approved', fingerprint: 'z'.repeat(64) };
|
|
160
|
+
noTs.hash = ledgerChainHash(fields, '');
|
|
161
|
+
check('K12 无 ts 的链行按物理链判定(不因缺 ts 误入新域)',
|
|
162
|
+
verifyChain([noTs], pol).brokenCount === 0);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
console.log(`\n[ledger-doc-chain.test] PASS ${pass} / FAIL ${fail}`);
|
|
166
|
+
process.exit(fail ? 1 : 0);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ledger-loader.mjs — 确认台账读取视图单源(2026-10-12 ledger-loader-p1)
|
|
3
|
+
// 为什么有这个文件:确认台账 `.agents/confirmations.jsonl` 此前有 6 处生产代码各自
|
|
4
|
+
// `readFileSync` + `JSON.parse` 复刻一遍(stage-gates / check-loop ×4 / check-metric-claims /
|
|
5
|
+
// gen-workflow-dashboard / gen-workflow-metrics),口径与容错各写各的。后续引入分片台账
|
|
6
|
+
// (多人 / 多 worktree 并发,见 README 台账并发口径)时,逐处加分支必然漏改——漏一处即
|
|
7
|
+
// 「门禁看不见分片行」=半盲,且不可见比全红更危险。本文件把**读**收成单源。
|
|
8
|
+
//
|
|
9
|
+
// 本步(Step 1)范围:纯重构,**行为逐字不变**——分片目录不存在时,视图与逐处直读等价。
|
|
10
|
+
// 故意**不做去重**:去重会改 gen-workflow-metrics 的漏斗计数口径(当前计全部行),
|
|
11
|
+
// 属口径变更,须随分片功能同批生效,不得混入本步。
|
|
12
|
+
//
|
|
13
|
+
// 两种视图(消费方需求不同,勿合并):
|
|
14
|
+
// readLedgerLines —— 原始文本行,给「按行计数 / 文本特征匹配」用(检查 16 确认负担)。
|
|
15
|
+
// readLedgerRows —— 解析后对象数组,坏行跳过,顺序 = 物理读序(主台账先、分片按文件名序)。
|
|
16
|
+
//
|
|
17
|
+
// **不归本文件管的两处(刻意保留直读,改动须先改这里)**:
|
|
18
|
+
// ① check-loop.mjs 检查 15 哈希链校验——需**物理行号对齐**与**物理顺序**,
|
|
19
|
+
// 任何合并/去重视图都会毁掉它(物理行号 ↔ 数组下标换算依赖逐行原序)。
|
|
20
|
+
// ② check-ledger-invariant.mjs——校验的是**物理文件本身**的 append-only 前缀不变量,
|
|
21
|
+
// 它审的是盘面字节,不是台账语义。
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
|
|
25
|
+
export const LEDGER_REL = '.agents/confirmations.jsonl';
|
|
26
|
+
export const SHARD_DIR_REL = '.agents/confirmations.d';
|
|
27
|
+
|
|
28
|
+
// ledgerSources:台账来源清单,顺序即读序——主台账(存量基线)先,分片按文件名序后。
|
|
29
|
+
// 分片视为较新(后读覆盖),与「append-only 台账末次生效」同向。
|
|
30
|
+
// 缺失即跳过(装户无台账 / 未启用分片 = 零影响,向上兼容)。
|
|
31
|
+
export function ledgerSources(root = process.cwd()) {
|
|
32
|
+
const out = [];
|
|
33
|
+
const mainAbs = path.join(root, ...LEDGER_REL.split('/'));
|
|
34
|
+
if (fs.existsSync(mainAbs)) out.push({ rel: LEDGER_REL, abs: mainAbs });
|
|
35
|
+
|
|
36
|
+
const shardAbs = path.join(root, ...SHARD_DIR_REL.split('/'));
|
|
37
|
+
if (fs.existsSync(shardAbs) && fs.statSync(shardAbs).isDirectory()) {
|
|
38
|
+
let files = [];
|
|
39
|
+
try {
|
|
40
|
+
files = fs.readdirSync(shardAbs).filter((f) => f.endsWith('.jsonl')).sort();
|
|
41
|
+
} catch (err) {
|
|
42
|
+
// 目录读不出来仍要把已收集的来源交出去——本文件是 hard 门(检查 15)的数据源,
|
|
43
|
+
// IO 抖动不得伪装成「确认未对账」(fail-closed 会误伤真台账)
|
|
44
|
+
console.error(`[ledger-loader] 分片目录读取失败: ${SHARD_DIR_REL} (${err.message})——台账视图可能缺该来源`);
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
for (const f of files) {
|
|
48
|
+
out.push({ rel: `${SHARD_DIR_REL}/${f}`, abs: path.join(shardAbs, f) });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// readLedgerLines:全部来源的非空原始行(保留行内原文,顺序 = 物理读序)。
|
|
55
|
+
export function readLedgerLines(root = process.cwd()) {
|
|
56
|
+
const lines = [];
|
|
57
|
+
for (const src of ledgerSources(root)) {
|
|
58
|
+
let text = '';
|
|
59
|
+
try {
|
|
60
|
+
text = fs.readFileSync(src.abs, 'utf8');
|
|
61
|
+
} catch (err) {
|
|
62
|
+
// 出账后继续:已读到的来源照常返回,缺的那个来源显式告警(不静默丢)
|
|
63
|
+
console.error(`[ledger-loader] 读取失败: ${src.rel} (${err.message})——台账视图可能缺该来源`);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
for (const line of text.split(/\r?\n/)) {
|
|
67
|
+
if (line.trim()) lines.push(line);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// readLedgerRows:全部来源解析后的行(坏行跳过,与检查 15 / 19 同容错口径)。
|
|
74
|
+
export function readLedgerRows(root = process.cwd()) {
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const line of readLedgerLines(root)) {
|
|
77
|
+
try {
|
|
78
|
+
const e = JSON.parse(line);
|
|
79
|
+
if (e && typeof e === 'object') out.push(e);
|
|
80
|
+
} catch { /* 坏行跳过 */ }
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// readLedgerEntries:**带来源与行号**的视图,读序 = 主台账在前、分片按文件名序在后。
|
|
86
|
+
// 返回 [{ rel, no, row }],坏行 row=null(保留占位,否则物理行号会漂移)。
|
|
87
|
+
// 消费方:check-loop 检查 15(链断裂要报「哪个文件的第几行」,跨来源后「物理行号」单一定位已不够,
|
|
88
|
+
// 必须带 rel 才能定位)。此前检查 15 只读主台账故无需本视图——引入分片后改走这里。
|
|
89
|
+
export function readLedgerEntries(root = process.cwd()) {
|
|
90
|
+
const out = [];
|
|
91
|
+
for (const src of ledgerSources(root)) {
|
|
92
|
+
let text = '';
|
|
93
|
+
try {
|
|
94
|
+
text = fs.readFileSync(src.abs, 'utf8');
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error(`[ledger-loader] 读取失败: ${src.rel} (${err.message})——台账视图可能缺该来源`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const lines = text.split(/\r?\n/);
|
|
100
|
+
for (let i = 0; i < lines.length; i++) {
|
|
101
|
+
if (!lines[i].trim()) continue;
|
|
102
|
+
let row = null;
|
|
103
|
+
try { row = JSON.parse(lines[i]); } catch { /* 坏行占位 */ }
|
|
104
|
+
out.push({ rel: src.rel, no: i + 1, row: (row && typeof row === 'object') ? row : null });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ledger-loader.test.mjs — 台账读取视图单源 fixture 套件(2026-10-12 ledger-loader-p1)
|
|
3
|
+
// 覆盖:主台账单源 / 分片并入与读序 / 坏行跳过 / 缺失静默 / IO 失败 fail-soft 出账 /
|
|
4
|
+
// lines 视图保行内原文 / 两侧视图与旧逐处直读的等价性(Step 1 行为不变的回归护栏)。
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
// 兄弟脚本动态载入必须走 file:// URL——win32 绝对路径('E:\...')直接 import 会被 ESM loader
|
|
12
|
+
// 判 ERR_UNSUPPORTED_ESM_URL_SCHEME(protocol 'e:'),非 Windows 平台不受影响
|
|
13
|
+
const { ledgerSources, readLedgerLines, readLedgerRows, LEDGER_REL, SHARD_DIR_REL } =
|
|
14
|
+
await import(pathToFileURL(path.join(SCRIPT_DIR, 'ledger-loader.mjs')).href);
|
|
15
|
+
|
|
16
|
+
let pass = 0, fail = 0;
|
|
17
|
+
const check = (name, ok, detail = '') => {
|
|
18
|
+
if (ok) { pass++; console.log(` ok ${name}`); }
|
|
19
|
+
else { fail++; console.error(`FAIL ${name}${detail ? ' — ' + detail : ''}`); }
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const mkRoot = () => fs.mkdtempSync(path.join(os.tmpdir(), 'ledger-loader-'));
|
|
23
|
+
const w = (root, rel, text) => {
|
|
24
|
+
const p = path.join(root, ...rel.split('/'));
|
|
25
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
26
|
+
fs.writeFileSync(p, text, 'utf8');
|
|
27
|
+
};
|
|
28
|
+
const row = (o) => JSON.stringify(o);
|
|
29
|
+
|
|
30
|
+
// ============ 场景 1:无台账(装户/新仓)→ 空视图,不抛 ============
|
|
31
|
+
{
|
|
32
|
+
const r = mkRoot();
|
|
33
|
+
check('S1 无台账时 sources 为空', ledgerSources(r).length === 0);
|
|
34
|
+
check('S1 无台账时 lines 为空', readLedgerLines(r).length === 0);
|
|
35
|
+
check('S1 无台账时 rows 为空', readLedgerRows(r).length === 0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ============ 场景 2:仅主台账 → 与旧逐处直读等价 ============
|
|
39
|
+
{
|
|
40
|
+
const r = mkRoot();
|
|
41
|
+
const rows = [
|
|
42
|
+
row({ ts: '2026-10-01T00:00:00Z', doc: 'workflow/intents/a.md', stage: 'approved', fingerprint: 'a'.repeat(64) }),
|
|
43
|
+
row({ ts: '2026-10-02T00:00:00Z', doc: 'workflow/intents/a.md', stage: 'done', fingerprint: 'a'.repeat(64) }),
|
|
44
|
+
row({ ts: '2026-10-03T00:00:00Z', doc: 'workflow/specs/b.md', stage: 'approved', fingerprint: 'b'.repeat(64) }),
|
|
45
|
+
];
|
|
46
|
+
w(r, LEDGER_REL, rows.join('\n') + '\n');
|
|
47
|
+
const got = readLedgerRows(r);
|
|
48
|
+
check('S2 rows 数与行数一致', got.length === 3, `实得 ${got.length}`);
|
|
49
|
+
check('S2 读序 = 物理序(末次 done 在后)',
|
|
50
|
+
got[1].stage === 'done' && got[2].doc === 'workflow/specs/b.md');
|
|
51
|
+
// 等价性护栏:旧口径(过滤空行 + JSON.parse + 跳过 null/非对象)
|
|
52
|
+
const legacy = rows.map((l) => JSON.parse(l));
|
|
53
|
+
check('S2 与旧逐处直读逐行等价', JSON.stringify(got) === JSON.stringify(legacy));
|
|
54
|
+
check('S2 lines 视图保留行内原文', readLedgerLines(r).length === 3 && /"stage":"approved"/.test(readLedgerLines(r)[0]));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ============ 场景 3:坏行跳过(与检查 15/19 同容错口径) ============
|
|
58
|
+
{
|
|
59
|
+
const r = mkRoot();
|
|
60
|
+
w(r, LEDGER_REL, [
|
|
61
|
+
row({ ts: '1', doc: 'workflow/intents/a.md', stage: 'approved' }),
|
|
62
|
+
'{不是 JSON',
|
|
63
|
+
'',
|
|
64
|
+
' ',
|
|
65
|
+
row({ ts: '2', doc: 'workflow/intents/b.md', stage: 'approved' }),
|
|
66
|
+
].join('\n') + '\n');
|
|
67
|
+
const got = readLedgerRows(r);
|
|
68
|
+
check('S3 坏行跳过、空行不计入', got.length === 2, `实得 ${got.length}`);
|
|
69
|
+
check('S3 坏行后仍继续读到后续行', got[1].doc === 'workflow/intents/b.md');
|
|
70
|
+
check('S3 lines 视图含坏行原文(计数口径要全量行)', readLedgerLines(r).length === 3, `实得 ${readLedgerLines(r).length}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ============ 场景 4:分片并入——主台账先、分片按文件名序后(分片=较新) ============
|
|
74
|
+
{
|
|
75
|
+
const r = mkRoot();
|
|
76
|
+
w(r, LEDGER_REL, row({ ts: '2026-10-01T00:00:00Z', doc: 'workflow/intents/a.md', stage: 'approved' }) + '\n');
|
|
77
|
+
w(r, `${SHARD_DIR_REL}/b.jsonl`, row({ ts: '2026-10-05T00:00:00Z', doc: 'workflow/intents/b.md', stage: 'approved' }) + '\n');
|
|
78
|
+
w(r, `${SHARD_DIR_REL}/a.jsonl`, row({ ts: '2026-10-09T00:00:00Z', doc: 'workflow/intents/c.md', stage: 'approved' }) + '\n');
|
|
79
|
+
const srcs = ledgerSources(r).map((s) => s.rel);
|
|
80
|
+
check('S4 来源清单:主台账在最前', srcs[0] === LEDGER_REL, JSON.stringify(srcs));
|
|
81
|
+
check('S4 来源清单含分片(文件名序 a → b)', srcs.length === 3 && srcs[1] === `${SHARD_DIR_REL}/a.jsonl`, JSON.stringify(srcs));
|
|
82
|
+
const got = readLedgerRows(r);
|
|
83
|
+
// 读序 = 主台账 → 分片按**文件名**序:a.jsonl(内含 c.md)先于 b.jsonl
|
|
84
|
+
check('S4 读序:主台账行在前、分片行按文件名序在后',
|
|
85
|
+
got[0].doc === 'workflow/intents/a.md' && got[1].doc === 'workflow/intents/c.md' && got[2].doc === 'workflow/intents/b.md',
|
|
86
|
+
JSON.stringify(got.map((e) => e.doc)));
|
|
87
|
+
check('S4 分片行可见(末次取值口径:分片较新)',
|
|
88
|
+
[...got.reduce((m, e) => m.set(e.doc, e.ts), new Map()).values()].length === 3);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ============ 场景 5:分片目录不存在 / 非目录 → 静默零影响 ============
|
|
92
|
+
{
|
|
93
|
+
const r = mkRoot();
|
|
94
|
+
w(r, LEDGER_REL, row({ ts: '1', doc: 'workflow/intents/a.md', stage: 'approved' }) + '\n');
|
|
95
|
+
w(r, 'not-a-dir.txt', 'x');
|
|
96
|
+
check('S5 无分片目录时只返回主台账', ledgerSources(r).length === 1);
|
|
97
|
+
// 分片路径被同名普通文件占位 → 非目录,按不启用处理
|
|
98
|
+
w(r, SHARD_DIR_REL, '我是文件不是目录');
|
|
99
|
+
check('S5 分片路径非目录时静默跳过', ledgerSources(r).length === 1 && readLedgerRows(r).length === 1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ============ 场景 6:IO 失败 fail-soft —— 已读来源照常返回,缺的来源出账不抛 ============
|
|
103
|
+
{
|
|
104
|
+
const r = mkRoot();
|
|
105
|
+
w(r, LEDGER_REL, row({ ts: '1', doc: 'workflow/intents/a.md', stage: 'approved' }) + '\n');
|
|
106
|
+
// 造一个 existsSync 为真但读取失败的来源:目录形态的 .jsonl(读目录抛 EISDIR)
|
|
107
|
+
const shardDir = path.join(r, ...SHARD_DIR_REL.split('/'));
|
|
108
|
+
fs.mkdirSync(shardDir, { recursive: true });
|
|
109
|
+
fs.mkdirSync(path.join(shardDir, 'boom.jsonl'), { recursive: true }); // 目录而非文件 → readFileSync 抛
|
|
110
|
+
const origErr = console.error;
|
|
111
|
+
const errs = [];
|
|
112
|
+
console.error = (...a) => { errs.push(a.join(' ')); };
|
|
113
|
+
let got = null;
|
|
114
|
+
try { got = readLedgerRows(r); } finally { console.error = origErr; }
|
|
115
|
+
check('S6 读取失败不抛异常', got !== null);
|
|
116
|
+
check('S6 主台账行仍正常返回', got.length === 1 && got[0].doc === 'workflow/intents/a.md');
|
|
117
|
+
check('S6 失败来源显式出账(不静默丢行)',
|
|
118
|
+
errs.some((e) => e.includes('boom.jsonl')), JSON.stringify(errs));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ============ 场景 7:CRLF 台账 → 行视图不残留 \r(消费方免各自处理) ============
|
|
122
|
+
{
|
|
123
|
+
const r = mkRoot();
|
|
124
|
+
w(r, LEDGER_REL, row({ ts: '1', doc: 'workflow/intents/a.md', stage: 'approved' }) + '\r\n');
|
|
125
|
+
const lines = readLedgerLines(r);
|
|
126
|
+
check('S7 CRLF 行尾不残留 \\r', lines.length === 1 && !lines[0].endsWith('\r'));
|
|
127
|
+
check('S7 CRLF 下 rows 仍可解析', readLedgerRows(r).length === 1);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
console.log(`\n[ledger-loader.test] PASS ${pass} / FAIL ${fail}`);
|
|
131
|
+
process.exit(fail ? 1 : 0);
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ledger-prune.mjs — 台账特许修剪的判据单源(2026-10-12 ledger-prune-manifest)
|
|
3
|
+
// 为什么需要它:确认台账 append-only(README 硬规则 6),历史行禁删改。但现实中存在**合法**的删行场景——
|
|
4
|
+
// 仓库所有者明令删除某批 workflow 文档(归档、误建单清理、GDPR/合规下架)。此时台账里对应的确认行
|
|
5
|
+
// 也必须随之删除,否则行级校验④「doc 在当前树存在」会持续报红,退路只剩「手改台账」→ 直接踩
|
|
6
|
+
// `--no-verify` 的红线。
|
|
7
|
+
// 在本模块之前,flow-kit 对此**无任何合法出口**——这不是一个「缺个功能」,而是一条会把人逼向绕过的死路。
|
|
8
|
+
//
|
|
9
|
+
// 本模块提供的唯一出口(**特许**,不是豁免):逐行对账 + 凭据入库。
|
|
10
|
+
// manifest = .agents/ledger-prune-manifest.json,格式:
|
|
11
|
+
// { "ts": "<ISO>", "authority": "<授权来源:谁/哪次指令>", "reason": "<为什么删>",
|
|
12
|
+
// "removes": ["<被删行的 sha256>", ...], "docs": ["<家族名>", ...] }
|
|
13
|
+
// 判据(matchPrune,四条缺一不可):
|
|
14
|
+
// ① manifest 存在且 removes 非空;
|
|
15
|
+
// ② 每个哈希**恰命中一行**(哈希不存在或声明重复都判失败——防「一个哈希对多行」的糊弄);
|
|
16
|
+
// ③ 新内容 == 旧内容 − manifest 所列各行(**保序**)——存在 manifest 之外的任何增删改即失败;
|
|
17
|
+
// ④ 每个被删行的 doc ∈ manifest.docs 清单(空清单 = 不约束)。
|
|
18
|
+
// 凭据(被删行哈希)随 manifest 入 git,事后可对质;manifest 自身是 owned 面,人工审查面。
|
|
19
|
+
//
|
|
20
|
+
// **删行后该 doc 的哈希链会断**:被删行 R1(hash=H1)之后的后继行 R2 带 prevHash=H1,而 R1 已不在表内
|
|
21
|
+
// → 校验时 byDoc(doc) 为空、R2 的 H1 失配。本模块导出 prunedHashes() 供链校验识别这一**有凭据的链段重启**:
|
|
22
|
+
// 「本行的 prevHash 出现在 manifest.prunedHashes 中」即视为合法重启点,而非篡改。
|
|
23
|
+
// 诚实边界:manifest 是可写文件,**刻意伪造一份 manifest 即可让任意断链合法化**——这与「台账本地可写」
|
|
24
|
+
// 的既有信任边界同级(见 README 确认门诚实边界 / policy.ledgerChainHash)。本模块不宣称通道关闭,
|
|
25
|
+
// 它把「顺手手改台账」抬到「必须提交一份带授权与理由、可被 git 历史对质的凭据」——性质不同,可事后追责。
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import { createHash } from 'node:crypto';
|
|
29
|
+
import { spawnSync } from 'node:child_process';
|
|
30
|
+
|
|
31
|
+
export const PRUNE_REL = '.agents/ledger-prune-manifest.json';
|
|
32
|
+
|
|
33
|
+
export const sha256line = (l) => createHash('sha256').update(l, 'utf8').digest('hex');
|
|
34
|
+
|
|
35
|
+
const linesOf = (text) => (text || '').replace(/\r\n/g, '\n').split('\n').filter((l) => l.trim().length > 0);
|
|
36
|
+
|
|
37
|
+
// loadManifestFromRef:从某个 git 对象引用读 manifest(历史全扫用:该提交树上的 manifest)。
|
|
38
|
+
// 用 spawnSync + status 判定而非 execFileSync:后者失败时 stderr 直通父进程,历史扫描探针在旧提交
|
|
39
|
+
// 尚无 manifest 时会刷屏 fatal 噪声(实测教训)——探测失败必须静默归 null。
|
|
40
|
+
export function loadManifestFromRef(ref, cwd) {
|
|
41
|
+
try {
|
|
42
|
+
const r = spawnSync('git', ['show', ref], { cwd, encoding: 'utf8' });
|
|
43
|
+
if (r.status !== 0) return null;
|
|
44
|
+
const m = JSON.parse(r.stdout);
|
|
45
|
+
return (m && typeof m === 'object') ? m : null;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// loadManifestFromDisk:读工作树上的 manifest(链校验用;缺文件 = 无修剪凭据,返回 null)。
|
|
52
|
+
export function loadManifestFromDisk(root) {
|
|
53
|
+
const p = path.join(root, ...PRUNE_REL.split('/'));
|
|
54
|
+
try {
|
|
55
|
+
if (!fs.existsSync(p)) return null;
|
|
56
|
+
const m = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
57
|
+
return (m && typeof m === 'object') ? m : null;
|
|
58
|
+
} catch {
|
|
59
|
+
return null; // 损坏 = 视同无凭据 → 链断裂照报(fail-closed 于校验面)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// matchPrune:逐行对账。返回 {removed, docs, hashes} 或 {err}。
|
|
64
|
+
export function matchPrune(headText, curText, manifest) {
|
|
65
|
+
if (!manifest || !Array.isArray(manifest.removes) || !manifest.removes.length) {
|
|
66
|
+
return { err: 'manifest 缺失或 removes 为空' };
|
|
67
|
+
}
|
|
68
|
+
const head = linesOf(headText);
|
|
69
|
+
const cur = linesOf(curText);
|
|
70
|
+
const want = new Set(manifest.removes);
|
|
71
|
+
const kept = head.filter((l) => !want.has(sha256line(l)));
|
|
72
|
+
const removedLines = head.filter((l) => want.has(sha256line(l)));
|
|
73
|
+
|
|
74
|
+
if (removedLines.length !== manifest.removes.length) {
|
|
75
|
+
return { err: `manifest 哈希命中 ${removedLines.length} 行 ≠ 声明 ${manifest.removes.length} 行(哈希不存在或声明重复)` };
|
|
76
|
+
}
|
|
77
|
+
if (kept.join('\n') !== cur.join('\n')) {
|
|
78
|
+
return { err: '新内容 ≠ 旧内容 − manifest 所列行(存在 manifest 之外的增删改)' };
|
|
79
|
+
}
|
|
80
|
+
const docs = new Set(manifest.docs || []);
|
|
81
|
+
for (const l of removedLines) {
|
|
82
|
+
let doc = '';
|
|
83
|
+
try { doc = JSON.parse(l).doc || ''; } catch { return { err: '被删行非 JSON' }; }
|
|
84
|
+
if (docs.size && !docs.has(path.basename(doc).replace(/\.md$/, ''))) {
|
|
85
|
+
return { err: `被删行 doc ${doc} 不在 manifest.docs 清单内` };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { removed: removedLines.length, docs: [...docs], hashes: [...want] };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// prunedHashes:工作树 manifest 声明的被删行哈希集合(链校验的合法重启点判定用)。
|
|
92
|
+
export function prunedHashes(root) {
|
|
93
|
+
const m = loadManifestFromDisk(root);
|
|
94
|
+
if (!m || !Array.isArray(m.removes)) return new Set();
|
|
95
|
+
return new Set(m.removes.filter((h) => typeof h === 'string'));
|
|
96
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ledger-prune.test.mjs — 台账特许修剪通道 fixture 套件(2026-10-12 ledger-prune-manifest)
|
|
3
|
+
// 覆盖:四条判据逐条(哈希命中数 / 内容对账 / docs 清单 / manifest 缺失)+ 链段重启凭据 +
|
|
4
|
+
// --staged 真 git 端到端(放行与拦截)+ 无 manifest 时 append-only 红线不变。
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
|
|
11
|
+
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const { matchPrune, sha256line, PRUNE_REL } = await import(pathToFileURL(path.join(SCRIPT_DIR, 'ledger-prune.mjs')).href);
|
|
13
|
+
// 夹具闭包(2026-10-12 fixture-closure-rootfix):check-ledger-invariant 及其传递依赖(ledger-loader /
|
|
14
|
+
// policy 等)整闭包拷入。此前手写枚举漏拷 ledger-loader.mjs → 该脚本 import 失败 → 门禁静默不跑 →
|
|
15
|
+
// 假绿;现在闭包自动跟随,且拷完 assertFixtureScripts 会在建夹具当场炸出全量缺失清单。
|
|
16
|
+
const { copyScriptClosure } = await import(pathToFileURL(path.join(SCRIPT_DIR, 'fixture-scripts.mjs')).href);
|
|
17
|
+
|
|
18
|
+
let pass = 0, fail = 0;
|
|
19
|
+
const check = (name, ok, detail = '') => {
|
|
20
|
+
if (ok) { pass++; console.log(` ok ${name}`); }
|
|
21
|
+
else { fail++; console.error(`FAIL ${name}${detail ? ' — ' + detail : ''}`); }
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const L = (...xs) => xs.filter(Boolean).join('\n') + '\n';
|
|
25
|
+
const rowA = L('{"ts":"2026-10-01T00:00:00Z","doc":"workflow/intents/2026-10-01-a.md","stage":"approved","fingerprint":"' + 'a'.repeat(64) + '"}');
|
|
26
|
+
const rowB = L('{"ts":"2026-10-02T00:00:00Z","doc":"workflow/plans/2026-10-01-a.md","stage":"approved","fingerprint":"' + 'b'.repeat(64) + '"}');
|
|
27
|
+
const rowC = L('{"ts":"2026-10-03T00:00:00Z","doc":"workflow/intents/2026-10-01-z.md","stage":"approved","fingerprint":"' + 'c'.repeat(64) + '"}');
|
|
28
|
+
const HEAD = rowA + rowB + rowC;
|
|
29
|
+
|
|
30
|
+
// ============ 场景 1:四条判据逐条 ============
|
|
31
|
+
{
|
|
32
|
+
const mk = (over = {}) => ({
|
|
33
|
+
ts: '2026-10-12T00:00:00Z', authority: '仓库所有者', reason: '归档下架一批单',
|
|
34
|
+
removes: [sha256line(rowB.trim())], docs: ['2026-10-01-a'], ...over,
|
|
35
|
+
});
|
|
36
|
+
// ① manifest 缺失 / removes 空
|
|
37
|
+
check('S1① manifest 缺失 → err', !!(matchPrune(HEAD, rowA + rowC, null) || {}).err);
|
|
38
|
+
check('S1① removes 为空 → err', !!(matchPrune(HEAD, rowA + rowC, mk({ removes: [] })).err));
|
|
39
|
+
// ② 哈希不存在
|
|
40
|
+
check('S1② 哈希不存在 → err(命中数不符)',
|
|
41
|
+
!!(matchPrune(HEAD, rowA + rowC, mk({ removes: ['0'.repeat(64)] })).err));
|
|
42
|
+
// ② 哈希声明重复(两行内容相同 → 一个哈希命中 2 行)
|
|
43
|
+
const dup = L('{"ts":"1","doc":"workflow/intents/d.md","stage":"approved"}', '{"ts":"1","doc":"workflow/intents/d.md","stage":"approved"}');
|
|
44
|
+
check('S1② 重复行声明单哈希 → err(命中数 ≠ 声明数)',
|
|
45
|
+
!!(matchPrune(dup, '', mk({ removes: [sha256line(dup.split('\n')[0])] })).err));
|
|
46
|
+
// ③ 存在 manifest 之外的改动
|
|
47
|
+
check('S1③ 多删一行(manifest 外) → err',
|
|
48
|
+
!!(matchPrune(HEAD, rowA, mk()).err));
|
|
49
|
+
check('S1③ 改写保留行内容 → err',
|
|
50
|
+
!!(matchPrune(HEAD, rowA + rowC.replace('approved', 'done'), mk()).err));
|
|
51
|
+
check('S1③ 插入新行 → err',
|
|
52
|
+
!!(matchPrune(HEAD, rowA + rowC + rowB, mk()).err));
|
|
53
|
+
// ④ doc 不在清单
|
|
54
|
+
check('S1④ 被删行 doc 不在 manifest.docs → err',
|
|
55
|
+
!!(matchPrune(HEAD, rowA + rowC, mk({ docs: ['some-other'] })).err));
|
|
56
|
+
check('S1④ docs 为空数组 = 不约束 → 放行',
|
|
57
|
+
!(matchPrune(HEAD, rowA + rowC, mk({ docs: [] })).err));
|
|
58
|
+
// 全对
|
|
59
|
+
const ok = matchPrune(HEAD, rowA + rowC, mk());
|
|
60
|
+
check('S1 全部判据通过 → 放行且计数正确',
|
|
61
|
+
!ok.err && ok.removed === 1 && ok.docs[0] === '2026-10-01-a', JSON.stringify(ok));
|
|
62
|
+
check('S1 返回被删哈希供链校验复用', Array.isArray(ok.hashes) && ok.hashes[0] === sha256line(rowB.trim()));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ============ 场景 2:--staged 真 git 端到端 ============
|
|
66
|
+
{
|
|
67
|
+
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'prune-e2e-'));
|
|
68
|
+
const git = (args, cwd = d) => spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
69
|
+
git(['init', '-q', '.']);
|
|
70
|
+
git(['config', 'user.email', 't@t.io']); git(['config', 'user.name', 't']);
|
|
71
|
+
git(['config', 'core.autocrlf', 'false']);
|
|
72
|
+
const mkfix = () => {
|
|
73
|
+
fs.mkdirSync(path.join(d, '.agents', 'scripts'), { recursive: true });
|
|
74
|
+
copyScriptClosure(path.join(d, '.agents', 'scripts'), ['check-ledger-invariant.mjs']);
|
|
75
|
+
for (const p of ['workflow/intents/2026-10-01-a.md', 'workflow/plans/2026-10-01-a.md', 'workflow/intents/2026-10-01-z.md']) {
|
|
76
|
+
fs.mkdirSync(path.join(d, path.dirname(p)), { recursive: true });
|
|
77
|
+
fs.writeFileSync(path.join(d, p), '---\n状态: approved\n---\n# doc\n', 'utf8');
|
|
78
|
+
}
|
|
79
|
+
fs.writeFileSync(path.join(d, '.agents', 'confirmations.jsonl'), HEAD, 'utf8');
|
|
80
|
+
git(['add', '-A']); git(['commit', '-qm', 'base']);
|
|
81
|
+
};
|
|
82
|
+
const runGate = () => spawnSync(process.execPath, ['.agents/scripts/check-ledger-invariant.mjs', '--staged'],
|
|
83
|
+
{ cwd: d, encoding: 'utf8' });
|
|
84
|
+
|
|
85
|
+
// ① 无 manifest 直接删行 → 仍 hard-block(append-only 红线不变)
|
|
86
|
+
mkfix();
|
|
87
|
+
fs.writeFileSync(path.join(d, '.agents', 'confirmations.jsonl'), rowA + rowC, 'utf8');
|
|
88
|
+
git(['add', '-A']);
|
|
89
|
+
let r = runGate();
|
|
90
|
+
check('S2① 无 manifest 删行 → exit 1(红线不变)', r.status === 1, `exit=${r.status} ${r.stderr}`);
|
|
91
|
+
check('S2① 报文含「特许修剪对账失败」', /台账重写/.test(r.stderr), r.stderr);
|
|
92
|
+
|
|
93
|
+
// ② 带 manifest 同笔暂存 → 放行
|
|
94
|
+
fs.writeFileSync(path.join(d, '.agents', 'confirmations.jsonl'), rowA + rowC, 'utf8');
|
|
95
|
+
fs.writeFileSync(path.join(d, PRUNE_REL), JSON.stringify({
|
|
96
|
+
ts: '2026-10-12T00:00:00Z', authority: '仓库所有者指令', reason: '下架 a 家族',
|
|
97
|
+
removes: [sha256line(rowB.trim())], docs: ['2026-10-01-a'],
|
|
98
|
+
}, null, 2), 'utf8');
|
|
99
|
+
git(['add', '-A']);
|
|
100
|
+
r = runGate();
|
|
101
|
+
check('S2② 带合法 manifest → exit 0 放行', r.status === 0, `exit=${r.status} ${r.stderr}`);
|
|
102
|
+
check('S2② 放行报文点名凭据与行数', /特许修剪通过/.test(r.stdout) && /移除 1 行/.test(r.stdout), r.stdout);
|
|
103
|
+
|
|
104
|
+
// ③ manifest 只写一半(另删一行未申报) → 仍拦
|
|
105
|
+
fs.writeFileSync(path.join(d, '.agents', 'confirmations.jsonl'), rowA, 'utf8');
|
|
106
|
+
git(['add', '-A']);
|
|
107
|
+
r = runGate();
|
|
108
|
+
check('S2③ manifest 外的额外删行 → exit 1', r.status === 1, `exit=${r.status}`);
|
|
109
|
+
check('S2③ 报文说明内容 ≠ 旧 − manifest', /旧内容 − manifest/.test(r.stderr), r.stderr);
|
|
110
|
+
|
|
111
|
+
// ④ 提交后跑历史全扫 → 认领修剪、不报前缀破坏
|
|
112
|
+
fs.writeFileSync(path.join(d, '.agents', 'confirmations.jsonl'), rowA + rowC, 'utf8');
|
|
113
|
+
git(['add', '-A']); git(['commit', '-qm', 'prune with manifest']);
|
|
114
|
+
r = spawnSync(process.execPath, ['.agents/scripts/check-ledger-invariant.mjs'], { cwd: d, encoding: 'utf8' });
|
|
115
|
+
check('S2④ 历史全扫认领修剪 → exit 0', r.status === 0, `exit=${r.status} ${r.stderr}`);
|
|
116
|
+
check('S2④ 全扫输出含修剪认领记录', /特许修剪 1 行/.test(r.stderr + r.stdout), (r.stderr + r.stdout).slice(0, 300));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
console.log(`\n[ledger-prune.test] PASS ${pass} / FAIL ${fail}`);
|
|
120
|
+
process.exit(fail ? 1 : 0);
|