agentic-flow-kit 0.9.1 → 1.0.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 (68) hide show
  1. package/README.md +93 -93
  2. package/modules/hosts/claude/commands/wf-build.md +3 -3
  3. package/modules/hosts/claude/commands/wf-design.md +3 -3
  4. package/modules/hosts/claude/commands/wf-new-task.md +14 -2
  5. package/modules/hosts/claude/commands/wf-pipeline-run.md +35 -0
  6. package/modules/hosts/claude/commands/wf-plan.md +3 -3
  7. package/modules/hosts/claude/commands/wf-sync-hosts.md +1 -1
  8. package/modules/hosts/claude/commands/wf-test.md +1 -1
  9. package/modules/hosts/codex/commands/wf-build.md +3 -3
  10. package/modules/hosts/codex/commands/wf-design.md +3 -3
  11. package/modules/hosts/codex/commands/wf-new-task.md +14 -2
  12. package/modules/hosts/codex/commands/wf-pipeline-run.md +35 -0
  13. package/modules/hosts/codex/commands/wf-plan.md +3 -3
  14. package/modules/hosts/codex/commands/wf-sync-hosts.md +1 -1
  15. package/modules/hosts/codex/commands/wf-test.md +1 -1
  16. package/modules/hosts/cursor/commands/wf-build.md +3 -3
  17. package/modules/hosts/cursor/commands/wf-design.md +3 -3
  18. package/modules/hosts/cursor/commands/wf-new-task.md +14 -2
  19. package/modules/hosts/cursor/commands/wf-pipeline-run.md +35 -0
  20. package/modules/hosts/cursor/commands/wf-plan.md +3 -3
  21. package/modules/hosts/cursor/commands/wf-sync-hosts.md +1 -1
  22. package/modules/hosts/cursor/commands/wf-test.md +1 -1
  23. package/modules/hosts/opencode/commands/wf-build.md +3 -3
  24. package/modules/hosts/opencode/commands/wf-design.md +3 -3
  25. package/modules/hosts/opencode/commands/wf-new-task.md +14 -2
  26. package/modules/hosts/opencode/commands/wf-pipeline-run.md +35 -0
  27. package/modules/hosts/opencode/commands/wf-plan.md +3 -3
  28. package/modules/hosts/opencode/commands/wf-sync-hosts.md +1 -1
  29. package/modules/hosts/opencode/commands/wf-test.md +1 -1
  30. package/modules/hosts/trae/commands/wf-build.md +3 -3
  31. package/modules/hosts/trae/commands/wf-design.md +3 -3
  32. package/modules/hosts/trae/commands/wf-new-task.md +14 -2
  33. package/modules/hosts/trae/commands/wf-pipeline-run.md +36 -0
  34. package/modules/hosts/trae/commands/wf-plan.md +3 -3
  35. package/modules/hosts/trae/commands/wf-sync-hosts.md +1 -1
  36. package/modules/hosts/trae/commands/wf-test.md +1 -1
  37. package/modules/hosts/trae/rules/project-workflow.md +1 -1
  38. package/package.json +45 -45
  39. package/templates/_agents/commands/build.md +2 -2
  40. package/templates/_agents/commands/design.md +1 -1
  41. package/templates/_agents/commands/new-task.md +1 -0
  42. package/templates/_agents/commands/pipeline-run.md +43 -0
  43. package/templates/_agents/commands/plan.md +3 -3
  44. package/templates/_agents/commands/sync-hosts.md +1 -1
  45. package/templates/_agents/scripts/check-lane-surface.mjs +20 -7
  46. package/templates/_agents/scripts/check-lane-surface.test.mjs +24 -2
  47. package/templates/_agents/scripts/check-ledger-invariant.mjs +135 -0
  48. package/templates/_agents/scripts/check-ledger-invariant.test.mjs +161 -0
  49. package/templates/_agents/scripts/check-loop.mjs +70 -18
  50. package/templates/_agents/scripts/check-loop.test.mjs +155 -8
  51. package/templates/_agents/scripts/confirm-doc.mjs +19 -7
  52. package/templates/_agents/scripts/confirm-doc.test.mjs +31 -0
  53. package/templates/_agents/scripts/fill-intent.mjs +1 -1
  54. package/templates/_agents/scripts/fill-plan.test.mjs +4 -3
  55. package/templates/_agents/scripts/gate-checklist.mjs +1 -0
  56. package/templates/_agents/scripts/gen-workflow-metrics.mjs +75 -5
  57. package/templates/_agents/scripts/gen-workflow-metrics.test.mjs +104 -2
  58. package/templates/_agents/scripts/pipeline-run.mjs +999 -0
  59. package/templates/_agents/scripts/pipeline-run.test.mjs +537 -0
  60. package/templates/_agents/scripts/policy.mjs +1 -0
  61. package/templates/_agents/scripts/pre-push.test.mjs +8 -1
  62. package/templates/_agents/scripts/solidify-task.test.mjs +5 -2
  63. package/templates/_agents/scripts/stage-gates.mjs +29 -6
  64. package/templates/_agents/scripts/trust-mode.test.mjs +110 -0
  65. package/templates/_agents/scripts-test-exempt.txt +10 -0
  66. package/templates/_githooks/pre-commit +12 -0
  67. package/templates/workflow/README.md +3 -2
  68. package/templates/workflow/plans/_TEMPLATE.md +1 -1
