agentic-flow-kit 1.5.0 → 1.7.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.
Files changed (43) hide show
  1. package/modules/hosts/claude/commands/wf-build.md +7 -0
  2. package/modules/hosts/claude/commands/wf-design.md +6 -0
  3. package/modules/hosts/claude/commands/wf-new-task.md +2 -0
  4. package/modules/hosts/codex/commands/wf-build.md +7 -0
  5. package/modules/hosts/codex/commands/wf-design.md +6 -0
  6. package/modules/hosts/codex/commands/wf-new-task.md +2 -0
  7. package/modules/hosts/cursor/commands/wf-build.md +7 -0
  8. package/modules/hosts/cursor/commands/wf-design.md +6 -0
  9. package/modules/hosts/cursor/commands/wf-new-task.md +2 -0
  10. package/modules/hosts/opencode/commands/wf-build.md +7 -0
  11. package/modules/hosts/opencode/commands/wf-design.md +6 -0
  12. package/modules/hosts/opencode/commands/wf-new-task.md +2 -0
  13. package/modules/hosts/trae/commands/wf-build.md +7 -0
  14. package/modules/hosts/trae/commands/wf-design.md +6 -0
  15. package/modules/hosts/trae/commands/wf-new-task.md +2 -0
  16. package/package.json +1 -1
  17. package/src/run-tests.mjs +16 -9
  18. package/src/sync-hosts.mjs +100 -62
  19. package/src/sync-hosts.test.mjs +39 -0
  20. package/templates/AGENTS.md +2 -1
  21. package/templates/_agents/commands/build.md +7 -0
  22. package/templates/_agents/commands/design.md +6 -0
  23. package/templates/_agents/commands/new-task.md +2 -0
  24. package/templates/_agents/scripts/check-ledger-invariant.mjs +18 -0
  25. package/templates/_agents/scripts/check-ledger-invariant.test.mjs +16 -0
  26. package/templates/_agents/scripts/check-loop.mjs +122 -28
  27. package/templates/_agents/scripts/check-loop.test.mjs +121 -0
  28. package/templates/_agents/scripts/check-stage-index.mjs +40 -0
  29. package/templates/_agents/scripts/check-stage-index.test.mjs +77 -0
  30. package/templates/_agents/scripts/confirm-doc.mjs +52 -3
  31. package/templates/_agents/scripts/confirm-doc.test.mjs +51 -1
  32. package/templates/_agents/scripts/edit-face-check.mjs +107 -0
  33. package/templates/_agents/scripts/edit-face-check.test.mjs +98 -0
  34. package/templates/_agents/scripts/fill-intent.mjs +32 -3
  35. package/templates/_agents/scripts/fill-intent.test.mjs +13 -0
  36. package/templates/_agents/scripts/gen-workflow-metrics.mjs +52 -1
  37. package/templates/_agents/scripts/policy.mjs +62 -0
  38. package/templates/_agents/scripts/verify-wiki-consistency.test.mjs +81 -0
  39. package/templates/_agents/scripts/verify.mjs +11 -3
  40. package/templates/_agents/scripts/verify.test.mjs +30 -3
  41. package/templates/_agents/scripts/wiki-search.test.mjs +58 -0
  42. package/templates/_agents/scripts-test-exempt.txt +2 -3
  43. package/templates/_github/workflows/kit-ci.yml +21 -1
@@ -20,6 +20,12 @@ next: .agents/commands/build.md
20
20
 
21
21
  > L2/L3 强制;L1 走快车道(直接跳 `next: build.md` 起草 Quick-Plan)。
22
22
 
23
+ ## 0. 三件套合并预览(L2/L3 起草期,2026-10-09 engine-quality-round2 C)
24
+
25
+ 起草 intent / spec / plan 任一件后,**在输出草稿全文前先给三件要点总览**(各一件:目标 / 关键取舍 /
26
+ 验收判据 diff,合计 ≤12 行)——用户一次看整体再逐件拍板,省三次盲确认的上下文切换。
27
+ 确认纪律不变:逐件过目全文 + 逐件 `confirm-doc` 代录(禁并录、禁复用同句)。本阶段总览侧重:spec 方案取舍与 L3 复核安排。
28
+
23
29
  ## 1. 执行
24
30
 
25
31
  1. 确认同名入口文档已批准:新需求为 approved intent,修复为已确认的 incident 草稿;否则拒绝,分别回 `plan.md` / `maintain.md`(2026-09-30 起机器强制:fill-spec / confirm-doc 对前置未过直接拒绝 exit 2、不落盘不记账)。
@@ -35,6 +35,8 @@ next: 按阶段路由
35
35
  | **协作道**(敏捷优先,审计异步补) | L0 例行 / L1 实现级 | L0 直接 commit,豁免 intent;L1 快车道(intent 一句话确认 + 极简 plan)即动工。独立复核不设同步前置,check-loop 与事后 review 兜底;确认/代录可 `--batch` 批量(仅 L0/L1) |
36
36
  | **防御道**(严谨优先,确认同步拦) | L2 规则/契约 / L3 数据与运行时结构 | 同步确认门:spec 确认通过方可起草 plan,plan 确认后方可动手;L3 加新会话独立复核,未确认不放行 |
37
37
 
38
+ > **L2/L3 起草期合并预览**:三件套起草按 [design.md §0](design.md) 一次呈现要点总览(逐件确认代录不变)。
39
+ >
38
40
  > 定级结果落 intent frontmatter `risk_level`(=级别,`fill-intent.mjs` 自动写入,机器选道用);协作道勾触达红线会被 check-loop 判「红线判低」hard 拦(就高升级 L2/L3 并补同名 spec)。
39
41
 
40
42
  ## 3. 路由判断
@@ -16,6 +16,12 @@
16
16
  // ④ doc 匹配 workflow/(intents|specs|plans|incidents)/[^/]+.md 且当前树存在
17
17
  // ⑤ stage ∈ 合法跳转终态集(approved/done/fixed/closed/superseded/cancelled)
18
18
  //
