agentic-flow-kit 1.8.0 → 1.8.2

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 (49) hide show
  1. package/modules/hosts/claude/commands/wf-new-task.md +1 -0
  2. package/modules/hosts/claude/commands/wf-review.md +1 -1
  3. package/modules/hosts/codex/commands/wf-new-task.md +1 -0
  4. package/modules/hosts/codex/commands/wf-review.md +1 -1
  5. package/modules/hosts/cursor/commands/wf-new-task.md +1 -0
  6. package/modules/hosts/cursor/commands/wf-review.md +1 -1
  7. package/modules/hosts/opencode/commands/wf-new-task.md +1 -0
  8. package/modules/hosts/opencode/commands/wf-review.md +1 -1
  9. package/modules/hosts/trae/commands/wf-new-task.md +1 -0
  10. package/modules/hosts/trae/commands/wf-review.md +1 -1
  11. package/package.json +1 -1
  12. package/templates/_agents/commands/new-task.md +1 -0
  13. package/templates/_agents/commands/review.md +1 -1
  14. package/templates/_agents/engine-lock.json +28 -20
  15. package/templates/_agents/hooks/commit-check.cjs +57 -30
  16. package/templates/_agents/notes/engine-internals.md +17 -0
  17. package/templates/_agents/rule-budgets.txt +7 -1
  18. package/templates/_agents/scripts/agg-judgments.mjs +77 -1
  19. package/templates/_agents/scripts/agg-judgments.test.mjs +49 -1
  20. package/templates/_agents/scripts/archive-sweep.mjs +3 -1
  21. package/templates/_agents/scripts/check-convergence.mjs +8 -3
  22. package/templates/_agents/scripts/check-lane-surface.mjs +9 -2
  23. package/templates/_agents/scripts/check-lane-surface.test.mjs +16 -2
  24. package/templates/_agents/scripts/check-ledger-invariant.mjs +30 -4
  25. package/templates/_agents/scripts/check-ledger-invariant.test.mjs +28 -0
  26. package/templates/_agents/scripts/check-loop-rev.test.mjs +8 -14
  27. package/templates/_agents/scripts/check-loop.mjs +9 -0
  28. package/templates/_agents/scripts/check-loop.test.mjs +17 -5
  29. package/templates/_agents/scripts/check-prejudgment.mjs +8 -3
  30. package/templates/_agents/scripts/closeout-l1.mjs +217 -0
  31. package/templates/_agents/scripts/closeout-l1.test.mjs +105 -0
  32. package/templates/_agents/scripts/commit-check-trigger.test.mjs +49 -0
  33. package/templates/_agents/scripts/confirm-doc.mjs +14 -2
  34. package/templates/_agents/scripts/confirm-doc.test.mjs +43 -0
  35. package/templates/_agents/scripts/gen-workflow-metrics.mjs +17 -0
  36. package/templates/_agents/scripts/ledger-shards.mjs +42 -5
  37. package/templates/_agents/scripts/ledger-shards.test.mjs +17 -0
  38. package/templates/_agents/scripts/mechanism-retire-watch.mjs +183 -0
  39. package/templates/_agents/scripts/mechanism-retire-watch.test.mjs +114 -0
  40. package/templates/_agents/scripts/pre-push.test.mjs +26 -10
  41. package/templates/_agents/scripts/rule-budget.mjs +14 -1
  42. package/templates/_agents/scripts/rule-budget.test.mjs +118 -0
  43. package/templates/_agents/scripts/verify.mjs +32 -8
  44. package/templates/_agents/scripts/verify.test.mjs +29 -0
  45. package/templates/_agents/scripts-test-exempt.txt +2 -1
  46. package/templates/_agents/skills/review-method/SKILL.md +55 -0
  47. package/templates/_githooks/pre-commit +9 -4
  48. package/templates/_githooks/pre-merge-commit +4 -3
  49. package/templates/_githooks/pre-push +129 -75
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env node
2
+ // mechanism-retire-watch.mjs — 机制退役催办自动化(2026-10-14 源仓 L5 自举接线 1b/1c;2026-10-16 回灌 kit)
3
+ // 用途:读机制台账「退役阈值/最近触发」+ judgments 聚合,阈值触达自动产动作项——让机制的「死」与「生」同样强制。
4
+ // 自动化的是**感知/排序**(谁触达了阈值),处置仍人放行(改状态/删代码走人 + 一进一出)。
5
+ // 口径单源:.agents/mechanism-registry.md 列序(机制|门禁位置|触发统计口径|退役阈值|最近触发|状态|处置期限)
6
+ // 与 .agents/judgments.jsonl(check-loop 每次真实运行落账,per-rule WARN/BLOCK 计数)。
7
+ // 判定(确定性,无 LLM):
8
+ // ① 现役行 + 阈值形态「连续 N 月 = 0」+ 口径列含「规则名」→ 以有数据的末月为终点取 N 个日历月,缺月不算零命中,全 0 即触达 → 动作项。
9
+ // ② 待退役行 + 处置期限为日期且已过 → 动作项(逾期)。
10
+ // ③ 其余阈值形态(不退役主干 / 使用率型 / 数据源型)→ 非自动可判,仅计数,交人工月判。
11
+ // 缺月不算零命中(2026-10-15 源仓修正):漏跑数月后陈旧月份不得凑成「连续」——日历窗口内有缺月即
12
+ // 交人工判,不产触达动作(与 agg-judgments --retire-candidates 的「数据月」口径互补:彼按有数据月,
13
+ // 此按日历连续)。
14
+ // 退出码恒 0(催办报告不该阻断调度器;测量失败打 [ERROR] 行)。
15
+ // 用法:node .agents/scripts/mechanism-retire-watch.mjs [--json] [--root <仓库根>] [--now <ISO 日期>]
16
+ // 测试:node .agents/scripts/mechanism-retire-watch.test.mjs(纯函数 evaluate 进程内断言)
17
+ // kit 适配(源仓→kit):台账路径 `.agents/config/mechanism-registry.md` → kit 平铺
18
+ // `.agents/mechanism-registry.md`(同 check-registry.mjs 检查 22 口径)。
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import { fileURLToPath, pathToFileURL } from 'node:url';
22
+ import { aggFromRows } from './agg-judgments.mjs';
23
+
24
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
25
+ const argv = process.argv.slice(2);
26
+ const argOf = (k) => { const i = argv.indexOf(k); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : ''; };
27
+ const asJson = argv.includes('--json');
28
+ const ROOT = path.resolve(argOf('--root') || path.join(SCRIPT_DIR, '..', '..'));
29
+ const NOW = argOf('--now') ? new Date(argOf('--now')) : new Date();
30
+
31
+ const REGISTRY_REL = path.join('.agents', 'mechanism-registry.md');
32
+ const LEDGER_REL = path.join('.agents', 'judgments.jsonl');
33
+
34
+ // parseRegistry(text):7 列管道表 → 行对象数组。列序固定(check-loop §22 按此解析)。
35
+ export function parseRegistry(text) {
36
+ const rows = [];
37
+ for (const l of String(text).split(/\r?\n/)) {
38
+ if (!/^\|/.test(l)) continue;
39
+ if (/^\|[\s:|-]+\|?\s*$/.test(l)) continue; // 分隔行
40
+ const cells = l.split('|').slice(1, -1).map((c) => c.trim());
41
+ if (cells.length !== 7) continue;
42
+ if (cells[0] === '机制') continue; // 表头
43
+ const [name, gate, criterion, threshold, lastTrigger, status, deadline] = cells;
44
+ rows.push({ name, gate, criterion, threshold, lastTrigger, status, deadline });
45
+ }
46
+ return rows;
47
+ }
48
+
49
+ // ruleNamesOf(criterion):从「触发统计口径」列抽「」内规则名(/ 分隔)。无「」= 无法自动映射。
50
+ export function ruleNamesOf(criterion) {
51
+ const names = [];
52
+ for (const m of String(criterion).matchAll(/「([^」]+)」/g)) {
53
+ for (const n of m[1].split('/')) {
54
+ const t = n.trim();
55
+ if (t) names.push(t);
56
+ }
57
+ }
58
+ return names;
59
+ }
60
+
61
+ // zeroMonthsOf(threshold):阈值形态「连续 N 月 = 0」→ N;否则 null(非自动可判)。
62
+ export function zeroMonthsOf(threshold) {
63
+ const m = String(threshold).match(/连续\s*(\d+)\s*月\s*=\s*0/);
64
+ return m ? Number(m[1]) : null;
65
+ }
66
+
67
+ // localDay(d):本地日历日 YYYY-MM-DD。期限比较不用 UTC 零点,避免北京时 08:00 后把当天判逾期。
68
+ export function localDay(d) {
69
+ const dt = d instanceof Date ? d : new Date(d);
70
+ const m = String(dt.getMonth() + 1).padStart(2, '0');
71
+ const day = String(dt.getDate()).padStart(2, '0');
72
+ return `${dt.getFullYear()}-${m}-${day}`;
73
+ }
74
+
75
+ // calendarWindow(endYm, n):以有数据的末月为终点,向前取 n 个日历月(含终点)。
76
+ export function calendarWindow(endYm, n) {
77
+ const [y, m] = String(endYm).split('-').map(Number);
78
+ const out = [];
79
+ for (let i = n - 1; i >= 0; i--) {
80
+ const d = new Date(Date.UTC(y, m - 1 - i, 1));
81
+ out.push(`${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`);
82
+ }
83
+ return out;
84
+ }
85
+
86
+ // evaluate(registryRows, judgmentRows, now):纯函数——产动作项。测试进程内断言。
87
+ export function evaluate(registryRows, judgmentRows, now = new Date()) {
88
+ const { byMonth } = aggFromRows(judgmentRows || [], null);
89
+ const months = Object.keys(byMonth).sort(); // 升序
90
+ const actions = [];
91
+ const notAuto = [];
92
+
93
+ for (const r of registryRows) {
94
+ if (r.status === '已退役') continue;
95
+
96
+ if (r.status === '待退役') {
97
+ const d = String(r.deadline).match(/\d{4}-\d{2}-\d{2}/);
98
+ if (d && localDay(now) > d[0]) {
99
+ actions.push({ kind: 'overdue', name: r.name, detail: `待退役处置期限 ${d[0]} 已过(今 ${localDay(now)})——按台账维护纪律「退役动作」当日删代码+测试+更新本表` });
100
+ } else if (!d) {
101
+ actions.push({ kind: 'no-deadline', name: r.name, detail: `待退役但处置期限非日期(${r.deadline || '空'})——补时间表或改回现役` });
102
+ }
103
+ continue;
104
+ }
105
+
106
+ // 现役
107
+ const n = zeroMonthsOf(r.threshold);
108
+ const rules = ruleNamesOf(r.criterion);
109
+ if (n === null) { notAuto.push({ name: r.name, reason: '阈值形态非「连续 N 月 = 0」' }); continue; }
110
+ if (!rules.length) { notAuto.push({ name: r.name, reason: '口径列未列规则名(无法映射 judgments)' }); continue; }
111
+ if (months.length < n) { notAuto.push({ name: r.name, reason: `judgments 仅 ${months.length} 月 < 阈值 ${n} 月(数据不足)` }); continue; }
112
+
113
+ // 日历连续:缺月不算零命中(漏跑数月后陈旧月份不得凑成「连续」)。
114
+ const window = calendarWindow(months[months.length - 1], n);
115
+ const missing = window.filter((ym) => !byMonth[ym]);
116
+ if (missing.length) {
117
+ notAuto.push({ name: r.name, reason: `日历窗口 ${window.join(' / ')} 缺月 ${missing.join(' / ')}(断档不算零命中)` });
118
+ continue;
119
+ }
120
+ const hits = window.map((ym) => {
121
+ const cell = byMonth[ym].rules;
122
+ let c = 0;
123
+ for (const rule of rules) {
124
+ const e = cell[rule];
125
+ if (e) c += (e.WARN || 0) + (e.BLOCK || 0);
126
+ }
127
+ return { ym, count: c };
128
+ });
129
+ const total = hits.reduce((s, h) => s + h.count, 0);
130
+ if (total === 0) {
131
+ actions.push({ kind: 'threshold', name: r.name, detail: `连续 ${n} 月零触发(${window.join(' / ')};规则 ${rules.join('、')})——阈值触达,评估退役(改状态「待退役」并填处置期限)` });
132
+ }
133
+ }
134
+ return { actions, notAuto, months, monthCount: months.length };
135
+ }
136
+
137
+ function readJudgments(root) {
138
+ const p = path.join(root, LEDGER_REL);
139
+ if (!fs.existsSync(p)) return { rows: [], missing: true };
140
+ const rows = [];
141
+ for (const line of fs.readFileSync(p, 'utf8').split(/\r?\n/)) {
142
+ if (!line.trim()) continue;
143
+ try { rows.push(JSON.parse(line)); } catch { /* 坏行跳过,不中断 */ }
144
+ }
145
+ return { rows, missing: false };
146
+ }
147
+
148
+ function main() {
149
+ const regPath = path.join(ROOT, REGISTRY_REL);
150
+ if (!fs.existsSync(regPath)) {
151
+ console.error(`[ERROR] mechanism-retire-watch: 台账不存在 ${REGISTRY_REL}`);
152
+ if (asJson) console.log(JSON.stringify({ error: 'registry-missing', actions: [] }, null, 2));
153
+ process.exit(0);
154
+ }
155
+ const registryRows = parseRegistry(fs.readFileSync(regPath, 'utf8'));
156
+ const { rows: judgmentRows, missing } = readJudgments(ROOT);
157
+ const { actions, notAuto, monthCount } = evaluate(registryRows, judgmentRows, NOW);
158
+
159
+ if (asJson) {
160
+ console.log(JSON.stringify({ registryRows: registryRows.length, judgmentMonths: monthCount, judgmentsMissing: missing, actions, notAuto }, null, 2));
161
+ return;
162
+ }
163
+
164
+ console.log(`# 机制退役催办(${NOW.toISOString()})`);
165
+ console.log('');
166
+ console.log(`台账 ${REGISTRY_REL}|机制行 ${registryRows.length}|judgments 月数 ${monthCount}${missing ? '(台账缺失——尚无运行记录)' : ''}`);
167
+ console.log('');
168
+ if (!actions.length) {
169
+ console.log('✅ 无阈值触达 / 无逾期——本轮无动作项。');
170
+ } else {
171
+ console.log(`## 动作项 ${actions.length} 条`);
172
+ for (const a of actions) console.log(`- [${a.kind}] ${a.name}:${a.detail}`);
173
+ }
174
+ if (notAuto.length) {
175
+ console.log('');
176
+ console.log(`## 非自动可判(${notAuto.length} 条,交人工月判)`);
177
+ for (const x of notAuto) console.log(`- ${x.name}:${x.reason}`);
178
+ }
179
+ console.log('');
180
+ console.log('> 处置仍人放行:改状态/删代码走人 + 一进一出(台账维护纪律)。本报告不自动改文件。');
181
+ }
182
+
183
+ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) main();
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ // mechanism-retire-watch.test.mjs — 1b 机制退役催办纯函数套件(2026-10-14 L5 自举接线 1b)
3
+ // parseRegistry / ruleNamesOf / zeroMonthsOf / evaluate 进程内断言。
4
+ import { parseRegistry, ruleNamesOf, zeroMonthsOf, evaluate } from './mechanism-retire-watch.mjs';
5
+
6
+ let pass = 0;
7
+ let fail = 0;
8
+ const check = (desc, cond, detail = '') => {
9
+ if (cond) { pass++; console.log(`PASS ${desc}`); }
10
+ else { fail++; console.log(`FAIL ${desc}${detail ? '\n ' + detail : ''}`); }
11
+ };
12
+
13
+ // ---- S1 parseRegistry ----
14
+ {
15
+ const text = [
16
+ '| 机制 | 门禁位置 | 触发统计口径 | 退役阈值 | 最近触发 | 状态 | 处置期限 |',
17
+ '|---|---|---|---|---|---|---|',
18
+ '| 甲门 | check-loop §1 | WARN「甲规则」计数(月) | 连续 6 月 = 0 → 评估退役 | 2026-10 在用 | 现役 | — |',
19
+ '| 乙门 | check-loop §2 | hard 计数 | 不退役(主干) | 2026-10 | 现役 | — |',
20
+ '| 丙门 | check-loop §3 | WARN 计数 | 连续 6 月 = 0 | 2026-10 | 待退役 | 2026-01-01 |',
21
+ '| 坏行 | 少列 |',
22
+ ].join('\n');
23
+ const rows = parseRegistry(text);
24
+ check('S1 只取 7 列数据行(表头/分隔/坏行剔除)', rows.length === 3, String(rows.length));
25
+ check('S1 列序正确', rows[0].name === '甲门' && rows[0].status === '现役' && rows[0].deadline === '—', JSON.stringify(rows[0]));
26
+ check('S1 待退役行期限解析', rows[2].status === '待退役' && rows[2].deadline === '2026-01-01');
27
+ }
28
+
29
+ // ---- S2 ruleNamesOf ----
30
+ check('S2 单规则名', ruleNamesOf('WARN「模板未填」计数(月)').join(',') === '模板未填');
31
+ check('S2 斜杠分隔多规则', ruleNamesOf('WARN「预判类型不符/预判级别不符/预判字段不可解析」计数').join(',') === '预判类型不符,预判级别不符,预判字段不可解析');
32
+ check('S2 无「」→ 空', ruleNamesOf('hard/WARN 计数(月)').length === 0);
33
+
34
+ // ---- S3 zeroMonthsOf ----
35
+ check('S3 连续 6 月 = 0', zeroMonthsOf('连续 6 月 = 0 → 评估退役') === 6);
36
+ check('S3 带前缀仍解析', zeroMonthsOf('WARN 命中连续 6 月 = 0 且新增预判字段停滞 → 评估退役') === 6);
37
+ check('S3 非零阈值形态 → null', zeroMonthsOf('不退役(防伪主干)') === null);
38
+ check('S3 使用率型 → null', zeroMonthsOf('<10% 且无新增 → 退役双协议') === null);
39
+
40
+ // ---- S4 evaluate:阈值触达 ----
41
+ {
42
+ const reg = [{ name: '甲门', criterion: 'WARN「甲规则」计数', threshold: '连续 2 月 = 0 → 评估退役', status: '现役', deadline: '—' }];
43
+ const j = [
44
+ { ts: '2026-08-01T00:00:00Z', rules: [{ rule: '甲规则', level: 'WARN', count: 0 }] },
45
+ { ts: '2026-09-01T00:00:00Z', rules: [] },
46
+ ];
47
+ const r = evaluate(reg, j, new Date('2026-10-14'));
48
+ check('S4 连续 2 月零触发 → 动作项', r.actions.length === 1 && r.actions[0].kind === 'threshold', JSON.stringify(r.actions));
49
+ check('S4 动作项含窗口与规则名', /2026-08 \/ 2026-09/.test(r.actions[0].detail) && /甲规则/.test(r.actions[0].detail), r.actions[0].detail);
50
+ }
51
+
52
+ // ---- S5 evaluate:窗口内有触发 → 不产动作项 ----
53
+ {
54
+ const reg = [{ name: '甲门', criterion: 'WARN「甲规则」计数', threshold: '连续 2 月 = 0', status: '现役', deadline: '—' }];
55
+ const j = [
56
+ { ts: '2026-08-01T00:00:00Z', rules: [{ rule: '甲规则', level: 'WARN', count: 1 }] },
57
+ { ts: '2026-09-01T00:00:00Z', rules: [] },
58
+ ];
59
+ const r = evaluate(reg, j, new Date('2026-10-14'));
60
+ check('S5 窗口内命中 → 无动作项', r.actions.length === 0, JSON.stringify(r.actions));
61
+ }
62
+
63
+ // ---- S6 evaluate:数据不足 / 无规则名 → notAuto ----
64
+ {
65
+ const reg = [
66
+ { name: '甲门', criterion: 'WARN「甲规则」计数', threshold: '连续 6 月 = 0', status: '现役', deadline: '—' },
67
+ { name: '乙门', criterion: 'hard 计数', threshold: '连续 6 月 = 0', status: '现役', deadline: '—' },
68
+ { name: '丙门', criterion: 'WARN「丙规则」计数', threshold: '不退役(主干)', status: '现役', deadline: '—' },
69
+ ];
70
+ const j = [{ ts: '2026-09-01T00:00:00Z', rules: [] }];
71
+ const r = evaluate(reg, j, new Date('2026-10-14'));
72
+ check('S6 数据不足 → notAuto', r.notAuto.some((x) => x.name === '甲门' && /数据不足/.test(x.reason)), JSON.stringify(r.notAuto));
73
+ check('S6 无规则名 → notAuto', r.notAuto.some((x) => x.name === '乙门' && /未列规则名/.test(x.reason)));
74
+ check('S6 非零阈值形态 → notAuto', r.notAuto.some((x) => x.name === '丙门' && /非「连续/.test(x.reason)));
75
+ check('S6 无动作项', r.actions.length === 0);
76
+ }
77
+
78
+ // ---- S7 evaluate:待退役逾期 / 缺期限 ----
79
+ {
80
+ const reg = [
81
+ { name: '甲门', criterion: '', threshold: '', status: '待退役', deadline: '2026-01-01' },
82
+ { name: '乙门', criterion: '', threshold: '', status: '待退役', deadline: '下批次' },
83
+ { name: '丙门', criterion: '', threshold: '', status: '已退役', deadline: '—' },
84
+ ];
85
+ const r = evaluate(reg, [], new Date('2026-10-14'));
86
+ check('S7 逾期 → overdue', r.actions.some((a) => a.kind === 'overdue' && a.name === '甲门'), JSON.stringify(r.actions));
87
+ check('S7 非日期期限 → no-deadline', r.actions.some((a) => a.kind === 'no-deadline' && a.name === '乙门'));
88
+ check('S7 已退役行跳过', !r.actions.some((a) => a.name === '丙门'));
89
+ }
90
+ // ---- S7b:期限当天不逾期(本地日,不当天 08:00 后提前催)----
91
+ {
92
+ const reg = [{ name: '甲门', criterion: '', threshold: '', status: '待退役', deadline: '2026-10-14' }];
93
+ const now = new Date(2026, 9, 14, 9, 0, 0);
94
+ const r = evaluate(reg, [], now);
95
+ check('S7b 期限当天本地 09:00 不逾期', r.actions.length === 0, JSON.stringify(r.actions));
96
+ }
97
+ // ---- S4b:有数据月不连续 → 缺月不算零命中 ----
98
+ {
99
+ const reg = [{ name: '甲门', criterion: 'WARN「甲规则」计数', threshold: '连续 2 月 = 0 → 评估退役', status: '现役', deadline: '—' }];
100
+ const j = [
101
+ { ts: '2026-08-01T00:00:00Z', rules: [] },
102
+ { ts: '2026-10-01T00:00:00Z', rules: [] },
103
+ ];
104
+ const r = evaluate(reg, j, new Date('2026-10-14T00:00:00Z'));
105
+ check('S4b 8 月与 10 月之间缺 9 月 → 不产退役动作', r.actions.length === 0, JSON.stringify(r.actions));
106
+ check('S4b 缺月记入 notAuto', r.notAuto.some((x) => x.name === '甲门' && /缺月/.test(x.reason) && /2026-09/.test(x.reason)), JSON.stringify(r.notAuto));
107
+ }
108
+
109
+ // ---- S8 空入参 ----
110
+ check('S8 空台账 → 无动作项', evaluate([], [], new Date()).actions.length === 0);
111
+ check('S8 null judgments 不 throw', evaluate([], null, new Date()).actions.length === 0);
112
+
113
+ console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
114
+ process.exit(fail ? 1 : 0);
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
- // pre-push.test.mjs — 加固门目标分支判定 fixture 套件(2026-10-01 v09-review-defects)
3
- // 真仓端到端:临时仓库挂载真实 pre-push,.agents/scripts/check-loop.sh 以桩脚本记录 argv。
2
+ // pre-push.test.mjs — 加固门目标分支判定 + 构建门 fixture 套件
3
+ // (2026-10-01 v09-review-defects 建;2026-10-16 随 pre-push node 化更新:桩改 .agents/scripts/check-loop.mjs
4
+ // 直调,补 commit-check 构建门桩与 --range 断言——自源仓 2ff869a9 回灌面)
5
+ // 真仓端到端:临时仓库挂载真实 pre-push,check-loop.mjs 与 commit-check.cjs 以桩记录 argv。
4
6
  // 断言三态 refspec:
5
- // main→main → argv 含 --hardening(加固门)
6
- // experiment/x→main → argv 含 --hardening(修复回归:直推漏口)
7
- // experiment/y→experiment/y → argv 不含 --hardening(advisory)
7
+ // main→main → argv 含 --hardening(加固门);构建门 --range 新分支首推=全量零 sha
8
+ // experiment/x→main → argv 含 --hardening(修复回归:直推漏口);构建门 old 为远端实 sha
9
+ // experiment/y→experiment/y → argv 不含 --hardening(advisory);构建门仍按范围跑(advisory 不阻断)
8
10
  import fs from 'node:fs';
9
11
  import os from 'node:os';
10
12
  import path from 'node:path';
@@ -34,11 +36,15 @@ const setup = () => {
34
36
  const remote = path.join(root, 'remote.git');
35
37
  fs.mkdirSync(path.join(root, '.githooks'), { recursive: true });
36
38
  fs.mkdirSync(path.join(root, '.agents', 'scripts'), { recursive: true });
37
- // 挂载真实 pre-push(装户同款),stub 替换 check-loop 记录 argv
39
+ fs.mkdirSync(path.join(root, '.agents', 'hooks'), { recursive: true });
40
+ // 挂载真实 pre-push(装户同款),stub 替换 check-loop 与 commit-check 记录 argv
38
41
  fs.copyFileSync(PRE_PUSH, path.join(root, '.githooks', 'pre-push'));
39
- // Linux:git 静默跳过不可执行钩子(推送照常成功、桩无记录)——挂载后必须设执行位;桩经 sh 调用无须 +x
42
+ // Linux:git 静默跳过不可执行钩子(推送照常成功、桩无记录)——挂载后必须设执行位;桩经 node 调用无须 +x
40
43
  fs.chmodSync(path.join(root, '.githooks', 'pre-push'), 0o755);
41
- fs.writeFileSync(path.join(root, '.agents', 'scripts', 'check-loop.sh'), '#!/bin/sh\necho "$@" >> .stub-args\nexit 0\n');
44
+ fs.writeFileSync(path.join(root, '.agents', 'scripts', 'check-loop.mjs'),
45
+ 'import fs from "node:fs";\nfs.appendFileSync(".stub-args", process.argv.slice(2).join(" ") + "\\n");\n');
46
+ fs.writeFileSync(path.join(root, '.agents', 'hooks', 'commit-check.cjs'),
47
+ 'const fs = require("node:fs");\nfs.appendFileSync(".stub-args-cc", process.argv.slice(2).join(" ") + "\\n");\n');
42
48
  fs.writeFileSync(path.join(root, 'a.txt'), 'a\n');
43
49
  git(root, ['init', '-q']);
44
50
  git(root, ['config', 'user.email', 't@t']);
@@ -54,15 +60,21 @@ const stubArgs = (root) => {
54
60
  const p = path.join(root, '.stub-args');
55
61
  return fs.existsSync(p) ? fs.readFileSync(p, 'utf8').split(/\n/).filter(Boolean) : [];
56
62
  };
63
+ const ccArgs = (root) => {
64
+ const p = path.join(root, '.stub-args-cc');
65
+ return fs.existsSync(p) ? fs.readFileSync(p, 'utf8').split(/\n/).filter(Boolean) : [];
66
+ };
57
67
 
58
68
  const root = setup();
59
69
  const w = (f, c) => fs.writeFileSync(path.join(root, f), c);
60
70
 
61
- // 1) main → main:应含 --hardening
71
+ // 1) main → main:应含 --hardening;构建门首推 = 全量零 sha
62
72
  git(root, ['checkout', '-q', '-b', 'main']);
63
73
  let r = run(root, ['push', 'origin', 'HEAD:main']);
64
74
  check('main→main 推送成功', r.status === 0, `${r.stdout}${r.stderr}`);
65
75
  check('main→main argv 含 --hardening', (stubArgs(root)[0] || '').includes('--hardening'), stubArgs(root).join('|'));
76
+ check('main→main 构建门 --range 新分支首推=全量零 sha(2026-10-16 构建门后移)',
77
+ (ccArgs(root)[0] || '').startsWith('--range 0000000000000000000000000000000000000000 '), ccArgs(root).join('|'));
66
78
 
67
79
  // 2) experiment/x → main(回归漏口):本地分支在 experiment,目标 main → 必须含 --hardening
68
80
  git(root, ['checkout', '-q', '-b', 'experiment/x']);
@@ -72,8 +84,10 @@ git(root, ['commit', '-q', '-m', 'x']);
72
84
  r = run(root, ['push', 'origin', 'HEAD:main']);
73
85
  check('experiment/x→main 推送成功', r.status === 0, `${r.stdout}${r.stderr}`);
74
86
  check('experiment/x→main argv 含 --hardening(直推漏口修复)', (stubArgs(root)[1] || '').includes('--hardening'), stubArgs(root).join('|'));
87
+ check('experiment/x→main 构建门 --range old 为远端实 sha(非零)',
88
+ /^--range [0-9a-f]{40} /.test(ccArgs(root)[1] || ''), ccArgs(root).join('|'));
75
89
 
76
- // 3) experiment/y → experiment/y:目标在探索泳道 → 不含 --hardening
90
+ // 3) experiment/y → experiment/y:目标在探索泳道 → 不含 --hardening;构建门仍按范围跑(advisory)
77
91
  git(root, ['fetch', '-q', 'origin']);
78
92
  git(root, ['branch', 'experiment/y', 'origin/main']);
79
93
  git(root, ['checkout', '-q', 'experiment/y']);
@@ -84,6 +98,8 @@ r = run(root, ['push', 'origin', 'HEAD:experiment/y']);
84
98
  check('experiment→experiment 推送成功', r.status === 0, `${r.stdout}${r.stderr}`);
85
99
  const line3 = stubArgs(root)[2] || '';
86
100
  check('experiment→experiment argv 不含 --hardening', !line3.includes('--hardening'), stubArgs(root).join('|'));
101
+ check('experiment→experiment 构建门仍按范围跑(advisory 不阻断)',
102
+ /^--range [0-9a-f]{40} /.test(ccArgs(root)[2] || ''), ccArgs(root).join('|'));
87
103
 
88
104
  console.log(`\n${fail ? `❌ ${fail} failed` : '✅ all passed'}(${pass} passed)`);
89
105
  process.exit(fail ? 1 : 0);
@@ -8,6 +8,10 @@
8
8
  // 「提交后树」= 索引(index)内容:staged 版优先于 HEAD;无 git(fixture)回退工作区文件。
9
9
  // 字节口径 = 索引 / HEAD 内容(git cat-file,LF 归一)。禁用工作区 wc -c 口径(CRLF 膨胀系统性偏大,
10
10
  // 2026-09-21 实测差 ~500B 一度误判超限)——本脚本所有字节数都走 cat-file,仅无 git 时才落工作区。
11
+ // 条目数预算(2026-10-15 减重手段② 自源仓回灌):上限字段形如 `<N>#count` 时按「匹配文件条数」执法
12
+ // 而非字节——规则面一进一出(新增一条须先退役一条,退役判定 agg-judgments --retire-candidates)。
13
+ // 旧格式行(纯数字)不受影响;`#count` 行对旧读者(未升级的脚本副本)因上限非纯数字被跳过——
14
+ // fail-open,不误拦。
11
15
  import { spawnSync } from 'node:child_process';
12
16
  import fs from 'node:fs';
13
17
  import path from 'node:path';
@@ -60,13 +64,22 @@ const expand = (pat) => {
60
64
 
61
65
  let viol = 0;
62
66
  const over = (what, bytes, lim) => { console.log(`常驻面超限:${what} ${bytes}B > 上限 ${lim}B`); viol = 1; };
67
+ const overCount = (pat, n, lim) => { console.log(`常驻面超限(条目数):${pat} ${n} 项 > 上限 ${lim} 项——一进一出:新增须先退役一条(退役判定 agg-judgments --retire-candidates)`); viol = 1; };
63
68
 
64
69
  for (const line of fs.readFileSync(BUDGETS, 'utf8').split(/\r?\n/)) {
65
70
  const sp = line.lastIndexOf(' ');
66
71
  if (sp < 0) continue;
67
72
  const pat = line.slice(0, sp), lim = line.slice(sp + 1);
68
73
  if (pat === '' || pat.startsWith('#')) continue;
69
- if (!/^\d+$/.test(lim)) continue;
74
+ // 条目数预算(2026-10-15 减重手段②):`<N>#count` = 匹配文件数上限(一进一出的执法面)
75
+ const cnt = /^(\d+)#count$/.exec(lim);
76
+ if (!cnt && !/^\d+$/.test(lim)) continue;
77
+ if (cnt) {
78
+ if (mode === '--staged' && !staged.some((sf) => sf.startsWith(pat))) continue;
79
+ const n = expand(pat).length;
80
+ if (n > Number(cnt[1])) overCount(pat, n, cnt[1]);
81
+ continue;
82
+ }
70
83
  if (pat.endsWith('/')) {
71
84
  // 目录合计:--staged 下仅当暂存触及该目录内文件才查(字面前缀匹配,同 sh 版 2026-09-24 口径)
72
85
  if (mode === '--staged' && !staged.some((sf) => sf.startsWith(pat))) continue;
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ // rule-budget.test.mjs — 常驻面预算黑盒套件(2026-10-15 减重手段② 自源仓回灌随附:#count 条目数指令)
3
+ // 夹具:os.tmpdir 下临时目录(非 git 环境——脚本 git rev-parse 失败留在 fixture 目录,
4
+ // 字节口径回退工作区文件;与真实仓「索引/HEAD」口径的差异不影响指令语义判定)。
5
+ // 覆盖:字节超限/未超、#count 超限/压线、旧格式行不受 #count 影响、缺预算表 exit 0、
6
+ // --staged 无 git(staged 空)时 #count 门跳过。
7
+ // 运行:node templates/_agents/scripts/rule-budget.test.mjs
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { spawnSync } from 'node:child_process';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const SCRIPT = path.join(path.dirname(fileURLToPath(import.meta.url)), 'rule-budget.mjs');
15
+
16
+ let pass = 0;
17
+ let fail = 0;
18
+ const check = (desc, cond, detail = '') => {
19
+ if (cond) { pass++; console.log(`PASS ${desc}`); }
20
+ else { fail++; console.log(`FAIL ${desc}${detail ? '\n ' + detail : ''}`); }
21
+ };
22
+
23
+ function makeFixture({ budgets, dirs = {}, files = {} }) {
24
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rule-budget-'));
25
+ if (budgets !== null) {
26
+ fs.mkdirSync(path.join(root, '.agents'), { recursive: true });
27
+ fs.writeFileSync(path.join(root, '.agents', 'rule-budgets.txt'), budgets, 'utf8');
28
+ }
29
+ for (const [dir, names] of Object.entries(dirs)) {
30
+ fs.mkdirSync(path.join(root, dir), { recursive: true });
31
+ for (const n of names) fs.writeFileSync(path.join(root, dir, n), 'x\n', 'utf8');
32
+ }
33
+ for (const [f, content] of Object.entries(files)) {
34
+ fs.mkdirSync(path.dirname(path.join(root, f)), { recursive: true });
35
+ fs.writeFileSync(path.join(root, f), content, 'utf8');
36
+ }
37
+ return root;
38
+ }
39
+
40
+ const run = (cwd, mode = '--all') => {
41
+ const r = spawnSync(process.execPath, [SCRIPT, mode], { cwd, encoding: 'utf8' });
42
+ return { code: r.status, out: String(r.stdout || '') };
43
+ };
44
+
45
+ let root;
46
+
47
+ // ---- T1 字节超限(旧格式语义不回归)----
48
+ root = makeFixture({
49
+ budgets: 'AGENTS.md 10\n',
50
+ files: { 'AGENTS.md': 'x'.repeat(50) },
51
+ });
52
+ {
53
+ const r = run(root);
54
+ check('T1 字节超限 exit 1 + 违例行', r.code === 1 && r.out.includes('常驻面超限:AGENTS.md') && r.out.includes('> 上限 10B'), JSON.stringify(r));
55
+ }
56
+
57
+ // ---- T2 字节未超 → 0 ----
58
+ root = makeFixture({
59
+ budgets: 'AGENTS.md 100\n',
60
+ files: { 'AGENTS.md': 'x'.repeat(50) },
61
+ });
62
+ check('T2 字节未超 exit 0', run(root).code === 0);
63
+
64
+ // ---- T3 #count 超限 ----
65
+ root = makeFixture({
66
+ budgets: '.agents/rules/ 2#count\n',
67
+ dirs: { '.agents/rules': ['r01-a.md', 'r02-b.md', 'r03-c.md'] },
68
+ });
69
+ {
70
+ const r = run(root);
71
+ check('T3 #count 超限 exit 1 + 条目数违例行 + 一进一出提示', r.code === 1 && r.out.includes('条目数') && r.out.includes('3 项 > 上限 2 项') && r.out.includes('一进一出'), JSON.stringify(r));
72
+ }
73
+
74
+ // ---- T4 #count 压线(= 上限)→ 0 ----
75
+ root = makeFixture({
76
+ budgets: '.agents/rules/ 3#count\n',
77
+ dirs: { '.agents/rules': ['r01-a.md', 'r02-b.md', 'r03-c.md'] },
78
+ });
79
+ check('T4 #count 压线 exit 0', run(root).code === 0);
80
+
81
+ // ---- T5 混合表:#count 行存在但未触其它字节条目 → 0(互不干扰)----
82
+ root = makeFixture({
83
+ budgets: 'AGENTS.md 100\n.agents/rules/ 3#count\n',
84
+ files: { 'AGENTS.md': 'x'.repeat(50) },
85
+ dirs: { '.agents/rules': ['r01-a.md', 'r02-b.md', 'r03-c.md'] },
86
+ });
87
+ check('T5 混合表双通过 exit 0', run(root).code === 0);
88
+
89
+ // ---- T6 缺预算表 → exit 0(fixture 沙箱不拦,既有语义)----
90
+ root = makeFixture({ budgets: null });
91
+ check('T6 缺预算表 exit 0', run(root).code === 0);
92
+
93
+ // ---- T7 --staged 无 git(staged 空):#count 门跳过不拦 ----
94
+ root = makeFixture({
95
+ budgets: '.agents/rules/ 2#count\n',
96
+ dirs: { '.agents/rules': ['r01-a.md', 'r02-b.md', 'r03-c.md'] },
97
+ });
98
+ {
99
+ const r = run(root, '--staged');
100
+ check('T7 --staged 无 git 环境跳过 #count 门 exit 0', r.code === 0, JSON.stringify(r));
101
+ }
102
+
103
+ // ---- T8 glob 形态 #count(* 后缀)也按条数执法 ----
104
+ root = makeFixture({
105
+ budgets: '.agents/commands/*.md 2#count\n',
106
+ dirs: { '.agents/commands': ['a.md', 'b.md', 'c.md', 'd.txt'] },
107
+ });
108
+ {
109
+ const r = run(root);
110
+ check('T8 glob #count 只数 *.md(3 项 > 2)exit 1', r.code === 1 && r.out.includes('3 项'), JSON.stringify(r));
111
+ }
112
+
113
+ for (const d of fs.readdirSync(os.tmpdir()).filter((f) => f.startsWith('rule-budget-'))) {
114
+ try { fs.rmSync(path.join(os.tmpdir(), d), { recursive: true, force: true }); } catch { /* Windows 文件锁容忍 */ }
115
+ }
116
+
117
+ console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
118
+ process.exit(fail ? 1 : 0);
@@ -24,9 +24,12 @@
24
24
  // 提交——新装户由 init 生成器首跑种子该文件,缺文件对 --check 是漂移(红),不静默放行。
25
25
  // 绿凭证摘要(gen-verify-evidence.mjs):全绿尾部自动重生成 workflow/verify-evidence.md——全绿事实与
26
26
  // 仓内摘要同刻生成,消除「本地台账已绿、committed 摘要滞后」的双真相分叉;生成失败响亮告警但不推翻绿裁决。
27
- // 用法:node .agents/scripts/verify.mjs [--list] [--test-cmd "<shell 命令>"] [--doc <本单 workflow 路径>]
28
- // --list:按执行序打印门 id 后退出,不执行(与 --test-cmd 互斥)。
27
+ // 用法:node .agents/scripts/verify.mjs [--list] [--test-cmd "<shell 命令>"] [--stub-gate "<命令>"] [--doc <本单 workflow 路径>]
28
+ // --list:按执行序打印门 id 后退出,不执行(与 --test-cmd / --stub-gate 互斥)。
29
29
  // --test-cmd 用于测试注入,或在无根 package.json 的装户仓指定项目测试命令;
30
+ // --stub-gate <命令>(2026-10-16 自源仓回灌):单门夹具但走**生产尾段**(落凭证 / 生成摘要)——
31
+ // 与 --test-cmd 的差异是不打 testCmd 标记、尾段按生产路径执行;**必须配 CHECK_LOOP_ROOT**(夹具根),
32
+ // 禁止在真实仓库写出可被当成关单凭证的绿行。
30
33
  // --doc 把凭证绑到本单,如 --doc workflow/intents/<日期>-<主题>.md
31
34
  // 测试:node templates/_agents/scripts/verify.test.mjs(--test-cmd 注入假命令 + CHECK_LOOP_ROOT 注入夹具)
32
35
  import fs from 'node:fs';
@@ -59,6 +62,24 @@ const fixtureGates = (cmd) => [
59
62
  const args = process.argv.slice(2);
60
63
  const explicitTestCmd = args.includes('--test-cmd') ? args[args.indexOf('--test-cmd') + 1] : null;
61
64
 
65
+ // --stub-gate(2026-10-16 自源仓 13f67ef7 回灌):单门夹具但走**生产尾段**(落凭证 / 生成摘要)。
66
+ // 与 --test-cmd 的关键差异:不打 testCmd 标记(fixture 模式绿行带 testCmd:true,生产凭证不与之混淆)。
67
+ // **必须配 CHECK_LOOP_ROOT**——禁止在真实仓库写出可被当成关单凭证的绿行(信任边界:夹具单门不是关单凭证)。
68
+ const stubIdx = args.indexOf('--stub-gate');
69
+ const stubCmd = stubIdx >= 0 ? args[stubIdx + 1] : null;
70
+ if (stubIdx >= 0 && !stubCmd) {
71
+ console.error('[verify] --stub-gate 需要一条命令');
72
+ process.exit(2);
73
+ }
74
+ if (stubCmd && explicitTestCmd) {
75
+ console.error('[verify] --stub-gate 与 --test-cmd 互斥');
76
+ process.exit(2);
77
+ }
78
+ if (stubCmd && !process.env.CHECK_LOOP_ROOT) {
79
+ console.error('[verify] --stub-gate 只允许配合 CHECK_LOOP_ROOT 夹具根(禁止在真实仓库落无关单凭证)');
80
+ process.exit(2);
81
+ }
82
+
62
83
  // --doc 解析(沿 --test-cmd 同款取下一 argv 的写法;缺值报用法错误 exit 1 与 --test-cmd 对称)。
63
84
  // 归一基准 = **ROOT**(不是 cwd)——消费方两处都以仓库根为基准(check-loop 检查 8 用
64
85
  // path.relative(ROOT, intent);confirm-doc 的 docRelOf 用 path.resolve(root, doc))。若这里按 cwd
@@ -78,12 +99,14 @@ const docRel = docArg
78
99
  // 装户仓判定:engine-tests 门只在**当前目录**有 package.json 时才跑(npm test 亦在该目录执行)。
79
100
  // 跳过时后续不落测试绿凭证(见全绿落账段)。显式 --test-cmd 不受此判定影响。
80
101
  const hasRootPackageJson = fs.existsSync(path.join(process.cwd(), 'package.json'));
81
- const skipTest = explicitTestCmd === null && !hasRootPackageJson;
82
- const gates = explicitTestCmd ? fixtureGates(explicitTestCmd) : GATES;
102
+ const skipTest = explicitTestCmd === null && stubCmd === null && !hasRootPackageJson;
103
+ const gates = explicitTestCmd ? fixtureGates(explicitTestCmd)
104
+ : stubCmd ? [{ id: 'stub', name: '夹具单门', cmd: stubCmd, folder: '.', type: 'shell' }]
105
+ : GATES;
83
106
 
84
107
  if (args.includes('--list')) {
85
- if (explicitTestCmd) {
86
- console.error('[verify] --list 与 --test-cmd 互斥');
108
+ if (explicitTestCmd || stubCmd) {
109
+ console.error('[verify] --list 与 --test-cmd / --stub-gate 互斥');
87
110
  process.exit(1);
88
111
  }
89
112
  for (const g of gates) console.log(g.id);
@@ -104,7 +127,7 @@ const recordFailure = (step, exitInfo, note) => {
104
127
  }
105
128
  };
106
129
 
107
- console.log(`[verify] 🚀 开始静态门验证 (共 ${gates.length} 项)${explicitTestCmd ? '【fixture 模式】' : ''}\n`);
130
+ console.log(`[verify] 🚀 开始静态门验证 (共 ${gates.length} 项)${explicitTestCmd ? '【fixture 模式】' : ''}${stubCmd ? '【stub 尾段】' : ''}\n`);
108
131
 
109
132
  let passedCount = 0;
110
133
  const outputs = {};
@@ -173,7 +196,8 @@ try {
173
196
 
174
197
  // 摘要事件原子化:全绿事实与仓内摘要(workflow/verify-evidence.md)同刻生成,消除「本地台账已绿、
175
198
  // committed 摘要滞后」的双真相分叉。仅生产模式(fixture 假门凭证不产 committed 摘要;测试门跳过时
176
- // 本就无凭证);生成失败响亮告警但不推翻绿裁决(同落账口径)。
199
+ // 本就无凭证);stub 尾段按生产路径执行(摘要落在 CHECK_LOOP_ROOT 夹具根,不触真实仓)。
200
+ // 生成失败响亮告警但不推翻绿裁决(同落账口径)。
177
201
  if (!explicitTestCmd && !skipTest) {
178
202
  const gen = spawnSync(process.execPath, [path.join(SCRIPT_DIR, 'gen-verify-evidence.mjs'), '--root', ROOT], { encoding: 'utf8' });
179
203
  if (gen.status === 0) {
@@ -257,6 +257,35 @@ const readLedger = (root) => {
257
257
  fs.rmSync(root10, { recursive: true, force: true });
258
258
  }
259
259
 
260
+ // ---- 场景 12:--stub-gate 走生产尾段(落凭证;2026-10-16 自源仓 13f67ef7 回灌)----
261
+ // 单门夹具但按生产路径落绿行(不打 testCmd 标记);必须配 CHECK_LOOP_ROOT。
262
+ {
263
+ const root = mkfix();
264
+ fs.mkdirSync(path.join(root, 'workflow'), { recursive: true });
265
+ const git = (args) => spawnSync('git', args, { cwd: root, encoding: 'utf8' });
266
+ git(['init', '-q']);
267
+ git(['config', 'user.email', 't@t']);
268
+ git(['config', 'user.name', 't']);
269
+ fs.writeFileSync(path.join(root, 'README.md'), 'stub\n');
270
+ git(['add', '-A']);
271
+ git(['commit', '-qm', 'base']);
272
+ const r = run(['--stub-gate', `node ${mkCmd(0)}`], { env: { ...process.env, CHECK_LOOP_ROOT: root, RUN_E2E: '' } });
273
+ const l = readLedger(root)[0] || null;
274
+ check('场景 12:stub 单门 exit 0', r.status === 0, `exit=${r.status}\n${r.stdout}\n${r.stderr}`);
275
+ check('场景 12:落绿行(suite=npm test)且无 testCmd 标记',
276
+ l !== null && l.suite === 'npm test' && l.exitCode === 0 && !('testCmd' in l), JSON.stringify(l));
277
+ fs.rmSync(root, { recursive: true, force: true });
278
+ }
279
+ // 场景 12b:无 CHECK_LOOP_ROOT → exit 2,不跑门(信任边界:夹具单门不得当真实凭证)
280
+ {
281
+ const env = { ...process.env, RUN_E2E: '' };
282
+ delete env.CHECK_LOOP_ROOT;
283
+ const r = run(['--stub-gate', 'node -e "process.exit(0)"'], { env });
284
+ check('场景 12b:--stub-gate 无 CHECK_LOOP_ROOT → exit 2 且不跑门',
285
+ r.status === 2 && String(r.stderr).includes('CHECK_LOOP_ROOT') && !String(r.stdout).includes('全绿'),
286
+ `exit=${r.status}\n${r.stdout}\n${r.stderr}`);
287
+ }
288
+
260
289
  // ---- 场景 11:--list 锁定 engine cohort 七门序,不执行门 ----
261
290
  {
262
291
  const expect = ['scripts-syntax', 'engine-tests', 'workflows-check', 'rule-budget', 'effective-policy', 'check-loop', 'ledger-invariant'];