@@ -0,0 +1,43 @@
1
+ ---
2
+ description: pipeline-run 自动化驱动入口——单命令驱动六阶段闭环(工单协议+机器门+原话代录)
3
+ stage: entry
4
+ triggers:
5
+ - "pipeline-run"
6
+ - "跑执行器"
7
+ - "全自动跑"
8
+ approval_required: true
9
+ fallback: 主智能体亲自做 AI 槽位(工单由主智能体完成,不依赖子智能体)
10
+ next: 按 run 停机点循环推进至 done
11
+ ---
12
+
13
+ # pipeline-run · 全自动闭环执行器入口
14
+
15
+ > 确定性部分由 `pipeline-run.mjs` 状态机驱动(机器门序列:fill-* → confirm-doc → verify.mjs → git,退出码全记录);
16
+ > AI 槽位经工单协议交宿主执行;人件门到点即停、原话代录。宿主只按本协议驱动,不自行改状态。
17
+
18
+ ## 驱动协议(宿主 AI 逐条执行)
19
+
20
+ 1. **启动**:`node .agents/scripts/pipeline-run.mjs start "<需求原文>"`(可预判则加 `--hint "kind=… level=… module=…"`)。
21
+ 2. **循环读停机点**:每次命令的 stdout 末行是机器标记 `PIPELINE-STOP <type> …`,按 type 分流:
22
+ - `work-order <runId> <工单id>`——读工单全文(目标/步骤/约束/验收),**按单干活**:
23
+ - triage:判级回填 `next --triage "kind=… level=… module=… topic=<英文kebab>"`(就高不就低)
24
+ - draft:起草指定文档;implement/fix:按授权文件实现(**偏离即停**——公共接口/计划外文件/新依赖→停机向用户上报,不自行放行);review/closeout:按单产出并写入指定位
25
+ - 干完回跑 `next`(implement/fix 须 `--files "a,b"` 回填改动清单;review-only 汇报后 `--done` 收尾)
26
+ - `await-confirm <runId> <doc>`——**人件门**:向用户转述「要点」索确认;用户明确放行后
27
+ `next --delegated "<用户原话>"`(逐字原话,**永不编造、永不复用旧句**;一次一份)
28
+ - `gate-fail <runId> <stage>`——读 problems 原因,修复后原路重跑(不绕门、不加 `--no-verify`)
29
+ - `done <runId>`——向用户汇报总结(阶段条/门数/提交链),流程结束
30
+ - `aborted <runId>`——已显式放弃,汇报放弃态与留档位置
31
+ 3. **放弃**:用户明确不做时 `abort --run <id> --to superseded|cancelled --delegated "<原话>"`(approved 文档逐份走 confirm-doc --to)。
32
+ 4. **观测**(可选):`status [--run <id>] [--json]` 全量事件流水;`watch` 终端轮询视图。
33
+
34
+ ## 硬纪律(违反即协议破坏)
35
+
36
+ - 状态迁移唯一入口是脚本内部的 confirm-doc 调用——宿主**永不直改** workflow 文档 frontmatter 状态。
37
+ - `--delegated` 的原话必须是用户当次对话原话(台账 `confirmations.jsonl` 留痕可对质)。
38
+ - 修复环超限(3 次)时停机交人工,不带病推进;verify 非绿不关单。
39
+ - run 文件(.agents/cache/pipeline-runs/)是脚本亲写的机器事实——宿主只读不写。
40
+
41
+ ## 与 new-task 的关系
42
+
43
+ 本命令是**自动化驱动的并列入口**(单命令走全程);new-task.md 是手动阶段路由入口。同一套门禁与确认语义,二者只选其一驱动一个任务,不混用。
@@ -21,11 +21,11 @@ next: L1 → .agents/commands/build.md (起草 Quick-Plan) ;L2/L3 → .agents/
21
21
  1. 若 `workflow/intents/` 已有目标 intent 且状态为 approved → 按级别跳 next(L1 → `build.md`,L2/L3 → `design.md`)
22
22
  2. 否则起草新 intent:
23
23
  - **先检索同类**:`node .agents/scripts/kb-search.mjs "<关键词>" --scope workflow --type intents,specs`——命中同类先读其结论与验收,新 intent 引用(防重复立项 / 重复踩坑)
24
- - **填空工具先跑**:`node .agents/scripts/fill-intent.mjs --module <模块> --level <L0|L1|L2|L3> --topic <主题> --output workflow/intents/<date>-<主题>.md --notes "<备注>"`——输出含 frontmatter 6 字段(含 risk_level)+ 7 节正文(背景与问题/目标/非目标/约束/影响面/触达红线/验收标准)的草稿;AI 据此填实,模板与节标题机器保证不出错
24
+ - **填空工具先跑**:`node .agents/scripts/fill-intent.mjs --module <模块> --level <L0|L1|L2|L3> --topic <主题> --output workflow/intents/<date>-<主题>.md --notes "<备注>"`——输出含 frontmatter 6 字段(含 risk_level)+ 9 节(背景与问题/历史教训防复发/目标/非目标/约束/影响面/触达红线/验收标准 8 节正文 + 确认与复核)的草稿;AI 据此填实,模板与节标题机器保证不出错
25
25
  - 复制 `workflow/intents/_TEMPLATE.md` → `workflow/intents/YYYY-MM-DD-<主题>.md`
