agentic-flow-kit 1.7.0 → 1.7.16
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 +81 -9
- package/modules/gates/README.md +1 -0
- package/modules/gates/test-protect/README.md +23 -0
- package/modules/gates/test-protect/check-test-protect.mjs +64 -0
- package/modules/gates/test-protect/local-pre-commit.line +1 -0
- package/modules/hosts/README.md +57 -0
- package/modules/hosts/claude/commands/wf-build.md +5 -24
- package/modules/hosts/claude/commands/wf-deploy.md +1 -0
- package/modules/hosts/claude/commands/wf-design.md +7 -6
- package/modules/hosts/claude/commands/wf-gate-checklist.md +1 -3
- package/modules/hosts/claude/commands/wf-new-task.md +42 -21
- package/modules/hosts/claude/commands/wf-plan.md +1 -1
- package/modules/hosts/claude/commands/wf-review.md +1 -1
- package/modules/hosts/claude/commands/wf-sync-hosts.md +8 -16
- package/modules/hosts/claude/commands/wf-test.md +5 -4
- package/modules/hosts/codex/commands/wf-build.md +5 -24
- package/modules/hosts/codex/commands/wf-deploy.md +1 -0
- package/modules/hosts/codex/commands/wf-design.md +7 -6
- package/modules/hosts/codex/commands/wf-gate-checklist.md +1 -3
- package/modules/hosts/codex/commands/wf-new-task.md +42 -21
- package/modules/hosts/codex/commands/wf-plan.md +1 -1
- package/modules/hosts/codex/commands/wf-review.md +1 -1
- package/modules/hosts/codex/commands/wf-sync-hosts.md +8 -16
- package/modules/hosts/codex/commands/wf-test.md +5 -4
- package/modules/hosts/cursor/commands/wf-build.md +5 -24
- package/modules/hosts/cursor/commands/wf-deploy.md +1 -0
- package/modules/hosts/cursor/commands/wf-design.md +7 -6
- package/modules/hosts/cursor/commands/wf-gate-checklist.md +1 -3
- package/modules/hosts/cursor/commands/wf-new-task.md +42 -21
- package/modules/hosts/cursor/commands/wf-plan.md +1 -1
- package/modules/hosts/cursor/commands/wf-review.md +1 -1
- package/modules/hosts/cursor/commands/wf-sync-hosts.md +8 -16
- package/modules/hosts/cursor/commands/wf-test.md +5 -4
- package/modules/hosts/opencode/commands/wf-build.md +5 -24
- package/modules/hosts/opencode/commands/wf-deploy.md +1 -0
- package/modules/hosts/opencode/commands/wf-design.md +7 -6
- package/modules/hosts/opencode/commands/wf-gate-checklist.md +1 -3
- package/modules/hosts/opencode/commands/wf-new-task.md +42 -21
- package/modules/hosts/opencode/commands/wf-plan.md +1 -1
- package/modules/hosts/opencode/commands/wf-review.md +1 -1
- package/modules/hosts/opencode/commands/wf-sync-hosts.md +8 -16
- package/modules/hosts/opencode/commands/wf-test.md +5 -4
- package/modules/hosts/trae/commands/wf-build.md +5 -24
- package/modules/hosts/trae/commands/wf-deploy.md +1 -0
- package/modules/hosts/trae/commands/wf-design.md +7 -6
- package/modules/hosts/trae/commands/wf-gate-checklist.md +1 -3
- package/modules/hosts/trae/commands/wf-new-task.md +42 -21
- package/modules/hosts/trae/commands/wf-plan.md +1 -1
- package/modules/hosts/trae/commands/wf-review.md +1 -1
- package/modules/hosts/trae/commands/wf-sync-hosts.md +8 -16
- package/modules/hosts/trae/commands/wf-test.md +5 -4
- package/package.json +2 -2
- package/src/add-gate.mjs +25 -1
- package/src/add-gate.test.mjs +103 -0
- package/src/cli.mjs +59 -5
- package/src/doctor.mjs +136 -12
- package/src/executor.mjs +245 -0
- package/src/executor.test.mjs +292 -0
- package/src/gates.test.mjs +17 -0
- package/src/init.mjs +14 -1
- package/src/init.test.mjs +19 -0
- package/src/instance-ref.test.mjs +62 -0
- package/src/jscpd-drift.test.mjs +144 -0
- package/src/profiles.mjs +3 -0
- package/src/render.mjs +6 -1
- package/src/rule-budget.test.mjs +103 -0
- package/src/sync-hosts.mjs +41 -45
- package/src/sync-hosts.test.mjs +67 -56
- package/src/trae-hooks.test.mjs +15 -3
- package/src/validate.mjs +72 -0
- package/src/validate.test.mjs +95 -0
- package/templates/.gitattributes +8 -0
- package/templates/AGENTS.md +2 -1
- package/templates/_agents/commands/build.md +5 -24
- package/templates/_agents/commands/deploy.md +1 -0
- package/templates/_agents/commands/design.md +7 -6
- package/templates/_agents/commands/gate-checklist.md +1 -3
- package/templates/_agents/commands/new-task.md +42 -21
- package/templates/_agents/commands/plan.md +1 -1
- package/templates/_agents/commands/review.md +1 -1
- package/templates/_agents/commands/sync-hosts.md +8 -16
- package/templates/_agents/commands/test.md +5 -4
- package/templates/_agents/control-surfaces.txt +53 -0
- package/templates/_agents/engine-lock.json +246 -0
- package/templates/_agents/lane-surfaces.txt +1 -1
- package/templates/_agents/level-hints.txt +30 -0
- package/templates/_agents/scripts/agg-delegations.cjs +51 -6
- package/templates/_agents/scripts/agg-delegations.test.mjs +84 -4
- package/templates/_agents/scripts/check-engine-integrity.mjs +217 -0
- package/templates/_agents/scripts/check-engine-integrity.test.mjs +215 -0
- package/templates/_agents/scripts/check-lane-surface.mjs +78 -24
- package/templates/_agents/scripts/check-lane-surface.test.mjs +66 -0
- package/templates/_agents/scripts/check-ledger-invariant.mjs +112 -14
- package/templates/_agents/scripts/check-ledger-invariant.test.mjs +5 -1
- package/templates/_agents/scripts/check-loop-rev.test.mjs +6 -4
- package/templates/_agents/scripts/check-loop.mjs +255 -45
- package/templates/_agents/scripts/check-loop.test.mjs +330 -15
- package/templates/_agents/scripts/check-metric-claims.mjs +4 -2
- 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 +54 -12
- package/templates/_agents/scripts/confirm-doc.test.mjs +59 -0
- package/templates/_agents/scripts/doctor.test.mjs +294 -1
- package/templates/_agents/scripts/draft-sediment.mjs +178 -0
- package/templates/_agents/scripts/draft-sediment.test.mjs +272 -0
- package/templates/_agents/scripts/edit-face-check.test.mjs +5 -4
- package/templates/_agents/scripts/fill-intent.mjs +2 -2
- package/templates/_agents/scripts/fixture-scripts.mjs +153 -0
- package/templates/_agents/scripts/fixture-scripts.test.mjs +107 -0
- package/templates/_agents/scripts/gate-checklist.mjs +6 -3
- package/templates/_agents/scripts/gate-checklist.test.mjs +2 -5
- package/templates/_agents/scripts/gen-wiki-board.mjs +61 -6
- package/templates/_agents/scripts/gen-wiki-board.test.mjs +74 -1
- package/templates/_agents/scripts/gen-workflow-dashboard.mjs +10 -13
- package/templates/_agents/scripts/gen-workflow-metrics.mjs +12 -30
- 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/kb-search.mjs +87 -12
- package/templates/_agents/scripts/kb-search.test.mjs +83 -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-ts-monotonic.test.mjs +135 -0
- package/templates/_agents/scripts/level-criteria.mjs +232 -0
- package/templates/_agents/scripts/level-criteria.test.mjs +279 -0
- package/templates/_agents/scripts/pipeline-run.mjs +5 -3
- package/templates/_agents/scripts/policy.mjs +47 -0
- package/templates/_agents/scripts/solidify-task.mjs +1 -1
- package/templates/_agents/scripts/stage-gates.mjs +52 -10
- package/templates/_agents/scripts/stage-gates.test.mjs +105 -0
- package/templates/_agents/scripts/verify-wiki-consistency.mjs +34 -0
- package/templates/_agents/scripts/verify-wiki-consistency.test.mjs +44 -2
- package/templates/_agents/scripts/verify.mjs +1 -1
- package/templates/_agents/scripts/wf-journal.test.mjs +1 -1
- package/templates/_agents/scripts/wiki-search.test.mjs +5 -6
- package/templates/_agents/scripts/workflows-check.mjs +1 -1
- package/templates/_agents/scripts-test-exempt.txt +3 -1
- package/templates/_agents/skills/wiki/SKILL.md +5 -3
- package/templates/_agents/workflows/pipeline-closing.md +4 -5
- package/templates/_githooks/pre-commit +3 -34
- package/templates/_githooks/pre-push +3 -3
- package/templates/wiki/INDEX.md +44 -1
- package/templates/wiki//347/237/245/350/257/206/346/262/211/346/267/200/346/200/273/350/247/210.html +22 -1
- package/templates/workflow/README.md +11 -0
- package/templates/workflow/delegations.md +5 -4
- package/templates/workflow/incidents/_TEMPLATE.md +2 -0
- package/templates/workflow/intents/_TEMPLATE.md +3 -0
- package/templates/workflow/plans/_TEMPLATE.md +4 -0
- package/templates/workflow/specs/_TEMPLATE.md +23 -0
- package/modules/hosts/claude/commands/wf-source-sync-check.md +0 -91
- package/modules/hosts/codex/commands/wf-source-sync-check.md +0 -91
- package/modules/hosts/cursor/commands/wf-source-sync-check.md +0 -91
- package/modules/hosts/opencode/commands/wf-source-sync-check.md +0 -91
- package/modules/hosts/trae/commands/wf-source-sync-check.md +0 -92
- package/templates/_agents/commands/source-sync-check.md +0 -105
- package/templates/_agents/scripts/check-ledger.mjs +0 -109
- package/templates/_agents/scripts/check-ledger.test.mjs +0 -197
- package/templates/_agents/scripts/source-sync-check.mjs +0 -156
- package/templates/_agents/scripts/source-sync-check.test.mjs +0 -281
- package/templates/_agents/workflows/source-sync-repair.md +0 -30
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
// 结构缓存(2026-09-23 intent kb-search-structure-cache):可检索子集预提取进 .agents/cache/kb-index.json,
|
|
11
11
|
// 按 [mtimeMs,size] 逐文件失效,口径指纹变化整份作废;匹配/排序/输出路径不变(三路输出与冷扫逐字节一致);
|
|
12
12
|
// 缓存任何读写异常静默回退全扫。`--include-generated` 走纯扫(bulk 文件不入缓存)。
|
|
13
|
+
// 检索留痕(2026-10-11 P1-1):每次实际检索后向 .agents/notes/kb-search-ledger.jsonl 追加一行 JSON
|
|
14
|
+
// (ts/query/scope/hits/top),KB 沉淀↔检索形成可审计闭环;--no-ledger 显式退出;
|
|
15
|
+
// 写失败仅 console.error 一行 WARN,不改变检索输出与退出码。
|
|
13
16
|
// 测试:node .agents/scripts/kb-search.test.mjs(fixture 回归,须全绿)
|
|
14
17
|
// 模块词表:.agents/workflow-modules.txt(--module 取值校验,单源)
|
|
15
18
|
import fs from 'node:fs';
|
|
@@ -33,6 +36,7 @@ const USAGE = `用法: node .agents/scripts/kb-search.mjs [选项] <词1> [词2
|
|
|
33
36
|
--no-cache 禁用结构缓存,全量扫(与缓存热路径输出逐字节一致)
|
|
34
37
|
--cache <文件> 结构缓存文件路径覆盖(默认 .agents/cache/kb-index.json;测试注入用)
|
|
35
38
|
--rebuild 忽略现有缓存重建后回写
|
|
39
|
+
--no-ledger 退出检索留痕:不向 .agents/notes/kb-search-ledger.jsonl 追加本次检索记录
|
|
36
40
|
--help 打印本说明
|
|
37
41
|
|
|
38
42
|
退出码:0 正常(含无命中);1 参数错误(无词 / 未知选项 / 非法枚举值)`;
|
|
@@ -49,6 +53,7 @@ let includeGenerated = false;
|
|
|
49
53
|
let noCache = false;
|
|
50
54
|
let cacheFileOverride = ''; // --cache <文件>(board-kb-p1):测试注入式缓存路径,不再直跑清真实开发缓存
|
|
51
55
|
let rebuild = false;
|
|
56
|
+
let noLedger = false; // --no-ledger(P1-1):留痕默认开启(审计要完整),显式退出才跳过写 ledger
|
|
52
57
|
const words = [];
|
|
53
58
|
for (let i = 0; i < args.length; i++) {
|
|
54
59
|
const a = args[i];
|
|
@@ -62,6 +67,7 @@ for (let i = 0; i < args.length; i++) {
|
|
|
62
67
|
else if (a === '--no-cache') noCache = true;
|
|
63
68
|
else if (a === '--cache') cacheFileOverride = args[++i] ?? '';
|
|
64
69
|
else if (a === '--rebuild') rebuild = true;
|
|
70
|
+
else if (a === '--no-ledger') noLedger = true;
|
|
65
71
|
else if (a === '--help' || a === '-h') { console.log(USAGE); process.exit(0); }
|
|
66
72
|
else words.push(a);
|
|
67
73
|
}
|
|
@@ -88,6 +94,9 @@ if (moduleFilter && !MODULES.includes(moduleFilter)) {
|
|
|
88
94
|
// 活跃口径 = 单源 doc.status.active ∪ incident.status.active(与 workflow/INDEX.md 活跃层一致,fixed 归档案默认也可命中)
|
|
89
95
|
const ACTIVE_STATUS = [...ENUMS['doc.status.active'], ...ENUMS['incident.status.active']];
|
|
90
96
|
const WF_SECTIONS = ['背景', '目标', '根因', '复盘三件套', '验收标准', '影响面'];
|
|
97
|
+
// wiki 侧节提取白名单(2026-10-10 A1):wiki 知识文档的骨架节——长文全文 includes 召回差,
|
|
98
|
+
// 节级提取让命中带节名上下文。放此处(WF_SECTIONS 旁,CFG_FP 之前)供指纹派生。
|
|
99
|
+
const WIKI_SECTIONS = ['标题', '摘要', '背景', '结论', '要点', '规范', '用法', '示例'];
|
|
91
100
|
const WIKI_EXCLUDE = new Set(['INDEX.md', '知识沉淀总览.html']);
|
|
92
101
|
const WIKI_MAX_KB = 180;
|
|
93
102
|
|
|
@@ -149,17 +158,20 @@ const ROOT_NS = `${path.resolve('.')}::`;
|
|
|
149
158
|
const WIKI_EXTS = includeGenerated ? /\.(md|sql|html|csv|json|txt)$/i : /\.(md|sql|html|txt)$/i;
|
|
150
159
|
|
|
151
160
|
// 行序列化/复活(缓存 schema 的定义者——board-kb-p1 起纳入 CFG_FP 指纹:schema 演进即整份缓存作废)
|
|
152
|
-
|
|
153
|
-
|
|
161
|
+
// 2026-10-10 A1:wiki 行从二元组 [ln, line] 升三元组 [ln, section, line](节级提取随动)——
|
|
162
|
+
// 两 kind 同构后 serialize/revive 按 kind 取三元组即可,旧缓存由 CFG_FP 指纹整份作废。
|
|
163
|
+
const serializeLines = (lines) => lines.map((x) => [x.ln, x.section, x.line]);
|
|
164
|
+
const reviveLines = (lines) => lines.map((a) => ({ ln: a[0], section: a[1], line: a[2] }));
|
|
154
165
|
|
|
155
166
|
// linesShapeOk:缓存条目 lines 形状校验(board-kb-p1)——JSON 合法但 schema 坏(lines 非数组 / 元素非数组)
|
|
156
167
|
// 一律视同 miss 走 build 重建(头注释「任何读写异常静默回退」契约恢复;此前直接 .map 抛 TypeError exit 1)
|
|
157
168
|
const linesShapeOk = (lines) => Array.isArray(lines) && (lines.length === 0 || lines.every(Array.isArray));
|
|
158
169
|
|
|
159
170
|
const CFG_FP = createHash('sha256').update(JSON.stringify({
|
|
160
|
-
v:
|
|
171
|
+
v: 3, // 2026-10-10 B3:wiki 条目也存 meta(来源 frontmatter 血缘显示),schema 演进作废旧缓存
|
|
172
|
+
WF_SECTIONS, WIKI_SECTIONS, ACTIVE_STATUS, WIKI_MAX_KB, excl: [...WIKI_EXCLUDE],
|
|
161
173
|
exts: WIKI_EXTS.source, // schema 派生(board-kb-p1:手抄摘要曾致扩展面演进不触发指纹失效;RegExp.source 跨 includeGenerated 分支稳定)
|
|
162
|
-
fns: [parseDoc.toString(), workflowLines.toString(), matchWords.toString(), serializeLines.toString(), reviveLines.toString()],
|
|
174
|
+
fns: [parseDoc.toString(), workflowLines.toString(), wikiLines.toString(), matchWords.toString(), serializeLines.toString(), reviveLines.toString()],
|
|
163
175
|
})).digest('hex').slice(0, 16);
|
|
164
176
|
const cacheUsable = !noCache && !includeGenerated;
|
|
165
177
|
let cache = null;
|
|
@@ -186,13 +198,13 @@ function cachedEntry(relPath, kind, build) {
|
|
|
186
198
|
const e = cache[key];
|
|
187
199
|
if (e && e.kind === kind && Array.isArray(e.st) && e.st[0] === sig[0] && e.st[1] === sig[1] && linesShapeOk(e.lines)) {
|
|
188
200
|
touched.add(key);
|
|
189
|
-
return { st: e.st, meta: e.meta, title: e.title, lines: reviveLines(e.lines
|
|
201
|
+
return { st: e.st, meta: e.meta, title: e.title, lines: reviveLines(e.lines) };
|
|
190
202
|
}
|
|
191
203
|
const built = build();
|
|
192
204
|
cache[key] = {
|
|
193
205
|
st: sig, kind,
|
|
194
|
-
...(
|
|
195
|
-
lines: serializeLines(built.lines
|
|
206
|
+
...(built.meta ? { meta: built.meta, ...(built.title ? { title: built.title } : {}) } : {}),
|
|
207
|
+
lines: serializeLines(built.lines),
|
|
196
208
|
};
|
|
197
209
|
touched.add(key);
|
|
198
210
|
return { st: sig, meta: built.meta, title: built.title, lines: built.lines };
|
|
@@ -249,7 +261,36 @@ function searchWorkflow(words) {
|
|
|
249
261
|
return results.sort((a, b) => b.score - a.score);
|
|
250
262
|
}
|
|
251
263
|
|
|
252
|
-
// ---- wiki 检索(口径同 2026-09-21 降噪版 wiki-search
|
|
264
|
+
// ---- wiki 检索(口径同 2026-09-21 降噪版 wiki-search;2026-10-10 A1:节级提取 + 主题路径加权)----
|
|
265
|
+
// wiki 侧与 workflow 侧同款行结构(ln + section + line),节提取用 wiki 自己的标题滚动:
|
|
266
|
+
// wiki 知识文档多为长文,全文 includes 召回差、命中行缺乏上下文——节级提取让命中带节名
|
|
267
|
+
// (`[<节名>] L<行>: <摘录>`,与 workflow 侧输出对齐)。frontmatter(如有)单列 '(frontmatter)' 节。
|
|
268
|
+
// 提取节白名单 WIKI_SECTIONS(常量定义在文件头部 WF_SECTIONS 旁),与 WF_SECTIONS 平行。
|
|
269
|
+
function wikiLines(text) {
|
|
270
|
+
const lines = text.split(/\r?\n/);
|
|
271
|
+
const out = [];
|
|
272
|
+
let i = 0;
|
|
273
|
+
if (/^---\s*$/.test(lines[0] || '')) {
|
|
274
|
+
out.push({ ln: 1, section: '(frontmatter)', line: lines[0] });
|
|
275
|
+
for (i = 1; i < lines.length; i++) {
|
|
276
|
+
out.push({ ln: i + 1, section: '(frontmatter)', line: lines[i] });
|
|
277
|
+
if (/^---\s*$/.test(lines[i])) { i++; break; }
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
let section = '';
|
|
281
|
+
// wiki 短文常无小节标题(如「# 说明\n正文」)——节白名单只覆盖骨架节,纯标题文档会零命中。
|
|
282
|
+
// 兜底:全文无任何 `## ` 时正文全收(section 记 '(正文)');有 ## 后回到白名单口径。
|
|
283
|
+
const sawH2 = lines.slice(i).some((l) => /^## /.test(l));
|
|
284
|
+
for (; i < lines.length; i++) {
|
|
285
|
+
const line = lines[i];
|
|
286
|
+
const h1 = line.match(/^# (.+?)\s*$/);
|
|
287
|
+
if (h1 && !/^## /.test(line)) { out.push({ ln: i + 1, section: '(标题)', line }); section = ''; continue; }
|
|
288
|
+
const h2 = line.match(/^## (.+?)\s*$/);
|
|
289
|
+
if (h2) section = h2[1];
|
|
290
|
+
if (!sawH2 || WIKI_SECTIONS.some((s) => section.startsWith(s))) out.push({ ln: i + 1, section: section || '(正文)', line });
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
253
294
|
const walkWiki = (dir, rel) => {
|
|
254
295
|
const out = [];
|
|
255
296
|
for (const e of fs.readdirSync(dir)) {
|
|
@@ -275,13 +316,21 @@ function searchWiki(words) {
|
|
|
275
316
|
const e = cachedEntry(p, 'wiki', () => {
|
|
276
317
|
let text;
|
|
277
318
|
try { text = fs.readFileSync(p, 'utf8'); } catch { return { lines: [] }; }
|
|
278
|
-
|
|
319
|
+
const { meta } = parseDoc(text);
|
|
320
|
+
return { meta, lines: wikiLines(text) };
|
|
279
321
|
});
|
|
280
322
|
if (!e.lines.length) continue;
|
|
281
323
|
const hits = matchWords(e.lines.filter((x) => words.some((w) => x.line.includes(w))), words);
|
|
282
324
|
if (!hits) continue;
|
|
283
325
|
const kb = size / 1024;
|
|
284
|
-
|
|
326
|
+
// 主题路径加权(A1):查询词出现在主题目录名(key 首段,如「项目规范」)时 ×1.2——
|
|
327
|
+
// 主题归属是 wiki 最有价值的结构特征,纯全文打分完全没用上;权重温和(不压倒命中数主序)
|
|
328
|
+
const topicHit = words.some((w) => key.includes(w));
|
|
329
|
+
const score = (hits.total / Math.sqrt(Math.max(kb, 0.1))) * (topicHit ? 1.2 : 1);
|
|
330
|
+
// 来源血缘(B3,2026-10-10):wiki frontmatter「来源: workflow/<类型>/<文件>」指回 workflow 归档——
|
|
331
|
+
// 与 intent/incident 的正向「沉淀:」字段对称。有则结果行显示,无则空(老 wiki 文件零行为变化)
|
|
332
|
+
const source = (e.meta && e.meta['来源']) || '';
|
|
333
|
+
results.push({ file: key, total: hits.total, shown: hits.shown, kb, score, source });
|
|
285
334
|
}
|
|
286
335
|
}
|
|
287
336
|
return { results: results.sort((a, b) => b.score - a.score), skipped };
|
|
@@ -290,11 +339,13 @@ function searchWiki(words) {
|
|
|
290
339
|
// ---- 输出 ----
|
|
291
340
|
const top = (arr) => (limit ? arr.slice(0, limit) : arr);
|
|
292
341
|
let anyHits = 0;
|
|
342
|
+
let topFile = null; // 首个展示文件(留痕用):按展示顺序 workflow→wiki 取 shown[0],无命中保持 null
|
|
293
343
|
|
|
294
344
|
if (scope === 'workflow' || scope === 'all') {
|
|
295
345
|
const res = searchWorkflow(words);
|
|
296
346
|
anyHits += res.length;
|
|
297
347
|
const shown = top(res);
|
|
348
|
+
if (shown.length && !topFile) topFile = `workflow/${shown[0].file}`;
|
|
298
349
|
console.log(`── workflow(${res.length} 个文件命中${res.length > shown.length ? `,展示前 ${shown.length}` : ''})──`);
|
|
299
350
|
for (const r of shown) {
|
|
300
351
|
console.log(`📄 workflow/${r.file} — ${r.status || '未填状态'} · ${r.level} · ${r.module} · ${r.total} 处命中 / ${r.kb < 10 ? r.kb.toFixed(1) : Math.round(r.kb)}KB`);
|
|
@@ -307,10 +358,11 @@ if (scope === 'wiki' || scope === 'all') {
|
|
|
307
358
|
const { results: res, skipped } = searchWiki(words);
|
|
308
359
|
anyHits += res.length;
|
|
309
360
|
const shown = top(res);
|
|
361
|
+
if (shown.length && !topFile) topFile = `wiki/${shown[0].file}`;
|
|
310
362
|
console.log(`── wiki(${res.length} 个文件命中${res.length > shown.length ? `,展示前 ${shown.length}` : ''})──`);
|
|
311
363
|
for (const r of shown) {
|
|
312
|
-
console.log(`📄 wiki/${r.file} — ${r.total} 处命中 / ${r.kb < 10 ? r.kb.toFixed(1) : Math.round(r.kb)}KB`);
|
|
313
|
-
for (const h of r.shown) console.log(` L${h.ln}: ${h.line.trim().slice(0, 130)}`);
|
|
364
|
+
console.log(`📄 wiki/${r.file} — ${r.total} 处命中 / ${r.kb < 10 ? r.kb.toFixed(1) : Math.round(r.kb)}KB${r.source ? ` · 来源 ${r.source}` : ''}`);
|
|
365
|
+
for (const h of r.shown) console.log(` [${h.section || '(无节)'}] L${h.ln}: ${h.line.trim().slice(0, 130)}`);
|
|
314
366
|
}
|
|
315
367
|
if (!res.length) console.log('(无命中)');
|
|
316
368
|
if (skipped) console.log(`(wiki 跳过 ${skipped} 个产物/大文件——--include-generated 纳入)`);
|
|
@@ -321,3 +373,26 @@ console.log(`\n${anyHits ? `合计 ${anyHits} 个文件命中(${words.join(' A
|
|
|
321
373
|
(scope === 'all' ? ';`--scope` 限定语料、`--status all` 显式全量' : ''));
|
|
322
374
|
|
|
323
375
|
saveCache();
|
|
376
|
+
|
|
377
|
+
// ---- 检索留痕(P1-1):append-only 一行 JSON,KB 沉淀↔检索可审计闭环 ----
|
|
378
|
+
// ROOT 与 workflow/、wiki/ 索引根一致(脚本按 cwd 相对路径读语料,ROOT_NS 同源);
|
|
379
|
+
// 写失败只 WARN 不阻断:留痕是旁路记录,异常必须可见,但绝不能改变检索结果与退出码。
|
|
380
|
+
const LEDGER_FILE = path.join('.agents', 'notes', 'kb-search-ledger.jsonl');
|
|
381
|
+
function appendLedger() {
|
|
382
|
+
if (noLedger) return;
|
|
383
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
384
|
+
const d = new Date();
|
|
385
|
+
try {
|
|
386
|
+
fs.mkdirSync(path.dirname(LEDGER_FILE), { recursive: true });
|
|
387
|
+
fs.appendFileSync(LEDGER_FILE, `${JSON.stringify({
|
|
388
|
+
ts: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`, // ISO 8601 本地时间
|
|
389
|
+
query: words.join(' '),
|
|
390
|
+
scope,
|
|
391
|
+
hits: anyHits, // 切 top 之前的命中总量(workflow + wiki)
|
|
392
|
+
top: topFile,
|
|
393
|
+
})}\n`, 'utf8');
|
|
394
|
+
} catch (e) {
|
|
395
|
+
console.error(`WARN 检索留痕写入失败(不影响检索结果):${e && e.message ? e.message : e}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
appendLedger();
|
|
@@ -175,6 +175,89 @@ const files = (out) => (out.match(/^📄 (workflow\/\S+)/gm) || []).map((l) => l
|
|
|
175
175
|
fs.rmSync(root, { recursive: true, force: true });
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
// ---- 场景 10:A1 wiki 侧节级提取 + 主题路径加权(2026-10-10)----
|
|
179
|
+
// wiki 命中带节名(骨架节或无小节兜底);主题目录命中查询词时排序提前;长文节外内容不参与检索
|
|
180
|
+
{
|
|
181
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-a1-'));
|
|
182
|
+
fs.mkdirSync(path.join(root, 'wiki', '项目规范'), { recursive: true });
|
|
183
|
+
fs.mkdirSync(path.join(root, 'wiki', '杂项'), { recursive: true });
|
|
184
|
+
// 长文:命中在白名单节内(## 结论)+ 节外(## 历史流水,不应被索引)
|
|
185
|
+
fs.writeFileSync(path.join(root, 'wiki', '项目规范', '权限收敛.md'),
|
|
186
|
+
'# 权限收敛方案\n\n## 结论\n- 权限收敛后只读接口不再放行写操作。\n\n## 历史流水\n- 2026-09 某次发布也提到权限收敛但属节外内容\n', 'utf8');
|
|
187
|
+
// 短文:无任何 ## —— 正文兜底全收
|
|
188
|
+
fs.writeFileSync(path.join(root, 'wiki', '杂项', '说明.md'), '# 说明\n\n权限收敛的领域知识条目。\n', 'utf8');
|
|
189
|
+
const r = run(root, ['--scope', 'wiki', '--cache', rootCache(root), '权限收敛']);
|
|
190
|
+
check('场景 10a wiki 长文命中带节名(结论节)',
|
|
191
|
+
r.status === 0 && /wiki\/项目规范\/权限收敛.md/.test(r.stdout) && /\[结论\]/.test(r.stdout), `exit=${r.status}\n${r.stdout}`);
|
|
192
|
+
check('场景 10b wiki 长文节外内容不参与检索(历史流水行不进命中摘录)',
|
|
193
|
+
!/历史流水/.test(r.stdout), r.stdout);
|
|
194
|
+
check('场景 10c wiki 短文无小节兜底(正文命中,节名 (正文))',
|
|
195
|
+
/wiki\/杂项\/说明.md/.test(r.stdout) && /\(正文\)/.test(r.stdout), `exit=${r.status}\n${r.stdout}`);
|
|
196
|
+
// 主题加权:两文件命中数同量级时,主题名含查询词的「项目规范」排前
|
|
197
|
+
const order = (r.stdout.match(/^📄 (wiki\/\S+)/gm) || []).map((l) => l.replace('📄 ', ''));
|
|
198
|
+
check('场景 10d 主题路径加权:主题名命中查询词的文件排前',
|
|
199
|
+
order.length === 2 && order[0].includes('项目规范'), `order=${JSON.stringify(order)}\n${r.stdout}`);
|
|
200
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---- 场景 11:B3 来源血缘显示(2026-10-10)----
|
|
204
|
+
// wiki frontmatter「来源: workflow/<类型>/<文件>」→ 结果行带「· 来源 …」;无来源老文件零变化
|
|
205
|
+
{
|
|
206
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-b3-'));
|
|
207
|
+
fs.mkdirSync(path.join(root, 'wiki', '项目规范'), { recursive: true });
|
|
208
|
+
fs.mkdirSync(path.join(root, 'workflow', 'intents'), { recursive: true });
|
|
209
|
+
// 带来源的 wiki 文件(B2 生成的草稿/人工沉淀都是这个形态)
|
|
210
|
+
fs.writeFileSync(path.join(root, 'wiki', '项目规范', '权限收敛.md'),
|
|
211
|
+
'---\n来源: workflow/intents/2026-09-12-perm-shrink.md\n---\n# 权限收敛方案\n\n## 结论\n- 权限收敛后只读接口不再放行写操作。\n', 'utf8');
|
|
212
|
+
// 无 frontmatter 的老 wiki 文件(零行为变化对照)
|
|
213
|
+
fs.writeFileSync(path.join(root, 'wiki', '项目规范', '老规范.md'), '# 老规范\n\n权限收敛的旧条目。\n', 'utf8');
|
|
214
|
+
const r = run(root, ['--scope', 'wiki', '--cache', rootCache(root), '权限收敛']);
|
|
215
|
+
check('场景 11a 有来源的 wiki 结果行显示来源血缘',
|
|
216
|
+
r.status === 0 && /来源 workflow\/intents\/2026-09-12-perm-shrink\.md/.test(r.stdout), `exit=${r.status}\n${r.stdout}`);
|
|
217
|
+
check('场景 11b 无 frontmatter 老文件正常命中且无来源行干扰',
|
|
218
|
+
r.status === 0 && /wiki\/项目规范\/老规范\.md/.test(r.stdout), `exit=${r.status}\n${r.stdout}`);
|
|
219
|
+
// 来源字段本身可被检索(frontmatter 已进可检索面——双向找链:从 wiki 侧反查 workflow 来源)
|
|
220
|
+
const r2 = run(root, ['--scope', 'wiki', '--cache', rootCache(root), 'perm-shrink']);
|
|
221
|
+
check('场景 11c 来源路径本身参与检索(frontmatter 命中)',
|
|
222
|
+
r2.status === 0 && /wiki\/项目规范\/权限收敛\.md/.test(r2.stdout), `exit=${r2.status}\n${r2.stdout}`);
|
|
223
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---- 场景 12:检索留痕(P1-1)——每次检索后 ledger 追加一行 / --no-ledger 退出 / notes 目录自动创建 ----
|
|
227
|
+
// ledger 落在 <临时根>/.agents/notes/kb-search-ledger.jsonl(与 workflow/、wiki/ 索引根同 ROOT)
|
|
228
|
+
{
|
|
229
|
+
const ledgerOf = (root) => path.join(root, '.agents', 'notes', 'kb-search-ledger.jsonl');
|
|
230
|
+
const ledgerLines = (root) => fs.readFileSync(ledgerOf(root), 'utf8').split('\n').filter(Boolean);
|
|
231
|
+
const root = mkfix();
|
|
232
|
+
check('场景 12 前置:.agents/notes 尚不存在', !fs.existsSync(path.join(root, '.agents')));
|
|
233
|
+
// a) 首次检索 → 恰 1 行、JSON 合法、字段齐全、hits 为数字
|
|
234
|
+
const r1 = run(root, ['--scope', 'workflow', '编码模板', '冲突']);
|
|
235
|
+
const lines1 = ledgerLines(root);
|
|
236
|
+
check('场景 12a 检索后 ledger 存在且恰 1 行', r1.status === 0 && lines1.length === 1, `exit=${r1.status}\n${r1.stderr}`);
|
|
237
|
+
const entry = JSON.parse(lines1[0]);
|
|
238
|
+
check('场景 12a ledger 行字段齐全(ts/query/scope/hits/top)',
|
|
239
|
+
typeof entry.ts === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(entry.ts)
|
|
240
|
+
&& entry.query === '编码模板 冲突' && entry.scope === 'workflow'
|
|
241
|
+
&& entry.hits === 2 && entry.top === 'workflow/intents/2026-09-21-bbb.md', lines1[0]);
|
|
242
|
+
// b) 已有 ledger 再跑 → 追加成 2 行且旧行原样
|
|
243
|
+
const r2 = run(root, ['--scope', 'wiki', '编码模板']);
|
|
244
|
+
const lines2 = ledgerLines(root);
|
|
245
|
+
check('场景 12b 再次检索追加成 2 行且旧行原样',
|
|
246
|
+
r2.status === 0 && lines2.length === 2 && lines2[0] === lines1[0]
|
|
247
|
+
&& JSON.parse(lines2[1]).scope === 'wiki' && JSON.parse(lines2[1]).top === 'wiki/测试主题/说明.md', lines2.join('\n'));
|
|
248
|
+
// c) --no-ledger → 不追加(仍 2 行)
|
|
249
|
+
const r3 = run(root, ['--no-ledger', '--scope', 'workflow', '编码模板']);
|
|
250
|
+
check('场景 12c --no-ledger 不追加不新建', r3.status === 0 && ledgerLines(root).length === 2, r3.stderr);
|
|
251
|
+
// d) 无命中检索也留痕(top=null、hits=0)
|
|
252
|
+
const r4 = run(root, ['--scope', 'workflow', 'zzz-绝不存在-zzz']);
|
|
253
|
+
const lines4 = ledgerLines(root);
|
|
254
|
+
const entry4 = JSON.parse(lines4[2]);
|
|
255
|
+
check('场景 12d 无命中留痕 top=null 且 hits=0',
|
|
256
|
+
r4.status === 0 && entry4.query === 'zzz-绝不存在-zzz' && entry4.hits === 0 && entry4.top === null, lines4.join('\n'));
|
|
257
|
+
check('场景 12e .agents/notes 目录由检索自动创建', fs.statSync(path.join(root, '.agents', 'notes')).isDirectory());
|
|
258
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
259
|
+
}
|
|
260
|
+
|
|
178
261
|
console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
|
|
179
262
|
if (fail) {
|
|
180
263
|
console.log(`\n${USAGE}`);
|
|
@@ -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
|
+
}
|