agentic-flow-kit 1.2.2 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -4
- package/modules/hosts/claude/commands/wf-test.md +1 -1
- package/modules/hosts/codex/commands/wf-test.md +1 -1
- package/modules/hosts/cursor/commands/wf-test.md +1 -1
- package/modules/hosts/opencode/commands/wf-test.md +1 -1
- package/modules/hosts/trae/commands/wf-test.md +1 -1
- package/package.json +1 -1
- package/src/fresh-init.test.mjs +10 -0
- package/src/init.mjs +1 -1
- package/src/profiles.mjs +12 -6
- package/src/sync.mjs +30 -26
- package/src/sync.test.mjs +82 -0
- package/templates/AGENTS.md +1 -0
- package/templates/_agents/commands/test.md +1 -1
- package/templates/_agents/rule-budgets.txt +3 -1
- package/templates/_agents/scripts/agg-delegations.cjs +56 -17
- package/templates/_agents/scripts/agg-delegations.test.mjs +76 -1
- package/templates/_agents/scripts/agg-gate-stats.mjs +113 -0
- package/templates/_agents/scripts/agg-gate-stats.test.mjs +95 -0
- package/templates/_agents/scripts/check-hygiene.mjs +167 -0
- package/templates/_agents/scripts/check-hygiene.test.mjs +182 -0
- package/templates/_agents/scripts/check-ledger.mjs +60 -16
- package/templates/_agents/scripts/check-ledger.test.mjs +70 -0
- package/templates/_agents/scripts/check-loop-unit.test.mjs +95 -0
- package/templates/_agents/scripts/check-loop.mjs +1119 -1097
- package/templates/_agents/scripts/check-loop.test.mjs +200 -1
- package/templates/_agents/scripts/confirm-doc.mjs +27 -6
- package/templates/_agents/scripts/confirm-doc.test.mjs +35 -0
- package/templates/_agents/scripts/gate-seg.mjs +54 -0
- package/templates/_agents/scripts/gate-seg.test.mjs +112 -0
- package/templates/_agents/scripts/gen-workflow-dashboard.mjs +249 -0
- package/templates/_agents/scripts/gen-workflow-dashboard.test.mjs +189 -0
- package/templates/_agents/scripts/policy.mjs +31 -3
- package/templates/_agents/scripts/verify.mjs +27 -2
- package/templates/_agents/scripts/verify.test.mjs +53 -0
- package/templates/_github/workflows/kit-ci.yml +28 -0
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// L0/L1 勾触达红线 → 「红线判低」hard——协作道不受理 STOP 级改动,就高不就低,
|
|
18
18
|
// 2026-09-30 hybrid-governance-risk-lanes) [hard-block]
|
|
19
19
|
// 2. 模板字段占位符残留(YYYY-MM-DD / <主题> 等未替换;<主题> 与 .md 同行 = 命名约定描述,豁免) [warning]
|
|
20
|
+
// (2026-10-08 selfmeasure-and-modularize:实现已迁 check-hygiene.mjs,判据与文案未变)
|
|
20
21
|
// 3. incidents 复盘三件套完整性 + 状态严格枚举 + 新 intent 回路(回路断档=hard,其他=warning)
|
|
21
22
|
// 4. 引用有效性(文档/指令中引用的 .agents/ 路径必须存在;支持 fill-{a,b,c}.mjs 花括号展开与 fill-*.mjs 通配;
|
|
22
23
|
// workflow 文档仅扫活跃态——终态件的引用是历史叙述,不扫,2026-09-27 audit-gate-hardening) [warning]
|
|
@@ -29,11 +30,15 @@
|
|
|
29
30
|
// 非 git / 查不到加入记录 → 不可判定 → 走存量口径(不误报 hard)
|
|
30
31
|
// 证据可写在 [x] 行的续行(仓库通写法「(证据:…)」另起一行;全/半角冒号皆认)
|
|
31
32
|
// 9. 文件名英文 kebab-case(非 ASCII 文件名=warning,2026-09-11 规则)
|
|
33
|
+
// (2026-10-08 selfmeasure-and-modularize:实现已迁 check-hygiene.mjs,判据与文案未变)
|
|
32
34
|
// 10. 级别 vs 迁移文件一致性(L1/L2 入口文档加入提交触及迁移 SQL/Migrations=疑似判低,warning)
|
|
33
|
-
// 11. workflow/INDEX.md
|
|
35
|
+
// 11. 生成物漂移:workflow/INDEX.md(2026-09-21 检索层) + workflow/DASHBOARD.md(2026-10-08 扩覆盖面)=warning;
|
|
36
|
+
// 各自调对应生成器 --check,口径单一不复刻渲染;实现已迁 check-hygiene.mjs
|
|
34
37
|
// 12. frontmatter「模块:」合法性(枚举非法 / 2026-09-22 起新建缺字段=warning;词表单源 .agents/workflow-modules.txt)
|
|
38
|
+
// (2026-10-08 selfmeasure-and-modularize:实现已迁 check-hygiene.mjs,判据与文案未变)
|
|
35
39
|
// 13. 常驻面体积预算(超限=warning;判定单源 rule-budget.sh——经 sh 调用,
|
|
36
40
|
// 无 sh 环境静默跳过:advisory 级且 pre-commit 侧在 git 钩子 sh 环境照常硬拦)
|
|
41
|
+
// (2026-10-08 selfmeasure-and-modularize:实现已迁 check-hygiene.mjs,判据与文案未变)
|
|
37
42
|
// 14. 新 done 的 spec/plan 须在 git 历史里出现过 `状态: approved`(确认环节留痕,2026-09-22;恒 advisory 永不升级 hard)
|
|
38
43
|
// 15. 确认指纹对账(2026-09-27 起:approved/done 须 confirm-doc.mjs 确认指纹+台账配对,缺=hard-block;
|
|
39
44
|
// 两形态——TTY 亲手 / --delegated 对话委托代录,台账 source 如实区分,配对判据与 source 无关)
|
|
@@ -102,6 +107,8 @@
|
|
|
102
107
|
// .githooks/pre-push 对 remote=refs/heads/main 的推送传入;不占 1-20 编号,gate-checklist 登记表
|
|
103
108
|
// 按号配对面不变,门体说明见加固门段注释)
|
|
104
109
|
// 测试:node templates/_agents/scripts/check-loop.test.mjs(fixture 注入 CHECK_LOOP_ROOT)
|
|
110
|
+
// import 契约(2026-10-08 checkloop-importable):import { runCheckLoop, delegationResultRows, versionGreater, fmStatus, bindingSha256 }
|
|
111
|
+
// ——零副作用;runCheckLoop({root?, rev?, hardening?}) 返回 { exitCode, blockers, warnings }(输出打印照旧,静默断言自行重定向 stderr)
|
|
105
112
|
import fs from 'node:fs';
|
|
106
113
|
import os from 'node:os';
|
|
107
114
|
import path from 'node:path';
|
|
@@ -116,6 +123,11 @@ import { laneOfEntry } from './stage-gates.mjs';
|
|
|
116
123
|
// MARK_RE:incident 留痕形态的单源(第七轮复核 N3——此前 check-loop 内联复制一份同口径字面量,
|
|
117
124
|
// 两处靠注释与人工同步;改为复用 stage-gates.mjs 的导出,从结构上消除漂移可能)
|
|
118
125
|
import { MARK_RE, approvedTraceHit } from './stage-gates.mjs';
|
|
126
|
+
// 卫生类检查已拆出(2026-10-08 selfmeasure-and-modularize):检查 2/9/11/12/13 迁入 check-hygiene.mjs,
|
|
127
|
+
// 本文件只组装 ctx 并消费返回的文案数组(先例 = check-metric-claims.mjs 承载检查 16)。
|
|
128
|
+
// 调用点位置即输出顺序位:须留在检查 10 与 12 之间以保持 warnings 行序(稳定输出契约),见该模块内注释。
|
|
129
|
+
import { runCheckHygiene } from './check-hygiene.mjs';
|
|
130
|
+
import { gateSeg, finishSegs, makeCollector } from './gate-seg.mjs';
|
|
119
131
|
|
|
120
132
|
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
121
133
|
// CHECK_LOOP_GIT:测试注入钩子(指向不存在/不可执行路径可稳定触发 spawn 异常);未设置时与原行为逐字节一致(2026-10-05-gitout-fail-open)
|
|
@@ -123,1214 +135,1258 @@ const GIT = process.env.CHECK_LOOP_GIT || (process.platform === 'win32' ? 'git.e
|
|
|
123
135
|
|
|
124
136
|
function takeOpts() {
|
|
125
137
|
const args = process.argv.slice(2);
|
|
126
|
-
const opts = { rev: null, hardening: false };
|
|
138
|
+
const opts = { rev: null, hardening: false, gateStats: false };
|
|
127
139
|
for (let i = 0; i < args.length; i++) {
|
|
128
140
|
if (args[i] === '--rev') {
|
|
129
141
|
opts.rev = args[++i] ?? '';
|
|
130
142
|
if (!opts.rev) {
|
|
131
|
-
console.error('check-loop: 用法 node check-loop.mjs [--rev <sha>] [--hardening]');
|
|
143
|
+
console.error('check-loop: 用法 node check-loop.mjs [--rev <sha>] [--hardening] [--gate-stats]');
|
|
132
144
|
process.exit(1);
|
|
133
145
|
}
|
|
134
146
|
} else if (args[i] === '--hardening') {
|
|
135
147
|
opts.hardening = true; // 加固门(2026-09-30 hybrid-governance-explore-hardening):pre-push 对 main 目标传入
|
|
148
|
+
} else if (args[i] === '--gate-stats') {
|
|
149
|
+
opts.gateStats = true; // 门禁 ROI 落盘(2026-10-08 gate-roi-metrics):默认路径输出逐字节不变
|
|
136
150
|
} else {
|
|
137
|
-
console.error('check-loop: 用法 node check-loop.mjs [--rev <sha>] [--hardening]');
|
|
151
|
+
console.error('check-loop: 用法 node check-loop.mjs [--rev <sha>] [--hardening] [--gate-stats]');
|
|
138
152
|
process.exit(1);
|
|
139
153
|
}
|
|
140
154
|
}
|
|
141
155
|
return opts;
|
|
142
156
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
157
|
+
|
|
158
|
+
// ---- runCheckLoop:门禁主流程可 import 化(2026-10-08 checkloop-importable;papercuts 2026-10-04 isMain 行点名单独立项)----
|
|
159
|
+
// import 本模块零副作用(isMain 主守卫,board-kb-p1 先例);opts = { root?, rev?, hardening? }——root 显式参数
|
|
160
|
+
// 优先于 CHECK_LOOP_ROOT env 优先于 git toplevel(CLI 缺省行为逐字节不变);返回 { exitCode, blockers, warnings },
|
|
161
|
+
// 输出打印照旧在本函数内(稳定输出契约:banner 后空行、条目 `- ` 前缀、HARD-BLOCK/WARN 两段式——消费者 .githooks/pre-push)。
|
|
162
|
+
// opts.rev 的 worktree 清理挂 process.on('exit')——进程内多次调用累计监听器(测试场景 ≤3 次,可接受)。
|
|
163
|
+
// ---- 门禁 ROI 插桩件已抽至 gate-seg.mjs(2026-10-08 gate-roi-metrics)----
|
|
164
|
+
// 抽因:check-loop.mjs 已 import check-hygiene.mjs,后者若再 import check-loop.mjs 即循环依赖——
|
|
165
|
+
// 而两者都要插桩,判据单源必须只有一处。gateSeg / finishSegs / makeCollector 见该件;
|
|
166
|
+
// 本文件在每段开头调 markGate(id,label),统计由数组长度差推出,不动任何 push 调用点。
|
|
167
|
+
|
|
168
|
+
// writeGateStats:门禁 ROI 落盘(2026-10-08 gate-roi-metrics)——append 一行到
|
|
169
|
+
// <root>/.agents/cache/gate-stats.jsonl(该目录已 gitignore,属运行时数据、不入 git、不进 kit.json
|
|
170
|
+
// managed 台账——刻意避开 papercuts 2026-10-05 探针临时件被 sync 收编的事故面)。
|
|
171
|
+
// IO 失败只出账不阻断(沿 verify.mjs 凭证落账同款容错——度量失败不该让门禁变成红灯)。
|
|
172
|
+
export function writeGateStats(root, segs) {
|
|
173
|
+
try {
|
|
174
|
+
const dir = path.join(root, '.agents', 'cache');
|
|
175
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
176
|
+
const total = segs.reduce((a, s) => a + s.ms, 0);
|
|
177
|
+
fs.appendFileSync(path.join(dir, 'gate-stats.jsonl'),
|
|
178
|
+
`${JSON.stringify({ ts: new Date().toISOString(), root, segs, totalMs: total })}\n`);
|
|
179
|
+
const hits = segs.filter((s) => s.warns || s.blocks).length;
|
|
180
|
+
console.error(`[gate-stats] ${segs.length} 段已记账(命中 ${hits} 段)→ .agents/cache/gate-stats.jsonl`);
|
|
181
|
+
} catch (e) {
|
|
182
|
+
console.error(`[gate-stats] ⚠️ 落盘失败(${e.code || e.message})——不阻断门禁`);
|
|
183
|
+
}
|
|
151
184
|
}
|
|
152
|
-
let REV = null;
|
|
153
185
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
process.exit(1);
|
|
186
|
+
export function runCheckLoop(opts = {}) {
|
|
187
|
+
const REV_ARG = opts.rev ?? null;
|
|
188
|
+
const HARDENING = !!opts.hardening;
|
|
189
|
+
if (REV_ARG && (opts.root || process.env.CHECK_LOOP_ROOT)) {
|
|
190
|
+
console.error('check-loop: --rev 不能同时使用 root 参数 / CHECK_LOOP_ROOT');
|
|
191
|
+
return { exitCode: 1, blockers: [], warnings: [] };
|
|
161
192
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (REV_ARG) {
|
|
166
|
-
console.error(`check-loop: --rev 剥不到提交 ${REV_ARG}`);
|
|
167
|
-
process.exit(1);
|
|
168
|
-
}
|
|
169
|
-
console.error('check-loop: 不在 git 仓库内,跳过扫描');
|
|
170
|
-
process.exit(0);
|
|
193
|
+
if (REV_ARG && /^0+$/.test(REV_ARG)) {
|
|
194
|
+
console.error(`check-loop: --rev 不是提交 ${REV_ARG}`);
|
|
195
|
+
return { exitCode: 1, blockers: [], warnings: [] };
|
|
171
196
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
197
|
+
let REV = null;
|
|
198
|
+
|
|
199
|
+
// ---- 根目录与枚举单源 ----
|
|
200
|
+
let ROOT;
|
|
201
|
+
if (opts.root) {
|
|
202
|
+
ROOT = path.resolve(opts.root);
|
|
203
|
+
if (!fs.existsSync(ROOT)) {
|
|
204
|
+
console.error('check-loop: root 不可访问:' + opts.root);
|
|
205
|
+
return { exitCode: 1, blockers: [], warnings: [] };
|
|
178
206
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
cwd: main, encoding: 'utf8',
|
|
185
|
-
});
|
|
186
|
-
if (add.status !== 0) {
|
|
187
|
-
fs.rmSync(parent, { recursive: true, force: true });
|
|
188
|
-
console.error(`check-loop: 导出提交失败 ${REV_ARG}\n${add.stderr || add.stdout || ''}`);
|
|
189
|
-
process.exit(1);
|
|
207
|
+
} else if (process.env.CHECK_LOOP_ROOT) {
|
|
208
|
+
ROOT = path.resolve(process.env.CHECK_LOOP_ROOT);
|
|
209
|
+
if (!fs.existsSync(ROOT)) {
|
|
210
|
+
console.error('check-loop: CHECK_LOOP_ROOT 不可访问:' + process.env.CHECK_LOOP_ROOT);
|
|
211
|
+
return { exitCode: 1, blockers: [], warnings: [] };
|
|
190
212
|
}
|
|
191
|
-
process.on('exit', () => {
|
|
192
|
-
spawnSync(GIT, ['worktree', 'remove', '--force', wt], { cwd: main });
|
|
193
|
-
fs.rmSync(parent, { recursive: true, force: true });
|
|
194
|
-
});
|
|
195
|
-
ROOT = wt;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
// 枚举单源(<root>/.agents/workflow-enums.txt——fixture 契约:缺文件/缺键 fail-loud)
|
|
199
|
-
let ENUMS;
|
|
200
|
-
try {
|
|
201
|
-
ENUMS = loadEnums(path.join(ROOT, '.agents', 'workflow-enums.txt'));
|
|
202
|
-
} catch (e) {
|
|
203
|
-
const msg = String(e.message || '');
|
|
204
|
-
if (msg.includes('不可读')) {
|
|
205
|
-
console.error(`check-loop: 缺 ${path.join('.agents', 'workflow-enums.txt')}(枚举单源)——跑 flow-kit sync 恢复后重试`);
|
|
206
213
|
} else {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
const stOkDoc = (st) => inSet(st, ENUMS['doc.status.confirmed']);
|
|
213
|
-
|
|
214
|
-
// git 调用(参数数组形式不走 shell——Windows cmd 不认单引号;git.exe 显式解析沿 doctor 先例)
|
|
215
|
-
// 基础设施异常与业务失败分流(2026-10-05-gitout-fail-open):spawn 异常响亮出账(进程级去重恰一条,
|
|
216
|
-
// 沿检查 16 装户「载入失败响亮出账」先例)+ 瞬时类错误码单次重试;「非 git 仓」(status≠0)仍是
|
|
217
|
-
// 合法装户语义、静默走降级。intentional-simple: 重试后仍异常继续走原降级/拦截语义(连续瞬时异常
|
|
218
|
-
// 残余面小且有账可查);升级路径 = 探测门(:720/:796)改三态拆分
|
|
219
|
-
let gitInfraWarned = false;
|
|
220
|
-
function warnGitInfra(args, code) {
|
|
221
|
-
if (gitInfraWarned) return;
|
|
222
|
-
gitInfraWarned = true;
|
|
223
|
-
console.error(`check-loop: git 探测异常 ${args[0]}(${code})——按非 git 仓降级,检查面可能收窄`);
|
|
224
|
-
}
|
|
225
|
-
const GIT_TRANSIENT = new Set(['EAGAIN', 'EPERM', 'EMFILE', 'EINTR']);
|
|
226
|
-
function spawnGit(args) {
|
|
227
|
-
const opts = { cwd: ROOT, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 };
|
|
228
|
-
let r = spawnSync(GIT, args, opts);
|
|
229
|
-
if (r.error) {
|
|
230
|
-
// 出账在重试终败后(与 linesOf 对称——瞬时重试恢复成功不打扰);非瞬时类直接出账
|
|
231
|
-
if (GIT_TRANSIENT.has(r.error.code)) r = spawnSync(GIT, args, opts);
|
|
232
|
-
if (r.error) warnGitInfra(args, r.error.code || r.error.message);
|
|
233
|
-
}
|
|
234
|
-
return r;
|
|
235
|
-
}
|
|
236
|
-
function gitOut(args, { ok = () => true } = {}) {
|
|
237
|
-
const r = spawnGit(args);
|
|
238
|
-
if (r.error || r.status !== 0 || !ok(r)) return null;
|
|
239
|
-
return r.stdout || '';
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// ---- frontmatter 受限子集读取(fm_get 语义:首行 --- 进入、下一 --- 闭合、键在行首、首个命中、值两侧去空白)----
|
|
243
|
-
// fs 读取同走 fail-loud(2026-10-05-gitout-fail-open 实现期诊断追加,与 spawnGit 同一故障类):
|
|
244
|
-
// readFileSync 瞬时异常(宿主资源压力 EMFILE/共享冲突类)重试一次,仍失败响亮出账(进程级去重)
|
|
245
|
-
// 后按 null 降级——调用方按空文档跳过该件,但异常本身不再静默
|
|
246
|
-
let fsReadWarned = new Set();
|
|
247
|
-
const fileLinesCache = new Map();
|
|
248
|
-
function linesOf(file) {
|
|
249
|
-
if (!fileLinesCache.has(file)) {
|
|
250
|
-
let lines = null;
|
|
251
|
-
try {
|
|
252
|
-
lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
|
|
253
|
-
} catch (e) {
|
|
254
|
-
try { lines = fs.readFileSync(file, 'utf8').split(/\r?\n/); } catch { lines = null; }
|
|
255
|
-
if (!lines && !fsReadWarned.has(file)) {
|
|
256
|
-
fsReadWarned.add(file);
|
|
257
|
-
console.error(`check-loop: 文档读取异常 ${path.basename(file)}(${e.code || e.message})——按空文档降级,检查面可能收窄`);
|
|
214
|
+
const top = gitOut(['rev-parse', '--show-toplevel']);
|
|
215
|
+
if (top === null) {
|
|
216
|
+
if (REV_ARG) {
|
|
217
|
+
console.error(`check-loop: --rev 剥不到提交 ${REV_ARG}`);
|
|
218
|
+
return { exitCode: 1, blockers: [], warnings: [] };
|
|
258
219
|
}
|
|
220
|
+
console.error('check-loop: 不在 git 仓库内,跳过扫描');
|
|
221
|
+
return { exitCode: 0, blockers: [], warnings: [] };
|
|
259
222
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
warnings.push = () => warnings.length;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// ---- 已提交(HEAD)过滤:仓库模式只扫 tracked(fixture 模式恒真;ls-tree 失败退化全扫)----
|
|
288
|
-
let trackedSet = null; // null = 不过滤
|
|
289
|
-
if (!process.env.CHECK_LOOP_ROOT) {
|
|
290
|
-
const out = gitOut(['ls-tree', '-r', '--name-only', 'HEAD', '--', 'workflow']);
|
|
291
|
-
if (out !== null) trackedSet = new Set(out.split('\n').map((l) => l.trim()).filter(Boolean));
|
|
292
|
-
}
|
|
293
|
-
const isTracked = (abs) => trackedSet === null || trackedSet.has(path.relative(ROOT, abs).split(path.sep).join('/'));
|
|
294
|
-
const docFiles = (sub) => {
|
|
295
|
-
const dir = path.join(ROOT, WF, sub);
|
|
296
|
-
if (!fs.existsSync(dir)) return [];
|
|
297
|
-
return fs.readdirSync(dir)
|
|
298
|
-
.filter((f) => f.endsWith('.md') && /^\d/.test(f))
|
|
299
|
-
.map((f) => path.join(dir, f))
|
|
300
|
-
.filter((f) => isTracked(f))
|
|
301
|
-
.sort();
|
|
302
|
-
};
|
|
303
|
-
|
|
304
|
-
// --- 1. intent/spec/plan 同名配对 + 状态确认 + L3 确认三件 + 风险泳道一致性 [hard-block] ---
|
|
305
|
-
for (const intent of docFiles('intents')) {
|
|
306
|
-
const base = path.basename(intent);
|
|
307
|
-
const st = fmGet(intent, '状态');
|
|
308
|
-
const lvl = fmGet(intent, '级别');
|
|
309
|
-
const risk = fmGet(intent, 'risk_level'); // 风险泳道字段(fill-intent 落,=级别;2026-09-30 hybrid-governance-risk-lanes)
|
|
310
|
-
if (!stOkDoc(st)) {
|
|
311
|
-
const msg = `${base}(frontmatter 状态键当前值:『${st || '缺失'}』)`;
|
|
312
|
-
if (lvl === 'L3') blockers.push(`- [状态未确认] L3 intent 必须为 approved/done/superseded/cancelled:${msg}`);
|
|
313
|
-
else warnings.push(`- [WARN 状态未确认] intent 必须为 approved/done/superseded/cancelled:${msg}`);
|
|
314
|
-
}
|
|
315
|
-
if (lvl === 'L2' || lvl === 'L3') {
|
|
316
|
-
if (!fs.existsSync(path.join(ROOT, WF, 'specs', base))) {
|
|
317
|
-
blockers.push(`- [配对断裂] intent 缺 spec:${base}(应在 ${WF}/specs/ 下同名)`);
|
|
223
|
+
ROOT = top.trim();
|
|
224
|
+
if (REV_ARG) {
|
|
225
|
+
const peeled = gitOut(['rev-parse', '--verify', `${REV_ARG}^{commit}`]);
|
|
226
|
+
if (!peeled) {
|
|
227
|
+
console.error(`check-loop: --rev 剥不到提交 ${REV_ARG}`);
|
|
228
|
+
return { exitCode: 1, blockers: [], warnings: [] };
|
|
229
|
+
}
|
|
230
|
+
REV = peeled.trim();
|
|
231
|
+
const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-rev-'));
|
|
232
|
+
const wt = path.join(parent, 'wt');
|
|
233
|
+
const main = ROOT;
|
|
234
|
+
const add = spawnSync(GIT, ['worktree', 'add', '--detach', '--quiet', wt, REV], {
|
|
235
|
+
cwd: main, encoding: 'utf8',
|
|
236
|
+
});
|
|
237
|
+
if (add.status !== 0) {
|
|
238
|
+
fs.rmSync(parent, { recursive: true, force: true });
|
|
239
|
+
console.error(`check-loop: 导出提交失败 ${REV_ARG}\n${add.stderr || add.stdout || ''}`);
|
|
240
|
+
return { exitCode: 1, blockers: [], warnings: [] };
|
|
241
|
+
}
|
|
242
|
+
process.on('exit', () => {
|
|
243
|
+
spawnSync(GIT, ['worktree', 'remove', '--force', wt], { cwd: main });
|
|
244
|
+
fs.rmSync(parent, { recursive: true, force: true });
|
|
245
|
+
});
|
|
246
|
+
ROOT = wt;
|
|
318
247
|
}
|
|
319
248
|
}
|
|
320
|
-
//
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
.
|
|
330
|
-
if (redlineTouched) {
|
|
331
|
-
blockers.push(`- [红线判低] ${base} 级别/风险泳道为 ${[lvl, risk].filter(Boolean).join('/')} 但触达红线已勾选(规则/契约或 schema/结构面)——协作道不受理 STOP 级改动:就高不就低,把「级别」与「risk_level」两字段对齐到 L2/L3(fill-intent 双写口径)并补同名 spec`);
|
|
249
|
+
// 枚举单源(<root>/.agents/workflow-enums.txt——fixture 契约:缺文件/缺键 fail-loud)
|
|
250
|
+
let ENUMS;
|
|
251
|
+
try {
|
|
252
|
+
ENUMS = loadEnums(path.join(ROOT, '.agents', 'workflow-enums.txt'));
|
|
253
|
+
} catch (e) {
|
|
254
|
+
const msg = String(e.message || '');
|
|
255
|
+
if (msg.includes('不可读')) {
|
|
256
|
+
console.error(`check-loop: 缺 ${path.join('.agents', 'workflow-enums.txt')}(枚举单源)——跑 flow-kit sync 恢复后重试`);
|
|
257
|
+
} else {
|
|
258
|
+
console.error('check-loop: 枚举单源 ' + msg);
|
|
332
259
|
}
|
|
260
|
+
return { exitCode: 1, blockers: [], warnings: [] };
|
|
333
261
|
}
|
|
334
|
-
|
|
262
|
+
const inSet = (v, arr) => arr.includes(v);
|
|
263
|
+
const stOkDoc = (st) => inSet(st, ENUMS['doc.status.confirmed']);
|
|
335
264
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
265
|
+
// git 调用(参数数组形式不走 shell——Windows cmd 不认单引号;git.exe 显式解析沿 doctor 先例)
|
|
266
|
+
// 基础设施异常与业务失败分流(2026-10-05-gitout-fail-open):spawn 异常响亮出账(进程级去重恰一条,
|
|
267
|
+
// 沿检查 16 装户「载入失败响亮出账」先例)+ 瞬时类错误码单次重试;「非 git 仓」(status≠0)仍是
|
|
268
|
+
// 合法装户语义、静默走降级。intentional-simple: 重试后仍异常继续走原降级/拦截语义(连续瞬时异常
|
|
269
|
+
// 残余面小且有账可查);升级路径 = 探测门(:720/:796)改三态拆分
|
|
270
|
+
let gitInfraWarned = false;
|
|
271
|
+
function warnGitInfra(args, code) {
|
|
272
|
+
if (gitInfraWarned) return;
|
|
273
|
+
gitInfraWarned = true;
|
|
274
|
+
console.error(`check-loop: git 探测异常 ${args[0]}(${code})——按非 git 仓降级,检查面可能收窄`);
|
|
346
275
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
if (
|
|
352
|
-
|
|
276
|
+
const GIT_TRANSIENT = new Set(['EAGAIN', 'EPERM', 'EMFILE', 'EINTR']);
|
|
277
|
+
function spawnGit(args) {
|
|
278
|
+
const opts = { cwd: ROOT, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 };
|
|
279
|
+
let r = spawnSync(GIT, args, opts);
|
|
280
|
+
if (r.error) {
|
|
281
|
+
// 出账在重试终败后(与 linesOf 对称——瞬时重试恢复成功不打扰);非瞬时类直接出账
|
|
282
|
+
if (GIT_TRANSIENT.has(r.error.code)) r = spawnSync(GIT, args, opts);
|
|
283
|
+
if (r.error) warnGitInfra(args, r.error.code || r.error.message);
|
|
353
284
|
}
|
|
285
|
+
return r;
|
|
354
286
|
}
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
const st = fmGet(plan, '状态');
|
|
360
|
-
const lvl = fmGet(plan, '级别');
|
|
361
|
-
const hasEntry = fs.existsSync(path.join(ROOT, WF, 'intents', base)) || fs.existsSync(path.join(ROOT, WF, 'incidents', base));
|
|
362
|
-
if (!hasEntry) blockers.push(`- [配对断裂] plan 缺 intent/incident:${base}(应在 ${WF}/intents/ 或 ${WF}/incidents/ 下同名)`);
|
|
363
|
-
if (!stOkDoc(st)) {
|
|
364
|
-
const msg = `${base}(frontmatter 状态键当前值:『${st || '缺失'}』)`;
|
|
365
|
-
if (lvl === 'L3') blockers.push(`- [状态未确认] L3 plan 必须为 approved/done/superseded/cancelled:${msg}`);
|
|
366
|
-
else warnings.push(`- [WARN 状态未确认] plan 必须为 approved/done/superseded/cancelled:${msg}`);
|
|
287
|
+
function gitOut(args, { ok = () => true } = {}) {
|
|
288
|
+
const r = spawnGit(args);
|
|
289
|
+
if (r.error || r.status !== 0 || !ok(r)) return null;
|
|
290
|
+
return r.stdout || '';
|
|
367
291
|
}
|
|
368
|
-
}
|
|
369
292
|
|
|
370
|
-
//
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
// /十二小时制等)出现假阳性再扩。
|
|
390
|
-
const stripSamples = (lines) => {
|
|
391
|
-
let inFence = false;
|
|
392
|
-
return lines.map((line) => {
|
|
393
|
-
if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; return ''; }
|
|
394
|
-
if (inFence) return '';
|
|
395
|
-
return line.replace(/`[^`]*`/g, '').replace(/YYYY-MM-DD\s+HH:mm(:ss)?/g, '');
|
|
396
|
-
});
|
|
397
|
-
};
|
|
398
|
-
const groups = new Map(); // file -> [ "行号:内容" ](首现序)
|
|
399
|
-
for (const sub of DOC_DIRS) {
|
|
400
|
-
for (const f of docFiles(sub)) {
|
|
401
|
-
const lines = linesOf(f) || [];
|
|
402
|
-
const hits = [];
|
|
403
|
-
stripSamples(lines).forEach((line, i) => {
|
|
404
|
-
if (phRe.test(line) && !boilerRe.test(line) && !isNamingConv(line)) hits.push(`${i + 1}:${lines[i]}`);
|
|
405
|
-
});
|
|
406
|
-
if (hits.length) groups.set(f, hits);
|
|
293
|
+
// ---- frontmatter 受限子集读取(fm_get 语义:首行 --- 进入、下一 --- 闭合、键在行首、首个命中、值两侧去空白)----
|
|
294
|
+
// fs 读取同走 fail-loud(2026-10-05-gitout-fail-open 实现期诊断追加,与 spawnGit 同一故障类):
|
|
295
|
+
// readFileSync 瞬时异常(宿主资源压力 EMFILE/共享冲突类)重试一次,仍失败响亮出账(进程级去重)
|
|
296
|
+
// 后按 null 降级——调用方按空文档跳过该件,但异常本身不再静默
|
|
297
|
+
let fsReadWarned = new Set();
|
|
298
|
+
const fileLinesCache = new Map();
|
|
299
|
+
function linesOf(file) {
|
|
300
|
+
if (!fileLinesCache.has(file)) {
|
|
301
|
+
let lines = null;
|
|
302
|
+
try {
|
|
303
|
+
lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
|
|
304
|
+
} catch (e) {
|
|
305
|
+
try { lines = fs.readFileSync(file, 'utf8').split(/\r?\n/); } catch { lines = null; }
|
|
306
|
+
if (!lines && !fsReadWarned.has(file)) {
|
|
307
|
+
fsReadWarned.add(file);
|
|
308
|
+
console.error(`check-loop: 文档读取异常 ${path.basename(file)}(${e.code || e.message})——按空文档降级,检查面可能收窄`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
fileLinesCache.set(file, lines);
|
|
407
312
|
}
|
|
313
|
+
return fileLinesCache.get(file);
|
|
408
314
|
}
|
|
409
|
-
|
|
410
|
-
|
|
315
|
+
function fmGet(file, key) {
|
|
316
|
+
const lines = linesOf(file);
|
|
317
|
+
if (!lines) return '';
|
|
318
|
+
const isDelim = (l) => /^---\s*$/.test(l);
|
|
319
|
+
if (!isDelim(lines[0] || '')) return '';
|
|
320
|
+
for (let i = 1; i < lines.length; i++) {
|
|
321
|
+
if (isDelim(lines[i])) return '';
|
|
322
|
+
if (lines[i].startsWith(key + ':')) return lines[i].slice(key.length + 1).trim();
|
|
323
|
+
}
|
|
324
|
+
return '';
|
|
411
325
|
}
|
|
412
|
-
}
|
|
413
326
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
const
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
327
|
+
const WF = 'workflow';
|
|
328
|
+
const DOC_DIRS = ['intents', 'specs', 'plans', 'incidents'];
|
|
329
|
+
const blockers = [];
|
|
330
|
+
const warnings = [];
|
|
331
|
+
// 门禁 ROI 收集器(2026-10-08 gate-roi-metrics):引用同一对数组 + 段游标,不复制任何数据。
|
|
332
|
+
// 默认路径不打印不写盘(见 gate-seg.mjs 注释的输出契约硬约束)。
|
|
333
|
+
const gateStats = makeCollector(warnings, blockers);
|
|
334
|
+
const markGate = (id, label) => gateSeg(gateStats, id, label); // 局部别名,避免遮蔽同名导出
|
|
335
|
+
// audit:false(init 新装)只保留 blockers。缺省与 audit:true 保持全量警告。
|
|
336
|
+
// 硬规则:配对 / 验收勾验的阻断 / 确认留痕 / 发版草稿。卫生项走 warnings。
|
|
337
|
+
const kitPolicy = loadKitPolicy(ROOT);
|
|
338
|
+
if (!auditEnabled(kitPolicy)) {
|
|
339
|
+
warnings.push = () => warnings.length;
|
|
340
|
+
}
|
|
420
341
|
|
|
421
|
-
|
|
422
|
-
|
|
342
|
+
// ---- 已提交(HEAD)过滤:仓库模式只扫 tracked(fixture 模式恒真;ls-tree 失败退化全扫)----
|
|
343
|
+
let trackedSet = null; // null = 不过滤
|
|
344
|
+
if (!process.env.CHECK_LOOP_ROOT) {
|
|
345
|
+
const out = gitOut(['ls-tree', '-r', '--name-only', 'HEAD', '--', 'workflow']);
|
|
346
|
+
if (out !== null) trackedSet = new Set(out.split('\n').map((l) => l.trim()).filter(Boolean));
|
|
423
347
|
}
|
|
348
|
+
const isTracked = (abs) => trackedSet === null || trackedSet.has(path.relative(ROOT, abs).split(path.sep).join('/'));
|
|
349
|
+
const docFiles = (sub) => {
|
|
350
|
+
const dir = path.join(ROOT, WF, sub);
|
|
351
|
+
if (!fs.existsSync(dir)) return [];
|
|
352
|
+
return fs.readdirSync(dir)
|
|
353
|
+
.filter((f) => f.endsWith('.md') && /^\d/.test(f))
|
|
354
|
+
.map((f) => path.join(dir, f))
|
|
355
|
+
.filter((f) => isTracked(f))
|
|
356
|
+
.sort();
|
|
357
|
+
};
|
|
424
358
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
359
|
+
// --- 1. intent/spec/plan 同名配对 + 状态确认 + L3 确认三件 + 风险泳道一致性 [hard-block] ---
|
|
360
|
+
markGate('1', '意图/spec/plan 配对');
|
|
361
|
+
for (const intent of docFiles('intents')) {
|
|
362
|
+
const base = path.basename(intent);
|
|
363
|
+
const st = fmGet(intent, '状态');
|
|
364
|
+
const lvl = fmGet(intent, '级别');
|
|
365
|
+
const risk = fmGet(intent, 'risk_level'); // 风险泳道字段(fill-intent 落,=级别;2026-09-30 hybrid-governance-risk-lanes)
|
|
366
|
+
if (!stOkDoc(st)) {
|
|
367
|
+
const msg = `${base}(frontmatter 状态键当前值:『${st || '缺失'}』)`;
|
|
368
|
+
if (lvl === 'L3') blockers.push(`- [状态未确认] L3 intent 必须为 approved/done/superseded/cancelled:${msg}`);
|
|
369
|
+
else warnings.push(`- [WARN 状态未确认] intent 必须为 approved/done/superseded/cancelled:${msg}`);
|
|
370
|
+
}
|
|
371
|
+
if (lvl === 'L2' || lvl === 'L3') {
|
|
372
|
+
if (!fs.existsSync(path.join(ROOT, WF, 'specs', base))) {
|
|
373
|
+
blockers.push(`- [配对断裂] intent 缺 spec:${base}(应在 ${WF}/specs/ 下同名)`);
|
|
431
374
|
}
|
|
432
|
-
|
|
433
|
-
|
|
375
|
+
}
|
|
376
|
+
// 协作道(L0)豁免 plan 配对——异步审计兜底(hybrid-governance-risk-lanes);L1 仍须 plan,L2/L3 须 spec+plan
|
|
377
|
+
if (lvl !== 'L0' && !fs.existsSync(path.join(ROOT, WF, 'plans', base))) {
|
|
378
|
+
blockers.push(`- [配对断裂] intent 缺 plan:${base}(应在 ${WF}/plans/ 下同名)`);
|
|
379
|
+
}
|
|
380
|
+
// 风险泳道一致性(2026-10-02 caliber-convergence 起单源 laneOfEntry·方案 C 分歧双严):非 high
|
|
381
|
+
// (low 或 suspect——级别/risk_level 缺失、非法、分歧)勾选触达红线 = 自认触及规则/契约或数据/运行时
|
|
382
|
+
// 结构面——就高不就低,协作道不受理 → hard 判低,须对齐两字段至 L2/L3 并补同名 spec
|
|
383
|
+
if (laneOfEntry(lvl, risk, kitPolicy.riskLevelSince, fmGet(intent, '日期')) !== 'high') {
|
|
384
|
+
const redlineTouched = (linesOf(intent) || [])
|
|
385
|
+
.some((l) => /^- \[x\]/.test(l) && /(\$\\rightarrow\$|→)\s*级别|级别至少\s*L[23]/.test(l));
|
|
386
|
+
if (redlineTouched) {
|
|
387
|
+
blockers.push(`- [红线判低] ${base} 级别/风险泳道为 ${[lvl, risk].filter(Boolean).join('/')} 但触达红线已勾选(规则/契约或 schema/结构面)——协作道不受理 STOP 级改动:就高不就低,把「级别」与「risk_level」两字段对齐到 L2/L3(fill-intent 双写口径)并补同名 spec`);
|
|
434
388
|
}
|
|
435
389
|
}
|
|
436
390
|
}
|
|
437
391
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
392
|
+
for (const spec of docFiles('specs')) {
|
|
393
|
+
const base = path.basename(spec);
|
|
394
|
+
const st = fmGet(spec, '状态');
|
|
395
|
+
const lvl = fmGet(spec, '级别');
|
|
396
|
+
const hasEntry = fs.existsSync(path.join(ROOT, WF, 'intents', base)) || fs.existsSync(path.join(ROOT, WF, 'incidents', base));
|
|
397
|
+
if (!hasEntry) blockers.push(`- [配对断裂] spec 缺 intent/incident:${base}(应在 ${WF}/intents/ 或 ${WF}/incidents/ 下同名)`);
|
|
398
|
+
if (!stOkDoc(st)) {
|
|
399
|
+
const msg = `${base}(frontmatter 状态键当前值:『${st || '缺失'}』)`;
|
|
400
|
+
if (lvl === 'L3') blockers.push(`- [状态未确认] L3 spec 必须为 approved/done/superseded/cancelled:${msg}`);
|
|
401
|
+
else warnings.push(`- [WARN 状态未确认] spec 必须为 approved/done/superseded/cancelled:${msg}`);
|
|
402
|
+
}
|
|
403
|
+
// L3 确认三件:确认结果/确认时间读 frontmatter;独立复核按正文行锚定(放弃件豁免 = 枚举单源 abandoned 集)
|
|
404
|
+
if (lvl === 'L3' && !inSet(st, ENUMS['doc.status.abandoned'])) {
|
|
405
|
+
if (fmGet(spec, '确认结果') !== 'approved') blockers.push(`- [L3 确认缺失] ${base} 确认结果必须为 approved`);
|
|
406
|
+
if (!fmGet(spec, '确认时间')) blockers.push(`- [L3 确认缺失] ${base} 必须记录确认时间`);
|
|
407
|
+
if (!(linesOf(spec) || []).some((l) => /^- 独立复核:[ \t]*[^<\s]/.test(l))) {
|
|
408
|
+
blockers.push(`- [L3 复核缺失] ${base} 必须记录新会话独立复核结论`);
|
|
409
|
+
}
|
|
442
410
|
}
|
|
443
411
|
}
|
|
444
|
-
const hasParent = lines.some((l) => l.includes('是否需要新 intent'));
|
|
445
|
-
if (!hasParent) {
|
|
446
|
-
warnings.push(`- [WARN 三件套不全] ${name} 缺「是否需要新 intent」子项`);
|
|
447
|
-
continue;
|
|
448
|
-
}
|
|
449
|
-
const yesLine = lines.find((l) => l.includes('是 → '));
|
|
450
|
-
if (yesLine) {
|
|
451
|
-
const m = yesLine.match(/intents\/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]-[^ )]*\.md/);
|
|
452
|
-
const ref = m ? m[0] : null;
|
|
453
|
-
if (!ref) blockers.push(`- [回路断档] ${name} 选「是」但未指明 intent 文件路径`);
|
|
454
|
-
else if (!fs.existsSync(path.join(ROOT, WF, ref))) blockers.push(`- [回路断档] ${name} 引用的 intent 不存在:${ref}`);
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
412
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
return m[2].split(/[,,]/).map((v) => m[1] + v.trim() + m[3]);
|
|
469
|
-
};
|
|
470
|
-
// globExists:`*` 通配引用按目录枚举匹配(仅文件名层,`*` 不跨 /;目录不存在 = false)
|
|
471
|
-
const globExists = (ref) => {
|
|
472
|
-
const re = new RegExp('^' + path.basename(ref).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/\\\\]*') + '$');
|
|
473
|
-
try { return fs.readdirSync(path.dirname(path.join(ROOT, ref))).some((f) => re.test(f)); } catch { return false; }
|
|
474
|
-
};
|
|
475
|
-
const refExists = (r) => (r.includes('*') ? globExists(r) : fs.existsSync(path.join(ROOT, r)));
|
|
476
|
-
const HOME_DIR = process.env.HOME || process.env.USERPROFILE || '';
|
|
477
|
-
const files = [];
|
|
478
|
-
if (fs.existsSync(path.join(ROOT, 'AGENTS.md'))) files.push('AGENTS.md');
|
|
479
|
-
for (const e of readdirOrNull(ROOT) || []) {
|
|
480
|
-
const p = path.join(ROOT, e, 'AGENTS.md');
|
|
481
|
-
if (isDirectoryOrNull(path.join(ROOT, e)) && fs.existsSync(p)) files.push(path.join(e, 'AGENTS.md'));
|
|
482
|
-
}
|
|
483
|
-
for (const f of readdirOrNull(path.join(ROOT, WF)) || []) {
|
|
484
|
-
if (f.endsWith('.md')) files.push(`${WF}/${f}`); // workflow 根级 *.md(README 等)
|
|
485
|
-
}
|
|
486
|
-
for (const sub of DOC_DIRS) {
|
|
487
|
-
const dir = path.join(ROOT, WF, sub);
|
|
488
|
-
// 仅扫活跃态(draft/approved/open)——终态件的引用是历史叙述(提及已改名/已废文件属常态),
|
|
489
|
-
// 扫了只会产生不可消除的 advisory 噪声,真漏点被淹没(2026-09-27 audit-gate-hardening)
|
|
490
|
-
const activeSet = sub === 'incidents' ? ENUMS['incident.status.active'] : ENUMS['doc.status.active'];
|
|
491
|
-
for (const f of readdirOrNull(dir) || []) {
|
|
492
|
-
if (!f.endsWith('.md') || !(f === '_TEMPLATE.md' || /^\d/.test(f))) continue;
|
|
493
|
-
if (f !== '_TEMPLATE.md' && !inSet(fmGet(path.join(dir, f), '状态'), activeSet)) continue;
|
|
494
|
-
files.push(`${WF}/${sub}/${f}`);
|
|
413
|
+
for (const plan of docFiles('plans')) {
|
|
414
|
+
const base = path.basename(plan);
|
|
415
|
+
const st = fmGet(plan, '状态');
|
|
416
|
+
const lvl = fmGet(plan, '级别');
|
|
417
|
+
const hasEntry = fs.existsSync(path.join(ROOT, WF, 'intents', base)) || fs.existsSync(path.join(ROOT, WF, 'incidents', base));
|
|
418
|
+
if (!hasEntry) blockers.push(`- [配对断裂] plan 缺 intent/incident:${base}(应在 ${WF}/intents/ 或 ${WF}/incidents/ 下同名)`);
|
|
419
|
+
if (!stOkDoc(st)) {
|
|
420
|
+
const msg = `${base}(frontmatter 状态键当前值:『${st || '缺失'}』)`;
|
|
421
|
+
if (lvl === 'L3') blockers.push(`- [状态未确认] L3 plan 必须为 approved/done/superseded/cancelled:${msg}`);
|
|
422
|
+
else warnings.push(`- [WARN 状态未确认] plan 必须为 approved/done/superseded/cancelled:${msg}`);
|
|
495
423
|
}
|
|
496
424
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
425
|
+
|
|
426
|
+
// --- 卫生类检查 2 / 9 / 11 / 12 / 13(2026-10-08 selfmeasure-and-modularize 拆出)---
|
|
427
|
+
// ⚠️ 调用点位置即输出顺序位:原实现分处五处,逐条顺序 2 → 9 → 11 → 12 → 13,而 warnings 按插入序输出、
|
|
428
|
+
// 行序属稳定输出契约(pre-push / pre-commit 消费者 + doctor 按 WARN 行计数)。拆成一次调用后本块统一落在
|
|
429
|
+
// 原检查 2 的位置,**块内顺序由 check-hygiene.mjs 保证**(与其内注释互引)。新增检查请按原编号插入
|
|
430
|
+
// 到正确位置,勿一律追加末尾。
|
|
431
|
+
// ctx 只传既有 helper、模块内零复刻:docFiles 的 tracked 过滤、fmGet 的 frontmatter 受限子集、
|
|
432
|
+
// linesOf 的读异常响亮出账各自承载不可漂移语义,复刻即造口径分叉。audit:false 下 warnings.push 是
|
|
433
|
+
// no-op,卫生项因此不出账(硬规则语义保持)。
|
|
434
|
+
warnings.push(...runCheckHygiene({ root: ROOT, WF, DOC_DIRS, docFiles, linesOf, fmGet, isTracked, readdirOrNull, kitPolicy, gateStats }));
|
|
435
|
+
// --- 3. incidents 复盘三件套 + 状态严格枚举 + 新 intent 回路(回路断档 = hard)---
|
|
436
|
+
markGate('3', 'incidents 复盘三件套');
|
|
437
|
+
for (const inc of docFiles('incidents')) {
|
|
438
|
+
const name = path.basename(inc);
|
|
439
|
+
const lines = linesOf(inc) || [];
|
|
440
|
+
const incLvl = fmGet(inc, '级别');
|
|
441
|
+
const incSt = fmGet(inc, '状态');
|
|
442
|
+
|
|
443
|
+
if (!inSet(incSt, ENUMS['incident.status.all'])) {
|
|
444
|
+
warnings.push(`- [WARN 状态非法] incident 状态必须为 open/fixed/closed:${name}(frontmatter 状态键当前值:『${incSt || '缺失'}』)`);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (fmGet(inc, '流程') !== 'legacy') {
|
|
448
|
+
if (!inSet(incLvl, ENUMS['level.all'])) {
|
|
449
|
+
warnings.push(`- [WARN 级别缺失] ${name} 必须填写 L0/L1/L2/L3(配对检查已跳过——补级别后重跑本脚本校验配对)`);
|
|
450
|
+
} else {
|
|
451
|
+
if (['L1', 'L2', 'L3'].includes(incLvl) && !fs.existsSync(path.join(ROOT, WF, 'plans', name))) {
|
|
452
|
+
blockers.push(`- [配对断裂] incident 缺 plan:${name}(应在 ${WF}/plans/ 下同名)`);
|
|
453
|
+
}
|
|
454
|
+
if (['L2', 'L3'].includes(incLvl) && !fs.existsSync(path.join(ROOT, WF, 'specs', name))) {
|
|
455
|
+
blockers.push(`- [配对断裂] incident 缺 spec:${name}(应在 ${WF}/specs/ 下同名)`);
|
|
456
|
+
}
|
|
514
457
|
}
|
|
515
458
|
}
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
459
|
|
|
519
|
-
//
|
|
520
|
-
{
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
};
|
|
529
|
-
for (const role of ['implementer', 'independent-reviewer', 'ui-verifier']) {
|
|
530
|
-
const rolePath = `.agents/roles/${role}.md`;
|
|
531
|
-
if (!fs.existsSync(path.join(ROOT, rolePath))) {
|
|
532
|
-
warnings.push(`- [WARN 角色缺失] 公共角色契约不存在:${rolePath}`);
|
|
460
|
+
// 三件套:正文行锚定 /^1\. /^2\. /^3\. (sh awk 同款)
|
|
461
|
+
for (const n of [1, 2, 3]) {
|
|
462
|
+
if (!lines.some((l) => l.startsWith(`${n}. `))) {
|
|
463
|
+
warnings.push(`- [WARN 三件套不全] ${name} 缺复盘三件套之 ${n}`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
const hasParent = lines.some((l) => l.includes('是否需要新 intent'));
|
|
467
|
+
if (!hasParent) {
|
|
468
|
+
warnings.push(`- [WARN 三件套不全] ${name} 缺「是否需要新 intent」子项`);
|
|
533
469
|
continue;
|
|
534
470
|
}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
471
|
+
const yesLine = lines.find((l) => l.includes('是 → '));
|
|
472
|
+
if (yesLine) {
|
|
473
|
+
const m = yesLine.match(/intents\/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]-[^ )]*\.md/);
|
|
474
|
+
const ref = m ? m[0] : null;
|
|
475
|
+
if (!ref) blockers.push(`- [回路断档] ${name} 选「是」但未指明 intent 文件路径`);
|
|
476
|
+
else if (!fs.existsSync(path.join(ROOT, WF, ref))) blockers.push(`- [回路断档] ${name} 引用的 intent 不存在:${ref}`);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// --- 4. 引用有效性 [warning](.agents/ 路径须存在;skills 特例允许用户级 ~/.agents/skills)---
|
|
481
|
+
markGate('4', '引用有效性');
|
|
482
|
+
{
|
|
483
|
+
// 引用可带 {a,b,c} 花括号展开(仓库通写法 `fill-{intent,spec,plan}.mjs`)或 `*` 通配(`fill-*.mjs`)——
|
|
484
|
+
// 花括号展开逐路查存在,任一路存在即有效;`*` 按目录枚举 glob 匹配,命中即有效;全不中才算断档
|
|
485
|
+
// (2026-09-26 审查修复:正则字符类不含 {} *,曾把 `fill-{intent,spec,plan}.mjs` / `fill-*.mjs`
|
|
486
|
+
// 截断成 `fill-` 误报引用断档)
|
|
487
|
+
// 全角逗号不入字符类(2026-10-08 papercuts-cleanup-batch 修 4):中文文档路径引用后紧跟「,」时
|
|
488
|
+
// 曾被并入引用串 → 引用断档误报(p0-gate-noise-batch 实证 2 条 + 本登记行自中);花括号保留({{VAR}} 引用形态)
|
|
489
|
+
const refRe = /\.agents\/(commands|hooks|scripts|skills|roles)\/[A-Za-z0-9_][A-Za-z0-9_./{},*-]*/g;
|
|
490
|
+
const expandRef = (ref) => {
|
|
491
|
+
const m = ref.match(/^([^{]*)\{([^}]*)\}(.*)$/);
|
|
492
|
+
if (!m) return [ref];
|
|
493
|
+
return m[2].split(/[,,]/).map((v) => m[1] + v.trim() + m[3]);
|
|
494
|
+
};
|
|
495
|
+
// globExists:`*` 通配引用按目录枚举匹配(仅文件名层,`*` 不跨 /;目录不存在 = false)
|
|
496
|
+
const globExists = (ref) => {
|
|
497
|
+
const re = new RegExp('^' + path.basename(ref).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/\\\\]*') + '$');
|
|
498
|
+
try { return fs.readdirSync(path.dirname(path.join(ROOT, ref))).some((f) => re.test(f)); } catch { return false; }
|
|
499
|
+
};
|
|
500
|
+
const refExists = (r) => (r.includes('*') ? globExists(r) : fs.existsSync(path.join(ROOT, r)));
|
|
501
|
+
const HOME_DIR = process.env.HOME || process.env.USERPROFILE || '';
|
|
502
|
+
const files = [];
|
|
503
|
+
if (fs.existsSync(path.join(ROOT, 'AGENTS.md'))) files.push('AGENTS.md');
|
|
504
|
+
for (const e of readdirOrNull(ROOT) || []) {
|
|
505
|
+
const p = path.join(ROOT, e, 'AGENTS.md');
|
|
506
|
+
if (isDirectoryOrNull(path.join(ROOT, e)) && fs.existsSync(p)) files.push(path.join(e, 'AGENTS.md'));
|
|
542
507
|
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
508
|
+
for (const f of readdirOrNull(path.join(ROOT, WF)) || []) {
|
|
509
|
+
if (f.endsWith('.md')) files.push(`${WF}/${f}`); // workflow 根级 *.md(README 等)
|
|
510
|
+
}
|
|
511
|
+
for (const sub of DOC_DIRS) {
|
|
512
|
+
const dir = path.join(ROOT, WF, sub);
|
|
513
|
+
// 仅扫活跃态(draft/approved/open)——终态件的引用是历史叙述(提及已改名/已废文件属常态),
|
|
514
|
+
// 扫了只会产生不可消除的 advisory 噪声,真漏点被淹没(2026-09-27 audit-gate-hardening)
|
|
515
|
+
const activeSet = sub === 'incidents' ? ENUMS['incident.status.active'] : ENUMS['doc.status.active'];
|
|
516
|
+
for (const f of readdirOrNull(dir) || []) {
|
|
517
|
+
if (!f.endsWith('.md') || !(f === '_TEMPLATE.md' || /^\d/.test(f))) continue;
|
|
518
|
+
if (f !== '_TEMPLATE.md' && !inSet(fmGet(path.join(dir, f), '状态'), activeSet)) continue;
|
|
519
|
+
files.push(`${WF}/${sub}/${f}`);
|
|
549
520
|
}
|
|
550
521
|
}
|
|
551
|
-
const
|
|
552
|
-
|
|
553
|
-
if (
|
|
554
|
-
if (!textOf(zc).some((l) => l.includes(rolePath))) warnings.push(`- [WARN Adapter 断线] ZCode ${role} 未引用公共角色:${rolePath}`);
|
|
522
|
+
const cmdDir = path.join(ROOT, '.agents', 'commands');
|
|
523
|
+
for (const f of readdirOrNull(cmdDir) || []) {
|
|
524
|
+
if (f.endsWith('.md')) files.push(`.agents/commands/${f}`);
|
|
555
525
|
}
|
|
556
|
-
const
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
textOf(rel).forEach((l, i) => { if (/^model:/.test(l) && !/^model:\s*inherit/.test(l)) modelHits.push(`${rel}:${i + 1}:${l}`); });
|
|
526
|
+
for (const rel of files) {
|
|
527
|
+
const text = linesOf(path.join(ROOT, rel));
|
|
528
|
+
if (!text) continue;
|
|
529
|
+
const seen = new Set();
|
|
530
|
+
for (const line of text) {
|
|
531
|
+
for (const m of line.matchAll(refRe)) {
|
|
532
|
+
const ref = m[0];
|
|
533
|
+
if (seen.has(ref)) continue;
|
|
534
|
+
seen.add(ref);
|
|
535
|
+
const refs = expandRef(ref);
|
|
536
|
+
if (refs.some(refExists)) continue;
|
|
537
|
+
if (refs.some((r) => r.startsWith('.agents/skills/') && !r.includes('*') && fs.existsSync(path.join(HOME_DIR, r)))) continue;
|
|
538
|
+
warnings.push(`- [WARN 引用断档] ${rel.split(path.sep).join('/')} 引用不存在的文件:${ref}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
572
541
|
}
|
|
573
542
|
}
|
|
574
|
-
if (modelHits.length) warnings.push(`- [WARN 宿主耦合] 薄 Adapter 不应固定模型:\n${modelHits.join('\n')}`);
|
|
575
|
-
}
|
|
576
543
|
|
|
577
|
-
// ---
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
544
|
+
// --- 5. 子智能体角色契约 + 宿主 Adapter 一致性 + 旧委派残留 + 钉死模型 [warning] ---
|
|
545
|
+
markGate('5', '角色契约与宿主 Adapter');
|
|
546
|
+
{
|
|
547
|
+
const cmdFiles = () => (readdirOrNull(path.join(ROOT, '.agents', 'commands')) || [])
|
|
548
|
+
.filter((f) => f.endsWith('.md')).map((f) => `.agents/commands/${f}`);
|
|
549
|
+
// existsSync 守卫(2026-10-06-check19-entry-enoent 同类):AGENTS.md 等可合法缺失(调用方按空数组
|
|
550
|
+
// 判定本就预期缺失态),fail-loud fs 读下按缺省跳过、不再响亮出账;其余调用方路径另有上游守卫
|
|
551
|
+
const textOf = (rel) => {
|
|
552
|
+
const p = path.join(ROOT, rel);
|
|
553
|
+
return fs.existsSync(p) ? (linesOf(p) || []) : [];
|
|
554
|
+
};
|
|
555
|
+
for (const role of ['implementer', 'independent-reviewer', 'ui-verifier']) {
|
|
556
|
+
const rolePath = `.agents/roles/${role}.md`;
|
|
557
|
+
if (!fs.existsSync(path.join(ROOT, rolePath))) {
|
|
558
|
+
warnings.push(`- [WARN 角色缺失] 公共角色契约不存在:${rolePath}`);
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
if (fs.existsSync(path.join(ROOT, '.opencode', 'agents'))) {
|
|
562
|
+
const p = `.opencode/agents/${role}.md`;
|
|
563
|
+
if (!fs.existsSync(path.join(ROOT, p))) warnings.push(`- [WARN Adapter 缺失] OpenCode 缺 ${role}:${p}`);
|
|
564
|
+
else {
|
|
565
|
+
if (!/^mode:\s*subagent\s*$/m.test(textOf(p).join('\n'))) warnings.push(`- [WARN Adapter 无效] OpenCode ${role} 未声明 mode:subagent:${p}`);
|
|
566
|
+
if (!textOf(p).some((l) => l.includes(rolePath))) warnings.push(`- [WARN Adapter 断线] OpenCode ${role} 未引用公共角色:${rolePath}`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
if (fs.existsSync(path.join(ROOT, '.trae', 'agents'))) {
|
|
570
|
+
const p = `.trae/agents/${role}.md`;
|
|
571
|
+
if (!fs.existsSync(path.join(ROOT, p))) warnings.push(`- [WARN Adapter 缺失] Trae 缺 ${role}:${p}`);
|
|
572
|
+
else {
|
|
573
|
+
if (!new RegExp(`^name:\\s*${role}\\s*$`, 'm').test(textOf(p).join('\n'))) warnings.push(`- [WARN Adapter 无效] Trae ${role} 的 name 与文件名不一致:${p}`);
|
|
574
|
+
if (!textOf(p).some((l) => l.includes(rolePath))) warnings.push(`- [WARN Adapter 断线] Trae ${role} 未引用公共角色:${rolePath}`);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
const zc = `.zcode/agents/${role}.md`;
|
|
578
|
+
if (fs.existsSync(path.join(ROOT, zc))) { // .zcode/agents/ 现为入库件(2026-10-05 起宿主一律入库);缺失只说明宿主未装,不告警,存在时校验一致性
|
|
579
|
+
if (!new RegExp(`^name:\\s*["']?${role}["']?\\s*$`, 'm').test(textOf(zc).join('\n'))) warnings.push(`- [WARN Adapter 无效] ZCode ${role} 的 name 与文件名不一致:${zc}`);
|
|
580
|
+
if (!textOf(zc).some((l) => l.includes(rolePath))) warnings.push(`- [WARN Adapter 断线] ZCode ${role} 未引用公共角色:${rolePath}`);
|
|
581
|
+
}
|
|
582
|
+
const cited = ['AGENTS.md', ...cmdFiles()].some((f) => textOf(f).some((l) => l.includes(rolePath)));
|
|
583
|
+
if (!cited) warnings.push(`- [WARN 命令断线] 工作流未引用公共角色:${rolePath}`);
|
|
584
|
+
}
|
|
585
|
+
// 旧委派残留 / 钉死模型(grep -nH 行格式 file:line:content)
|
|
586
|
+
const legacyRe = /(^subagent:|subagent_type=|general_purpose_task|browser_use)/;
|
|
587
|
+
const legacyHits = [];
|
|
588
|
+
for (const f of ['AGENTS.md', ...cmdFiles()]) {
|
|
589
|
+
textOf(f).forEach((l, i) => { if (legacyRe.test(l)) legacyHits.push(`${f}:${i + 1}:${l}`); });
|
|
590
|
+
}
|
|
591
|
+
if (legacyHits.length) warnings.push(`- [WARN 旧委派残留] 主规则或阶段命令仍含宿主特定/旧委派声明:\n${legacyHits.join('\n')}`);
|
|
592
|
+
const modelHits = [];
|
|
593
|
+
for (const h of ['.opencode', '.trae', '.zcode']) {
|
|
594
|
+
for (const f of readdirOrNull(path.join(ROOT, h, 'agents')) || []) {
|
|
595
|
+
if (!f.endsWith('.md')) continue;
|
|
596
|
+
const rel = `${h}/agents/${f}`;
|
|
597
|
+
textOf(rel).forEach((l, i) => { if (/^model:/.test(l) && !/^model:\s*inherit/.test(l)) modelHits.push(`${rel}:${i + 1}:${l}`); });
|
|
598
|
+
}
|
|
586
599
|
}
|
|
600
|
+
if (modelHits.length) warnings.push(`- [WARN 宿主耦合] 薄 Adapter 不应固定模型:\n${modelHits.join('\n')}`);
|
|
587
601
|
}
|
|
588
|
-
}
|
|
589
602
|
|
|
590
|
-
// ---
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
const addedDates = (() => {
|
|
603
|
-
if (gitOut(['rev-parse', '--git-dir']) === null) return null;
|
|
604
|
-
const log = gitOut(['log', '--diff-filter=A', '--format=@%aI', '--name-only']);
|
|
605
|
-
if (log === null) return null;
|
|
606
|
-
const m = new Map();
|
|
607
|
-
let cur = null;
|
|
608
|
-
for (const line of log.split('\n')) {
|
|
609
|
-
if (line.startsWith('@')) { cur = line.slice(1); continue; }
|
|
610
|
-
if (line.trim() && cur && !m.has(line.trim())) m.set(line.trim(), cur); // 新→旧序首遇 = 最早
|
|
603
|
+
// --- 6. 阶段索引同步 [warning] ---
|
|
604
|
+
markGate('6', '阶段索引同步');
|
|
605
|
+
for (const cmd of ['plan', 'design', 'build', 'test', 'deploy', 'maintain', 'review']) {
|
|
606
|
+
for (const doc of ['AGENTS.md', '.agents/commands/new-task.md']) {
|
|
607
|
+
// existsSync 守卫(2026-10-06-check19-entry-enoent 同类):两文件可合法缺失(下方 text 判空本就
|
|
608
|
+
// 预期缺失态),fail-loud fs 读下按空跳过、不再响亮出账
|
|
609
|
+
const docPath = path.join(ROOT, doc);
|
|
610
|
+
const text = fs.existsSync(docPath) ? linesOf(docPath) : null;
|
|
611
|
+
if (text && !text.some((l) => l.includes(`.agents/commands/${cmd}.md`))) {
|
|
612
|
+
warnings.push(`- [WARN 阶段索引漂移] ${doc} 缺 ${cmd} 指令索引(两处阶段表须同步维护)`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
611
615
|
}
|
|
612
|
-
return m;
|
|
613
|
-
})();
|
|
614
|
-
// 某文档「首次加入日期」(YYYY-MM-DD);不可判定(非 git / 查不到)返回 ''
|
|
615
|
-
const addedDateOf = (absDoc) => {
|
|
616
|
-
if (!addedDates) return '';
|
|
617
|
-
const rel = path.relative(ROOT, absDoc).split(path.sep).join('/');
|
|
618
|
-
const iso = addedDates.get(rel);
|
|
619
|
-
return iso ? iso.slice(0, 10) : '';
|
|
620
|
-
};
|
|
621
616
|
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
if (
|
|
643
|
-
warnEvidenceExempt('verify', planBase);
|
|
644
|
-
return { ok: true, type: 'verify' };
|
|
645
|
-
}
|
|
646
|
-
return { ok: true, type: 'verify-missing' };
|
|
617
|
+
// --- 共享:文件「首次加入 git」的日期索引(2026-09-28 check8-git-anchor)---
|
|
618
|
+
// 用途:检查 8 的生效日锚从**可手填的文件名前缀**(项目命名规范本身即 `YYYY-MM-DD-<主题>`,作者
|
|
619
|
+
// 日常手写该前缀,「写早」零成本、顺手即发生——一条 hard 门可被平凡绕过)改为**首次加入 git 的日期**。
|
|
620
|
+
// **诚实边界(复核 P1 更正)**:该锚取 `git log --diff-filter=A --format=@%aI`,即 **author date
|
|
621
|
+
// (作者自报时间戳)**,**不是** commit 时刻——`git commit --date=<过去>` 一条参数即可伪造,故
|
|
622
|
+
// 「锚不可手填」的说法**不成立**。本改动的确切收益是:关掉「改文件名(日常必写、零成本)」这条通道,
|
|
623
|
+
// 把伪造成本抬到「主动加参数伪造时间戳」——后者与「伪造台账」同属**本地信任边界内不可机器防**的范畴
|
|
624
|
+
// (事后对质靠 git 历史与台账)。此处如实声明,不宣称「通道已关闭」。
|
|
625
|
+
// 口径:git 按时间新→旧遍历,同一文件首遇即最早那次加入(与检查 10 的 addpath 同款判据)。
|
|
626
|
+
// 非 git / 命令失败 → null,调用方按既有「非 git 跳过」语义退化(不 fail-loud)。
|
|
627
|
+
// 注:检查 10 需要的是「文件→加入 commit」+「commit→文件清单」两份映射(判断同提交是否触及迁移 SQL),
|
|
628
|
+
// 与本索引「文件→日期」不同构,故**不合并**——避免把两处判据耦死。
|
|
629
|
+
const addedDates = (() => {
|
|
630
|
+
if (gitOut(['rev-parse', '--git-dir']) === null) return null;
|
|
631
|
+
const log = gitOut(['log', '--diff-filter=A', '--format=@%aI', '--name-only']);
|
|
632
|
+
if (log === null) return null;
|
|
633
|
+
const m = new Map();
|
|
634
|
+
let cur = null;
|
|
635
|
+
for (const line of log.split('\n')) {
|
|
636
|
+
if (line.startsWith('@')) { cur = line.slice(1); continue; }
|
|
637
|
+
if (line.trim() && cur && !m.has(line.trim())) m.set(line.trim(), cur); // 新→旧序首遇 = 最早
|
|
647
638
|
}
|
|
648
|
-
|
|
649
|
-
|
|
639
|
+
return m;
|
|
640
|
+
})();
|
|
641
|
+
// 某文档「首次加入日期」(YYYY-MM-DD);不可判定(非 git / 查不到)返回 ''
|
|
642
|
+
const addedDateOf = (absDoc) => {
|
|
643
|
+
if (!addedDates) return '';
|
|
644
|
+
const rel = path.relative(ROOT, absDoc).split(path.sep).join('/');
|
|
645
|
+
const iso = addedDates.get(rel);
|
|
646
|
+
return iso ? iso.slice(0, 10) : '';
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
// 豁免/放行分支出账(fail-loud 口径——「裁决消失」不可归因的教训:豁免路径零痕迹;
|
|
650
|
+
// 2026-10-05-check8-digit-sha-misfire):按 type 进程级去重,每类至多一条;stderr 不改 stdout 协议
|
|
651
|
+
const evidenceExemptWarned = new Set();
|
|
652
|
+
function warnEvidenceExempt(type, base) {
|
|
653
|
+
if (evidenceExemptWarned.has(type)) return;
|
|
654
|
+
evidenceExemptWarned.add(type);
|
|
655
|
+
console.error(`check-loop: 证据豁免 ${type} ${base}——该文档的验收证据未做真相核验`);
|
|
650
656
|
}
|
|
651
657
|
|
|
652
|
-
|
|
653
|
-
//
|
|
654
|
-
//
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
658
|
+
// 证据真相校验:检查证据字符串是否包含合法的 commit SHA,且该 commit 触及了 plan 声明的文件
|
|
659
|
+
// 测试绿声明对账(2026-10-07 verify-evidence):ctx = { verifyRelevant, hasFresh } 时,无 SHA 且命中
|
|
660
|
+
// 关键词的证据走凭证对账——有 24h 绿行 → verify 型豁免出账;无 → verify-missing(聚合段出 warning,
|
|
661
|
+
// 灰度第一档非 blocker)。ctx 由调用方按「intent 加入 git ≥ verifySince(v5 锚)」组装;锚不可判定
|
|
662
|
+
// → ctx null → 维持 text 豁免(无判定依据不产出告警)。关键词表最小集起步(check2-datetime 先例:
|
|
663
|
+
// 只豁免实证形态,变体实证再扩)。
|
|
664
|
+
const VERIFY_GREEN_RE = /测试通过|测试全绿|测试绿|全绿/;
|
|
665
|
+
function verifyEvidenceTruth(evidenceStr, planBase, root, ctx) {
|
|
666
|
+
const shaMatch = evidenceStr.match(/\b([a-f0-9]{7,40})\b/i);
|
|
667
|
+
if (!shaMatch) {
|
|
668
|
+
if (ctx && ctx.verifyRelevant && VERIFY_GREEN_RE.test(evidenceStr)) {
|
|
669
|
+
if (ctx.hasFresh) {
|
|
670
|
+
warnEvidenceExempt('verify', planBase);
|
|
671
|
+
return { ok: true, type: 'verify' };
|
|
672
|
+
}
|
|
673
|
+
return { ok: true, type: 'verify-missing' };
|
|
674
|
+
}
|
|
661
675
|
warnEvidenceExempt('text', planBase);
|
|
662
676
|
return { ok: true, type: 'text' };
|
|
663
677
|
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
//
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
678
|
+
|
|
679
|
+
const sha = shaMatch[1];
|
|
680
|
+
// 直连走 spawnGit(与 gitOut 同一 loud+retry 通道,注释互引):spawn 异常不再被静默算作业务失败;
|
|
681
|
+
// 重试后仍异常按下方 status!==0 判定(fail-closed 保持——本地核验不了就按伪造拦)
|
|
682
|
+
const revParse = spawnGit(['rev-parse', sha]);
|
|
683
|
+
if (revParse.status !== 0) {
|
|
684
|
+
// git 通道坏死(spawn 异常,非 git 的正常回答)→ 禁用下方两类豁免:「实证分流」依赖 rev-parse
|
|
685
|
+
// 可用,通道失效时纯数字/外部引用都无法实证,按伪造拦(fail-closed,与 :672 注释一致)。
|
|
686
|
+
// v1.3.0 Release CI 实证:fixture 短 sha 恰全数字(≈4%/run)时,纯数字 text 豁免曾把本场景
|
|
687
|
+
// fail-closed 漏成 exit 0(ubuntu 腿);win32 sha 含字母走 forged 故本地恒绿——平台概率差异。
|
|
688
|
+
if (revParse.error) {
|
|
689
|
+
return { ok: false, type: 'forged', msg: `提交 ${sha} 不存在` };
|
|
690
|
+
}
|
|
691
|
+
// 纯数字串且解析失败 → 时间戳/ID 维持文本豁免(2026-10-05-check8-digit-sha-misfire:形态守卫
|
|
692
|
+
// 曾把全数字短 SHA≈4.4%/夹具 误分类为文本致核验静默跳过——改由 rev-parse 实证分流)。
|
|
693
|
+
// 纪律:文本启发式须可被实证兜底,禁以字符形态抢先分类。
|
|
694
|
+
if (/^\d+$/.test(sha)) {
|
|
695
|
+
warnEvidenceExempt('text', planBase);
|
|
696
|
+
return { ok: true, type: 'text' };
|
|
697
|
+
}
|
|
698
|
+
// 区分本地伪造与外部仓库引用:
|
|
699
|
+
// 剔除 SHA 与泛指词后仍有其他内容(如项目名、路径描述)→ 视为外部仓库引用,不硬拦(本地无法核验他仓哈希);
|
|
700
|
+
// 剔除后为空(如纯「commit a1b2c3d」「提交 a1b2c3d」)→ 视为本地引用伪造,硬拦。
|
|
701
|
+
const residue = evidenceStr.replace(sha, '')
|
|
702
|
+
.replace(/证据[::]|commit|提交|[()()。—-]/gi, '')
|
|
703
|
+
.trim();
|
|
704
|
+
if (residue.length > 0) {
|
|
705
|
+
warnEvidenceExempt('external', planBase);
|
|
706
|
+
return { ok: true, type: 'external' };
|
|
707
|
+
}
|
|
708
|
+
return { ok: false, type: 'forged', msg: `提交 ${sha} 不存在` };
|
|
673
709
|
}
|
|
674
|
-
return { ok: false, type: 'forged', msg: `提交 ${sha} 不存在` };
|
|
675
|
-
}
|
|
676
710
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
711
|
+
// 解析成功(含纯数字短 SHA 命中提交/ref 的情形)→ 真相核验
|
|
712
|
+
const planPath = path.join(ROOT, WF, 'plans', planBase + '.md');
|
|
713
|
+
if (!fs.existsSync(planPath)) {
|
|
714
|
+
warnEvidenceExempt('no-plan', planBase);
|
|
715
|
+
return { ok: true, type: 'no-plan' }; // 缺 plan 走 Check 1 拦截
|
|
716
|
+
}
|
|
683
717
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
718
|
+
const planLines = linesOf(planPath) || [];
|
|
719
|
+
const declaredFiles = [];
|
|
720
|
+
// 扫描「改动面」「任务拆解」或「改动方案」(L1 Quick-Plan 生成器 fill-plan 输出)节
|
|
721
|
+
let inDeclaredSec = false;
|
|
722
|
+
for (const line of planLines) {
|
|
723
|
+
if (/^##\s+(改动面|任务拆解|改动方案)/.test(line)) { inDeclaredSec = true; continue; }
|
|
724
|
+
if (inDeclaredSec && /^##\s+/.test(line)) { inDeclaredSec = false; continue; }
|
|
725
|
+
if (inDeclaredSec) {
|
|
726
|
+
const fileMatch = line.match(/([a-zA-Z0-9._\/-]+\.[a-zA-Z0-9]+)/);
|
|
727
|
+
if (fileMatch) declaredFiles.push(fileMatch[1]);
|
|
728
|
+
}
|
|
694
729
|
}
|
|
695
|
-
}
|
|
696
730
|
|
|
697
|
-
|
|
698
|
-
|
|
731
|
+
const show = spawnGit(['show', '--name-only', sha]);
|
|
732
|
+
if (show.status !== 0) return { ok: false, type: 'error', msg: `无法读取提交 ${sha} 的文件列表` };
|
|
699
733
|
|
|
700
|
-
|
|
701
|
-
|
|
734
|
+
const changedFiles = show.stdout.split(/\r?\n/).filter(Boolean);
|
|
735
|
+
const hasIntersection = changedFiles.some(f => declaredFiles.some(df => f.includes(df) || df.includes(f)));
|
|
702
736
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
737
|
+
if (!hasIntersection) {
|
|
738
|
+
// 过程证据(2026-10-03 check-evidence-process):执行器驱动的提交可作验收证据——提交信息带
|
|
739
|
+
// 「pipeline-run <runId>」标记(docsCommit 四种格式共有段,runId 严格形态 日期8-时间4-主题-尾;
|
|
740
|
+
// 与 run 事件流同源,git 历史留痕可对质)。只救 irrelevant:forged(SHA 不存在)在前已判,其余判据不动。
|
|
741
|
+
// 记录型标志(2026-10-06-v114-backflow-batch):docs 系 Conventional Commit(冒烟报告 / 留痕 / 关单台账)
|
|
742
|
+
// 不触 plan 声明面属预期——是否降级豁免由检查 8 聚合段判(须同文档存在可过检实现证据),此处只出标志。
|
|
743
|
+
const subj = spawnGit(['log', '-1', '--format=%s', sha]);
|
|
744
|
+
const recordCommit = subj.status === 0 && /^docs[(:]/.test(subj.stdout.trim());
|
|
745
|
+
if (subj.status === 0 && /pipeline-run [0-9]{8}-[0-9]{4}-[A-Za-z0-9-]+/.test(subj.stdout)) {
|
|
746
|
+
warnEvidenceExempt('process', planBase);
|
|
747
|
+
return { ok: true, type: 'process' };
|
|
748
|
+
}
|
|
749
|
+
return { ok: false, type: 'irrelevant', msg: `提交 ${sha} 未触及 plan 声明的任何文件`, recordCommit };
|
|
714
750
|
}
|
|
715
|
-
return { ok: false, type: 'irrelevant', msg: `提交 ${sha} 未触及 plan 声明的任何文件`, recordCommit };
|
|
716
|
-
}
|
|
717
751
|
|
|
718
|
-
|
|
719
|
-
}
|
|
752
|
+
return { ok: true, type: 'sha' };
|
|
753
|
+
}
|
|
720
754
|
|
|
721
|
-
// --- 8. intent 验收标准对账(done 须逐条勾验并补证据;新建 hard,存量聚合 warning)---
|
|
722
|
-
|
|
723
|
-
//
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
const
|
|
740
|
-
//
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
755
|
+
// --- 8. intent 验收标准对账(done 须逐条勾验并补证据;新建 hard,存量聚合 warning)---
|
|
756
|
+
markGate('8', '验收标准对账');
|
|
757
|
+
// 生效日锚 2026-09-28 改「git 首次加入日期」(此前取文件名前 10 字符——命名规范强制的字段、
|
|
758
|
+
// 写早零成本,见 incidents/2026-09-28-check8-git-anchor)。非 git → 不可判定 → 走存量口径(不误报 hard)。
|
|
759
|
+
{
|
|
760
|
+
const accCutoff = '2026-09-12';
|
|
761
|
+
let legacyUnaccounted = 0;
|
|
762
|
+
// 测试绿凭证状态(2026-10-07 verify-evidence):台账读一次循环外缓存;policy 缺 verifySince
|
|
763
|
+
//(v1-v4)→ ctxBase null → 对账整体跳过(向后兼容)
|
|
764
|
+
const verifySinceOf = kitPolicy.verifySince;
|
|
765
|
+
// 台账内容循环外读一次并缓存(doc 过滤是纯内存行匹配,不新增子进程——门禁成本零增加)。
|
|
766
|
+
const verifyLines = (() => {
|
|
767
|
+
try {
|
|
768
|
+
const vp = path.join(ROOT, '.agents', 'verifications.jsonl');
|
|
769
|
+
return fs.existsSync(vp) ? fs.readFileSync(vp, 'utf8').split(/\r?\n/) : null;
|
|
770
|
+
} catch { return null; }
|
|
771
|
+
})();
|
|
772
|
+
// 全局绿行(v5 语义):仅供 v1-v5 装户走原路径,本仓 v6+ 不再消费它。
|
|
773
|
+
const verifyFreshGlobal = verifyLines ? hasFreshVerifyLine(verifyLines) : false;
|
|
774
|
+
// 2026-10-08 verify-doc-binding:v6+(verifyDocSince)按本 doc 精确匹配;缺键维持全局布尔。
|
|
775
|
+
const verifyDocBound = typeof kitPolicy.verifyDocSince === 'string';
|
|
776
|
+
const verifyFreshFor = (rel) => (verifyLines
|
|
777
|
+
? hasFreshVerifyLine(verifyLines, Date.now(), verifyDocBound ? rel : null)
|
|
778
|
+
: false);
|
|
779
|
+
for (const intent of docFiles('intents')) {
|
|
780
|
+
if (fmGet(intent, '状态') !== 'done') continue;
|
|
781
|
+
const base = path.basename(intent);
|
|
782
|
+
const rel = path.relative(ROOT, intent).split(path.sep).join('/'); // 与 verify.mjs --doc 落账口径同源
|
|
783
|
+
const lines = linesOf(intent) || [];
|
|
784
|
+
// 节扫描:hs=有验收节;insec 至下一个二级标题;uc=未勾项;ne=勾选缺证据;ex=存量豁免声明
|
|
785
|
+
// 证据可写在 [x] 行的续行(仓库通写法「(证据:…)」另起一行)——pendX 记「待证据项」,
|
|
786
|
+
// 遇证据行(全角「:」/半角「:」皆认)清零;遇下一条目 / 下一小节 / 节末仍无证据 → ne
|
|
787
|
+
let hs = false, uc = false, ne = false;
|
|
788
|
+
let insec = false, ex = false;
|
|
789
|
+
let pendX = false; // 上一 [x] 项尚无证据,证据可能在紧随的续行
|
|
790
|
+
let currentEvidence = ''; // 当前条目的证据内容,用于语义校验
|
|
791
|
+
const evidenceItems = []; // 逐条证据(2026-10-06-v114-backflow-batch:两遍裁决——先文档级判实现证据在场,再逐条落判)
|
|
792
|
+
const evRe = /证据[::]/;
|
|
793
|
+
const closeItem = () => {
|
|
794
|
+
if (pendX) {
|
|
795
|
+
ne = true;
|
|
796
|
+
pendX = false;
|
|
797
|
+
}
|
|
798
|
+
if (currentEvidence && !ex) {
|
|
799
|
+
evidenceItems.push(currentEvidence);
|
|
800
|
+
}
|
|
801
|
+
currentEvidence = '';
|
|
802
|
+
};
|
|
803
|
+
for (const line of lines) {
|
|
804
|
+
if (/存量对账豁免(/.test(line)) ex = true;
|
|
805
|
+
if (/^\s*##\s+[^#]*验收标准/.test(line)) { hs = true; insec = true; closeItem(); continue; }
|
|
806
|
+
if (insec && /^\s*##\s/.test(line)) { insec = false; closeItem(); continue; }
|
|
807
|
+
if (!insec) continue;
|
|
808
|
+
if (/^\s*- \[ \]/.test(line)) { closeItem(); uc = true; continue; }
|
|
809
|
+
if (/^\s*- \[x\]/.test(line)) {
|
|
810
|
+
closeItem();
|
|
811
|
+
pendX = !evRe.test(line);
|
|
812
|
+
if (evRe.test(line)) currentEvidence = line.split(/证据[::]/)[1].trim();
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
if (pendX && evRe.test(line)) {
|
|
816
|
+
pendX = false;
|
|
817
|
+
currentEvidence = line.split(/证据[::]/)[1].trim();
|
|
818
|
+
} else if (currentEvidence && line.trim() && !/^\s*- /.test(line)) {
|
|
819
|
+
// 简单累加续行证据
|
|
820
|
+
currentEvidence += ' ' + line.trim();
|
|
821
|
+
}
|
|
756
822
|
}
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
if (
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
823
|
+
closeItem(); // 节末(或全文末)仍无证据 → 计缺证据
|
|
824
|
+
// 两遍裁决(2026-10-06-v114-backflow-batch 记录型提交豁免):文档级先判「存在可过检实现证据」
|
|
825
|
+
// (sha / process 型任一),记录型提交(subject=docs 系)证据仅在实现证据在场时降级豁免告警——
|
|
826
|
+
// 冒烟报告 / approved 留痕 / 关单提交不改 plan 声明面属预期;仅引记录型而无实现证据 → 维持 hard-block
|
|
827
|
+
//(豁免通道不得独立成立,防「只引 docs 提交洗白代码改动」)。
|
|
828
|
+
if (evidenceItems.length) {
|
|
829
|
+
const planBase = base.replace(/.md$/, '');
|
|
830
|
+
// verify 对账 ctx:intent 首次加入 git ≥ verifySince(v5 锚)才参与;不可判定 → null 走 text 豁免
|
|
831
|
+
const iadd = addedDateOf(intent);
|
|
832
|
+
const vctx = typeof verifySinceOf === 'string' && iadd !== '' && iadd >= verifySinceOf
|
|
833
|
+
? { verifyRelevant: true, hasFresh: verifyDocBound ? verifyFreshFor(rel) : verifyFreshGlobal } : null;
|
|
834
|
+
const results = evidenceItems.map((ev) => ({ t: verifyEvidenceTruth(ev, planBase, ROOT, vctx) }));
|
|
835
|
+
const hasImplEvidence = results.some((r) => r.t.ok && (r.t.type === 'sha' || r.t.type === 'process'));
|
|
836
|
+
for (const r of results) {
|
|
837
|
+
if (r.t.ok && r.t.type === 'verify-missing') {
|
|
838
|
+
// ⚠️ 文案锚点硬约束(既有 check-loop.test.mjs:2743 锚定「测试绿缺凭证」四字连续,零改动前提):
|
|
839
|
+
// doc 与修复命令只能追加在整句之后,不得插进这四个字中间。
|
|
840
|
+
warnings.push(`- [WARN 测试绿缺凭证] ${base} 验收证据声明「测试绿」但 24h 内无 verify 凭证(verifications.jsonl${verifyDocBound ? `;本单须带 --doc 落账` : ''})——跑 node .agents/scripts/verify.mjs${verifyDocBound ? ` --doc ${rel}` : ''} 后重跑(advisory,灰度第一档)`);
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
if (r.t.ok) continue;
|
|
844
|
+
if (r.t.type === 'irrelevant' && r.t.recordCommit && hasImplEvidence) {
|
|
845
|
+
warnEvidenceExempt('record', planBase);
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
blockers.push(`- [证据${r.t.type === 'forged' ? '伪造' : '无关'}] ${base} 验收证据校验失败: ${r.t.msg}`);
|
|
849
|
+
}
|
|
770
850
|
}
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
851
|
+
// 生效日锚 = git 首次加入日期(2026-09-28 改;此前取文件名前 10 字符)。**准确收益**:关掉
|
|
852
|
+
// 「改文件名日期(日常必写、零成本)即整段跳过 hard 门」这条通道;锚取 author date(%aI),
|
|
853
|
+
// 刻意伪造(`git commit --date=`)仍可绕过——与伪造台账同属本地信任边界内,不宣称"通道已关闭"。
|
|
854
|
+
// 非 git / 查不到 → '' → isNew=false → 走存量口径(与既有语义一致:不可判定时不误报 hard)。
|
|
855
|
+
const adddate = addedDateOf(intent);
|
|
856
|
+
const isNew = adddate !== '' && adddate >= accCutoff;
|
|
857
|
+
if (!hs) {
|
|
858
|
+
if (isNew) blockers.push(`- [验收未对账] done intent 缺「## 验收标准」节(勾验无从核对):${base}`);
|
|
859
|
+
else if (!ex) legacyUnaccounted++;
|
|
860
|
+
continue;
|
|
777
861
|
}
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
if (evidenceItems.length) {
|
|
785
|
-
const planBase = base.replace(/.md$/, '');
|
|
786
|
-
// verify 对账 ctx:intent 首次加入 git ≥ verifySince(v5 锚)才参与;不可判定 → null 走 text 豁免
|
|
787
|
-
const iadd = addedDateOf(intent);
|
|
788
|
-
const vctx = typeof verifySinceOf === 'string' && iadd !== '' && iadd >= verifySinceOf
|
|
789
|
-
? { verifyRelevant: true, hasFresh: verifyFresh } : null;
|
|
790
|
-
const results = evidenceItems.map((ev) => ({ t: verifyEvidenceTruth(ev, planBase, ROOT, vctx) }));
|
|
791
|
-
const hasImplEvidence = results.some((r) => r.t.ok && (r.t.type === 'sha' || r.t.type === 'process'));
|
|
792
|
-
for (const r of results) {
|
|
793
|
-
if (r.t.ok && r.t.type === 'verify-missing') {
|
|
794
|
-
warnings.push(`- [WARN 测试绿缺凭证] ${base} 验收证据声明「测试绿」但 24h 内无 verify 凭证(verifications.jsonl)——跑 node .agents/scripts/verify.mjs 后重跑(advisory,灰度第一档)`);
|
|
795
|
-
continue;
|
|
796
|
-
}
|
|
797
|
-
if (r.t.ok) continue;
|
|
798
|
-
if (r.t.type === 'irrelevant' && r.t.recordCommit && hasImplEvidence) {
|
|
799
|
-
warnEvidenceExempt('record', planBase);
|
|
800
|
-
continue;
|
|
862
|
+
if (!uc && !ne) continue;
|
|
863
|
+
if (isNew) {
|
|
864
|
+
if (uc) blockers.push(`- [验收未对账] done intent 验收标准有未勾验项:${base}(逐条勾验并补证据:commit/用例/冒烟输出)`);
|
|
865
|
+
if (ne) blockers.push(`- [验收缺证据] ${base} 验收标准勾选项缺「证据:」标注——证据是闭环准确性的唯一机器事实`);
|
|
866
|
+
} else if (!ex) {
|
|
867
|
+
legacyUnaccounted++;
|
|
801
868
|
}
|
|
802
|
-
blockers.push(`- [证据${r.t.type === 'forged' ? '伪造' : '无关'}] ${base} 验收证据校验失败: ${r.t.msg}`);
|
|
803
|
-
}
|
|
804
869
|
}
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
// 刻意伪造(`git commit --date=`)仍可绕过——与伪造台账同属本地信任边界内,不宣称"通道已关闭"。
|
|
808
|
-
// 非 git / 查不到 → '' → isNew=false → 走存量口径(与既有语义一致:不可判定时不误报 hard)。
|
|
809
|
-
const adddate = addedDateOf(intent);
|
|
810
|
-
const isNew = adddate !== '' && adddate >= accCutoff;
|
|
811
|
-
if (!hs) {
|
|
812
|
-
if (isNew) blockers.push(`- [验收未对账] done intent 缺「## 验收标准」节(勾验无从核对):${base}`);
|
|
813
|
-
else if (!ex) legacyUnaccounted++;
|
|
814
|
-
continue;
|
|
815
|
-
}
|
|
816
|
-
if (!uc && !ne) continue;
|
|
817
|
-
if (isNew) {
|
|
818
|
-
if (uc) blockers.push(`- [验收未对账] done intent 验收标准有未勾验项:${base}(逐条勾验并补证据:commit/用例/冒烟输出)`);
|
|
819
|
-
if (ne) blockers.push(`- [验收缺证据] ${base} 验收标准勾选项缺「证据:」标注——证据是闭环准确性的唯一机器事实`);
|
|
820
|
-
} else if (!ex) {
|
|
821
|
-
legacyUnaccounted++;
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
if (legacyUnaccounted > 0) {
|
|
825
|
-
warnings.push(`- [WARN 验收对账存量] ${legacyUnaccounted} 个存量 done intent 验收标准未对账(生效日锚之前加入仓库,豁免 hard,不回填)`);
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
// --- 9. 文件名英文 kebab-case [warning] ---
|
|
830
|
-
for (const sub of DOC_DIRS) {
|
|
831
|
-
const dir = path.join(ROOT, WF, sub);
|
|
832
|
-
for (const f of readdirOrNull(dir) || []) {
|
|
833
|
-
if (!f.endsWith('.md') || f === '_TEMPLATE.md') continue;
|
|
834
|
-
const abs = path.join(dir, f);
|
|
835
|
-
if (!isTracked(abs)) continue;
|
|
836
|
-
if (!/^[\x20-\x7e]*$/.test(f)) {
|
|
837
|
-
warnings.push(`- [WARN 文件名非英文] 文件名含非 ASCII 字符,应为英文 kebab-case:${WF}/${sub}/${f}`);
|
|
870
|
+
if (legacyUnaccounted > 0) {
|
|
871
|
+
warnings.push(`- [WARN 验收对账存量] ${legacyUnaccounted} 个存量 done intent 验收标准未对账(生效日锚之前加入仓库,豁免 hard,不回填)`);
|
|
838
872
|
}
|
|
839
873
|
}
|
|
840
|
-
}
|
|
841
874
|
|
|
842
|
-
// --- 10. 级别 vs 迁移文件一致性 [warning](启发式:入口文档加入提交触及迁移 SQL/Migrations → 疑似判低;非 git 跳过)---
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
addpath.
|
|
853
|
-
|
|
875
|
+
// --- 10. 级别 vs 迁移文件一致性 [warning](启发式:入口文档加入提交触及迁移 SQL/Migrations → 疑似判低;非 git 跳过)---
|
|
876
|
+
markGate('10', '级别 vs 迁移文件');
|
|
877
|
+
if (gitOut(['rev-parse', '--git-dir']) !== null) {
|
|
878
|
+
const log = gitOut(['log', '--diff-filter=A', '--format=@%H', '--name-only']);
|
|
879
|
+
if (log !== null) {
|
|
880
|
+
const addpath = new Map(); // 文件 → 首次加入 commit(新→旧序首遇)
|
|
881
|
+
const cfiles = new Map(); // commit → 文件清单
|
|
882
|
+
let cur = null;
|
|
883
|
+
for (const line of log.split('\n')) {
|
|
884
|
+
if (line.startsWith('@')) { cur = line.slice(1); cfiles.set(cur, []); continue; }
|
|
885
|
+
if (line.trim() && cur && !addpath.has(line.trim())) {
|
|
886
|
+
addpath.set(line.trim(), cur);
|
|
887
|
+
cfiles.get(cur).push(line.trim());
|
|
888
|
+
}
|
|
854
889
|
}
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
890
|
+
const docs = [...docFiles('intents'), ...docFiles('incidents')];
|
|
891
|
+
for (const doc of docs) {
|
|
892
|
+
const rel = path.relative(ROOT, doc).split(path.sep).join('/');
|
|
893
|
+
const lvl = fmGet(doc, '级别');
|
|
894
|
+
if (lvl !== 'L1' && lvl !== 'L2') continue;
|
|
895
|
+
const c = addpath.get(rel);
|
|
896
|
+
if (!c) continue;
|
|
897
|
+
const hits = (cfiles.get(c) || []).filter((f) => /(^|\/)docs\/scripts\/.*\.sql$/.test(f) || /(^|\/)Migrations\//.test(f));
|
|
898
|
+
if (hits.length) {
|
|
899
|
+
warnings.push(`- [WARN 级别疑似判低] ${path.basename(doc)} 级别 ${lvl} 但其加入提交触及迁移文件(混合改动应就高不就低,请复核级别):\n${hits.join('\n')}`);
|
|
900
|
+
}
|
|
866
901
|
}
|
|
867
902
|
}
|
|
868
903
|
}
|
|
869
|
-
}
|
|
870
904
|
|
|
871
|
-
// ---
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
{
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
905
|
+
// --- 14. 新 done 的 spec/plan 须有确认留痕:git 历史「状态: approved」 或 confirmations.jsonl 台账 approved 行 [warning](恒 advisory;非 git 跳过)---
|
|
906
|
+
markGate('14', '确认留痕(approved 快照)');
|
|
907
|
+
// 2026-10-08 papercuts-cleanup-batch 修 3:两跳确认同批提交时 git 历史不出现行首「状态: approved」曾误报,
|
|
908
|
+
// 而台账已证明两跳均走(2026-09-28-adopter-derivers 实证)——判据改 OR 并集(git 判据保留不弱化,
|
|
909
|
+
// 台账为第二通道;确认事件以台账为准)。台账伪造面由检查 15 指纹 hard 门把关,本修仅消 advisory 误报。
|
|
910
|
+
if (gitOut(['rev-parse', '--git-dir']) !== null && gitOut(['rev-parse', '-q', '--verify', 'HEAD']) !== null) {
|
|
911
|
+
// 台账 approved 文档集合:单次读取(坏行容忍跳过,与检查 15 同口径);缺失视为空集(git 判据仍守门)
|
|
912
|
+
const ledgerApproved = new Set();
|
|
913
|
+
try {
|
|
914
|
+
const lp = path.join(ROOT, '.agents', 'confirmations.jsonl');
|
|
915
|
+
if (fs.existsSync(lp)) {
|
|
916
|
+
for (const line of fs.readFileSync(lp, 'utf8').split(/\r?\n/)) {
|
|
917
|
+
if (!line.trim()) continue;
|
|
918
|
+
try {
|
|
919
|
+
const e = JSON.parse(line);
|
|
920
|
+
if (e && typeof e.doc === 'string' && e.stage === 'approved') ledgerApproved.add(e.doc);
|
|
921
|
+
} catch { /* 坏行容忍跳过 */ }
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
} catch { /* 台账不可读视为无 approved 行 */ }
|
|
925
|
+
for (const sub of ['specs', 'plans']) {
|
|
890
926
|
for (const doc of docFiles(sub)) {
|
|
927
|
+
if (fmGet(doc, '状态') !== 'done') continue;
|
|
891
928
|
const base = path.basename(doc);
|
|
892
|
-
const mod = fmGet(doc, '模块');
|
|
893
929
|
let d = fmGet(doc, '日期') || fmGet(doc, '发现');
|
|
894
930
|
if (!d) d = /^\d{4}-\d{2}-\d{2}$/.test(base.slice(0, 10)) ? base.slice(0, 10) : '';
|
|
895
|
-
if (
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
931
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(d) || d < kitPolicy.check14Since) continue; // policy.mjs check14Since
|
|
932
|
+
const lines = linesOf(doc) || [];
|
|
933
|
+
if (lines.some((l) => l.includes('存量确认态豁免('))) continue; // 保留:服务对象随修 3 自然消失,退役条件=连续两版本周期零命中
|
|
934
|
+
const rel = path.relative(ROOT, doc).split(path.sep).join('/');
|
|
935
|
+
// 命中判据单源(stage-gates.approvedTraceHit,2026-09-30 confirm-gate-approved-history):
|
|
936
|
+
// 返回 ''=从未出现 / <sha>=命中 / null=git 调用失败(不报,沿本段 fail-open 口径)
|
|
937
|
+
const hit = approvedTraceHit(ROOT, rel);
|
|
938
|
+
if (hit !== null && hit === '' && !ledgerApproved.has(rel)) {
|
|
939
|
+
warnings.push(`- [WARN 确认态缺失] ${base} 状态已 done 但 git 历史中从未出现行首「状态: approved」——确认环节未留痕(draft 直跳 done)`);
|
|
899
940
|
}
|
|
900
941
|
}
|
|
901
942
|
}
|
|
902
943
|
}
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
// --- 13. 常驻面体积预算 [warning](判定单源 rule-budget.sh;无 sh 环境静默跳过——advisory,pre-commit 硬拦兜底)---
|
|
906
|
-
{
|
|
907
|
-
const rb = path.join(ROOT, '.agents', 'scripts', 'rule-budget.sh');
|
|
908
|
-
const budgets = path.join(ROOT, '.agents', 'rule-budgets.txt');
|
|
909
|
-
if (fs.existsSync(rb) && fs.existsSync(budgets)) {
|
|
910
|
-
const r = spawnSync('sh', [rb, '--all'], { cwd: ROOT, encoding: 'utf8' });
|
|
911
|
-
if (!r.error && r.status !== 0) {
|
|
912
|
-
const head = `${r.stdout || ''}${r.stderr || ''}`.split('\n').slice(0, 5).map((l) => ' ' + l).join('\n');
|
|
913
|
-
warnings.push(`- [WARN 常驻面超限] 常驻面体积超预算(先删除或下沉被取代条目再增——一进一出):\n${head}`);
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
944
|
|
|
918
|
-
// ---
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
945
|
+
// --- 15. 确认指纹对账 [hard-block](2026-09-26 confirm-gate-machine;2026-09-27 confirm-gate-delegated 两形态;
|
|
946
|
+
// 生效日锚 2026-09-28 改台账 ts——此前锚取自文档自报「日期/发现」,新档只要把日期写早即整段跳过判定,
|
|
947
|
+
// incident 2026-09-28-confirm-gate-effective-date-anchor 实证)---
|
|
948
|
+
markGate('15', '确认指纹对账');
|
|
949
|
+
// 确认两形态(confirm-doc.mjs):TTY 亲手键入「可以」/ --delegated 对话委托代录(用户对话内明确放行后
|
|
950
|
+
// AI 代录,台账行如实记 source=chat-delegated + quote 原话,永不伪装 TTY)。intents/specs/plans 凡
|
|
951
|
+
// approved/done 须有 confirm-doc 产生的 frontmatter 确认指纹 + 台账(.agents/confirmations.jsonl)
|
|
952
|
+
// 配对行——配对判据 doc/stage/fingerprint 三键,与 source 无关。
|
|
953
|
+
// **生效日锚 = 台账 ts(2026-09-28 起)**:受管准入 = 该 doc 在台账中有合法跳转行(stage ∈ approved/done/
|
|
954
|
+
// fixed/closed/superseded/cancelled,append-only 取末次);无台账行 = 存量豁免(不追溯)。自报日期
|
|
955
|
+
// (日期/发现/文件名)不再参与门禁判定——它可被作者手填,把「门是否生效」的决定权交给被约束对象本身
|
|
956
|
+
// 是自举漏洞;台账行是「确认确实发生过」的唯一机器事实。docs 与 incidents 共用同一锚(原 incidents
|
|
957
|
+
// 独立生效日 2026-09-28 常量退役:台账起算日 = docs 生效日,两档等价)。
|
|
958
|
+
// 台账坏行容忍跳过(审计件,jsonl 追加式);frontmatter 存 16 位、台账存 64 位,按前 16 位配对。
|
|
959
|
+
{
|
|
960
|
+
const EFFECTIVE = kitPolicy.confirmDocsEffective;
|
|
961
|
+
const ledgerPath = path.join(ROOT, '.agents', 'confirmations.jsonl');
|
|
962
|
+
const ledger = [];
|
|
963
|
+
if (fs.existsSync(ledgerPath)) {
|
|
964
|
+
for (const line of linesOf(ledgerPath) || []) {
|
|
965
|
+
if (!line.trim()) continue;
|
|
966
|
+
try { ledger.push(JSON.parse(line)); } catch { /* 坏行跳过 */ }
|
|
935
967
|
}
|
|
936
968
|
}
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
//
|
|
941
|
-
//
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
//
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
const dateManaged = /^\d{4}-\d{2}-\d{2}$/.test(d) && d >= eff;
|
|
993
|
-
if (!docEntries.length && !dateManaged) continue; // 存量豁免
|
|
994
|
-
const fp = fmGet(doc, '确认指纹');
|
|
995
|
-
const ok = !!fp && docEntries.some((e) => e.stage === st
|
|
996
|
-
&& typeof e.fingerprint === 'string' && e.fingerprint.startsWith(fp));
|
|
997
|
-
if (!ok) {
|
|
998
|
-
blockers.push(`- [确认未对账] ${base} 状态 ${st} 无用户确认记录——AI 不得代确认,用户在终端跑 node .agents/scripts/confirm-doc.mjs ${rel} 后重试`);
|
|
999
|
-
}
|
|
1000
|
-
// 内容绑定(2026-09-27 audit-gate-hardening;生效锚 2026-09-27 gate-hardening-p2-batch 自文档自报日期
|
|
1001
|
-
// 改为台账 done 行 ts):done 文档当前内容须与台账 done 行确认时的内容一致——按 prev 复原跳转前文本
|
|
1002
|
-
// 重算比对,防「确认后篡改」(改验收标准/正文均触发)。复原口径 = confirm-doc computeFingerprint 的
|
|
1003
|
-
// 逆推:CRLF 归一 → frontmatter 首个「状态:」行保分隔符换值为台账 prev(双空格等非规范分隔符不误伤,
|
|
1004
|
-
// 与前向按原行字面计算对称)→ 剔「确认指纹:」行。两条降级路径:台账行缺 prev(schema 演进前存量,
|
|
1005
|
-
// 无以复原)→ warning 不拦;entry.ts 早于 2026-09-28(UTC 字符串比较——该次确认在旧关单顺序时代完成)→
|
|
1006
|
-
// 豁免绑定(配对判定已过,3 份失配存量即此列)。关单编辑顺序新约定不变:confirm-doc 是最后一次写入,
|
|
1007
|
-
// 此后修订走 superseded 或新 intent。
|
|
1008
|
-
if (ok && ['done', 'closed', 'superseded', 'cancelled'].includes(st)) {
|
|
1009
|
-
const doneEntries = ledger.filter((e) => e && e.doc === rel && e.stage === st && typeof e.fingerprint === 'string');
|
|
1010
|
-
const entry = doneEntries[doneEntries.length - 1]; // append-only 台账,末次生效(重确认场景)
|
|
1011
|
-
if (!entry || !entry.prev) {
|
|
1012
|
-
warnings.push(`- [WARN 绑定降级] ${base} 台账 stage=${st} 行缺 prev 字段(schema 演进前行),内容绑定跳过——仅配对判定`);
|
|
1013
|
-
} else if (!(typeof entry.ts === 'string' && entry.ts >= kitPolicy.bindingTs)) {
|
|
1014
|
-
// 该 done 确认发生在生效锚前(旧关单顺序时代)——豁免内容绑定,配对判定照常
|
|
1015
|
-
} else if (bindingSha256(linesOf(doc) || [], entry.prev) !== entry.fingerprint) {
|
|
1016
|
-
blockers.push(`- [确认内容漂移] ${base} ${st} 后内容与确认台账不符——已关单文档不得直接改(关单编辑先于关单确认);确需修订走 superseded 或新 intent 引用`);
|
|
969
|
+
// incidents 侧覆盖(2026-09-27 gate-coverage):fixed/closed 为已确认态(open = 起草态不加门——
|
|
970
|
+
// maintain.md 创建即对话确认的既有口径)
|
|
971
|
+
// 放弃态覆盖(2026-09-27 closing-coverage):superseded/cancelled 入配对集——confirm-doc --to 是唯一
|
|
972
|
+
// 合法产生通道(cancelled 自 draft/approved/open/fixed;superseded 自 approved/done/fixed/closed),
|
|
973
|
+
// 此前手改 frontmatter 即出账的口子收死
|
|
974
|
+
const CONFIRMED_BY_SUB = {
|
|
975
|
+
intents: ['approved', 'done', 'superseded', 'cancelled'],
|
|
976
|
+
specs: ['approved', 'done', 'superseded', 'cancelled'],
|
|
977
|
+
plans: ['approved', 'done', 'superseded', 'cancelled'],
|
|
978
|
+
incidents: ['fixed', 'closed', 'superseded', 'cancelled'],
|
|
979
|
+
};
|
|
980
|
+
// 合法跳转 stage 集(台账行「确认确实发生过」的判据;revert-draft / revert-open 类回退注记行不算)
|
|
981
|
+
const VALID_STAGES = new Set(['approved', 'done', 'fixed', 'closed', 'superseded', 'cancelled']);
|
|
982
|
+
for (const [sub, confirmedSet] of Object.entries(CONFIRMED_BY_SUB)) {
|
|
983
|
+
for (const doc of docFiles(sub)) {
|
|
984
|
+
const st = fmGet(doc, '状态');
|
|
985
|
+
if (!confirmedSet.includes(st)) continue;
|
|
986
|
+
const base = path.basename(doc);
|
|
987
|
+
const rel = path.relative(ROOT, doc).split(path.sep).join('/');
|
|
988
|
+
// 受管准入 = 两条件取或(2026-09-28 改锚):
|
|
989
|
+
// ① 台账中有该 doc 的合法跳转行——「确认确实发生过」的机器事实;它独立于自报日期成立,
|
|
990
|
+
// 故堵死「确认过却把日期写早以逃掉对账」这条攻破路径(本 incident 的核心缺陷);
|
|
991
|
+
// ② 自报日期 ≥ 生效日——覆盖「新档完全没跑 confirm-doc」的漏网面(此类无台账行,
|
|
992
|
+
// 仅靠 ① 会被误判为存量豁免而静默放行)。
|
|
993
|
+
// 两条件皆不满足 = 存量豁免(生效日前既有、从未走确认门,不追溯)。
|
|
994
|
+
const docEntries = ledger.filter((e) => e && e.doc === rel && VALID_STAGES.has(e.stage));
|
|
995
|
+
const eff = sub === 'incidents' ? kitPolicy.confirmIncidentsEffective : EFFECTIVE;
|
|
996
|
+
let d = fmGet(doc, '日期') || fmGet(doc, '发现');
|
|
997
|
+
if (!d) d = /^\d{4}-\d{2}-\d{2}$/.test(base.slice(0, 10)) ? base.slice(0, 10) : '';
|
|
998
|
+
const dateManaged = /^\d{4}-\d{2}-\d{2}$/.test(d) && d >= eff;
|
|
999
|
+
if (!docEntries.length && !dateManaged) continue; // 存量豁免
|
|
1000
|
+
const fp = fmGet(doc, '确认指纹');
|
|
1001
|
+
const ok = !!fp && docEntries.some((e) => e.stage === st
|
|
1002
|
+
&& typeof e.fingerprint === 'string' && e.fingerprint.startsWith(fp));
|
|
1003
|
+
if (!ok) {
|
|
1004
|
+
blockers.push(`- [确认未对账] ${base} 状态 ${st} 无用户确认记录——AI 不得代确认,用户在终端跑 node .agents/scripts/confirm-doc.mjs ${rel} 后重试`);
|
|
1005
|
+
}
|
|
1006
|
+
// 内容绑定(2026-09-27 audit-gate-hardening;生效锚 2026-09-27 gate-hardening-p2-batch 自文档自报日期
|
|
1007
|
+
// 改为台账 done 行 ts):done 文档当前内容须与台账 done 行确认时的内容一致——按 prev 复原跳转前文本
|
|
1008
|
+
// 重算比对,防「确认后篡改」(改验收标准/正文均触发)。复原口径 = confirm-doc computeFingerprint 的
|
|
1009
|
+
// 逆推:CRLF 归一 → frontmatter 首个「状态:」行保分隔符换值为台账 prev(双空格等非规范分隔符不误伤,
|
|
1010
|
+
// 与前向按原行字面计算对称)→ 剔「确认指纹:」行。两条降级路径:台账行缺 prev(schema 演进前存量,
|
|
1011
|
+
// 无以复原)→ warning 不拦;entry.ts 早于 2026-09-28(UTC 字符串比较——该次确认在旧关单顺序时代完成)→
|
|
1012
|
+
// 豁免绑定(配对判定已过,3 份失配存量即此列)。关单编辑顺序新约定不变:confirm-doc 是最后一次写入,
|
|
1013
|
+
// 此后修订走 superseded 或新 intent。
|
|
1014
|
+
if (ok && ['done', 'closed', 'superseded', 'cancelled'].includes(st)) {
|
|
1015
|
+
const doneEntries = ledger.filter((e) => e && e.doc === rel && e.stage === st && typeof e.fingerprint === 'string');
|
|
1016
|
+
const entry = doneEntries[doneEntries.length - 1]; // append-only 台账,末次生效(重确认场景)
|
|
1017
|
+
if (!entry || !entry.prev) {
|
|
1018
|
+
warnings.push(`- [WARN 绑定降级] ${base} 台账 stage=${st} 行缺 prev 字段(schema 演进前行),内容绑定跳过——仅配对判定`);
|
|
1019
|
+
} else if (!(typeof entry.ts === 'string' && entry.ts >= kitPolicy.bindingTs)) {
|
|
1020
|
+
// 该 done 确认发生在生效锚前(旧关单顺序时代)——豁免内容绑定,配对判定照常
|
|
1021
|
+
} else if (bindingSha256(linesOf(doc) || [], entry.prev) !== entry.fingerprint) {
|
|
1022
|
+
blockers.push(`- [确认内容漂移] ${base} ${st} 后内容与确认台账不符——已关单文档不得直接改(关单编辑先于关单确认);确需修订走 superseded 或新 intent 引用`);
|
|
1023
|
+
}
|
|
1017
1024
|
}
|
|
1018
1025
|
}
|
|
1019
1026
|
}
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1027
|
+
// 并录批次审计(2026-09-28 batch-ledger-audit 改判据;原 2026-09-27 confirm-gate-one-per-call 引入):
|
|
1028
|
+
// **读调用事实,不猜时间戳模式**。判据 = 台账行按 `batch` 分组,组内 `of > 1` → warning「确认并录」。
|
|
1029
|
+
// batch/seq/of 由 confirm-doc 在**写入时**记录(那才是「本次调用落了几份」确定已知的时刻);
|
|
1030
|
+
// 旧判据「同 quote + 相邻 ts 差 < 2s」是拿两个间接信号反推该事实,已实证四类失效并**整段退役**:
|
|
1031
|
+
// ① 假阳性:合规逐件调用复用同句(用户两次都说「可以」)→ 必然误报;
|
|
1032
|
+
// ② 假阴性:并录时给每份换不同 quote → 零告警(判据的实际效果是引导伪装者改 quote);
|
|
1033
|
+
// ③ 噪声不可消退:不按日期门豁免,退役前本仓 22 条 advisory 中 14 条为该告警,且文案自认「不可区分」;
|
|
1034
|
+
// ④ 字段职责冲突:`quote` 同时当「对质凭据」与「并录指纹」——现归还单一职责,只记用户原话。
|
|
1035
|
+
// 无 `batch` 字段的历史行(schema 演进前,本仓全部 67 行均无该字段)→ **静默跳过**,不降级出账、不回溯:
|
|
1036
|
+
// 按 audit-gate-hardening P3 教训(「扫了只产生不可消除的 advisory 噪声,真漏点被淹没」),无判定依据
|
|
1037
|
+
// 的行不该产出告警。VALID_STAGES 复用外层声明(复核 P2-3 更正:此前本块内另有一份同名声明遮蔽外层)。
|
|
1038
|
+
{
|
|
1039
|
+
const byBatch = new Map(); // batch → 该批的台账行(仅计合法跳转 stage)
|
|
1040
|
+
for (const e of ledger) {
|
|
1041
|
+
if (!e || !VALID_STAGES.has(e.stage)) continue;
|
|
1042
|
+
if (typeof e.batch !== 'string' || !e.batch) continue; // 历史行无 batch → 无判定依据,跳过
|
|
1043
|
+
if (!byBatch.has(e.batch)) byBatch.set(e.batch, []);
|
|
1044
|
+
byBatch.get(e.batch).push(e);
|
|
1045
|
+
}
|
|
1046
|
+
for (const [b, rows] of byBatch) {
|
|
1047
|
+
// 判据取组内**任一行** of>1(复核 P2-3 更正:原取 rows[0].of 使结论依赖行序——同批首行 of=1、
|
|
1048
|
+
// 次行 of=2 会漏报。confirm-doc 对同批所有行写同一个 of=docs.length,故正常批次组内一致;
|
|
1049
|
+
// 改用 some 消除该顺序脆弱性,并对 craft 出来的不一致批次按「最大值」报,方向偏严不偏松)
|
|
1050
|
+
if (!rows.some((r) => typeof r.of === 'number' && r.of > 1)) continue;
|
|
1051
|
+
// of > 1 = 一次调用落多份态。delegated 形态下这正是「并录」(入口已拒多份,此处抓历史/绕行);
|
|
1052
|
+
// TTY 形态天然逐份过目(用户亲手键入),of>1 不构成违规——故只对 delegated 行报。
|
|
1053
|
+
if (!rows.some((r) => r.source === 'chat-delegated')) continue;
|
|
1054
|
+
// 协作道批量豁免(2026-09-30 hybrid-governance-risk-lanes):confirm-doc --batch 仅受理 L0/L1
|
|
1055
|
+
// (逐份读级别,L2/L3 逐份拒绝),台账行带 brief:true——全行带标记的批次是 sanctioned 泳道行为
|
|
1056
|
+
// (异步审计兜底),不出「确认并录」;缺标记(旧版脚本/手造台账)照旧告警,判据偏严不偏松。
|
|
1057
|
+
if (rows.every((r) => r.brief === true)) continue;
|
|
1058
|
+
const of = Math.max(...rows.map((r) => (typeof r.of === 'number' ? r.of : 0)));
|
|
1059
|
+
const docs = rows.map((r) => String(r.doc).replace(/^workflow\//, '')).join('、');
|
|
1060
|
+
warnings.push(`- [WARN 确认并录] 单次调用落账 ${rows.length} 份(batch ${b},of=${of}):${docs}——同一次 --delegated 调用放行多份,塌掉「逐件确认」门(build.md);口径见 workflow/papercuts.md 2026-09-28 与 incidents/2026-09-28-batch-ledger-audit.md`);
|
|
1061
|
+
}
|
|
1055
1062
|
}
|
|
1056
1063
|
}
|
|
1057
|
-
}
|
|
1058
1064
|
|
|
1059
|
-
// --- 16. 量化断言指标签名对账 [warning](2026-09-28 claim-exceeds-fix;登记表单源 .agents/metric-claims.txt)---
|
|
1060
|
-
|
|
1061
|
-
//
|
|
1062
|
-
//
|
|
1063
|
-
//
|
|
1064
|
-
//
|
|
1065
|
-
//
|
|
1066
|
-
//
|
|
1067
|
-
|
|
1065
|
+
// --- 16. 量化断言指标签名对账 [warning](2026-09-28 claim-exceeds-fix;登记表单源 .agents/metric-claims.txt)---
|
|
1066
|
+
markGate('16', '量化断言签名对账');
|
|
1067
|
+
// 判据要点:活跃态文档(draft/approved/open)中的 `{{指标名}}`(小写点分)签名须替换为实时值,留签名=未回填=
|
|
1068
|
+
// warning;只查显式签名、不全文扫数字(历史叙述假阳性恒 0);未登记签名 / 取数器缺失 → fail-loud 出账;
|
|
1069
|
+
// 登记表缺失 → 静默跳过(未启用该检查的装户零噪声);装户自有指标走 .agents/metric-derivers.cjs(CJS 契约,
|
|
1070
|
+
// 内置优先)。实现自 2026-09-28 check16-inline-debt 起迁出至 check-metric-claims.mjs(原内联块,判据语义/
|
|
1071
|
+
// 消息文案/exit 语义零变化,迁出经全量输出 diff 基线验证)。ctx 显式携带本检查引用的外层符号
|
|
1072
|
+
// (ROOT/ENUMS/docFiles/fmGet/inSet/isTracked/linesOf/readdirOrNull/warnings,以代码实际引用集为准);
|
|
1073
|
+
// 调用位置须保持在检查 15 之后、输出段之前——warnings 按插入序输出,位置变化会改变输出行序。
|
|
1074
|
+
runCheck16({ ROOT, ENUMS, docFiles, fmGet, inSet, isTracked, linesOf, readdirOrNull, warnings });
|
|
1068
1075
|
|
|
1069
|
-
// --- 17. 发版提交树上仍未收口的 intent/spec/plan [hard-block] ---
|
|
1070
|
-
|
|
1071
|
-
//
|
|
1072
|
-
//
|
|
1073
|
-
//
|
|
1074
|
-
//
|
|
1075
|
-
//
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
const
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1076
|
+
// --- 17. 发版提交树上仍未收口的 intent/spec/plan [hard-block] ---
|
|
1077
|
+
markGate('17', '发版树未收口文档');
|
|
1078
|
+
// 最近一次 package.json version 发生变化的提交里,当时状态已是 draft 或 approved 的
|
|
1079
|
+
// intent/spec/plan,被扫描的树上仍是 draft 或 approved 则阻断。
|
|
1080
|
+
// 当时已是 draft:不看版本锚。当时已是 approved:只在发版版本大于
|
|
1081
|
+
// policy.check17UnclosedAfter 时阻断。版本 1 没有该键,因此不启用后两格。
|
|
1082
|
+
// 版本按主、次、修订三段整数比较。沿 package.json 的全部历史查找,不设次数上限。
|
|
1083
|
+
// 该提交之后新建的文件不在范围内。open 的 incident 不在此列。
|
|
1084
|
+
{
|
|
1085
|
+
const log = gitOut(['log', '--format=%H', '--', 'package.json']);
|
|
1086
|
+
if (log) {
|
|
1087
|
+
let release = null;
|
|
1088
|
+
let releaseVer = '';
|
|
1089
|
+
for (const sha of log.split('\n').map((s) => s.trim()).filter(Boolean)) {
|
|
1090
|
+
const curTxt = gitOut(['show', `${sha}:package.json`]);
|
|
1091
|
+
if (!curTxt) continue;
|
|
1092
|
+
let cur = null;
|
|
1093
|
+
try { cur = JSON.parse(curTxt).version; } catch { continue; }
|
|
1094
|
+
const prevTxt = gitOut(['show', `${sha}^:package.json`]);
|
|
1095
|
+
let prev = null;
|
|
1096
|
+
if (prevTxt) { try { prev = JSON.parse(prevTxt).version; } catch { prev = null; } }
|
|
1097
|
+
if (cur && cur !== prev) { release = sha; releaseVer = cur; break; }
|
|
1098
|
+
}
|
|
1099
|
+
const tree = release && gitOut(['ls-tree', '-r', '--name-only', release, '--', `${WF}/intents`, `${WF}/specs`, `${WF}/plans`]);
|
|
1100
|
+
if (release && tree) {
|
|
1101
|
+
for (const rel of tree.split('\n').map((s) => s.trim()).filter(Boolean)) {
|
|
1102
|
+
if (!rel.endsWith('.md') || rel.endsWith('_TEMPLATE.md')) continue;
|
|
1103
|
+
const thenTxt = gitOut(['show', `${release}:${rel}`]);
|
|
1104
|
+
if (!thenTxt) continue;
|
|
1105
|
+
const thenSt = fmStatus(thenTxt);
|
|
1106
|
+
const nowSt = fmGet(path.join(ROOT, rel), '状态');
|
|
1107
|
+
if ((thenSt !== 'draft' && thenSt !== 'approved') || (nowSt !== 'draft' && nowSt !== 'approved')) continue;
|
|
1108
|
+
if (thenSt === 'approved' && !versionGreater(releaseVer, kitPolicy.check17UnclosedAfter)) continue;
|
|
1109
|
+
const here = REV ? `提交 ${REV.slice(0, 7)}` : '工作区';
|
|
1110
|
+
blockers.push(`- [发版草稿] ${rel} 在 version=${releaseVer} 的提交 ${release.slice(0, 7)} 上已是 ${thenSt},${here}仍是 ${nowSt}。发版提交树上未收口的 intent/spec/plan 须先离开 draft 与 approved。覆盖面只含该提交当时已在树上的 intent/spec/plan;其后新建的文件不在此列`);
|
|
1111
|
+
}
|
|
1103
1112
|
}
|
|
1104
1113
|
}
|
|
1105
1114
|
}
|
|
1106
|
-
}
|
|
1107
1115
|
|
|
1108
|
-
// --- 18. 委派台账对账 [warning] ---
|
|
1109
|
-
|
|
1110
|
-
//
|
|
1111
|
-
//
|
|
1112
|
-
//
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
for (const
|
|
1117
|
-
const
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1116
|
+
// --- 18. 委派台账对账 [warning] ---
|
|
1117
|
+
markGate('18', '委派台账对账');
|
|
1118
|
+
// L2/L3 的 intent、spec、plan 状态为 done,或同级别 incident 状态为 fixed 或 closed 时,
|
|
1119
|
+
// 「委派结果」表与「自做任务结果」表均没有日期不早于文档日期、且含该文件名(带/不带 .md 均匹配)的一行,则警告。
|
|
1120
|
+
// 缺级别、缺日期、以及其他状态不警告。audit:false 时 warnings.push 已被换成空函数。
|
|
1121
|
+
// 两表解析口径与 agg-delegations.cjs splitTables 保持一致,一边改另一边须跟。
|
|
1122
|
+
{
|
|
1123
|
+
const rows = delegationResultRows(path.join(ROOT, WF, 'delegations.md'));
|
|
1124
|
+
for (const sub of ['intents', 'specs', 'plans', 'incidents']) {
|
|
1125
|
+
for (const abs of docFiles(sub)) {
|
|
1126
|
+
const lvl = fmGet(abs, '级别');
|
|
1127
|
+
if (lvl !== 'L2' && lvl !== 'L3') continue;
|
|
1128
|
+
const st = fmGet(abs, '状态');
|
|
1129
|
+
const inScope = sub === 'incidents' ? (st === 'fixed' || st === 'closed') : st === 'done';
|
|
1130
|
+
if (!inScope) continue;
|
|
1131
|
+
const date = sub === 'incidents' ? (fmGet(abs, '发现') || fmGet(abs, '日期')) : fmGet(abs, '日期');
|
|
1132
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) continue;
|
|
1133
|
+
// delegationSince 生效日豁免(policy v4,2026-10-06 loop-audit-remediation):锚前文档为存量回溯件
|
|
1134
|
+
// ——台账记法约定(delegations.md 头部「任务一句话含文件名」)自生效日才立,存量不回填;缺键
|
|
1135
|
+
// (v1-v3)→ 维持原全量对账行为(向后兼容,检查面不放松)
|
|
1136
|
+
if (typeof kitPolicy.delegationSince === 'string' && date < kitPolicy.delegationSince) continue;
|
|
1137
|
+
const base = path.basename(abs);
|
|
1138
|
+
const stripped = base.replace(/\.md$/, '');
|
|
1139
|
+
if (rows.some((r) => r.date >= date && (r.line.includes(base) || r.line.includes(stripped)))) continue;
|
|
1140
|
+
const rel = path.relative(ROOT, abs).split(path.sep).join('/');
|
|
1141
|
+
warnings.push(`- [WARN 委派台账] ${rel} 在 ${date} 及之后的委派结果/自做任务结果表中没有该文件名`);
|
|
1142
|
+
}
|
|
1133
1143
|
}
|
|
1134
1144
|
}
|
|
1135
|
-
}
|
|
1136
1145
|
|
|
1137
|
-
// --- 19. 逐阶段审计 [warning](2026-09-30 stage-gate-machine;口径与 stage-gates.mjs / fill-* / confirm-doc 前置门互引) ---
|
|
1138
|
-
|
|
1139
|
-
//
|
|
1140
|
-
//
|
|
1141
|
-
//
|
|
1142
|
-
//
|
|
1143
|
-
//
|
|
1144
|
-
//
|
|
1145
|
-
//
|
|
1146
|
-
//
|
|
1147
|
-
//
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1146
|
+
// --- 19. 逐阶段审计 [warning](2026-09-30 stage-gate-machine;口径与 stage-gates.mjs / fill-* / confirm-doc 前置门互引) ---
|
|
1147
|
+
markGate('19', '逐阶段审计');
|
|
1148
|
+
// 判据 A(在途扫描):同名 spec/plan 为 draft 且其日期 ≥ stageGateSince 时——入口未确认(intent:状态非
|
|
1149
|
+
// approved/done、或缺台账行且非存量;incident:「时间线」小节缺行首「用户确认」条目)→ warning;
|
|
1150
|
+
// 入口缺合法「级别」→ warning(与起草门 fail-closed 同口径,2026-09-30 复核 P2-7);
|
|
1151
|
+
// plan 另判(入口级别 L2/L3 且 spec 未确认或缺失)→ warning。
|
|
1152
|
+
// 判据 B(台账顺序):同主题 {intents,specs,plans} 的 approved 行(每 doc 取最早)时间须非降序;仅对
|
|
1153
|
+
// 组内最早 ts 日期 ≥ stageGateSince 的主题判定;含更早 ts 的主题整组跳过(历史豁免)。
|
|
1154
|
+
// 协作道豁免(2026-09-30 hybrid-governance-risk-lanes):入口 intent 为 draft 且级别 L0/L1 → 判据 A 静默
|
|
1155
|
+
// (「先动手后确认」是泳道语义;approved/done 无台账行仍照报);判据 B 对入口 L0/L1 的主题豁免顺序
|
|
1156
|
+
// 倒置判定(入口文件缺失不豁免)。
|
|
1157
|
+
// stageGateSince 缺键(policy v1)→ 本检查整体跳过;全部 warning、不 hard-block。
|
|
1158
|
+
{
|
|
1159
|
+
const since = kitPolicy.stageGateSince;
|
|
1160
|
+
if (typeof since === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(since)) {
|
|
1161
|
+
const ledgerRows = [];
|
|
1162
|
+
{
|
|
1163
|
+
const lp = path.join(ROOT, '.agents', 'confirmations.jsonl');
|
|
1164
|
+
if (fs.existsSync(lp)) {
|
|
1165
|
+
for (const line of linesOf(lp) || []) {
|
|
1166
|
+
if (!line.trim()) continue;
|
|
1167
|
+
try { ledgerRows.push(JSON.parse(line)); } catch { /* 坏行跳过(同检查 15 口径) */ }
|
|
1168
|
+
}
|
|
1158
1169
|
}
|
|
1159
1170
|
}
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
if (inTimeline && MARK_RE.test(l)) return true;
|
|
1180
|
-
}
|
|
1181
|
-
return false;
|
|
1182
|
-
};
|
|
1183
|
-
const entryConfirmed19 = (base) => {
|
|
1184
|
-
const irel = `${WF}/intents/${base}.md`;
|
|
1185
|
-
const iabs = path.join(ROOT, irel);
|
|
1186
|
-
if (fs.existsSync(iabs)) {
|
|
1187
|
-
const st19 = fmGet(iabs, '状态');
|
|
1188
|
-
const lvl19 = fmGet(iabs, '级别');
|
|
1189
|
-
const r = docConfirmed(irel, iabs);
|
|
1190
|
-
const body = (linesOf(iabs) || []).join('\n');
|
|
1191
|
-
const d19 = fmGet(iabs, '日期') || fmGet(iabs, '发现');
|
|
1192
|
-
const storLegacy = /^流程: legacy/m.test(body) || (/^\d{4}-\d{2}-\d{2}$/.test(d19) && d19 < kitPolicy.confirmDocsEffective);
|
|
1193
|
-
return { ...r, level: lvl19, risk: fmGet(iabs, 'risk_level'), date: d19, entryDraft: st19 === 'draft', storageLegacy: storLegacy };
|
|
1194
|
-
}
|
|
1195
|
-
const crel = `${WF}/incidents/${base}.md`;
|
|
1196
|
-
const cabs = path.join(ROOT, crel);
|
|
1197
|
-
if (fs.existsSync(cabs)) {
|
|
1198
|
-
const st = fmGet(cabs, '状态');
|
|
1199
|
-
if ((st === 'open' || st === 'fixed' || st === 'closed') && incidentMarked19(linesOf(cabs))) {
|
|
1200
|
-
return { ok: true, level: fmGet(cabs, '级别'), legacy: false, storageLegacy: false };
|
|
1171
|
+
const hasRow = (rel) => ledgerRows.some((e) => e && e.doc === rel && (e.stage === 'approved' || e.stage === 'done'));
|
|
1172
|
+
const docConfirmed = (rel, abs) => {
|
|
1173
|
+
const st = fmGet(abs, '状态');
|
|
1174
|
+
if (st !== 'approved' && st !== 'done') return { ok: false, legacy: false, why: `状态「${st || '缺失'}」` };
|
|
1175
|
+
if (hasRow(rel)) return { ok: true, level: fmGet(abs, '级别'), legacy: false };
|
|
1176
|
+
const d = fmGet(abs, '日期') || fmGet(abs, '发现');
|
|
1177
|
+
const legacy = (linesOf(abs) || []).some((l) => /^流程: legacy/.test(l))
|
|
1178
|
+
|| (/^\d{4}-\d{2}-\d{2}$/.test(d) && d < kitPolicy.confirmDocsEffective);
|
|
1179
|
+
if (legacy) return { ok: true, level: fmGet(abs, '级别'), legacy: true };
|
|
1180
|
+
return { ok: false, legacy: false, why: '无确认台账行' };
|
|
1181
|
+
};
|
|
1182
|
+
// incident 留痕判定:**直接复用 stage-gates.mjs 的 MARK_RE**(第七轮复核 N3:此前此处内联复制
|
|
1183
|
+
// 一份同口径字面量,两处靠注释人工同步、无测试钉住——改为单源复用,从结构上消除漂移可能)。
|
|
1184
|
+
// 判据同源:「## 时间线」小节内、行首列表条目含「用户确认」(词边界见 MARK_RE)。
|
|
1185
|
+
const incidentMarked19 = (lines) => {
|
|
1186
|
+
let inTimeline = false;
|
|
1187
|
+
for (const l of lines || []) {
|
|
1188
|
+
if (/^##\s/.test(l)) { inTimeline = /^##\s*时间线/.test(l); continue; }
|
|
1189
|
+
if (inTimeline && MARK_RE.test(l)) return true;
|
|
1201
1190
|
}
|
|
1202
|
-
return
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
const ent = entryConfirmed19(base);
|
|
1216
|
-
if (!ent.ok) {
|
|
1217
|
-
// 协作道异步审计豁免(2026-09-30 hybrid-governance-risk-lanes;2026-10-02 caliber-convergence
|
|
1218
|
-
// 起单源 laneOfEntry·方案 C):入口 intent 仍为 draft 且泳道 low(两字段一致 L0/L1)→「先动手后
|
|
1219
|
-
// 确认」是泳道语义,不告警;suspect(缺失/非法/分歧)不豁免;approved/done 却无台账行仍照报
|
|
1220
|
-
// (防手改状态冒充确认,与交叉一致性口径同);incident 入口不豁免(过目留痕本就轻量)。
|
|
1221
|
-
if (ent.entryDraft && laneOfEntry(ent.level, ent.risk, kitPolicy.riskLevelSince, ent.date) === 'low') continue;
|
|
1222
|
-
warnings.push(`- [WARN 逐阶段] ${rel} 起草先于入口确认(${ent.why})`); continue;
|
|
1191
|
+
return false;
|
|
1192
|
+
};
|
|
1193
|
+
const entryConfirmed19 = (base) => {
|
|
1194
|
+
const irel = `${WF}/intents/${base}.md`;
|
|
1195
|
+
const iabs = path.join(ROOT, irel);
|
|
1196
|
+
if (fs.existsSync(iabs)) {
|
|
1197
|
+
const st19 = fmGet(iabs, '状态');
|
|
1198
|
+
const lvl19 = fmGet(iabs, '级别');
|
|
1199
|
+
const r = docConfirmed(irel, iabs);
|
|
1200
|
+
const body = (linesOf(iabs) || []).join('\n');
|
|
1201
|
+
const d19 = fmGet(iabs, '日期') || fmGet(iabs, '发现');
|
|
1202
|
+
const storLegacy = /^流程: legacy/m.test(body) || (/^\d{4}-\d{2}-\d{2}$/.test(d19) && d19 < kitPolicy.confirmDocsEffective);
|
|
1203
|
+
return { ...r, level: lvl19, risk: fmGet(iabs, 'risk_level'), date: d19, entryDraft: st19 === 'draft', storageLegacy: storLegacy };
|
|
1223
1204
|
}
|
|
1224
|
-
|
|
1225
|
-
const
|
|
1226
|
-
if (
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1205
|
+
const crel = `${WF}/incidents/${base}.md`;
|
|
1206
|
+
const cabs = path.join(ROOT, crel);
|
|
1207
|
+
if (fs.existsSync(cabs)) {
|
|
1208
|
+
const st = fmGet(cabs, '状态');
|
|
1209
|
+
if ((st === 'open' || st === 'fixed' || st === 'closed') && incidentMarked19(linesOf(cabs))) {
|
|
1210
|
+
return { ok: true, level: fmGet(cabs, '级别'), legacy: false, storageLegacy: false };
|
|
1211
|
+
}
|
|
1212
|
+
return { ok: false, legacy: false, storageLegacy: false, why: 'incident 未见过目留痕(时间线条目缺失)' };
|
|
1213
|
+
}
|
|
1214
|
+
return { ok: false, legacy: false, storageLegacy: false, why: '无同名入口' };
|
|
1215
|
+
};
|
|
1216
|
+
for (const sub of ['specs', 'plans']) {
|
|
1217
|
+
for (const abs of docFiles(sub)) {
|
|
1218
|
+
if (fmGet(abs, '状态') !== 'draft') continue;
|
|
1219
|
+
const base = path.basename(abs).replace(/\.md$/, '');
|
|
1220
|
+
// 日期:frontmatter 优先,缺则回落文件名前缀(plan 无「日期」字段;沿检查 15 口径)
|
|
1221
|
+
let d = fmGet(abs, '日期');
|
|
1222
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) d = /^\d{4}-\d{2}-\d{2}$/.test(base.slice(0, 10)) ? base.slice(0, 10) : '';
|
|
1223
|
+
if (!(/^\d{4}-\d{2}-\d{2}$/.test(d) && d >= since)) continue;
|
|
1224
|
+
const rel = path.relative(ROOT, abs).split(path.sep).join('/');
|
|
1225
|
+
const ent = entryConfirmed19(base);
|
|
1226
|
+
if (!ent.ok) {
|
|
1227
|
+
// 协作道异步审计豁免(2026-09-30 hybrid-governance-risk-lanes;2026-10-02 caliber-convergence
|
|
1228
|
+
// 起单源 laneOfEntry·方案 C):入口 intent 仍为 draft 且泳道 low(两字段一致 L0/L1)→「先动手后
|
|
1229
|
+
// 确认」是泳道语义,不告警;suspect(缺失/非法/分歧)不豁免;approved/done 却无台账行仍照报
|
|
1230
|
+
// (防手改状态冒充确认,与交叉一致性口径同);incident 入口不豁免(过目留痕本就轻量)。
|
|
1231
|
+
if (ent.entryDraft && laneOfEntry(ent.level, ent.risk, kitPolicy.riskLevelSince, ent.date) === 'low') continue;
|
|
1232
|
+
warnings.push(`- [WARN 逐阶段] ${rel} 起草先于入口确认(${ent.why})`); continue;
|
|
1233
|
+
}
|
|
1234
|
+
// 库存量形态(标记 / 日期早于生效日)短路 level 校验与 spec 档(与起草门同口径,N1)
|
|
1235
|
+
const storLegacy = ent.storageLegacy === true;
|
|
1236
|
+
if (!storLegacy && !/^L[0-3]$/.test(ent.level)) { warnings.push(`- [WARN 逐阶段] ${rel} 入口缺合法「级别」字段(无法判定前置深度;与起草门 fail-closed 同口径,库存量形态除外)`); continue; }
|
|
1237
|
+
if (sub === 'plans' && !storLegacy && (ent.level === 'L2' || ent.level === 'L3')) {
|
|
1238
|
+
const srel = `${WF}/specs/${base}.md`;
|
|
1239
|
+
const sabs = path.join(ROOT, srel);
|
|
1240
|
+
const sp = fs.existsSync(sabs) ? docConfirmed(srel, sabs) : { ok: false, why: '缺同名 spec' };
|
|
1241
|
+
if (!sp.ok) warnings.push(`- [WARN 逐阶段] ${rel} 起草先于 spec 确认(${sp.why})`);
|
|
1242
|
+
}
|
|
1232
1243
|
}
|
|
1233
1244
|
}
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1245
|
+
const firstApproved = new Map(); // base -> { intents?, specs?, plans? } 最早 approved ts
|
|
1246
|
+
for (const e of ledgerRows) {
|
|
1247
|
+
if (!e || e.stage !== 'approved' || typeof e.doc !== 'string') continue;
|
|
1248
|
+
const m = /^workflow\/(intents|specs|plans)\/(.+)\.md$/.exec(e.doc);
|
|
1249
|
+
if (!m) continue;
|
|
1250
|
+
const rec = firstApproved.get(m[2]) || {};
|
|
1251
|
+
if (!rec[m[1]] && typeof e.ts === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(e.ts)) rec[m[1]] = e.ts;
|
|
1252
|
+
firstApproved.set(m[2], rec);
|
|
1253
|
+
}
|
|
1254
|
+
for (const [base, rec] of firstApproved) {
|
|
1255
|
+
const seq = ['intents', 'specs', 'plans'].filter((k) => rec[k]);
|
|
1256
|
+
if (seq.length < 2) continue;
|
|
1257
|
+
// 协作道豁免(2026-09-30 hybrid-governance-risk-lanes):入口 L0/L1 的审批顺序倒置(如 plan 先于
|
|
1258
|
+
// intent approved——「动手后确认」)是异步审计泳道语义,非流程违规;入口文件缺失(纯台账历史行 /
|
|
1259
|
+
// incident 侧)不豁免,维持既有判据。
|
|
1260
|
+
// existsSync 守卫(2026-10-06-check19-entry-enoent):incident 闭环主题无同名 intent,「读空判级」
|
|
1261
|
+
// 惯用法(缺文件→级别空→不豁免,判定面不变)在 fail-loud fs 读(2026-10-05-gitout-fail-open)下
|
|
1262
|
+
// 每次推送响亮出账 N 条 ENOENT 噪声——守卫跳过读取,语义与上注释一致;同款先例 entryConfirmed19
|
|
1263
|
+
// 与下方 spec 读取。
|
|
1264
|
+
const iabsB = path.join(ROOT, WF, 'intents', `${base}.md`);
|
|
1265
|
+
const ilvlB = fs.existsSync(iabsB) ? fmGet(iabsB, '级别') : '';
|
|
1266
|
+
if (ilvlB === 'L0' || ilvlB === 'L1') continue;
|
|
1267
|
+
const earliest = seq.map((k) => rec[k]).sort()[0];
|
|
1268
|
+
if (!(earliest.slice(0, 10) >= since)) continue;
|
|
1269
|
+
let ordered = true;
|
|
1270
|
+
for (let i = 1; i < seq.length; i++) if (rec[seq[i]] < rec[seq[i - 1]]) ordered = false;
|
|
1271
|
+
if (!ordered) warnings.push(`- [WARN 逐阶段] 台账审批顺序倒置:${base}(${seq.map((k) => `${k}=${rec[k].slice(0, 19)}`).join(' / ')})`);
|
|
1272
|
+
}
|
|
1262
1273
|
}
|
|
1263
1274
|
}
|
|
1264
|
-
}
|
|
1265
1275
|
|
|
1266
|
-
// --- 加固门(仅 --hardening 启用;2026-09-30 hybrid-governance-explore-hardening)[hard-block] ---
|
|
1267
|
-
// 探索泳道(experiment/* 分支 + intent frontmatter「阶段: exploring」标记;「状态: exploring」亦认——
|
|
1268
|
-
// 非法状态本就会在检查 5 出账)允许「先动手后确认」快速 PoC;成果转正(合入 main)前必须完成加固:
|
|
1269
|
-
// ① intent 状态离开 draft → approved/done(探索作废走显式放弃态 superseded/cancelled——留档即出列,
|
|
1270
|
-
// 不加固:放弃态无代码应随行);
|
|
1271
|
-
// ② 同名 plan 在场且状态收口(∈ doc.status.confirmed;L0 亦不豁免——本门是转正门槛,不是配对门);
|
|
1272
|
-
// ③ 泳道 L2/L3(级别优先、缺失/非法回落 risk_level,与 stage-gates 起草门同款取法)另须同名 spec
|
|
1273
|
-
// 在场且收口。
|
|
1274
|
-
// 触发面:.githooks/pre-push 对 remote=refs/heads/main 的推送以 --hardening 运行本脚本(hard 失败即阻断
|
|
1275
|
-
// push);手跑 `node check-loop.mjs --hardening` 可转正前预检。诚实边界:分支名不进 commit,扫描树无法
|
|
1276
|
-
// 机械判定「曾在 experiment/* 上」——标记靠泳道纪律写入(intent 模板 / workflow README 口径),漏标记的
|
|
1277
|
-
// 探索件不被本门覆盖;本门拦的是「已标记未收口」。
|
|
1278
|
-
if (HARDENING) {
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1276
|
+
// --- 加固门(仅 --hardening 启用;2026-09-30 hybrid-governance-explore-hardening)[hard-block] ---
|
|
1277
|
+
// 探索泳道(experiment/* 分支 + intent frontmatter「阶段: exploring」标记;「状态: exploring」亦认——
|
|
1278
|
+
// 非法状态本就会在检查 5 出账)允许「先动手后确认」快速 PoC;成果转正(合入 main)前必须完成加固:
|
|
1279
|
+
// ① intent 状态离开 draft → approved/done(探索作废走显式放弃态 superseded/cancelled——留档即出列,
|
|
1280
|
+
// 不加固:放弃态无代码应随行);
|
|
1281
|
+
// ② 同名 plan 在场且状态收口(∈ doc.status.confirmed;L0 亦不豁免——本门是转正门槛,不是配对门);
|
|
1282
|
+
// ③ 泳道 L2/L3(级别优先、缺失/非法回落 risk_level,与 stage-gates 起草门同款取法)另须同名 spec
|
|
1283
|
+
// 在场且收口。
|
|
1284
|
+
// 触发面:.githooks/pre-push 对 remote=refs/heads/main 的推送以 --hardening 运行本脚本(hard 失败即阻断
|
|
1285
|
+
// push);手跑 `node check-loop.mjs --hardening` 可转正前预检。诚实边界:分支名不进 commit,扫描树无法
|
|
1286
|
+
// 机械判定「曾在 experiment/* 上」——标记靠泳道纪律写入(intent 模板 / workflow README 口径),漏标记的
|
|
1287
|
+
// 探索件不被本门覆盖;本门拦的是「已标记未收口」。
|
|
1288
|
+
if (HARDENING) {
|
|
1289
|
+
for (const intent of docFiles('intents')) {
|
|
1290
|
+
if (fmGet(intent, '阶段') !== 'exploring' && fmGet(intent, '状态') !== 'exploring') continue;
|
|
1291
|
+
const base = path.basename(intent);
|
|
1292
|
+
const st = fmGet(intent, '状态');
|
|
1293
|
+
if (st === 'superseded' || st === 'cancelled') continue; // 放弃态:探索作废留档,不加固
|
|
1294
|
+
const lvl = fmGet(intent, '级别');
|
|
1295
|
+
// 泳道单源(2026-10-02 caliber-convergence 方案 C):suspect(缺失/非法/分歧)按 high——L2/L3 另须 spec
|
|
1296
|
+
const lane = laneOfEntry(lvl, fmGet(intent, 'risk_level'), kitPolicy.riskLevelSince, fmGet(intent, '日期'));
|
|
1297
|
+
if (st !== 'approved' && st !== 'done') {
|
|
1298
|
+
blockers.push(`- [加固未过] exploring 任务入 main 须先收口:${base} intent 状态仍为『${st || '缺失'}』——先确认至 approved/done 并补齐同名 spec+plan,或显式放弃(superseded/cancelled)`);
|
|
1299
|
+
continue;
|
|
1300
|
+
}
|
|
1301
|
+
const planAbs = path.join(ROOT, WF, 'plans', base);
|
|
1302
|
+
const planSt = fs.existsSync(planAbs) ? fmGet(planAbs, '状态') : '';
|
|
1303
|
+
if (!fs.existsSync(planAbs)) {
|
|
1304
|
+
blockers.push(`- [加固未过] exploring 任务入 main 缺同名 plan:${base}(应在 ${WF}/plans/ 下同名)`);
|
|
1305
|
+
} else if (!stOkDoc(planSt)) {
|
|
1306
|
+
blockers.push(`- [加固未过] exploring 任务入 main 的 plan 未收口:${base}(plan 状态『${planSt || '缺失'}』须为 approved/done)`);
|
|
1307
|
+
}
|
|
1308
|
+
// 泳道单源(2026-10-02 caliber-convergence 方案 C;复核 P1-1 修正):suspect(缺失/非法/分歧)与
|
|
1309
|
+
// high 同样另须同名 spec——仅 low(两字段一致 L0/L1)免 spec,只增不松。
|
|
1310
|
+
if (lane !== 'low') {
|
|
1311
|
+
const specAbs = path.join(ROOT, WF, 'specs', base);
|
|
1312
|
+
const specSt = fs.existsSync(specAbs) ? fmGet(specAbs, '状态') : '';
|
|
1313
|
+
if (!fs.existsSync(specAbs)) {
|
|
1314
|
+
blockers.push(`- [加固未过] exploring 任务入 main 缺同名 spec:${base}(级别 ${lvl}/risk ${fmGet(intent, 'risk_level') || '缺失'},应在 ${WF}/specs/ 下同名)`);
|
|
1315
|
+
} else if (!stOkDoc(specSt)) {
|
|
1316
|
+
blockers.push(`- [加固未过] exploring 任务入 main 的 spec 未收口:${base}(spec 状态『${specSt || '缺失'}』须为 approved/done)`);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1297
1319
|
}
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// --- 20. 引擎脚本测试覆盖 [warning](2026-10-01 gate-script-test-coverage;仅包源环境) ---
|
|
1323
|
+
markGate('20', '引擎脚本测试覆盖');
|
|
1324
|
+
// 包源开发纪律:templates/_agents/scripts/ 下每个非 *.test.mjs 引擎脚本须有同名兄弟 .test.mjs,
|
|
1325
|
+
// 或在 .agents/scripts-test-exempt.txt 登记豁免(managed 下发)。装户(无 templates/)整体跳过。
|
|
1326
|
+
{
|
|
1327
|
+
const scriptsDir = path.join(ROOT, 'templates', '_agents', 'scripts');
|
|
1328
|
+
if (fs.existsSync(scriptsDir)) {
|
|
1329
|
+
const exemptFile = path.join(ROOT, '.agents', 'scripts-test-exempt.txt');
|
|
1330
|
+
const exempt = fs.existsSync(exemptFile)
|
|
1331
|
+
? fs.readFileSync(exemptFile, 'utf8').split(/\r?\n/)
|
|
1332
|
+
.map((l) => l.trim())
|
|
1333
|
+
.filter((l) => l && !l.startsWith('#'))
|
|
1334
|
+
.map((l) => l.split('|')[0].trim())
|
|
1335
|
+
.filter(Boolean)
|
|
1336
|
+
: [];
|
|
1337
|
+
const orphans = fs.readdirSync(scriptsDir)
|
|
1338
|
+
.filter((f) => f.endsWith('.mjs') && !f.endsWith('.test.mjs') && !exempt.includes(f))
|
|
1339
|
+
.filter((f) => !fs.existsSync(path.join(scriptsDir, f.replace(/\.mjs$/, '.test.mjs'))));
|
|
1340
|
+
for (const f of orphans.sort()) {
|
|
1341
|
+
warnings.push(`- [WARN 脚本测试缺失] ${f} 缺同名 .test.mjs——新增引擎脚本默认必须带测试;库件/工具可在 .agents/scripts-test-exempt.txt 登记豁免`);
|
|
1307
1342
|
}
|
|
1308
1343
|
}
|
|
1309
1344
|
}
|
|
1345
|
+
|
|
1346
|
+
// --- 输出(banner 沿 sh 版字样与格式 = 稳定输出契约:banner 后空行、条目 '- ' 前缀)---
|
|
1347
|
+
// 门禁 ROI 收口(2026-10-08 gate-roi-metrics):末段结算(末段之后无下次 gateSeg 调用)+ 可选落盘。
|
|
1348
|
+
// ⚠️ 结算在输出**之前**:否则检查 20 之后的段不计入。落盘只在 --gate-stats 时发生。
|
|
1349
|
+
if (opts.gateStats) writeGateStats(ROOT, finishSegs(gateStats));
|
|
1350
|
+
if (blockers.length) {
|
|
1351
|
+
process.stderr.write(`闭环骨架断档(check-loop.sh)— HARD-BLOCK:\n\n${blockers.join('\n')}\n\n`);
|
|
1352
|
+
if (warnings.length) process.stderr.write(`WARN(advisory,不阻断):\n\n${warnings.join('\n')}\n`);
|
|
1353
|
+
return { exitCode: 1, blockers, warnings };
|
|
1354
|
+
}
|
|
1355
|
+
if (warnings.length) {
|
|
1356
|
+
process.stderr.write(`check-loop.sh WARN(advisory,不阻断):\n\n${warnings.join('\n')}\n`);
|
|
1357
|
+
}
|
|
1358
|
+
return { exitCode: 0, blockers, warnings };
|
|
1310
1359
|
}
|
|
1311
1360
|
|
|
1312
|
-
|
|
1361
|
+
// ---- CLI 入口(isMain 守卫)——takeOpts 的用法错误仍 process.exit(1)(CLI 专属路径)----
|
|
1362
|
+
const isMain = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('check-loop.mjs');
|
|
1363
|
+
if (isMain) process.exit(runCheckLoop(takeOpts()).exitCode);
|
|
1364
|
+
|
|
1365
|
+
export function delegationResultRows(file) {
|
|
1313
1366
|
let text = '';
|
|
1314
1367
|
try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
|
|
1315
1368
|
const lines = text.split(/\r?\n/);
|
|
1316
|
-
|
|
1317
|
-
|
|
1369
|
+
// 两表识别与 agg-delegations.cjs splitTables 表头签名同口径(一边改另一边须跟,2026-10-08 papercuts-cleanup-batch 修 2):
|
|
1370
|
+
// 委派表头=含「被委派方」列;自做表头=首列「日期」且含「任务一句话」列——节标题只服务人类阅读,
|
|
1371
|
+
// 表头才是数据边界(节标题漂移不再静默停解析);「##」节边界仍作收集终止符(表不跨节)。
|
|
1372
|
+
let inTable = false;
|
|
1318
1373
|
const rows = [];
|
|
1319
1374
|
for (const line of lines) {
|
|
1320
|
-
if (/^##\s+/.test(line)) {
|
|
1321
|
-
|
|
1322
|
-
continue;
|
|
1323
|
-
}
|
|
1324
|
-
if (!inSec || !/^\s*\|/.test(line)) continue;
|
|
1375
|
+
if (/^##\s+/.test(line)) { inTable = false; continue; }
|
|
1376
|
+
if (!/^\s*\|/.test(line)) continue;
|
|
1325
1377
|
const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
|
|
1326
1378
|
if (!cells.length || cells.every((c) => /^[-: ]*$/.test(c))) continue;
|
|
1327
|
-
if (
|
|
1379
|
+
if (cells.some((c) => c.startsWith('被委派方')) || (cells[0] === '日期' && cells.some((c) => c.startsWith('任务一句话')))) {
|
|
1380
|
+
inTable = true;
|
|
1381
|
+
continue;
|
|
1382
|
+
}
|
|
1383
|
+
if (!inTable || !/^\d{4}-\d{2}-\d{2}$/.test(cells[0])) continue;
|
|
1328
1384
|
rows.push({ date: cells[0], line });
|
|
1329
1385
|
}
|
|
1330
1386
|
return rows;
|
|
1331
1387
|
}
|
|
1332
1388
|
|
|
1333
|
-
function versionGreater(a, b) {
|
|
1389
|
+
export function versionGreater(a, b) {
|
|
1334
1390
|
const parse = (v) => {
|
|
1335
1391
|
const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(v ?? ''));
|
|
1336
1392
|
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
@@ -1344,7 +1400,7 @@ function versionGreater(a, b) {
|
|
|
1344
1400
|
return false;
|
|
1345
1401
|
}
|
|
1346
1402
|
|
|
1347
|
-
function fmStatus(text) {
|
|
1403
|
+
export function fmStatus(text) {
|
|
1348
1404
|
const lines = String(text || '').split(/\r?\n/);
|
|
1349
1405
|
if (!/^---\s*$/.test(lines[0] || '')) return '';
|
|
1350
1406
|
for (let i = 1; i < lines.length; i++) {
|
|
@@ -1354,45 +1410,11 @@ function fmStatus(text) {
|
|
|
1354
1410
|
return '';
|
|
1355
1411
|
}
|
|
1356
1412
|
|
|
1357
|
-
// --- 20. 引擎脚本测试覆盖 [warning](2026-10-01 gate-script-test-coverage;仅包源环境) ---
|
|
1358
|
-
// 包源开发纪律:templates/_agents/scripts/ 下每个非 *.test.mjs 引擎脚本须有同名兄弟 .test.mjs,
|
|
1359
|
-
// 或在 .agents/scripts-test-exempt.txt 登记豁免(managed 下发)。装户(无 templates/)整体跳过。
|
|
1360
|
-
{
|
|
1361
|
-
const scriptsDir = path.join(ROOT, 'templates', '_agents', 'scripts');
|
|
1362
|
-
if (fs.existsSync(scriptsDir)) {
|
|
1363
|
-
const exemptFile = path.join(ROOT, '.agents', 'scripts-test-exempt.txt');
|
|
1364
|
-
const exempt = fs.existsSync(exemptFile)
|
|
1365
|
-
? fs.readFileSync(exemptFile, 'utf8').split(/\r?\n/)
|
|
1366
|
-
.map((l) => l.trim())
|
|
1367
|
-
.filter((l) => l && !l.startsWith('#'))
|
|
1368
|
-
.map((l) => l.split('|')[0].trim())
|
|
1369
|
-
.filter(Boolean)
|
|
1370
|
-
: [];
|
|
1371
|
-
const orphans = fs.readdirSync(scriptsDir)
|
|
1372
|
-
.filter((f) => f.endsWith('.mjs') && !f.endsWith('.test.mjs') && !exempt.includes(f))
|
|
1373
|
-
.filter((f) => !fs.existsSync(path.join(scriptsDir, f.replace(/\.mjs$/, '.test.mjs'))));
|
|
1374
|
-
for (const f of orphans.sort()) {
|
|
1375
|
-
warnings.push(`- [WARN 脚本测试缺失] ${f} 缺同名 .test.mjs——新增引擎脚本默认必须带测试;库件/工具可在 .agents/scripts-test-exempt.txt 登记豁免`);
|
|
1376
|
-
}
|
|
1377
|
-
}
|
|
1378
|
-
}
|
|
1379
|
-
|
|
1380
|
-
// --- 输出(banner 沿 sh 版字样与格式 = 稳定输出契约:banner 后空行、条目 '- ' 前缀)---
|
|
1381
|
-
if (blockers.length) {
|
|
1382
|
-
process.stderr.write(`闭环骨架断档(check-loop.sh)— HARD-BLOCK:\n\n${blockers.join('\n')}\n\n`);
|
|
1383
|
-
if (warnings.length) process.stderr.write(`WARN(advisory,不阻断):\n\n${warnings.join('\n')}\n`);
|
|
1384
|
-
process.exit(1);
|
|
1385
|
-
}
|
|
1386
|
-
if (warnings.length) {
|
|
1387
|
-
process.stderr.write(`check-loop.sh WARN(advisory,不阻断):\n\n${warnings.join('\n')}\n`);
|
|
1388
|
-
}
|
|
1389
|
-
process.exit(0);
|
|
1390
|
-
|
|
1391
1413
|
// bindingSha256:done 内容绑定的复原重算(检查 15 专用)——confirm-doc.mjs computeFingerprint 的逆推:
|
|
1392
1414
|
// lines 已 CRLF 归一(split(/\r?\n/)),frontmatter 区内首个「状态:」行保分隔符换值为 prev(`状态: done`
|
|
1393
1415
|
// 双空格 → `状态: approved`——与前向按原行字面计算对称,非规范分隔符不误伤;行尾空白等更奇异格式仍会
|
|
1394
1416
|
// 失配,触发前提本身已违反「确认落态唯一入口」约定,接受),全文剔「确认指纹:」行后 join('\n') 再 sha256。
|
|
1395
|
-
function bindingSha256(lines, prevStatus) {
|
|
1417
|
+
export function bindingSha256(lines, prevStatus) {
|
|
1396
1418
|
const isDelim = (l) => /^---\s*$/.test(l);
|
|
1397
1419
|
const out = [];
|
|
1398
1420
|
let i = 0;
|