19
+ // 行级豁免(2026-10-09 ledger-line-exempt,CI 红事故处置):.agents/ledger-line-exempt.json 登记
20
+ // 已定性的污染行({line, doc, reason}——line = 校验序号即非空行序)——行级五项对豁免行整体跳过、
21
+ // 行**保留在台账**(审计面完整),豁免计数照常出账保持可见。定位:事故行无法追加修复(行级扫最终树
22
+ // 全行)且不可就地改(历史前缀不变量)——superseded 重立单之外的唯一出口。**滥用边界**:豁免文件
23
+ // 自身入库、git 历史可对质;逐条须 papercuts/incident 定性引用,无定性登记 = 审计事件。
24
+ //
19
25
  // 静默条件:台账不存在 / 零提交历史(装户、新仓)/ --staged 且暂存未触台账或无 HEAD 版本。
20
26
  // 已知边界:git log --follow 未用——台账自创建无重命名(单一路径取史);克隆深度不足时仅扫可得
21
27
  // 历史(CI 侧 fetch-depth:0 兜全史,深度不足会告警声明边界,fail-open 于深度、fail-closed 于内容)。
@@ -102,9 +108,20 @@ let rows = [];
102
108
  try {
103
109
  rows = fs.readFileSync(FILE, 'utf8').split(/\r?\n/).filter((l) => l.trim()).map((l) => { try { return JSON.parse(l); } catch { return null; } });
104
110
  } catch { /* 上面 existsSync 已兜 */ }
111
+ const EXEMPT_P = path.join(ROOT, '.agents', 'ledger-line-exempt.json');
112
+ const exemptLines = new Set();
113
+ try {
114
+ if (fs.existsSync(EXEMPT_P)) {
115
+ for (const e of JSON.parse(fs.readFileSync(EXEMPT_P, 'utf8'))) {
116
+ if (e && Number.isInteger(e.line)) exemptLines.add(e.line);
117
+ }
118
+ }
119
+ } catch { /* 豁免表损坏 → 视同无豁免(fail-closed 于校验面) */ }
120
+ let exemptCount = 0;
105
121
  let prevTs = '';