26
26
  - frontmatter 填 `模块:`(词表见 `.agents/workflow-modules.txt`,新建文档必填,check-loop 会警告)
27
- - 按 6 节填写:背景与问题 / 目标(可验证) / 非目标 / 约束 / 影响面 / 触达红线(L1 微改动可省非目标/约束两节,不硬填)
28
- - 红线勾选对照根 `AGENTS.md`「Working rules」+ 改动域目录级 `AGENTS.md`(如有);**没触及就全不勾**,不要硬填
27
+ - 按 8 节填写:背景与问题 / 历史教训防复发 / 目标(可验证) / 非目标 / 约束 / 影响面 / 触达红线 / 验收标准(L1 微改动可省非目标/约束两节,不硬填)
28
+ - 红线勾选对照根 `AGENTS.md` 红线/约束相关节(如「项目适配区」「门禁与提交」)+ 改动域目录级 `AGENTS.md`(如有);**没触及就全不勾**,不要硬填
29
29
  3. 写不出可测试判据 = 还没想清楚 → 反问澄清,不要硬填
30
30
  4. 起草后停下,输出草稿全文给用户过目,一句"可以"即确认
31
31
 
@@ -22,7 +22,7 @@ next: .agents/commands/build.md(漂移属 build 改权威源未同步导致的
22
22
 
23
23
  ## 何时跑
24
24
 
25
- - 改 `templates/_agents/commands/*.md` 正文后(8 个 commands 文件)
25
+ - 改 `templates/_agents/commands/*.md` 正文后(11 个 commands 文件)
26
26
  - 改 `templates/_agents/roles/*.md` 正文后(3 个 roles 文件)
27
27
  - 装户跑 `flow-kit doctor` 报"跨宿主薄适配正文漂移 N 对" WARN 时
28
28
  - 不跑:仅改 frontmatter;改 trae 钩子/规则;改包源其他文件
@@ -23,6 +23,8 @@
23
23
  import fs from 'node:fs';
24
24
  import path from 'node:path';
25
25
  import { spawnSync } from 'node:child_process';
26
+ import { laneOfEntry, laneOfDoc } from './stage-gates.mjs';
27
+ import { loadKitPolicy } from './policy.mjs';
26
28
 
27
29
  const argv = process.argv.slice(2);
28
30
  const argOf = (k) => {
@@ -44,6 +46,8 @@ const STAGED = stagedArg
44
46
  : gitOut(ROOT, ['diff', '--cached', '--name-only', '--diff-filter=ACMR']).split(/\r?\n/).filter(Boolean);
45
47
 
46
48
  const isExperiment = /^experiment\//.test(BRANCH);
49
+ // 泳道协议锚(单源 policy.mjs):无 kit.json 的测试夹具回落 v1(无键=双字段协议从未生效,单字段判)
50
+ const RISK_LEVEL_SINCE = loadKitPolicy(ROOT).riskLevelSince;
47
51
 
48
52
  // frontmatter 读取(CRLF 容忍;口径同 check-loop fmGet:--- 包夹、键行前缀匹配)
49
53
  const fmOf = (file) => {
@@ -88,7 +92,10 @@ const loadPatterns = () => {
88
92
  return pats;
89
93
  };
90
94
 
91
- // 活跃入口收集(incident≡intent 等价;日期键 intent=日期 / incident=发现)
95
+ // 活跃入口收集(incident≡intent 等价;日期键 intent=日期 / incident=发现)。
96
+ // 泳道单源(2026-10-02 caliber-convergence 方案 C 分歧双严):intent 走 laneOfEntry(双字段三态,
97
+ // 协议锚 policy.riskLevelSince),其余走 laneOfDoc(级别单字段)——suspect(缺失/非法/分歧)不计
98
+ // high(不作豁免依据)、计入拦截集(消息按 suspect 给「两字段对齐」指引)。
92
99
  const ACTIVE = {
93
100
  intent: new Set(['draft', 'approved']),
94
101
  incident: new Set(['open', 'fixed']),
@@ -102,7 +109,10 @@ const collectActive = () => {
102
109
  if (!f.endsWith('.md') || f === '_TEMPLATE.md') continue;
103
110
  const fm = fmOf(path.join(dirPath, f));
104
111
  if (!ACTIVE[kind].has(fm['状态'] || '')) continue;
105
- out.push({ rel: `workflow/${dir}/${f}`, base: f.replace(/\.md$/, ''), level: fm['级别'] || '', date: fm[dateKey] || '', kind });
112
+ const lane = kind === 'intent'
113
+ ? laneOfEntry(fm['级别'] || '', fm['risk_level'] || '', RISK_LEVEL_SINCE, fm['日期'] || '')
114
+ : laneOfDoc(fm['级别'] || '');
115
+ out.push({ rel: `workflow/${dir}/${f}`, base: f.replace(/\.md$/, ''), level: fm['级别'] || '', risk: fm['risk_level'] || '', lane, date: fm[dateKey] || '', kind });
106
116
  }
107
117
  }
108
118
  return out;
@@ -116,16 +126,19 @@ const patterns = loadPatterns();
116
126
  const hits = patterns.length ? STAGED.filter((p) => patterns.some((re) => re.test(p))) : [];
117
127
  if (hits.length) {
118
128
  const active = collectActive();
119
- const hasHigh = active.some((e) => e.level === 'L2' || e.level === 'L3');
129
+ const hasHigh = active.some((e) => e.lane === 'high');
120
130
  if (!hasHigh) {
121
- const low = active.filter((e) => e.level !== 'L2' && e.level !== 'L3');
131
+ const low = active.filter((e) => e.lane !== 'high'); // low + suspect 均拦(suspect 不作豁免依据)
122
132
  if (low.length) {
123
133
  const newest = [...low].sort((a, b) => (b.date || '').localeCompare(a.date || '') || b.rel.localeCompare(a.rel))[0];
134
+ const fixVerb = newest.lane === 'suspect'
135
+ ? '① 对齐该入口「级别」与「risk_level」两字段至一致的 L2/L3(fill-intent 双写口径;现两值缺失/非法/分歧)并补同名 spec(.agents/commands/design.md)'
136
+ : '① 升级该入口「级别」与「risk_level」两字段至 L2/L3 并补同名 spec(.agents/commands/design.md)';
124
137
  const text = [
125
- `[触达面判低] 暂存命中 L2 触达面,但活跃入口全为 L0/L1(含级别缺失):`,
138
+ `[触达面判低] 暂存命中 L2 触达面,但活跃入口无泳道 high(存在 low/suspect):`,
126
139
  ` 命中: ${hits.join(' ')}`,
127
- ` 关联入口: ${newest.rel}(${newest.level || '级别缺失'}${newest.date ? `,${newest.date}` : ''};活跃 L0/L1 共 ${low.length} 件)`,
128
- ` 修法(三选一): ① 升级该入口「级别/risk_level」至 L2/L3 并补同名 spec(.agents/commands/design.md)`,
140
+ ` 关联入口: ${newest.rel}(级别 ${newest.level || '缺失'}/risk_level ${newest.risk || '缺失'}→ 泳道 ${newest.lane}${newest.date ? `,${newest.date}` : ''};活跃非 high 共 ${low.length} 件)`,
141
+ ` 修法(三选一): ${fixVerb}`,
129
142
  ` ② 该入口已完成则关单(done)或显式放弃(superseded/cancelled)——不留活跃残留`,
130
143
  ` ③ 面清单误配则修 .agents/lane-surfaces.txt`,
131
144
  ].join('\n');
@@ -133,12 +133,34 @@ const FM_L0_NEW = '状态: draft\n级别: L0\n日期: 2026-10-01\n模块: ui';
133
133
  check('incident 等价:fixed L1 算活跃低级入口 → 阻断',
134
134
  r.code === 1 && r.out.includes('[触达面判低]') && r.out.includes('incidents/2026-09-30-inc.md'), r.out);
135
135
  }
136
- // --- 级别缺失:按低级计(不能自证 L2+ 即拦)---
136
+ // --- 级别缺失:suspect 拦截(方案 C:不能自证 high 即拦,消息给两字段对齐指引)---
137
137
  {
138
138
  const d = mkfix();
139
139
  base(d, { level: '', status: 'draft' });
140
140
  const r = run(d, { staged: ['templates/t.md'] });
141
- check('级别缺失的活跃入口按低级计 → 阻断且消息含「级别缺失」', r.code === 1 && r.out.includes('级别缺失'), r.out);
141
+ check('级别缺失 → 泳道 suspect 拦断且消息含两字段对齐指引',
142
+ r.code === 1 && r.out.includes('suspect') && r.out.includes('对齐'), r.out);
143
+ }
144
+ // --- 方案 C:级别与 risk_level 分歧 → suspect 拦截(两字段协议期)---
145
+ {
146
+ const d = mkfix();
147
+ w(d, '.agents/lane-surfaces.txt', '^templates/\n');
148
+ w(d, '.agents/kit.json', JSON.stringify({ policyVersion: 2 }));
149
+ w(d, 'workflow/intents/2026-10-02-divergent.md',
150
+ intent('x', '状态: approved\n级别: L2\nrisk_level: L1\n日期: 2026-10-02\n模块: pipeline'));
151
+ const r = run(d, { staged: ['templates/t.md'] });
152
+ check('分歧(L2/L1)→ suspect 拦断(不作豁免依据)',
153
+ r.code === 1 && r.out.includes('suspect') && r.out.includes('2026-10-02-divergent'), r.out);
154
+ }
155
+ // --- 方案 C:两字段一致 high → 豁免(对照)---
156
+ {
157
+ const d = mkfix();
158
+ w(d, '.agents/lane-surfaces.txt', '^templates/\n');
159
+ w(d, '.agents/kit.json', JSON.stringify({ policyVersion: 2 }));
160
+ w(d, 'workflow/intents/2026-10-02-aligned.md',
161
+ intent('x', '状态: approved\n级别: L2\nrisk_level: L2\n日期: 2026-10-02\n模块: pipeline'));
162
+ const r = run(d, { staged: ['templates/t.md'] });
163
+ check('两字段一致 L2 → 豁免放行', r.code === 0 && !r.out.includes('触达面判低'), r.out);
142
164
  }
143
165
  // --- 平台边界:CRLF frontmatter ---
144
166
  {
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ // check-ledger-invariant — confirmations.jsonl 提交不变量(2026-10-02 ledger-ci-invariant)
3
+ // 目的:把「台账本地可写」的伪造面从事后对质级升为机器可检出级(能力评审审计维度扣分项)。
4
+ // 台账不可变原则:confirmations.jsonl 历史行禁删改——修正只能走追加补偿行(README 硬规则 6)。
5
+ //
6
+ // 模式:
7
+ // 历史全扫(缺省,CI 机器门):遍历台账全部提交历史(时间正序),每次变更必须以旧 blob 为
8
+ // **逐字节前缀**(允许纯追加;禁止删行/改行/重排);再对最终树做行级校验(五项,见下)。
9
+ // blob 读取走 git 对象库(入库 LF 归一口径),不受工作树 autocrlf 影响。
10
+ // --staged(pre-commit 前置):暂存 blob 必须以 HEAD blob 为逐字节前缀——提交时刻即拦重写。
11
+ //
12
+ // 行级校验(终树,五项):
13
+ // ① ts 为 ISO 字符串且全表非降序(追加时序单调)
14
+ // ② fingerprint 为 64 位 hex
15
+ // ③ source=chat-delegated 行必有非空 quote(原话对质面)
16
+ // ④ doc 匹配 workflow/(intents|specs|plans|incidents)/[^/]+.md 且当前树存在
17
+ // ⑤ stage ∈ 合法跳转终态集(approved/done/fixed/closed/superseded/cancelled)
18
+ //
19
+ // 静默条件:台账不存在 / 零提交历史(装户、新仓)/ --staged 且暂存未触台账或无 HEAD 版本。
20
+ // 已知边界:git log --follow 未用——台账自创建无重命名(单一路径取史);克隆深度不足时仅扫可得
21
+ // 历史(CI 侧 fetch-depth:0 兜全史,深度不足会告警声明边界,fail-open 于深度、fail-closed 于内容)。
22
+ // 用法:node check-ledger-invariant.mjs [--root <仓库根>] [--staged]
23
+ import { execFileSync } from 'node:child_process';
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+
27
+ const argv = process.argv.slice(2);
28
+ const argOf = (k) => {
29
+ const i = argv.indexOf(k);
30
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : '';
31
+ };
32
+ const staged = argv.includes('--staged');
33
+ const ROOT = argOf('--root') || (staged ? process.cwd() : execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim());
34
+ const REL = '.agents/confirmations.jsonl';
35
+ const FILE = path.join(ROOT, REL);
36
+ const VALID_STAGES = new Set(['approved', 'done', 'fixed', 'closed', 'superseded', 'cancelled',
37
+ 'revert-draft', 'revert-open']); // 回退注记行(check-loop :822 认可;fingerprint 恒 n/a)
38
+
39
+ const git = (args, opts = {}) => execFileSync('git', args, { cwd: ROOT, encoding: 'buffer', maxBuffer: 64 * 1024 * 1024, ...opts });
40
+ const gitText = (args) => execFileSync('git', args, { cwd: ROOT, encoding: 'utf8' });
41
+ const blobOf = (ref) => git(['show', ref]); // ref 必须为完整对象引用(<sha>:<path> / HEAD:<path> / :<path>)
42
+
43
+ const problems = [];
44
+ const fail = (msg) => { console.error(msg); process.exit(1); };
45
+
46
+ // ---- --staged:暂存 blob ⊇ HEAD blob ----
47
+ if (staged) {
48
+ const stagedHit = gitText(['-c', 'core.quotepath=off', 'diff', '--cached', '--name-only']).split(/\r?\n/).includes(REL);
49
+ if (!stagedHit) process.exit(0);
50
+ const headExists = (() => { try { gitText(['rev-parse', '--verify', '--quiet', `HEAD:${REL}`]); return true; } catch { return false; } })();
51
+ if (!headExists) process.exit(0); // 首次入库:无 HEAD 基线,历史全扫后续覆盖
52
+ let head, cur;
53
+ try {
54
+ head = blobOf(`HEAD:${REL}`);
55
+ cur = blobOf(`:${REL}`); // 暂存区
56
+ } catch {
57
+ process.exit(0); // 对象读取异常(如极端并发)交由 CI 全扫兜底
58
+ }
59
+ if (!cur.subarray(0, head.length).equals(head)) {
60
+ let off = 0;
61
+ while (off < Math.min(head.length, cur.length) && head[off] === cur[off]) off++;
62
+ problems.push(`- [台账重写] 暂存内容不是 HEAD 的前缀扩展(首异偏移 ${off},HEAD ${head.length}B → 暂存 ${cur.length}B)`);
63
+ console.error('台账不可变(--staged)— BLOCK:\n' + problems.join('\n'));
64
+ console.error(' 修法:台账历史行禁删改——误写走追加补偿行(node .agents/scripts/confirm-doc.mjs ...);确需撤销历史提交请 git revert 后重新追加');
65
+ process.exit(1);
66
+ }
67
+ process.exit(0);
68
+ }
69
+
70
+ // ---- 历史全扫(缺省)----
71
+ if (!fs.existsSync(FILE)) process.exit(0); // 装户/新仓无台账面
72
+ let shas;
73
+ try {
74
+ shas = gitText(['log', '--format=%H', '--reverse', '--', REL]).split(/\s+/).filter(Boolean);
75
+ } catch {
76
+ process.exit(0); // 非仓库或文件未入库(测试夹具)
77
+ }
78
+ if (!shas.length) process.exit(0);
79
+
80
+ let prev = null;
81
+ let prevSha = null;
82
+ for (const sha of shas) {
83
+ let cur;
84
+ // 删除提交不是静默边界(复核 P2 收口):台账在 append-only 世界里永不应删除——任一历史提交删除
85
+ // 台账即 hard fail(否则「删除→次提交重加」会把重加 blob 当新基线绕过前缀判据)。
86
+ try { cur = blobOf(`${sha}:${REL}`); } catch {
87
+ problems.push(`- [台账删除] ${sha.slice(0, 10)}:该提交删除了 confirmations.jsonl(append-only 世界禁删文件)`);
88
+ prev = null; // 后续重加无基线可比,仅受行级校验约束
89
+ continue;
90
+ }
91
+ if (prev !== null && !cur.subarray(0, prev.length).equals(prev)) {
92
+ let off = 0;
93
+ while (off < Math.min(prev.length, cur.length) && prev[off] === cur[off]) off++;
94
+ const removed = prev.length > cur.length;
95
+ problems.push(`- [前缀破坏] ${sha.slice(0, 10)}(前承 ${prevSha.slice(0, 10)}):历史内容被${removed ? `删减 ${prev.length - cur.length}B` : `改写(首异偏移 ${off},旧 ${prev.length}B → 新 ${cur.length}B)`}`);
96
+ }
97
+ prev = cur;
98
+ prevSha = sha;
99
+ }
100
+
101
+ // ---- 行级校验(终树)----
102
+ let rows = [];
103
+ try {
104
+ rows = fs.readFileSync(FILE, 'utf8').split(/\r?\n/).filter((l) => l.trim()).map((l) => { try { return JSON.parse(l); } catch { return null; } });
105
+ } catch { /* 上面 existsSync 已兜 */ }
106
+ let prevTs = '';
107
+ rows.forEach((e, idx) => {
108
+ const at = `第 ${idx + 1} 行`;
109
+ if (!e || typeof e !== 'object') { problems.push(`- [行级] ${at}:非 JSON 对象行`); return; }
110
+ if (typeof e.ts !== 'string' || Number.isNaN(Date.parse(e.ts))) problems.push(`- [行级] ${at}:ts 非 ISO 字符串`);
111
+ else {
112
+ if (prevTs && e.ts < prevTs) problems.push(`- [行级] ${at}:ts ${e.ts} 早于前行 ${prevTs}(追加时序非单调)`);
113
+ prevTs = e.ts;
114
+ }
115
+ const isRevertNote = typeof e.stage === 'string' && e.stage.startsWith('revert-');
116
+ if (isRevertNote) {
117
+ if (e.fingerprint !== 'n/a') problems.push(`- [行级] ${at}:回退注记行 fingerprint 恒为「n/a」`);
118
+ } else if (typeof e.fingerprint !== 'string' || !/^[0-9a-f]{64}$/.test(e.fingerprint)) {
119
+ problems.push(`- [行级] ${at}:fingerprint 非 64 位 hex`);
120
+ }
121
+ if (e.source === 'chat-delegated' && !(typeof e.quote === 'string' && e.quote.trim())) problems.push(`- [行级] ${at}:chat-delegated 行缺非空 quote(原话对质面)`);
122
+ if (typeof e.doc !== 'string' || !/^workflow\/(intents|specs|plans|incidents)\/[^/]+\.md$/.test(e.doc)) {
123
+ problems.push(`- [行级] ${at}:doc 路径不在 workflow 四目录协议内`);
124
+ } else if (!fs.existsSync(path.join(ROOT, e.doc))) {
125
+ problems.push(`- [行级] ${at}:doc ${e.doc} 在当前树不存在`);
126
+ }
127
+ if (!VALID_STAGES.has(e.stage)) problems.push(`- [行级] ${at}:stage「${e.stage || '缺失'}」不在合法跳转终态集`);
128
+ });
129
+
130
+ if (problems.length) {
131
+ console.error(`台账不可变(历史全扫)— HARD-BLOCK:\n\n${problems.join('\n')}\n`);
132
+ console.error(' 修法:台账历史行禁删改(append-only 前缀不变量)——误写只能追加补偿行;确需撤销历史提交请 git revert 后重新追加。篡改属审计事件,保留现场联系仓库所有者。');
133
+ process.exit(1);
134
+ }
135
+ process.exit(0);
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+ // check-ledger-invariant.test.mjs — 台账提交不变量 fixture 套件(2026-10-02 ledger-ci-invariant)
3
+ // 真 git 仓注入:合法追加放行 / 六类篡改与行级违例检出 / --staged 双态 / 首次入库与空台账静默。
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
11
+ const SCRIPT = path.join(SCRIPT_DIR, 'check-ledger-invariant.mjs');
12
+
13
+ let pass = 0;
14
+ let fail = 0;
15
+ const check = (desc, cond, detail = '') => {
16
+ if (cond) { pass++; console.log(`PASS ${desc}`); }
17
+ else { fail++; console.log(`FAIL ${desc}${detail ? '\n ' + detail : ''}`); }
18
+ };
19
+
20
+ const HEX = 'a'.repeat(64);
21
+ const row = (o = {}) => JSON.stringify({
22
+ ts: '2026-10-02T00:00:00.000Z', doc: 'workflow/intents/demo.md', stage: 'approved',
23
+ fingerprint: HEX, prev: 'draft', source: 'chat-delegated', quote: '确认', ...o,
24
+ });
25
+
26
+ const G = (root, args) => spawnSync('git', args, { cwd: root, encoding: 'utf8' });
27
+ const commitAll = (root, msg) => { G(root, ['add', '-A']); G(root, ['-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', msg]); };
28
+ const run = (root, args = []) => {
29
+ const r = spawnSync(process.execPath, [SCRIPT, '--root', root, ...args], { encoding: 'utf8' });
30
+ return { code: r.status, out: `${r.stdout || ''}${r.stderr || ''}` };
31
+ };
32
+
33
+ // mkrepo:临时真仓(workflow/intents/demo.md 在树 + 台账首行已提交)
34
+ const mkrepo = (firstRow = row()) => {
35
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'ledger-inv-test-'));
36
+ fs.mkdirSync(path.join(d, 'workflow', 'intents'), { recursive: true });
37
+ fs.mkdirSync(path.join(d, '.agents'), { recursive: true });
38
+ fs.writeFileSync(path.join(d, 'workflow', 'intents', 'demo.md'), '---\n状态: approved\n级别: L1\n日期: 2026-10-02\n模块: pipeline\n---\n# I\n');
39
+ fs.writeFileSync(path.join(d, '.agents', 'confirmations.jsonl'), firstRow + '\n');
40
+ G(d, ['init', '-q']);
41
+ G(d, ['config', 'user.email', 't@t']);
42
+ G(d, ['config', 'user.name', 't']);
43
+ commitAll(d, 'base ledger');
44
+ return d;
45
+ };
46
+ const ledPath = (d) => path.join(d, '.agents', 'confirmations.jsonl');
47
+ const append = (d, line) => fs.appendFileSync(ledPath(d), line + '\n');
48
+
49
+ // S1:合法追加 ×2 → 历史全扫过
50
+ {
51
+ const d = mkrepo();
52
+ append(d, row({ ts: '2026-10-02T01:00:00.000Z', stage: 'done' }));
53
+ commitAll(d, 'append 2nd');
54
+ const r = run(d);
55
+ check('S1 合法追加两次 → 历史全扫 exit 0', r.code === 0, r.out);
56
+ }
57
+ // S2:删历史行 → 前缀破坏拦
58
+ {
59
+ const d = mkrepo();
60
+ append(d, row({ ts: '2026-10-02T01:00:00.000Z', stage: 'done' }));
61
+ commitAll(d, 'append');
62
+ fs.writeFileSync(ledPath(d), row({ ts: '2026-10-02T01:00:00.000Z', stage: 'done' }) + '\n'); // 删首行
63
+ commitAll(d, 'drop first row');
64
+ const r = run(d);
65
+ check('S2 删历史行 → 前缀破坏 exit 1', r.code === 1 && r.out.includes('前缀破坏') && r.out.includes('删减'), r.out);
66
+ }
67
+ // S3:改历史行内容 → 拦
68
+ {
69
+ const d = mkrepo();
70
+ append(d, row({ ts: '2026-10-02T01:00:00.000Z', stage: 'done' }));
71
+ commitAll(d, 'append');
72
+ const lines = fs.readFileSync(ledPath(d), 'utf8').split('\n').filter(Boolean);
73
+ lines[0] = lines[0].replace('确认', '改成别的'); // 改首行 quote
74
+ fs.writeFileSync(ledPath(d), lines.join('\n') + '\n');
75
+ commitAll(d, 'rewrite first line');
76
+ const r = run(d);
77
+ check('S3 改历史行 → 前缀破坏 exit 1(首异偏移)', r.code === 1 && r.out.includes('改写') && r.out.includes('首异偏移'), r.out);
78
+ }
79
+ // S4:非前缀改写(截断+异内容)→ 拦
80
+ {
81
+ const d = mkrepo();
82
+ append(d, row({ ts: '2026-10-02T01:00:00.000Z', stage: 'done' }));
83
+ commitAll(d, 'append');
84
+ fs.writeFileSync(ledPath(d), row({ ts: '2027-01-01T00:00:00.000Z', quote: '另起炉灶' }) + '\n');
85
+ commitAll(d, 'replace all');
86
+ const r = run(d);
87
+ check('S4 非前缀整体改写 → exit 1', r.code === 1 && r.out.includes('前缀破坏'), r.out);
88
+ }
89
+ // S5:ts 乱序追加 → 行级拦
90
+ {
91
+ const d = mkrepo();
92
+ append(d, row({ ts: '2026-10-01T00:00:00.000Z', stage: 'done' })); // 早于首行
93
+ commitAll(d, 'append out-of-order');
94
+ const r = run(d);
95
+ check('S5 ts 非单调 → 行级 exit 1', r.code === 1 && r.out.includes('非单调'), r.out);
96
+ }
97
+ // S6:伪指纹(非 64-hex)→ 行级拦
98
+ {
99
+ const d = mkrepo();
100
+ append(d, row({ ts: '2026-10-02T01:00:00.000Z', stage: 'done', fingerprint: 'zzz' }));
101
+ commitAll(d, 'append bad fp');
102
+ const r = run(d);
103
+ check('S6 fingerprint 非 64-hex → 行级 exit 1', r.code === 1 && r.out.includes('64 位 hex'), r.out);
104
+ }
105
+ // S7:chat-delegated 空 quote → 行级拦
106
+ {
107
+ const d = mkrepo();
108
+ append(d, row({ ts: '2026-10-02T01:00:00.000Z', stage: 'done', quote: '' }));
109
+ commitAll(d, 'append empty quote');
110
+ const r = run(d);
111
+ check('S7 chat-delegated 空 quote → 行级 exit 1', r.code === 1 && r.out.includes('quote'), r.out);
112
+ }
113
+ // S8:doc 路径在树中不存在 → 行级拦
114
+ {
115
+ const d = mkrepo();
116
+ append(d, row({ ts: '2026-10-02T01:00:00.000Z', stage: 'done', doc: 'workflow/intents/ghost.md' }));
117
+ commitAll(d, 'append ghost doc');
118
+ const r = run(d);
119
+ check('S8 doc 当前树不存在 → 行级 exit 1', r.code === 1 && r.out.includes('不存在'), r.out);
120
+ }
121
+ // S9:--staged 合法追加 → 过
122
+ {
123
+ const d = mkrepo();
124
+ append(d, row({ ts: '2026-10-02T01:00:00.000Z', stage: 'done' }));
125
+ G(d, ['add', '-A']);
126
+ const r = run(d, ['--staged']);
127
+ check('S9 --staged 合法追加 → exit 0', r.code === 0, r.out);
128
+ }
129
+ // S10:--staged 删历史行 → 拦
130
+ {
131
+ const d = mkrepo();
132
+ append(d, row({ ts: '2026-10-02T01:00:00.000Z', stage: 'done' }));
133
+ commitAll(d, 'append');
134
+ fs.writeFileSync(ledPath(d), row({ ts: '2026-10-02T01:00:00.000Z', stage: 'done' }) + '\n');
135
+ G(d, ['add', '-A']);
136
+ const r = run(d, ['--staged']);
137
+ check('S10 --staged 删历史行 → exit 1', r.code === 1 && r.out.includes('台账重写'), r.out);
138
+ }
139
+ // S11:首次入库(无 HEAD 基线)与空台账 → 静默过
140
+ {
141
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'ledger-inv-fresh-'));
142
+ fs.mkdirSync(path.join(d, '.agents'), { recursive: true });
143
+ G(d, ['init', '-q']);
144
+ G(d, ['config', 'user.email', 't@t']);
145
+ G(d, ['config', 'user.name', 't']);
146
+ fs.writeFileSync(ledPath(d), row() + '\n');
147
+ G(d, ['add', '-A']);
148
+ const rs = run(d, ['--staged']);
149
+ const rh = run(d);
150
+ check('S11 首次入库(staged 无 HEAD 基线 / 历史零提交)均静默过', rs.code === 0 && rh.code === 0, `staged=${rs.out} hist=${rh.out}`);
151
+ fs.rmSync(d, { recursive: true, force: true });
152
+ }
153
+ // S12:回退注记行(revert-draft / fingerprint n/a)合法放行
154
+ {
155
+ const d = mkrepo(row({ ts: '2026-10-02T00:00:00.000Z', stage: 'revert-draft', fingerprint: 'n/a', prev: 'approved' }));
156
+ const r = run(d);
157
+ check('S12 回退注记行(n/a 指纹)→ 历史全扫过', r.code === 0, r.out);
158
+ }
159
+
160
+ console.log(`\n${fail ? `❌ ${fail} failed` : '✅ all passed'}(${pass} passed)`);
161
+ process.exit(fail ? 1 : 0);