106
122
  rows.forEach((e, idx) => {
107
123
  const at = `第 ${idx + 1} 行`;
124
+ if (exemptLines.has(idx + 1)) { exemptCount++; return; } // 事故行豁免(登记文件入库可对质)
108
125
  if (!e || typeof e !== 'object') { problems.push(`- [行级] ${at}:非 JSON 对象行`); return; }
109
126
  if (typeof e.ts !== 'string' || Number.isNaN(Date.parse(e.ts))) problems.push(`- [行级] ${at}:ts 非 ISO 字符串`);
110
127
  else {
@@ -126,6 +143,7 @@ rows.forEach((e, idx) => {
126
143
  if (!VALID_STAGES.has(e.stage)) problems.push(`- [行级] ${at}:stage「${e.stage || '缺失'}」不在合法跳转终态集`);
127
144
  });
128
145
 
146
+ if (exemptCount) console.log(`ℹ️ 台账豁免行 ${exemptCount} 条(定性见 .agents/ledger-line-exempt.json)`);
129
147
  if (problems.length) {
130
148
  console.error(`台账不可变(历史全扫)— HARD-BLOCK:\n\n${problems.join('\n')}\n`);
131
149
  console.error(' 修法:台账历史行禁删改(append-only 前缀不变量)——误写只能追加补偿行;确需撤销历史提交请 git revert 后重新追加。篡改属审计事件,保留现场联系仓库所有者。');
@@ -157,5 +157,21 @@ const append = (d, line) => fs.appendFileSync(ledPath(d), line + '\n');
157
157
  check('S12 回退注记行(n/a 指纹)→ 历史全扫过', r.code === 0, r.out);
158
158
  }
159
159
 
160
+ // ---- 豁免登记(2026-10-09 ledger-line-exempt,CI 红事故处置)----
161
+ {
162
+ // 坏行(fingerprint 非 hex + doc 不存在)→ 红;登记豁免 → 绿 + 计数可见
163
+ const d = mkrepo(row({ fingerprint: 'probe-fp', doc: 'workflow/intents/ghost.md' }));
164
+ let r = run(d);
165
+ check('豁免前:行级坏行 hard(fingerprint/doc 双违规)', r.code === 1 && r.out.includes('行级'), r.out.slice(0, 200));
166
+ fs.writeFileSync(path.join(d, '.agents', 'ledger-line-exempt.json'), JSON.stringify([{ line: 1, doc: 'workflow/intents/ghost.md', reason: '事故定性' }], null, 2));
167
+ r = run(d);
168
+ check('豁免后:exit 0 且计数可见', r.code === 0 && r.out.includes('台账豁免行 1 条'), r.out.slice(0, 200));
169
+ // 行号错位豁免不生效(line=99 无对应坏行)
170
+ fs.writeFileSync(path.join(d, '.agents', 'ledger-line-exempt.json'), JSON.stringify([{ line: 99, reason: '错位' }]));
171
+ r = run(d);
172
+ check('豁免行号不匹配:仍 hard(精确豁免)', r.code === 1, r.out.slice(0, 150));
173
+ fs.rmSync(d, { recursive: true, force: true });
174
+ }
175
+
160
176
  console.log(`\n${fail ? `❌ ${fail} failed` : '✅ all passed'}(${pass} passed)`);
161
177
  process.exit(fail ? 1 : 0);
@@ -60,6 +60,13 @@
60
60
  // 按 prev 复原跳转前文本重算 sha256 与台账全量比对,不符=hard「确认内容漂移」;状态行保分隔符换值
61
61
  // (非规范格式不误伤);台账行缺 prev 降级 warning;关单编辑顺序新约定:勾验/回填先于关单确认,
62
62
  // confirm-doc 是最后一次写入)
63
+ // + scope 子项(2026-10-09 审查 A1 干净版):受管行(ts≥policy.scopeSince,**时刻**锚,缺键 v1-v6 整体跳过
64
+ // →存量 406 行零新增告警)须带机器派生的改动面 scope{files,sha256}——写入侧 confirm-doc.machineScope
65
+ // 自动派生(被确认文档 + `git status --porcelain` 未提交改动集),**AI 无输入面**,分辨力从
66
+ // 「一句可复用的用户原话」换成「机器实测的这 N 个文件」。判据:缺 scope→warning(灰度第一档);
67
+ // scope.files 重算 sha256 不符→hard(「改了 files 没改摘要」= 同一信任边界内手改痕迹,兜住整文件
68
+ // 重写重建链后的二次篡改)。**边界**:scope 证「放行时盘面上哪些文件在动」,不证「这些改动语义正确」
69
+ // ——后者由 fingerprint(文档内容绑定)+ commit diff(事后对质)承担,不越界宣称
63
70
  // + 并录批次审计子检查(2026-09-28 batch-ledger-audit 改判据;**读调用事实,不猜时间戳模式**):
64
71
  // 判据 = 台账行按 batch 分组、组内 of>1(delegated 行) → warning「确认并录」;batch/seq/of 由
65
72
  // confirm-doc 写入时记录(那才是"本次调用落几份"确定已知的时刻)。旧判据「同 quote + 相邻 ts<2s」
@@ -111,7 +118,7 @@ import { spawnSync } from 'node:child_process';
111
118
  import { createHash } from 'node:crypto';
112
119
  import { loadEnums } from './workflow-enums.mjs';
113
120
  import { runCheck16 } from './check-metric-claims.mjs';
114
- import { auditEnabled, loadKitPolicy, hasFreshVerifyLine } from './policy.mjs';
121
+ import { auditEnabled, loadKitPolicy, hasFreshVerifyLine, ledgerChainHash, scopeMatches } from './policy.mjs';
115
122
  import { laneOfEntry } from './stage-gates.mjs';
116
123
  // MARK_RE:incident 留痕形态的单源(第七轮复核 N3——此前 check-loop 内联复制一份同口径字面量,
117
124
  // 两处靠注释与人工同步;改为复用 stage-gates.mjs 的导出,从结构上消除漂移可能)
@@ -120,6 +127,8 @@ import { MARK_RE, approvedTraceHit } from './stage-gates.mjs';
120
127
  // 本文件只组装 ctx 并消费返回的文案数组(先例 = check-metric-claims.mjs 承载检查 16)。
121
128
  // 调用点位置即输出顺序位:须留在检查 10 与 12 之间以保持 warnings 行序(稳定输出契约),见该模块内注释。
122
129
  import { runCheckHygiene } from './check-hygiene.mjs';
130
+ // 检查 7 拆模块(2026-10-09 engine-quality-round2 A1):阶段索引同步迁 check-stage-index.mjs
131
+ import { runCheckStageIndex } from './check-stage-index.mjs';
123
132
  import { gateSeg, finishSegs, makeCollector } from './gate-seg.mjs';
124
133
 
125
134
  // CHECK_LOOP_GIT:测试注入钩子(指向不存在/不可执行路径可稳定触发 spawn 异常);未设置时与原行为逐字节一致(2026-10-05-gitout-fail-open)
@@ -592,19 +601,10 @@ export function runCheckLoop(opts = {}) {
592
601
  if (modelHits.length) warnings.push(`- [WARN 宿主耦合] 薄 Adapter 不应固定模型:\n${modelHits.join('\n')}`);
593
602
  }
594
603
 
595
- // --- 6. 阶段索引同步 [warning] ---
596
- markGate('6', '阶段索引同步');
597
- for (const cmd of ['plan', 'design', 'build', 'test', 'deploy', 'maintain', 'review']) {
598
- for (const doc of ['AGENTS.md', '.agents/commands/new-task.md']) {
599
- // existsSync 守卫(2026-10-06-check19-entry-enoent 同类):两文件可合法缺失(下方 text 判空本就
600
- // 预期缺失态),fail-loud fs 读下按空跳过、不再响亮出账
601
- const docPath = path.join(ROOT, doc);
602
- const text = fs.existsSync(docPath) ? linesOf(docPath) : null;
603
- if (text && !text.some((l) => l.includes(`.agents/commands/${cmd}.md`))) {
604
- warnings.push(`- [WARN 阶段索引漂移] ${doc} 缺 ${cmd} 指令索引(两处阶段表须同步维护)`);
605
- }
606
- }
607
- }
604
+ // --- 6. 阶段索引同步 [warning](2026-10-09 engine-quality-round2 A1:实现迁 check-stage-index.mjs——
605
+ // 先例 check-hygiene / check-metric-claims;段 id 沿 sh 版 '6',清单编号 7 与文案逐字不动;
606
+ // 调用点位置即输出顺序契约)---
607
+ warnings.push(...runCheckStageIndex({ root: ROOT, linesOf, gateStats }));
608
608
 
609
609
  // --- 共享:文件「首次加入 git」的日期索引(2026-09-28 check8-git-anchor)---
610
610
  // 用途:检查 8 的生效日锚从**可手填的文件名前缀**(项目命名规范本身即 `YYYY-MM-DD-<主题>`,作者
@@ -618,18 +618,13 @@ export function runCheckLoop(opts = {}) {
618
618
  // 非 git / 命令失败 → null,调用方按既有「非 git 跳过」语义退化(不 fail-loud)。
619
619
  // 注:检查 10 需要的是「文件→加入 commit」+「commit→文件清单」两份映射(判断同提交是否触及迁移 SQL),
620
620
  // 与本索引「文件→日期」不同构,故**不合并**——避免把两处判据耦死。
621
- const addedDates = (() => {
622
- if (gitOut(['rev-parse', '--git-dir']) === null) return null;
623
- const log = gitOut(['log', '--diff-filter=A', '--format=@%aI', '--name-only']);
624
- if (log === null) return null;
625
- const m = new Map();
626
- let cur = null;
627
- for (const line of log.split('\n')) {
628
- if (line.startsWith('@')) { cur = line.slice(1); continue; }
629
- if (line.trim() && cur && !m.has(line.trim())) m.set(line.trim(), cur); // 新→旧序首遇 = 最早
630
- }
631
- return m;
632
- })();
621
+ // addedDates(2026-10-09 engine-quality-round3 W1):全史解析走持久缓存(HEAD 键)——
622
+ // 同 HEAD 多入口复跑(verify/edit-face-check/pre-commit/CI)不再重复全算;git-dir 探测保留在前。
623
+ const addedDates = gitOut(['rev-parse', '--git-dir']) === null ? null : addedDatesWithCache({
624
+ head: gitOut(['rev-parse', 'HEAD']),
625
+ gitLog: () => gitOut(['log', '--diff-filter=A', '--format=@%aI', '--name-only']),
626
+ cachePath: path.join(ROOT, '.agents', 'cache', 'added-dates.json'),
627
+ });
633
628
  // 某文档「首次加入日期」(YYYY-MM-DD);不可判定(非 git / 查不到)返回 ''
634
629
  const addedDateOf = (absDoc) => {
635
630
  if (!addedDates) return '';
@@ -983,10 +978,72 @@ export function runCheckLoop(opts = {}) {
983
978
  const EFFECTIVE = kitPolicy.confirmDocsEffective;
984
979
  const ledgerPath = path.join(ROOT, '.agents', 'confirmations.jsonl');
985
980
  const ledger = [];
981
+ const ledgerBadLines = []; // 坏行(JSON.parse 失败)的物理行号(复核 P2-1:报文行号须对齐物理行)
986
982
  if (fs.existsSync(ledgerPath)) {
987
- for (const line of linesOf(ledgerPath) || []) {
983
+ for (const [lineNo, line] of (linesOf(ledgerPath) || []).entries()) {
988
984
  if (!line.trim()) continue;
989
- try { ledger.push(JSON.parse(line)); } catch { /* 坏行跳过 */ }
985
+ try { ledger.push(JSON.parse(line)); } catch { ledgerBadLines.push(lineNo + 1); }
986
+ }
987
+ }
988
+ // 台账哈希链校验(2026-10-09 engine-quality-round2 D;写入侧=confirm-doc appendLedger,哈希单源
989
+ // policy.ledgerChainHash——判定零复刻)。判据:连续带 hash 行段内,① prevHash 须等于前一带 hash
990
+ // 行的 hash(无 hash 行后链段重启 '',与写入侧读末行口径一致);② hash 须可重算一致。
991
+ // **坏行口径(复核 P2-1 勘误,此前注释误称「与写入侧一致」)**:坏行被本解析整体剔除、不重置游标
992
+ // ——坏行后写入的链行(写入侧读末行遇坏行 prevHash='')验链必失配出账。这是**预期 fail-loud**
993
+ // (坏行本身即台账完整性事故,应人工查验),非口径一致场景。
994
+ // 断裂 = hard「台账链断裂」(只报首断**物理行号** + 影响行计数,不做逐行刷屏)——就地改/删中间行
995
+ // 必留痕迹;边界:整文件重写可重建链,不可机器防,兜底 = 台账自身 git 历史(check-ledger-invariant)。
996
+ // 历史行(v1.5.0 前,无 hash 字段)零回填零告警——向后兼容。
997
+ {
998
+ let prevHash = '';
999
+ let brokenAt = 0;
1000
+ let brokenCount = 0;
1001
+ ledger.forEach((row, idx) => {
1002
+ if (!row || typeof row.hash !== 'string') { prevHash = ''; return; } // 存量行/无链字段 → 链段重启
1003
+ const { hash, prevHash: p, ...rest } = row;
1004
+ const ok = (p ?? '') === prevHash && ledgerChainHash(rest, p ?? '') === hash;
1005
+ if (!ok) {
1006
+ if (!brokenAt) brokenAt = idx + 1;
1007
+ brokenCount++;
1008
+ }
1009
+ prevHash = hash;
1010
+ });
1011
+ if (brokenAt) {
1012
+ const physical = brokenAt + ledgerBadLines.filter((n) => n <= brokenAt).length; // 物理行号(复核 P2-1:剔除坏行会令数组下标漂移)
1013
+ blockers.push(`- [台账链断裂] confirmations.jsonl 自第 ${physical} 行(物理)起哈希链校验失败(共 ${brokenCount} 行不一致——append-only 台账出现就地改/删痕迹;prevHash 接续或内容重算不符。整文件重写不可机器防,对质走台账自身 git 历史)`);
1014
+ }
1015
+ }
1016
+ // scope 子项(2026-10-09 审查 A1 干净版):受管台账行须带**机器派生**的改动面 scope{files,sha256}。
1017
+ // 背景:委托代录的授权凭据原是 `--delegated` 的用户原话——自由文本机器不可校验、AI 可零成本复用
1018
+ // (本仓实测「可以」133 次、「确认」60 次,406 行里仅 6 行 tty),整条授权链源头不可核对。
1019
+ // 干净版把分辨力从「一句说过的话」换成「机器实测的这 N 个文件」:写入侧 confirm-doc.machineScope
1020
+ // 自动派生(被确认文档 + `git status --porcelain` 未提交改动集),AI 无输入面。
1021
+ // 判定(scopeSince 生效**时刻**锚,缺键 v1-v6 整体跳过——存量行零新增告警、不追溯):
1022
+ // ① 缺 scope → warning(灰度第一档:新机制先观察,升 hard 走后续 policyVersion 演进)
1023
+ // ② scope 存在但按其 files 重算 sha256 不符 → hard-block(「改了 files 没改摘要」= 同一信任
1024
+ // 边界内的手改痕迹;哈希链本已覆盖就地改,本项兜住「整文件重写重建链」后二次篡改的场景)
1025
+ // 注:scope 证明的是「放行时盘面上哪些文件在动」,**不是**「这些改动语义正确」——后者由 fingerprint
1026
+ // (文档内容绑定)+ commit diff(事后对质)承担。不越界宣称(沿本检查既有口径纪律)。
1027
+ {
1028
+ const since = loadKitPolicy(ROOT).scopeSince;
1029
+ if (typeof since === 'string' && Number.isFinite(Date.parse(since))) {
1030
+ const sinceT = Date.parse(since);
1031
+ const missing = [];
1032
+ const mismatched = [];
1033
+ for (const row of ledger) {
1034
+ if (!row || typeof row.ts !== 'string') continue;
1035
+ const t = Date.parse(row.ts);
1036
+ if (!Number.isFinite(t) || t < sinceT) continue; // 存量豁免(不追溯)
1037
+ const name = row.doc || '(未知 doc)';
1038
+ if (!row.scope) { missing.push(name); continue; }
1039
+ if (!scopeMatches(row)) mismatched.push(name);
1040
+ }
1041
+ if (missing.length) {
1042
+ warnings.push(`- [WARN 缺机器改动面] ${missing.length} 条受管台账行无 scope 字段(${missing.slice(0, 3).join('、')}${missing.length > 3 ? ' …' : ''})——confirm-doc 落账自动派生 scope;此形态多见于手工/旧脚本写的行,advisory 不阻断`);
1043
+ }
1044
+ if (mismatched.length) {
1045
+ blockers.push(`- [台账 scope 失配] ${mismatched.length} 条受管台账行的 scope.sha256 与其 files 重算不符(${mismatched.slice(0, 3).join('、')}${mismatched.length > 3 ? ' …' : ''})——「改了 files 没同步摘要」,同一信任边界内的手改痕迹;对质走台账 git 历史`);
1046
+ }
990
1047
  }
991
1048
  }
992
1049
  // incidents 侧覆盖(2026-09-27 gate-coverage):fixed/closed 为已确认态(open = 起草态不加门——
@@ -1437,6 +1494,43 @@ export function fmStatus(text) {
1437
1494
  // lines 已 CRLF 归一(split(/\r?\n/)),frontmatter 区内首个「状态:」行保分隔符换值为 prev(`状态: done`
1438
1495
  // 双空格 → `状态: approved`——与前向按原行字面计算对称,非规范分隔符不误伤;行尾空白等更奇异格式仍会
1439
1496
  // 失配,触发前提本身已违反「确认落态唯一入口」约定,接受),全文剔「确认指纹:」行后 join('\n') 再 sha256。
1497
+ // parseAddedDatesLog:git log(--diff-filter=A --format=@%aI --name-only)文本 → Map(rel → 加入 ISO 时间)。
1498
+ // 新→旧序首遇 = 最新一次加入(复核 P2-1 勘误:旧注释「最早」系误述——行为原样零复刻,检查 8 isNew 取最新加入日期更保守)。
1499
+ export function parseAddedDatesLog(logText) {
1500
+ const m = new Map();
1501
+ let cur = null;
1502
+ for (const line of logText.split('\n')) {
1503
+ if (line.startsWith('@')) { cur = line.slice(1); continue; }
1504
+ if (line.trim() && cur && !m.has(line.trim())) m.set(line.trim(), cur);
1505
+ }
1506
+ return m;
1507
+ }
1508
+
1509
+ // addedDatesWithCache:addedDates 的持久缓存层(2026-10-09 engine-quality-round3 W1)。
1510
+ // 键 = HEAD sha(同 commit 祖先不可变 → log 幂等;rebase 必改 HEAD 必重算;复核 P2-4:shallow→unshallow 同 HEAD 可见性增长有陈旧窗口——后果退化为旧行为 addedDateOf 返回 "",HEAD 变化自愈);
1511
+ // 缓存位于 .agents/cache/(gitignored 运行态);读/写任一失败 fail-open 回退全算(门禁不失效);
1512
+ // head 缺省(detached --rev worktree 等场景)→ 不读写缓存直接全算(语义不变)。
1513
+ export function addedDatesWithCache({ head, gitLog, cachePath }) {
1514
+ if (head) {
1515
+ try {
1516
+ const c = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
1517
+ if (c && c.head === head && c.map && Object.keys(c.map).length) return new Map(Object.entries(c.map));
1518
+ } catch { /* 缓存缺失/损坏 → 全算 */ }
1519
+ }
1520
+ const log = gitLog();
1521
+ if (log === null) return null;
1522
+ const m = parseAddedDatesLog(log);
1523
+ if (head) {
1524
+ try {
1525
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
1526
+ const tmp = cachePath + '.tmp';
1527
+ fs.writeFileSync(tmp, JSON.stringify({ head, map: Object.fromEntries(m), savedAt: new Date().toISOString() }) + '\n');
1528
+ fs.renameSync(tmp, cachePath);
1529
+ } catch { /* 写失败 fail-open:本轮已用全算结果,缓存留待下次 */ }
1530
+ }
1531
+ return m;
1532
+ }
1533
+
1440
1534
  export function bindingSha256(lines, prevStatus) {
1441
1535
  const isDelim = (l) => /^---\s*$/.test(l);
1442
1536
  const out = [];
@@ -13,6 +13,7 @@ import { fileURLToPath } from 'node:url';
13
13
  import { computeFingerprint } from './confirm-doc.mjs';
14
14
  import { POLICIES, loadKitPolicy } from './policy.mjs';
15
15
  import { entryConfirmed, laneOfEntry, laneOfDoc } from './stage-gates.mjs';
16
+ import { parseAddedDatesLog, addedDatesWithCache } from './check-loop.mjs';
16
17
 
17
18
  const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
18
19
  const CHECK_LOOP = path.join(SCRIPT_DIR, 'check-loop.mjs');
@@ -2902,5 +2903,125 @@ w(T, 'workflow/intents/2026-09-12-deep.md', INTENT('deep', '状态: draft\n级
2902
2903
  rmfix(T);
2903
2904
  }
2904
2905
 
2906
+ // ---- 场景 21:台账哈希链(2026-10-09 engine-quality-round2 D)——篡改必抓 / 存量零告警 ----
2907
+ {
2908
+ // 21a:连续带 hash 行中间被篡改(改 quote 不动链字段)→ hard「台账链断裂」
2909
+ const T = mkfix();
2910
+ const { ledgerChainHash } = await import('./policy.mjs');
2911
+ const chainRow = (prevHash, fields) => {
2912
+ const row = { ...fields, prevHash };
2913
+ return { ...row, hash: ledgerChainHash(fields, prevHash) };
2914
+ };
2915
+ const row1 = chainRow('', { ts: '2026-10-09T01:00:00Z', doc: 'workflow/intents/2026-10-09-a.md', stage: 'approved', fingerprint: 'a'.repeat(64), prev: 'draft', source: 'chat-delegated', quote: '真原话一' });
2916
+ const row2 = chainRow(row1.hash, { ts: '2026-10-09T02:00:00Z', doc: 'workflow/intents/2026-10-09-a.md', stage: 'done', fingerprint: 'b'.repeat(64), prev: 'approved', source: 'chat-delegated', quote: '真原话二' });
2917
+ // 篡改第一行 quote(链字段不动 → hash 失配)
2918
+ const tampered = { ...row1, quote: '篡改后的假原话' };
2919
+ writeLedger(T, [tampered, row2]);
2920
+ let r = run(T);
2921
+ let out = outOf(r);
2922
+ check('场景 21a 链断裂:中间行篡改 → hard 台账链断裂(指首断行号)',
2923
+ r.status !== 0 && out.includes('台账链断裂') && /第 1 行/.test(out), `exit=${r.status}\n${out.slice(-400)}`);
2924
+ // 反证还原:篡改行恢复原 quote → 链复通(断言非恒绿)
2925
+ writeLedger(T, [row1, row2]);
2926
+ r = run(T);
2927
+ check('场景 21b 反证还原:篡改修复 → 链校验通过', !outOf(r).includes('台账链断裂'), outOf(r).slice(-300));
2928
+ rmfix(T);
2929
+ }
2930
+ {
2931
+ // 21c:全无 hash 的存量台账(v1.5.0 前形态)→ 零链告警(向后兼容)
2932
+ const T = mkfix();
2933
+ writeLedger(T, [
2934
+ { ts: '2026-09-20T01:00:00Z', doc: 'workflow/intents/2026-09-20-legacy.md', stage: 'approved', fingerprint: 'c'.repeat(64), prev: 'draft' },
2935
+ { ts: '2026-09-20T02:00:00Z', doc: 'workflow/intents/2026-09-20-legacy.md', stage: 'done', fingerprint: 'd'.repeat(64), prev: 'approved' },
2936
+ ]);
2937
+ const r = run(T);
2938
+ check('场景 21c 存量无 hash 行:零链告警(不追溯)', !outOf(r).includes('台账链断裂'), outOf(r).slice(-300));
2939
+ rmfix(T);
2940
+ }
2941
+
2942
+
2943
+ // ---- 场景 23:台账 scope 子项(2026-10-09 审查 A1 干净版)——缺 scope 出账 / 摘要失配 hard / 存量与旧 policy 零告警 ----
2944
+ // 背景:委托代录的授权凭据原是「用户原话」(自由文本、AI 可零成本复用)。干净版改记机器派生的
2945
+ // scope{files,sha256}(AI 无输入面),本子项校验受管行带该字段且摘要自洽。
2946
+ {
2947
+ const AFTER = '2026-10-09T12:00:00Z'; // ≥ policy v7 scopeSince(生效时刻锚)
2948
+ const BEFORE = '2026-10-01T00:00:00Z'; // 存量(生效前):豁免、不追溯
2949
+ const { scopeDigest } = await import('./policy.mjs');
2950
+
2951
+ // 23a:受管行缺 scope → advisory 出账(灰度第一档,不阻断)
2952
+ const Ta = mkfix();
2953
+ w(Ta, '.agents/kit.json', '{"policyVersion":7}\n');
2954
+ writeLedger(Ta, [{ ts: AFTER, doc: 'workflow/intents/2026-10-09-a.md', stage: 'approved', fingerprint: 'a'.repeat(64), prev: 'draft', source: 'chat-delegated', quote: '可以' }]);
2955
+ const ra = run(Ta);
2956
+ check('场景 23a 缺机器改动面:受管行无 scope → WARN 出账且不阻断',
2957
+ ra.status === 0 && outOf(ra).includes('缺机器改动面'), `exit=${ra.status}\n${outOf(ra).slice(-400)}`);
2958
+ rmfix(Ta);
2959
+
2960
+ // 23b:scope 存在但摘要与 files 重算不符(「改了 files 没改摘要」)→ hard 阻断
2961
+ const Tb = mkfix();
2962
+ w(Tb, '.agents/kit.json', '{"policyVersion":7}\n');
2963
+ const good = scopeDigest(['workflow/intents/2026-10-09-b.md', 'src/x.mjs']);
2964
+ writeLedger(Tb, [{ ts: AFTER, doc: 'workflow/intents/2026-10-09-b.md', stage: 'approved', fingerprint: 'b'.repeat(64), prev: 'draft', source: 'chat-delegated', quote: '可以', scope: { ...good, files: [...good.files, 'src/悄悄加的.mjs'] } }]);
2965
+ const rb = run(Tb);
2966
+ check('场景 23b scope 摘要失配:files 被改未同步 sha256 → hard 阻断',
2967
+ rb.status !== 0 && outOf(rb).includes('台账 scope 失配'), `exit=${rb.status}\n${outOf(rb).slice(-400)}`);
2968
+ rmfix(Tb);
2969
+
2970
+ // 23c:scope 完整自洽 → 零 scope 告警(断言非恒红)
2971
+ const Tc = mkfix();
2972
+ w(Tc, '.agents/kit.json', '{"policyVersion":7}\n');
2973
+ writeLedger(Tc, [{ ts: AFTER, doc: 'workflow/intents/2026-10-09-c.md', stage: 'approved', fingerprint: 'c'.repeat(64), prev: 'draft', source: 'chat-delegated', quote: '可以', scope: scopeDigest(['workflow/intents/2026-10-09-c.md']) }]);
2974
+ const rc = run(Tc);
2975
+ check('场景 23c scope 自洽:零 scope 告警(断言非恒红)',
2976
+ !outOf(rc).includes('缺机器改动面') && !outOf(rc).includes('台账 scope 失配'), outOf(rc).slice(-300));
2977
+ rmfix(Tc);
2978
+
2979
+ // 23d:存量行(ts < scopeSince)缺 scope → 零告警(生效日锚不追溯,不产不可消除噪声)
2980
+ const Td = mkfix();
2981
+ w(Td, '.agents/kit.json', '{"policyVersion":7}\n');
2982
+ writeLedger(Td, [{ ts: BEFORE, doc: 'workflow/intents/2026-10-01-d.md', stage: 'approved', fingerprint: 'd'.repeat(64), prev: 'draft', source: 'chat-delegated', quote: '可以' }]);
2983
+ const rd = run(Td);
2984
+ check('场景 23d 存量豁免:ts < scopeSince 的行缺 scope → 零告警(不追溯)',
2985
+ !outOf(rd).includes('缺机器改动面'), outOf(rd).slice(-300));
2986
+ rmfix(Td);
2987
+
2988
+ // 23e:旧 policy(v6 无 scopeSince 键)→ 子项整体跳过,存量 406 行零新增告警
2989
+ const Te = mkfix();
2990
+ w(Te, '.agents/kit.json', '{"policyVersion":6}\n');
2991
+ writeLedger(Te, [{ ts: AFTER, doc: 'workflow/intents/2026-10-09-e.md', stage: 'approved', fingerprint: 'e'.repeat(64), prev: 'draft', source: 'chat-delegated', quote: '可以' }]);
2992
+ const re = run(Te);
2993
+ check('场景 23e 旧 policy 跳过:v6 无 scopeSince 键 → 子项整体跳过(装户零新增告警)',
2994
+ !outOf(re).includes('缺机器改动面') && !outOf(re).includes('台账 scope 失配'), outOf(re).slice(-300));
2995
+ rmfix(Te);
2996
+ }
2997
+
2998
+ // ---- 场景 22:addedDates 持久缓存(2026-10-09 engine-quality-round3 W1)----
2999
+ {
3000
+ const log = '@2026-10-02T10:00:00+08:00\nworkflow/intents/a.md\nworkflow/plans/a.md\n@2026-10-01T09:00:00+08:00\nworkflow/intents/a.md\n';
3001
+ const m = parseAddedDatesLog(log);
3002
+ check('场景 22a 提取:新→旧序首遇 = 最新一次加入(原 IIFE 语义零复刻;注释「最早」为历史误述)', m.get('workflow/intents/a.md') === '2026-10-02T10:00:00+08:00' && m.get('workflow/plans/a.md') === '2026-10-02T10:00:00+08:00', JSON.stringify([...m]));
3003
+ }
3004
+ {
3005
+ const cacheP = path.join(os.tmpdir(), 'adc-' + Date.now() + '.json');
3006
+ let gitLogCalls = 0;
3007
+ const gitLog = () => { gitLogCalls++; return '@2026-10-01T09:00:00+08:00\nworkflow/intents/a.md\n'; };
3008
+ const head = 'head-sha-1';
3009
+ const m1 = addedDatesWithCache({ head, gitLog, cachePath: cacheP });
3010
+ const m2 = addedDatesWithCache({ head, gitLog, cachePath: cacheP });
3011
+ check('场景 22b 缓存命中:同 HEAD 二次调用 gitLog 仅 1 次且结果一致',
3012
+ gitLogCalls === 1 && m1.get('workflow/intents/a.md') === m2.get('workflow/intents/a.md'), 'calls=' + gitLogCalls);
3013
+ const m3 = addedDatesWithCache({ head: 'head-sha-2', gitLog, cachePath: cacheP });
3014
+ check('场景 22c HEAD 变更:重算(gitLog 计 2 次)', gitLogCalls === 2 && m3.get('workflow/intents/a.md') === '2026-10-01T09:00:00+08:00', 'calls=' + gitLogCalls);
3015
+ fs.writeFileSync(cacheP, '{broken json');
3016
+ const before = gitLogCalls;
3017
+ const m4 = addedDatesWithCache({ head: 'head-sha-2', gitLog, cachePath: cacheP });
3018
+ check('场景 22d 缓存损坏:fail-open 全算(gitLog 再调 1 次)且结果正确',
3019
+ gitLogCalls === before + 1 && m4.get('workflow/intents/a.md') === '2026-10-01T09:00:00+08:00', 'calls=' + gitLogCalls);
3020
+ const before2 = gitLogCalls;
3021
+ addedDatesWithCache({ head: null, gitLog, cachePath: cacheP });
3022
+ check('场景 22e 无 head:不读缓存直接全算(gitLog 再调 1 次)', gitLogCalls === before2 + 1, 'calls=' + gitLogCalls);
3023
+ fs.rmSync(cacheP, { force: true });
3024
+ }
3025
+
2905
3026
  console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
2906
3027
  process.exit(fail ? 1 : 0);
@@ -0,0 +1,40 @@
1
+ // check-stage-index.mjs — check-loop 检查 7 模块(阶段索引同步,2026-10-09 engine-quality-round2 A1)
2
+ // 拆分先例 = check-hygiene.mjs(检查 2/9/11/12/13)/ check-metric-claims.mjs(检查 16):
3
+ // ctx 传入既有 helper(linesOf——读异常响亮出账 + 按空文档降级的语义承载者,零复刻),
4
+ // 模块只返回 warnings 数组,不打印、不 exit、不改 ctx。
5
+ // 输出顺序契约:调用点位置即 warnings 插入序(check-loop 原位调用,段 id 沿 sh 版 '6'——清单编号 7
6
+ // 不动,gate-checklist 按 id 消费面不受影响)。
7
+ // 判据(沿 sh 版逐条):AGENTS.md 与 .agents/commands/new-task.md 须各含全部阶段指令路径串
8
+ // `.agents/commands/<cmd>.md`(双向索引);缺任一 = WARN。两文件可合法缺失(text 判空预期缺失态)。
9
+ // 测试:node templates/_agents/scripts/check-stage-index.test.mjs
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { gateSeg, finishSegs, makeCollector, appendSegs } from './gate-seg.mjs';
13
+
14
+ const STAGE_CMDS = ['plan', 'design', 'build', 'test', 'deploy', 'maintain', 'review'];
15
+ const FACE_DOCS = ['AGENTS.md', '.agents/commands/new-task.md'];
16
+
17
+ export function runCheckStageIndex(ctx) {
18
+ const { root: ROOT, linesOf } = ctx;
19
+ const warnings = [];
20
+ // 插桩(selfmeasure 先例):局部收集器 + appendSegs 并入全局——模块产出局部数组,直接共用全局
21
+ // 游标会错账(check-hygiene.mjs:36-39 实测教训)
22
+ const localStats = makeCollector(warnings, []);
23
+ const markGate = (id, label) => { if (ctx.gateStats) gateSeg(localStats, id, label); };
24
+
25
+ markGate('6', '阶段索引同步');
26
+ for (const cmd of STAGE_CMDS) {
27
+ for (const doc of FACE_DOCS) {
28
+ // existsSync 守卫(2026-10-06-check19-entry-enoent 同类):两文件可合法缺失(下方 text 判空本就
29
+ // 预期缺失态),fail-loud fs 读下按空跳过、不再响亮出账
30
+ const docPath = path.join(ROOT, doc);
31
+ const text = fs.existsSync(docPath) ? linesOf(docPath) : null;
32
+ if (text && !text.some((l) => l.includes(`.agents/commands/${cmd}.md`))) {
33
+ warnings.push(`- [WARN 阶段索引漂移] ${doc} 缺 ${cmd} 指令索引(两处阶段表须同步维护)`);
34
+ }
35
+ }
36
+ }
37
+
38
+ if (ctx.gateStats) appendSegs(ctx.gateStats, finishSegs(localStats));
39
+ return warnings;
40
+ }
@@ -0,0 +1,77 @@
1
+ // check-stage-index.test.mjs — 检查 7 模块直测(2026-10-09 engine-quality-round2 A1)
2
+ // 四面:全绿 / AGENTS 侧缺索引 / new-task 文件缺失合法 / gate-seg 段归属(局部收集器并入)
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { runCheckStageIndex } from './check-stage-index.mjs';
7
+ import { makeCollector, finishSegs } from './gate-seg.mjs';
8
+
9
+ let pass = 0, fail = 0;
10
+ const check = (name, cond, detail = '') => {
11
+ if (cond) { pass++; console.log(`PASS ${name}`); }
12
+ else { fail++; console.log(`FAIL ${name}${detail ? `——${detail}` : ''}`); }
13
+ };
14
+
15
+ // ctx 构造:linesOf 提供读异常→null 语义(与 check-loop 内部实现同语义;「判定零复刻」指模块不重写
16
+ // 判据,测试 fixture 提供测试版 helper 是 check-hygiene.test 先例)
17
+ function makeCtx(root, overrides = {}) {
18
+ const fileLinesCache = new Map();
19
+ const linesOf = (file) => {
20
+ if (!fileLinesCache.has(file)) {
21
+ let lines = null;
22
+ try { lines = fs.readFileSync(file, 'utf8').split(/\r?\n/); } catch { lines = null; }
23
+ fileLinesCache.set(file, lines);
24
+ }
25
+ return fileLinesCache.get(file);
26
+ };
27
+ return { root, linesOf, ...overrides };
28
+ }
29
+ const ROUTES = '.agents/commands/plan.md → .agents/commands/design.md → .agents/commands/build.md → .agents/commands/test.md → .agents/commands/deploy.md → .agents/commands/maintain.md';
30
+ const mkRoot = (agentsBody, newTaskBody, { noAgents, noNewTask } = {}) => {
31
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'csi-'));
32
+ fs.mkdirSync(path.join(root, '.agents', 'commands'), { recursive: true });
33
+ if (!noAgents) fs.writeFileSync(path.join(root, 'AGENTS.md'), agentsBody);
34
+ if (!noNewTask) fs.writeFileSync(path.join(root, '.agents', 'commands', 'new-task.md'), newTaskBody);
35
+ return root;
36
+ };
37
+ const FULL = `# AGENTS\n\n${ROUTES}\n\n**横切**:.agents/commands/review.md\n`;
38
+ const FULL_NT = `# new-task\n\n| Plan | ${ROUTES} | Review(.agents/commands/review.md) |\n`;
39
+
40
+ {
41
+ // ① 双面全索引 → 零告警
42
+ const root = mkRoot(FULL, FULL_NT);
43
+ const r = runCheckStageIndex(makeCtx(root));
44
+ check('① 双面全索引 → 零告警', r.length === 0, JSON.stringify(r));
45
+ fs.rmSync(root, { recursive: true, force: true });
46
+ }
47
+ {
48
+ // ② AGENTS 侧缺 plan → 仅 AGENTS.md 侧告警(new-task 索引齐全不误报)
49
+ const root = mkRoot(FULL.replace('.agents/commands/plan.md → ', ''), FULL_NT);
50
+ const r = runCheckStageIndex(makeCtx(root));
51
+ check('② AGENTS 侧缺 plan → WARN 点名 AGENTS.md',
52
+ r.some((w) => w.includes('AGENTS.md 缺 plan 指令索引')), JSON.stringify(r));
53
+ check('② new-task 侧不告警(其索引齐全)', !r.some((w) => w.includes('new-task.md 缺')), JSON.stringify(r));
54
+ fs.rmSync(root, { recursive: true, force: true });
55
+ }
56
+ {
57
+ // ③ new-task 文件不存在 = 合法缺失态(existsSync 守卫)→ 零告警
58
+ const root = mkRoot(FULL, FULL_NT, { noNewTask: true });
59
+ const r = runCheckStageIndex(makeCtx(root));
60
+ check('③ new-task 文件缺失 → 合法缺失零告警', r.length === 0, JSON.stringify(r));
61
+ fs.rmSync(root, { recursive: true, force: true });
62
+ }
63
+ {
64
+ // ④ gate-seg 段归属:ctx.gateStats 在场时段 '6' 入账、warns 与实际 WARN 数一致(selfmeasure 接线
65
+ // 教训:模块须用局部收集器 + appendSegs,直接共用全局游标会错账)
66
+ const root = mkRoot(FULL.replace('.agents/commands/plan.md → ', ''), FULL_NT);
67
+ const stats = makeCollector([], []);
68
+ const r = runCheckStageIndex(makeCtx(root, { gateStats: stats }));
69
+ const seg6 = finishSegs(stats).filter((s) => s.id === '6');
70
+ check('④ gate-seg:段 6 入账一次且 warns 与实际 WARN 数一致',
71
+ seg6.length === 1 && seg6[0].warns === r.length && r.length === 1,
72
+ JSON.stringify({ seg6, ret: r }));
73
+ fs.rmSync(root, { recursive: true, force: true });
74
+ }
75
+
76
+ console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
77
+ process.exit(fail ? 1 : 0);