agentic-flow-kit 1.6.0 → 1.7.15
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 +40 -6
- package/modules/gates/README.md +1 -0
- package/modules/gates/test-protect/README.md +23 -0
- package/modules/gates/test-protect/check-test-protect.mjs +64 -0
- package/modules/gates/test-protect/local-pre-commit.line +1 -0
- package/modules/hosts/README.md +57 -0
- package/modules/hosts/claude/commands/wf-build.md +5 -24
- package/modules/hosts/claude/commands/wf-deploy.md +1 -0
- package/modules/hosts/claude/commands/wf-design.md +2 -2
- package/modules/hosts/claude/commands/wf-gate-checklist.md +1 -3
- package/modules/hosts/claude/commands/wf-plan.md +1 -1
- package/modules/hosts/claude/commands/wf-source-sync-check.md +1 -3
- package/modules/hosts/claude/commands/wf-sync-hosts.md +1 -3
- package/modules/hosts/claude/commands/wf-test.md +5 -4
- package/modules/hosts/codex/commands/wf-build.md +5 -24
- package/modules/hosts/codex/commands/wf-deploy.md +1 -0
- package/modules/hosts/codex/commands/wf-design.md +2 -2
- package/modules/hosts/codex/commands/wf-gate-checklist.md +1 -3
- package/modules/hosts/codex/commands/wf-plan.md +1 -1
- package/modules/hosts/codex/commands/wf-source-sync-check.md +1 -3
- package/modules/hosts/codex/commands/wf-sync-hosts.md +1 -3
- package/modules/hosts/codex/commands/wf-test.md +5 -4
- package/modules/hosts/cursor/commands/wf-build.md +5 -24
- package/modules/hosts/cursor/commands/wf-deploy.md +1 -0
- package/modules/hosts/cursor/commands/wf-design.md +2 -2
- package/modules/hosts/cursor/commands/wf-gate-checklist.md +1 -3
- package/modules/hosts/cursor/commands/wf-plan.md +1 -1
- package/modules/hosts/cursor/commands/wf-source-sync-check.md +1 -3
- package/modules/hosts/cursor/commands/wf-sync-hosts.md +1 -3
- package/modules/hosts/cursor/commands/wf-test.md +5 -4
- package/modules/hosts/opencode/commands/wf-build.md +5 -24
- package/modules/hosts/opencode/commands/wf-deploy.md +1 -0
- package/modules/hosts/opencode/commands/wf-design.md +2 -2
- package/modules/hosts/opencode/commands/wf-gate-checklist.md +1 -3
- package/modules/hosts/opencode/commands/wf-plan.md +1 -1
- package/modules/hosts/opencode/commands/wf-source-sync-check.md +1 -3
- package/modules/hosts/opencode/commands/wf-sync-hosts.md +1 -3
- package/modules/hosts/opencode/commands/wf-test.md +5 -4
- package/modules/hosts/trae/commands/wf-build.md +5 -24
- package/modules/hosts/trae/commands/wf-deploy.md +1 -0
- package/modules/hosts/trae/commands/wf-design.md +2 -2
- package/modules/hosts/trae/commands/wf-gate-checklist.md +1 -3
- package/modules/hosts/trae/commands/wf-plan.md +1 -1
- package/modules/hosts/trae/commands/wf-source-sync-check.md +1 -3
- package/modules/hosts/trae/commands/wf-sync-hosts.md +1 -3
- package/modules/hosts/trae/commands/wf-test.md +5 -4
- package/package.json +2 -2
- package/src/add-gate.mjs +25 -1
- package/src/add-gate.test.mjs +103 -0
- package/src/cli.mjs +17 -2
- package/src/doctor.mjs +99 -11
- package/src/gates.test.mjs +17 -0
- package/src/init.mjs +7 -1
- package/src/init.test.mjs +19 -0
- package/src/instance-ref.test.mjs +62 -0
- package/src/jscpd-drift.test.mjs +144 -0
- package/src/render.mjs +5 -0
- package/src/rule-budget.test.mjs +103 -0
- package/src/run-tests.mjs +16 -9
- package/src/sync-hosts.mjs +4 -2
- package/src/trae-hooks.test.mjs +15 -3
- package/src/validate.mjs +72 -0
- package/src/validate.test.mjs +95 -0
- package/templates/AGENTS.md +3 -1
- package/templates/_agents/commands/build.md +5 -24
- package/templates/_agents/commands/deploy.md +1 -0
- package/templates/_agents/commands/design.md +2 -2
- package/templates/_agents/commands/gate-checklist.md +1 -3
- package/templates/_agents/commands/plan.md +1 -1
- package/templates/_agents/commands/source-sync-check.md +1 -3
- package/templates/_agents/commands/sync-hosts.md +1 -3
- package/templates/_agents/commands/test.md +5 -4
- package/templates/_agents/control-surfaces.txt +53 -0
- package/templates/_agents/scripts/agg-delegations.cjs +51 -6
- package/templates/_agents/scripts/agg-delegations.test.mjs +79 -1
- package/templates/_agents/scripts/check-engine-integrity.mjs +288 -0
- package/templates/_agents/scripts/check-engine-integrity.test.mjs +227 -0
- package/templates/_agents/scripts/check-lane-surface.mjs +7 -2
- package/templates/_agents/scripts/check-ledger-invariant.mjs +18 -0
- package/templates/_agents/scripts/check-ledger-invariant.test.mjs +16 -0
- package/templates/_agents/scripts/check-ledger.test.mjs +2 -0
- package/templates/_agents/scripts/check-loop.mjs +247 -5
- package/templates/_agents/scripts/check-loop.test.mjs +367 -2
- package/templates/_agents/scripts/confirm-doc.mjs +50 -3
- package/templates/_agents/scripts/confirm-doc.test.mjs +93 -1
- package/templates/_agents/scripts/doctor.test.mjs +251 -1
- package/templates/_agents/scripts/draft-sediment.mjs +178 -0
- package/templates/_agents/scripts/draft-sediment.test.mjs +272 -0
- package/templates/_agents/scripts/gate-checklist.mjs +10 -2
- package/templates/_agents/scripts/gate-checklist.test.mjs +7 -1
- package/templates/_agents/scripts/gen-wiki-board.mjs +61 -6
- package/templates/_agents/scripts/gen-wiki-board.test.mjs +74 -1
- package/templates/_agents/scripts/gen-workflow-dashboard.mjs +2 -2
- package/templates/_agents/scripts/gen-workflow-metrics.mjs +11 -5
- package/templates/_agents/scripts/kb-search.mjs +87 -12
- package/templates/_agents/scripts/kb-search.test.mjs +83 -0
- package/templates/_agents/scripts/pipeline-run.mjs +1 -1
- package/templates/_agents/scripts/policy.mjs +49 -0
- package/templates/_agents/scripts/solidify-task.mjs +1 -1
- package/templates/_agents/scripts/source-sync-check.test.mjs +9 -4
- package/templates/_agents/scripts/stage-gates.mjs +48 -1
- package/templates/_agents/scripts/stage-gates.test.mjs +105 -0
- package/templates/_agents/scripts/verify-wiki-consistency.mjs +34 -0
- package/templates/_agents/scripts/verify-wiki-consistency.test.mjs +44 -2
- package/templates/_agents/scripts/verify.mjs +12 -4
- package/templates/_agents/scripts/verify.test.mjs +30 -3
- package/templates/_agents/scripts-test-exempt.txt +3 -1
- package/templates/_agents/skills/wiki/SKILL.md +5 -3
- package/templates/wiki/INDEX.md +44 -1
- package/templates/wiki//347/237/245/350/257/206/346/262/211/346/267/200/346/200/273/350/247/210.html +22 -1
- package/templates/workflow/delegations.md +5 -4
- package/templates/workflow/incidents/_TEMPLATE.md +2 -0
- package/templates/workflow/intents/_TEMPLATE.md +3 -0
- package/templates/workflow/plans/_TEMPLATE.md +4 -0
- package/templates/workflow/specs/_TEMPLATE.md +23 -0
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
// 结构缓存(2026-09-23 intent kb-search-structure-cache):可检索子集预提取进 .agents/cache/kb-index.json,
|
|
11
11
|
// 按 [mtimeMs,size] 逐文件失效,口径指纹变化整份作废;匹配/排序/输出路径不变(三路输出与冷扫逐字节一致);
|
|
12
12
|
// 缓存任何读写异常静默回退全扫。`--include-generated` 走纯扫(bulk 文件不入缓存)。
|
|
13
|
+
// 检索留痕(2026-10-11 P1-1):每次实际检索后向 .agents/notes/kb-search-ledger.jsonl 追加一行 JSON
|
|
14
|
+
// (ts/query/scope/hits/top),KB 沉淀↔检索形成可审计闭环;--no-ledger 显式退出;
|
|
15
|
+
// 写失败仅 console.error 一行 WARN,不改变检索输出与退出码。
|
|
13
16
|
// 测试:node .agents/scripts/kb-search.test.mjs(fixture 回归,须全绿)
|
|
14
17
|
// 模块词表:.agents/workflow-modules.txt(--module 取值校验,单源)
|
|
15
18
|
import fs from 'node:fs';
|
|
@@ -33,6 +36,7 @@ const USAGE = `用法: node .agents/scripts/kb-search.mjs [选项] <词1> [词2
|
|
|
33
36
|
--no-cache 禁用结构缓存,全量扫(与缓存热路径输出逐字节一致)
|
|
34
37
|
--cache <文件> 结构缓存文件路径覆盖(默认 .agents/cache/kb-index.json;测试注入用)
|
|
35
38
|
--rebuild 忽略现有缓存重建后回写
|
|
39
|
+
--no-ledger 退出检索留痕:不向 .agents/notes/kb-search-ledger.jsonl 追加本次检索记录
|
|
36
40
|
--help 打印本说明
|
|
37
41
|
|
|
38
42
|
退出码:0 正常(含无命中);1 参数错误(无词 / 未知选项 / 非法枚举值)`;
|
|
@@ -49,6 +53,7 @@ let includeGenerated = false;
|
|
|
49
53
|
let noCache = false;
|
|
50
54
|
let cacheFileOverride = ''; // --cache <文件>(board-kb-p1):测试注入式缓存路径,不再直跑清真实开发缓存
|
|
51
55
|
let rebuild = false;
|
|
56
|
+
let noLedger = false; // --no-ledger(P1-1):留痕默认开启(审计要完整),显式退出才跳过写 ledger
|
|
52
57
|
const words = [];
|
|
53
58
|
for (let i = 0; i < args.length; i++) {
|
|
54
59
|
const a = args[i];
|
|
@@ -62,6 +67,7 @@ for (let i = 0; i < args.length; i++) {
|
|
|
62
67
|
else if (a === '--no-cache') noCache = true;
|
|
63
68
|
else if (a === '--cache') cacheFileOverride = args[++i] ?? '';
|
|
64
69
|
else if (a === '--rebuild') rebuild = true;
|
|
70
|
+
else if (a === '--no-ledger') noLedger = true;
|
|
65
71
|
else if (a === '--help' || a === '-h') { console.log(USAGE); process.exit(0); }
|
|
66
72
|
else words.push(a);
|
|
67
73
|
}
|
|
@@ -88,6 +94,9 @@ if (moduleFilter && !MODULES.includes(moduleFilter)) {
|
|
|
88
94
|
// 活跃口径 = 单源 doc.status.active ∪ incident.status.active(与 workflow/INDEX.md 活跃层一致,fixed 归档案默认也可命中)
|
|
89
95
|
const ACTIVE_STATUS = [...ENUMS['doc.status.active'], ...ENUMS['incident.status.active']];
|
|
90
96
|
const WF_SECTIONS = ['背景', '目标', '根因', '复盘三件套', '验收标准', '影响面'];
|
|
97
|
+
// wiki 侧节提取白名单(2026-10-10 A1):wiki 知识文档的骨架节——长文全文 includes 召回差,
|
|
98
|
+
// 节级提取让命中带节名上下文。放此处(WF_SECTIONS 旁,CFG_FP 之前)供指纹派生。
|
|
99
|
+
const WIKI_SECTIONS = ['标题', '摘要', '背景', '结论', '要点', '规范', '用法', '示例'];
|
|
91
100
|
const WIKI_EXCLUDE = new Set(['INDEX.md', '知识沉淀总览.html']);
|
|
92
101
|
const WIKI_MAX_KB = 180;
|
|
93
102
|
|
|
@@ -149,17 +158,20 @@ const ROOT_NS = `${path.resolve('.')}::`;
|
|
|
149
158
|
const WIKI_EXTS = includeGenerated ? /\.(md|sql|html|csv|json|txt)$/i : /\.(md|sql|html|txt)$/i;
|
|
150
159
|
|
|
151
160
|
// 行序列化/复活(缓存 schema 的定义者——board-kb-p1 起纳入 CFG_FP 指纹:schema 演进即整份缓存作废)
|
|
152
|
-
|
|
153
|
-
|
|
161
|
+
// 2026-10-10 A1:wiki 行从二元组 [ln, line] 升三元组 [ln, section, line](节级提取随动)——
|
|
162
|
+
// 两 kind 同构后 serialize/revive 按 kind 取三元组即可,旧缓存由 CFG_FP 指纹整份作废。
|
|
163
|
+
const serializeLines = (lines) => lines.map((x) => [x.ln, x.section, x.line]);
|
|
164
|
+
const reviveLines = (lines) => lines.map((a) => ({ ln: a[0], section: a[1], line: a[2] }));
|
|
154
165
|
|
|
155
166
|
// linesShapeOk:缓存条目 lines 形状校验(board-kb-p1)——JSON 合法但 schema 坏(lines 非数组 / 元素非数组)
|
|
156
167
|
// 一律视同 miss 走 build 重建(头注释「任何读写异常静默回退」契约恢复;此前直接 .map 抛 TypeError exit 1)
|
|
157
168
|
const linesShapeOk = (lines) => Array.isArray(lines) && (lines.length === 0 || lines.every(Array.isArray));
|
|
158
169
|
|
|
159
170
|
const CFG_FP = createHash('sha256').update(JSON.stringify({
|
|
160
|
-
v:
|
|
171
|
+
v: 3, // 2026-10-10 B3:wiki 条目也存 meta(来源 frontmatter 血缘显示),schema 演进作废旧缓存
|
|
172
|
+
WF_SECTIONS, WIKI_SECTIONS, ACTIVE_STATUS, WIKI_MAX_KB, excl: [...WIKI_EXCLUDE],
|
|
161
173
|
exts: WIKI_EXTS.source, // schema 派生(board-kb-p1:手抄摘要曾致扩展面演进不触发指纹失效;RegExp.source 跨 includeGenerated 分支稳定)
|
|
162
|
-
fns: [parseDoc.toString(), workflowLines.toString(), matchWords.toString(), serializeLines.toString(), reviveLines.toString()],
|
|
174
|
+
fns: [parseDoc.toString(), workflowLines.toString(), wikiLines.toString(), matchWords.toString(), serializeLines.toString(), reviveLines.toString()],
|
|
163
175
|
})).digest('hex').slice(0, 16);
|
|
164
176
|
const cacheUsable = !noCache && !includeGenerated;
|
|
165
177
|
let cache = null;
|
|
@@ -186,13 +198,13 @@ function cachedEntry(relPath, kind, build) {
|
|
|
186
198
|
const e = cache[key];
|
|
187
199
|
if (e && e.kind === kind && Array.isArray(e.st) && e.st[0] === sig[0] && e.st[1] === sig[1] && linesShapeOk(e.lines)) {
|
|
188
200
|
touched.add(key);
|
|
189
|
-
return { st: e.st, meta: e.meta, title: e.title, lines: reviveLines(e.lines
|
|
201
|
+
return { st: e.st, meta: e.meta, title: e.title, lines: reviveLines(e.lines) };
|
|
190
202
|
}
|
|
191
203
|
const built = build();
|
|
192
204
|
cache[key] = {
|
|
193
205
|
st: sig, kind,
|
|
194
|
-
...(
|
|
195
|
-
lines: serializeLines(built.lines
|
|
206
|
+
...(built.meta ? { meta: built.meta, ...(built.title ? { title: built.title } : {}) } : {}),
|
|
207
|
+
lines: serializeLines(built.lines),
|
|
196
208
|
};
|
|
197
209
|
touched.add(key);
|
|
198
210
|
return { st: sig, meta: built.meta, title: built.title, lines: built.lines };
|
|
@@ -249,7 +261,36 @@ function searchWorkflow(words) {
|
|
|
249
261
|
return results.sort((a, b) => b.score - a.score);
|
|
250
262
|
}
|
|
251
263
|
|
|
252
|
-
// ---- wiki 检索(口径同 2026-09-21 降噪版 wiki-search
|
|
264
|
+
// ---- wiki 检索(口径同 2026-09-21 降噪版 wiki-search;2026-10-10 A1:节级提取 + 主题路径加权)----
|
|
265
|
+
// wiki 侧与 workflow 侧同款行结构(ln + section + line),节提取用 wiki 自己的标题滚动:
|
|
266
|
+
// wiki 知识文档多为长文,全文 includes 召回差、命中行缺乏上下文——节级提取让命中带节名
|
|
267
|
+
// (`[<节名>] L<行>: <摘录>`,与 workflow 侧输出对齐)。frontmatter(如有)单列 '(frontmatter)' 节。
|
|
268
|
+
// 提取节白名单 WIKI_SECTIONS(常量定义在文件头部 WF_SECTIONS 旁),与 WF_SECTIONS 平行。
|
|
269
|
+
function wikiLines(text) {
|
|
270
|
+
const lines = text.split(/\r?\n/);
|
|
271
|
+
const out = [];
|
|
272
|
+
let i = 0;
|
|
273
|
+
if (/^---\s*$/.test(lines[0] || '')) {
|
|
274
|
+
out.push({ ln: 1, section: '(frontmatter)', line: lines[0] });
|
|
275
|
+
for (i = 1; i < lines.length; i++) {
|
|
276
|
+
out.push({ ln: i + 1, section: '(frontmatter)', line: lines[i] });
|
|
277
|
+
if (/^---\s*$/.test(lines[i])) { i++; break; }
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
let section = '';
|
|
281
|
+
// wiki 短文常无小节标题(如「# 说明\n正文」)——节白名单只覆盖骨架节,纯标题文档会零命中。
|
|
282
|
+
// 兜底:全文无任何 `## ` 时正文全收(section 记 '(正文)');有 ## 后回到白名单口径。
|
|
283
|
+
const sawH2 = lines.slice(i).some((l) => /^## /.test(l));
|
|
284
|
+
for (; i < lines.length; i++) {
|
|
285
|
+
const line = lines[i];
|
|
286
|
+
const h1 = line.match(/^# (.+?)\s*$/);
|
|
287
|
+
if (h1 && !/^## /.test(line)) { out.push({ ln: i + 1, section: '(标题)', line }); section = ''; continue; }
|
|
288
|
+
const h2 = line.match(/^## (.+?)\s*$/);
|
|
289
|
+
if (h2) section = h2[1];
|
|
290
|
+
if (!sawH2 || WIKI_SECTIONS.some((s) => section.startsWith(s))) out.push({ ln: i + 1, section: section || '(正文)', line });
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
253
294
|
const walkWiki = (dir, rel) => {
|
|
254
295
|
const out = [];
|
|
255
296
|
for (const e of fs.readdirSync(dir)) {
|
|
@@ -275,13 +316,21 @@ function searchWiki(words) {
|
|
|
275
316
|
const e = cachedEntry(p, 'wiki', () => {
|
|
276
317
|
let text;
|
|
277
318
|
try { text = fs.readFileSync(p, 'utf8'); } catch { return { lines: [] }; }
|
|
278
|
-
|
|
319
|
+
const { meta } = parseDoc(text);
|
|
320
|
+
return { meta, lines: wikiLines(text) };
|
|
279
321
|
});
|
|
280
322
|
if (!e.lines.length) continue;
|
|
281
323
|
const hits = matchWords(e.lines.filter((x) => words.some((w) => x.line.includes(w))), words);
|
|
282
324
|
if (!hits) continue;
|
|
283
325
|
const kb = size / 1024;
|
|
284
|
-
|
|
326
|
+
// 主题路径加权(A1):查询词出现在主题目录名(key 首段,如「项目规范」)时 ×1.2——
|
|
327
|
+
// 主题归属是 wiki 最有价值的结构特征,纯全文打分完全没用上;权重温和(不压倒命中数主序)
|
|
328
|
+
const topicHit = words.some((w) => key.includes(w));
|
|
329
|
+
const score = (hits.total / Math.sqrt(Math.max(kb, 0.1))) * (topicHit ? 1.2 : 1);
|
|
330
|
+
// 来源血缘(B3,2026-10-10):wiki frontmatter「来源: workflow/<类型>/<文件>」指回 workflow 归档——
|
|
331
|
+
// 与 intent/incident 的正向「沉淀:」字段对称。有则结果行显示,无则空(老 wiki 文件零行为变化)
|
|
332
|
+
const source = (e.meta && e.meta['来源']) || '';
|
|
333
|
+
results.push({ file: key, total: hits.total, shown: hits.shown, kb, score, source });
|
|
285
334
|
}
|
|
286
335
|
}
|
|
287
336
|
return { results: results.sort((a, b) => b.score - a.score), skipped };
|
|
@@ -290,11 +339,13 @@ function searchWiki(words) {
|
|
|
290
339
|
// ---- 输出 ----
|
|
291
340
|
const top = (arr) => (limit ? arr.slice(0, limit) : arr);
|
|
292
341
|
let anyHits = 0;
|
|
342
|
+
let topFile = null; // 首个展示文件(留痕用):按展示顺序 workflow→wiki 取 shown[0],无命中保持 null
|
|
293
343
|
|
|
294
344
|
if (scope === 'workflow' || scope === 'all') {
|
|
295
345
|
const res = searchWorkflow(words);
|
|
296
346
|
anyHits += res.length;
|
|
297
347
|
const shown = top(res);
|
|
348
|
+
if (shown.length && !topFile) topFile = `workflow/${shown[0].file}`;
|
|
298
349
|
console.log(`── workflow(${res.length} 个文件命中${res.length > shown.length ? `,展示前 ${shown.length}` : ''})──`);
|
|
299
350
|
for (const r of shown) {
|
|
300
351
|
console.log(`📄 workflow/${r.file} — ${r.status || '未填状态'} · ${r.level} · ${r.module} · ${r.total} 处命中 / ${r.kb < 10 ? r.kb.toFixed(1) : Math.round(r.kb)}KB`);
|
|
@@ -307,10 +358,11 @@ if (scope === 'wiki' || scope === 'all') {
|
|
|
307
358
|
const { results: res, skipped } = searchWiki(words);
|
|
308
359
|
anyHits += res.length;
|
|
309
360
|
const shown = top(res);
|
|
361
|
+
if (shown.length && !topFile) topFile = `wiki/${shown[0].file}`;
|
|
310
362
|
console.log(`── wiki(${res.length} 个文件命中${res.length > shown.length ? `,展示前 ${shown.length}` : ''})──`);
|
|
311
363
|
for (const r of shown) {
|
|
312
|
-
console.log(`📄 wiki/${r.file} — ${r.total} 处命中 / ${r.kb < 10 ? r.kb.toFixed(1) : Math.round(r.kb)}KB`);
|
|
313
|
-
for (const h of r.shown) console.log(` L${h.ln}: ${h.line.trim().slice(0, 130)}`);
|
|
364
|
+
console.log(`📄 wiki/${r.file} — ${r.total} 处命中 / ${r.kb < 10 ? r.kb.toFixed(1) : Math.round(r.kb)}KB${r.source ? ` · 来源 ${r.source}` : ''}`);
|
|
365
|
+
for (const h of r.shown) console.log(` [${h.section || '(无节)'}] L${h.ln}: ${h.line.trim().slice(0, 130)}`);
|
|
314
366
|
}
|
|
315
367
|
if (!res.length) console.log('(无命中)');
|
|
316
368
|
if (skipped) console.log(`(wiki 跳过 ${skipped} 个产物/大文件——--include-generated 纳入)`);
|
|
@@ -321,3 +373,26 @@ console.log(`\n${anyHits ? `合计 ${anyHits} 个文件命中(${words.join(' A
|
|
|
321
373
|
(scope === 'all' ? ';`--scope` 限定语料、`--status all` 显式全量' : ''));
|
|
322
374
|
|
|
323
375
|
saveCache();
|
|
376
|
+
|
|
377
|
+
// ---- 检索留痕(P1-1):append-only 一行 JSON,KB 沉淀↔检索可审计闭环 ----
|
|
378
|
+
// ROOT 与 workflow/、wiki/ 索引根一致(脚本按 cwd 相对路径读语料,ROOT_NS 同源);
|
|
379
|
+
// 写失败只 WARN 不阻断:留痕是旁路记录,异常必须可见,但绝不能改变检索结果与退出码。
|
|
380
|
+
const LEDGER_FILE = path.join('.agents', 'notes', 'kb-search-ledger.jsonl');
|
|
381
|
+
function appendLedger() {
|
|
382
|
+
if (noLedger) return;
|
|
383
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
384
|
+
const d = new Date();
|
|
385
|
+
try {
|
|
386
|
+
fs.mkdirSync(path.dirname(LEDGER_FILE), { recursive: true });
|
|
387
|
+
fs.appendFileSync(LEDGER_FILE, `${JSON.stringify({
|
|
388
|
+
ts: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`, // ISO 8601 本地时间
|
|
389
|
+
query: words.join(' '),
|
|
390
|
+
scope,
|
|
391
|
+
hits: anyHits, // 切 top 之前的命中总量(workflow + wiki)
|
|
392
|
+
top: topFile,
|
|
393
|
+
})}\n`, 'utf8');
|
|
394
|
+
} catch (e) {
|
|
395
|
+
console.error(`WARN 检索留痕写入失败(不影响检索结果):${e && e.message ? e.message : e}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
appendLedger();
|
|
@@ -175,6 +175,89 @@ const files = (out) => (out.match(/^📄 (workflow\/\S+)/gm) || []).map((l) => l
|
|
|
175
175
|
fs.rmSync(root, { recursive: true, force: true });
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
// ---- 场景 10:A1 wiki 侧节级提取 + 主题路径加权(2026-10-10)----
|
|
179
|
+
// wiki 命中带节名(骨架节或无小节兜底);主题目录命中查询词时排序提前;长文节外内容不参与检索
|
|
180
|
+
{
|
|
181
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-a1-'));
|
|
182
|
+
fs.mkdirSync(path.join(root, 'wiki', '项目规范'), { recursive: true });
|
|
183
|
+
fs.mkdirSync(path.join(root, 'wiki', '杂项'), { recursive: true });
|
|
184
|
+
// 长文:命中在白名单节内(## 结论)+ 节外(## 历史流水,不应被索引)
|
|
185
|
+
fs.writeFileSync(path.join(root, 'wiki', '项目规范', '权限收敛.md'),
|
|
186
|
+
'# 权限收敛方案\n\n## 结论\n- 权限收敛后只读接口不再放行写操作。\n\n## 历史流水\n- 2026-09 某次发布也提到权限收敛但属节外内容\n', 'utf8');
|
|
187
|
+
// 短文:无任何 ## —— 正文兜底全收
|
|
188
|
+
fs.writeFileSync(path.join(root, 'wiki', '杂项', '说明.md'), '# 说明\n\n权限收敛的领域知识条目。\n', 'utf8');
|
|
189
|
+
const r = run(root, ['--scope', 'wiki', '--cache', rootCache(root), '权限收敛']);
|
|
190
|
+
check('场景 10a wiki 长文命中带节名(结论节)',
|
|
191
|
+
r.status === 0 && /wiki\/项目规范\/权限收敛.md/.test(r.stdout) && /\[结论\]/.test(r.stdout), `exit=${r.status}\n${r.stdout}`);
|
|
192
|
+
check('场景 10b wiki 长文节外内容不参与检索(历史流水行不进命中摘录)',
|
|
193
|
+
!/历史流水/.test(r.stdout), r.stdout);
|
|
194
|
+
check('场景 10c wiki 短文无小节兜底(正文命中,节名 (正文))',
|
|
195
|
+
/wiki\/杂项\/说明.md/.test(r.stdout) && /\(正文\)/.test(r.stdout), `exit=${r.status}\n${r.stdout}`);
|
|
196
|
+
// 主题加权:两文件命中数同量级时,主题名含查询词的「项目规范」排前
|
|
197
|
+
const order = (r.stdout.match(/^📄 (wiki\/\S+)/gm) || []).map((l) => l.replace('📄 ', ''));
|
|
198
|
+
check('场景 10d 主题路径加权:主题名命中查询词的文件排前',
|
|
199
|
+
order.length === 2 && order[0].includes('项目规范'), `order=${JSON.stringify(order)}\n${r.stdout}`);
|
|
200
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---- 场景 11:B3 来源血缘显示(2026-10-10)----
|
|
204
|
+
// wiki frontmatter「来源: workflow/<类型>/<文件>」→ 结果行带「· 来源 …」;无来源老文件零变化
|
|
205
|
+
{
|
|
206
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-b3-'));
|
|
207
|
+
fs.mkdirSync(path.join(root, 'wiki', '项目规范'), { recursive: true });
|
|
208
|
+
fs.mkdirSync(path.join(root, 'workflow', 'intents'), { recursive: true });
|
|
209
|
+
// 带来源的 wiki 文件(B2 生成的草稿/人工沉淀都是这个形态)
|
|
210
|
+
fs.writeFileSync(path.join(root, 'wiki', '项目规范', '权限收敛.md'),
|
|
211
|
+
'---\n来源: workflow/intents/2026-09-12-perm-shrink.md\n---\n# 权限收敛方案\n\n## 结论\n- 权限收敛后只读接口不再放行写操作。\n', 'utf8');
|
|
212
|
+
// 无 frontmatter 的老 wiki 文件(零行为变化对照)
|
|
213
|
+
fs.writeFileSync(path.join(root, 'wiki', '项目规范', '老规范.md'), '# 老规范\n\n权限收敛的旧条目。\n', 'utf8');
|
|
214
|
+
const r = run(root, ['--scope', 'wiki', '--cache', rootCache(root), '权限收敛']);
|
|
215
|
+
check('场景 11a 有来源的 wiki 结果行显示来源血缘',
|
|
216
|
+
r.status === 0 && /来源 workflow\/intents\/2026-09-12-perm-shrink\.md/.test(r.stdout), `exit=${r.status}\n${r.stdout}`);
|
|
217
|
+
check('场景 11b 无 frontmatter 老文件正常命中且无来源行干扰',
|
|
218
|
+
r.status === 0 && /wiki\/项目规范\/老规范\.md/.test(r.stdout), `exit=${r.status}\n${r.stdout}`);
|
|
219
|
+
// 来源字段本身可被检索(frontmatter 已进可检索面——双向找链:从 wiki 侧反查 workflow 来源)
|
|
220
|
+
const r2 = run(root, ['--scope', 'wiki', '--cache', rootCache(root), 'perm-shrink']);
|
|
221
|
+
check('场景 11c 来源路径本身参与检索(frontmatter 命中)',
|
|
222
|
+
r2.status === 0 && /wiki\/项目规范\/权限收敛\.md/.test(r2.stdout), `exit=${r2.status}\n${r2.stdout}`);
|
|
223
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---- 场景 12:检索留痕(P1-1)——每次检索后 ledger 追加一行 / --no-ledger 退出 / notes 目录自动创建 ----
|
|
227
|
+
// ledger 落在 <临时根>/.agents/notes/kb-search-ledger.jsonl(与 workflow/、wiki/ 索引根同 ROOT)
|
|
228
|
+
{
|
|
229
|
+
const ledgerOf = (root) => path.join(root, '.agents', 'notes', 'kb-search-ledger.jsonl');
|
|
230
|
+
const ledgerLines = (root) => fs.readFileSync(ledgerOf(root), 'utf8').split('\n').filter(Boolean);
|
|
231
|
+
const root = mkfix();
|
|
232
|
+
check('场景 12 前置:.agents/notes 尚不存在', !fs.existsSync(path.join(root, '.agents')));
|
|
233
|
+
// a) 首次检索 → 恰 1 行、JSON 合法、字段齐全、hits 为数字
|
|
234
|
+
const r1 = run(root, ['--scope', 'workflow', '编码模板', '冲突']);
|
|
235
|
+
const lines1 = ledgerLines(root);
|
|
236
|
+
check('场景 12a 检索后 ledger 存在且恰 1 行', r1.status === 0 && lines1.length === 1, `exit=${r1.status}\n${r1.stderr}`);
|
|
237
|
+
const entry = JSON.parse(lines1[0]);
|
|
238
|
+
check('场景 12a ledger 行字段齐全(ts/query/scope/hits/top)',
|
|
239
|
+
typeof entry.ts === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(entry.ts)
|
|
240
|
+
&& entry.query === '编码模板 冲突' && entry.scope === 'workflow'
|
|
241
|
+
&& entry.hits === 2 && entry.top === 'workflow/intents/2026-09-21-bbb.md', lines1[0]);
|
|
242
|
+
// b) 已有 ledger 再跑 → 追加成 2 行且旧行原样
|
|
243
|
+
const r2 = run(root, ['--scope', 'wiki', '编码模板']);
|
|
244
|
+
const lines2 = ledgerLines(root);
|
|
245
|
+
check('场景 12b 再次检索追加成 2 行且旧行原样',
|
|
246
|
+
r2.status === 0 && lines2.length === 2 && lines2[0] === lines1[0]
|
|
247
|
+
&& JSON.parse(lines2[1]).scope === 'wiki' && JSON.parse(lines2[1]).top === 'wiki/测试主题/说明.md', lines2.join('\n'));
|
|
248
|
+
// c) --no-ledger → 不追加(仍 2 行)
|
|
249
|
+
const r3 = run(root, ['--no-ledger', '--scope', 'workflow', '编码模板']);
|
|
250
|
+
check('场景 12c --no-ledger 不追加不新建', r3.status === 0 && ledgerLines(root).length === 2, r3.stderr);
|
|
251
|
+
// d) 无命中检索也留痕(top=null、hits=0)
|
|
252
|
+
const r4 = run(root, ['--scope', 'workflow', 'zzz-绝不存在-zzz']);
|
|
253
|
+
const lines4 = ledgerLines(root);
|
|
254
|
+
const entry4 = JSON.parse(lines4[2]);
|
|
255
|
+
check('场景 12d 无命中留痕 top=null 且 hits=0',
|
|
256
|
+
r4.status === 0 && entry4.query === 'zzz-绝不存在-zzz' && entry4.hits === 0 && entry4.top === null, lines4.join('\n'));
|
|
257
|
+
check('场景 12e .agents/notes 目录由检索自动创建', fs.statSync(path.join(root, '.agents', 'notes')).isDirectory());
|
|
258
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
259
|
+
}
|
|
260
|
+
|
|
178
261
|
console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
|
|
179
262
|
if (fail) {
|
|
180
263
|
console.log(`\n${USAGE}`);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// pipeline-run — 跨宿主全自动闭环执行器(2026-10-02 pipeline-run
|
|
2
|
+
// pipeline-run — 跨宿主全自动闭环执行器(2026-10-02 pipeline-run 立法)
|
|
3
3
|
// 用途:把六阶段闭环的确定性部分代码化为状态机——start/next/status/watch/abort 五子命令;
|
|
4
4
|
// AI 槽位经工单协议交宿主执行,人件门到点即停、next --delegated "<原话>" 逐份过 confirm-doc。
|
|
5
5
|
// 红线:绝不 spawn AI 会话 / 零依赖 / git 永不 --no-verify / 永不伪造原话 / 永不直改文档状态(唯一入口 confirm-doc)。
|
|
@@ -95,6 +95,30 @@ export const POLICIES = {
|
|
|
95
95
|
// 消费方:检查 8 的 verifyDocInWindow(done ts ≥ 本值才启用精确匹配)。
|
|
96
96
|
verifyDocSince: '2026-10-08T09:37:35Z',
|
|
97
97
|
},
|
|
98
|
+
// 版本 7 = 版本 6 的全部键 + scopeSince(台账「机器派生改动面」的生效时刻,2026-10-09 审查 A1 干净版)。
|
|
99
|
+
// 背景:委托代录的授权凭据是 `--delegated` 的**用户原话**——机器无法校验自由文本,AI 可无成本复用同一句
|
|
100
|
+
// (本仓实测「可以」133 次、「确认」60 次),且台账 406 行里仅 6 行为 tty。整条授权链的源头不可核对,
|
|
101
|
+
// 下游的哈希链/指纹对账/内容绑定全建得很好却是建在空地基上。
|
|
102
|
+
// 干净版解法:**改动面改由机器派生,AI 无输入面**——confirm-doc 落账时自动记
|
|
103
|
+
// `scope:{files:[…], sha256}`(被确认文档 + `git status --porcelain` 实测的未提交改动集,归一去重排序后
|
|
104
|
+
// 取 sha256)。AI 无法伪造这个字段,因为它根本不参与输入;事后对质问的是「这 N 个文件」,而不是一句可复用的原话。
|
|
105
|
+
// 本版本只加一个开关键:**缺键(v1-v6)时该子检查整体跳过**,存量 406 行零新增告警;抄全键仍是铁律。
|
|
106
|
+
7: {
|
|
107
|
+
moduleSince: '2026-09-22',
|
|
108
|
+
check14Since: '2026-09-26',
|
|
109
|
+
confirmDocsEffective: '2026-09-27',
|
|
110
|
+
confirmIncidentsEffective: '2026-09-28',
|
|
111
|
+
bindingTs: '2026-09-28',
|
|
112
|
+
check17UnclosedAfter: '0.8.0',
|
|
113
|
+
stageGateSince: '2026-09-29',
|
|
114
|
+
riskLevelSince: '2026-10-01',
|
|
115
|
+
verifySince: '2026-10-07',
|
|
116
|
+
verifyDocSince: '2026-10-08T09:37:35Z',
|
|
117
|
+
// 生效「时刻」(ISO 8601,UTC)而非日期:本日 04:29Z 已有一批旧口径台账行写入,日期粒度会把它们
|
|
118
|
+
// 纳入受审范围产出不可消退告警——沿 v6 verifyDocSince 升级为时刻的同款教训。
|
|
119
|
+
// 消费方:check-loop 检查 15 的 scope 子项(台账行 ts ≥ 本值才判)。
|
|
120
|
+
scopeSince: '2026-10-09T11:24:43Z',
|
|
121
|
+
},
|
|
98
122
|
};
|
|
99
123
|
|
|
100
124
|
export function loadKitPolicy(root) {
|
|
@@ -155,3 +179,28 @@ export function hasFreshVerifyLine(lines, now = Date.now(), doc = null) {
|
|
|
155
179
|
export function ledgerChainHash(row, prevHash) {
|
|
156
180
|
return createHash('sha256').update((prevHash || '') + JSON.stringify(row)).digest('hex');
|
|
157
181
|
}
|
|
182
|
+
|
|
183
|
+
// ---- 台账 scope(机器派生改动面,2026-10-09 审查 A1 干净版)----
|
|
184
|
+
// scopeDigest(files) = {files, sha256}:files 归一(反斜杠→posix)、去重、排序;sha256 = files.join('\n') 的摘要。
|
|
185
|
+
// **单源**(confirm-doc 落账侧构造与 check-loop 检查 15 校验侧重算共用——防两处归一/排序口径漂移,
|
|
186
|
+
// N3 教训同款):任一侧改了口径而另一侧没改,台账会整体判失效。
|
|
187
|
+
// 边界(诚实声明):scope 是**机器实测**的「确认时刻工作树处于改动态的文件集合」,不是「本次任务应改什么」
|
|
188
|
+
// 的语义判定——它能证明「你放行时盘面上就是这些文件在动」,不能证明「这些改动就是你要的」。
|
|
189
|
+
// 语义正确性仍由指纹(文档内容)+ commit diff(事后对质)承担。
|
|
190
|
+
export function scopeDigest(files) {
|
|
191
|
+
const norm = [...new Set((Array.isArray(files) ? files : [])
|
|
192
|
+
.map((f) => String(f).replace(/\\/g, '/').replace(/^\.\//, ''))
|
|
193
|
+
.filter(Boolean))]
|
|
194
|
+
.sort();
|
|
195
|
+
return { files: norm, sha256: createHash('sha256').update(norm.join('\n'), 'utf8').digest('hex') };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// scopeMatches(row):台账行自带的 scope 是否与按其 files 重算的结果逐字一致。
|
|
199
|
+
// 用于检出「行里改了 files 却没同步 sha256」的手改形态(哈希链能兜住就地改,但整文件重写可重建链——
|
|
200
|
+
// 同一信任边界内的第二道,见 ledgerChainHash 边界声明)。
|
|
201
|
+
export function scopeMatches(row) {
|
|
202
|
+
if (!row || typeof row !== 'object' || !row.scope) return false;
|
|
203
|
+
const s = row.scope;
|
|
204
|
+
if (!Array.isArray(s.files) || typeof s.sha256 !== 'string') return false;
|
|
205
|
+
return scopeDigest(s.files).sha256 === s.sha256;
|
|
206
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// # 迁移后,以调用方显式传入的用户原话逐份 confirm-doc --delegated(quote 原样转发)
|
|
8
8
|
// node .agents/scripts/solidify-task.mjs --topic <主题> --auto
|
|
9
9
|
// # 迁移后,逐份 confirm-doc --auto(trust-mode=Trusted 才放行;Strict 下被拒并计数失败)
|
|
10
|
-
// 确认门契约(
|
|
10
|
+
// 确认门契约(2026-10-01 v09 复盘立法):--delegated 的 quote 必须来自用户在对话内的
|
|
11
11
|
// 明确原话,由调用方显式传入——本脚本永不编造用户话术。无确认来源时只迁移不落账,末尾打印指引。
|
|
12
12
|
import fs from 'node:fs';
|
|
13
13
|
import path from 'node:path';
|
|
@@ -102,15 +102,20 @@ function mkFixture() {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
// ---- 场景 5:实际仓库扫描 baseline(包源 ≥ 30 份 / 装副本 ≥ 30 份;装户环境无包源 → SKIP)----
|
|
105
|
+
// 纯包源仓(装户端内容清理后)无 `.agents/` 装副本——包源侧仍扫得到,装副本侧 SKIP 不算失败。
|
|
105
106
|
if (!SRC_ROOT) {
|
|
106
107
|
check('S5 baseline SKIP(未探测到包源 templates/_agents——装户环境直跑,不算失败)', true);
|
|
107
108
|
} else {
|
|
108
109
|
const r = sourceSyncCheck({ pkgRoot: SRC_ROOT, target: SRC_ROOT });
|
|
109
110
|
check('S5 实际仓库 包源 ≥ 30 份', r.pkgCount >= 30, '实际 ' + r.pkgCount);
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
111
|
+
if (!fs.existsSync(path.join(SRC_ROOT, '.agents'))) {
|
|
112
|
+
check('S5 装副本 SKIP(纯包源仓无 .agents/——装户端内容清理后形态,不算失败)', true);
|
|
113
|
+
} else {
|
|
114
|
+
check('S5 实际仓库 装副本 ≥ 30 份', r.tgtCount >= 30, '实际 ' + r.tgtCount);
|
|
115
|
+
// 装副本 kit.json / settings.json 不应在孤儿
|
|
116
|
+
check('S5 实际仓库 装副本独有(kit.json / settings.json)排除',
|
|
117
|
+
!r.orphan.some((o) => o.rel === 'kit.json' || o.rel === 'settings.json'));
|
|
118
|
+
}
|
|
114
119
|
}
|
|
115
120
|
|
|
116
121
|
// ---- 场景 6:--json 输出可解析(CLI 子流程)----
|
|
@@ -204,7 +204,54 @@ export function draftGateFor(kind, root, base) {
|
|
|
204
204
|
return { ok: true, entry };
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
// ---- 待澄清标记(2026-10-12 needs-clarification,spec-kit NEEDS CLARIFICATION 对应物)----
|
|
208
|
+
// 动机:本项目的歧义处理此前只有一种形态——**停下来问**(确认门停机)。那是「事后阻塞」:AI 停住等
|
|
209
|
+
// 人,而人在上下文里未必答得上(他刚提的需求,细节是你算出来的)。spec-kit 的做法是**起草期把
|
|
210
|
+
// 歧义显式写下来**(`[NEEDS CLARIFICATION]`),不阻塞起草但禁臆测——「我不知道」是负责,「猜一个」
|
|
211
|
+
// 是失职。本件补这个表达:**零阻塞 + 不许糊过去**。
|
|
212
|
+
// 形态:正文行 `- 待澄清:<问题>`(行首可选列表符;半角冒号兼容)。行首 `- 已澄清:<问题>` **不算**
|
|
213
|
+
// (同一行改前缀即声明解决,不删行——保留「曾经不清楚过」的痕迹,供事后对质)。
|
|
214
|
+
// ⚠️ 与检查 2(模板占位符)刻意区分:占位符 = 「忘了填」(失职),本标记 = 「这里我不知道」
|
|
215
|
+
// (负责)。两者都要求作者动作,但语义相反,故不复用同一正则——复用即把负责判成失职。
|
|
216
|
+
// 边界(诚实声明):**起草门不阻断**(这才是零阻塞的意义),仅在 draft→approved 确认门点名——
|
|
217
|
+
// 确认即「我认可这份文档」,文档里还挂着未澄清项则确认本身失去含义,故此处拒绝落账。
|
|
218
|
+
// 例外:<日期>/YYYY-MM-DD 等占位由检查 2 管,本件不重复拦。
|
|
219
|
+
const CLARIFY_RE = /^\s*[-*]\s*(待澄清|需澄清|待确认细节)\s*[::]\s*(\S.*)$/;
|
|
220
|
+
|
|
221
|
+
// openClarifications:抽出正文里未澄清的行(pure,供直测)。返回 [{ lineNo, text }]
|
|
222
|
+
// HTML 注释行不计入——模板自带说明写在注释里,不该被判成作者留下的未澄清项。
|
|
223
|
+
export function openClarifications(body) {
|
|
224
|
+
const out = [];
|
|
225
|
+
const lines = String(body || '').replace(/\r\n/g, '\n').split('\n');
|
|
226
|
+
for (let i = 0; i < lines.length; i++) {
|
|
227
|
+
const l = lines[i];
|
|
228
|
+
if (/^\s*<!--/.test(l) || /-->\s*$/.test(l)) continue; // 整行注释
|
|
229
|
+
const m = l.match(CLARIFY_RE);
|
|
230
|
+
if (m) out.push({ lineNo: i + 1, text: m[2].trim(), kind: m[1] });
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// clarifyGateFor:确认门「待澄清项已解决」前置判(confirm-doc draft→approved 用)
|
|
236
|
+
// 返回 { ok, items?, reason?, hint? }。ok=false 时 items 供逐条点名(不只说「有未澄清项」——
|
|
237
|
+
// 说清是哪几行哪几句,作者才不用回去逐行找)。
|
|
238
|
+
export function clarifyGateFor(abs) {
|
|
239
|
+
let body = '';
|
|
240
|
+
try { body = fs.readFileSync(abs, 'utf8'); } catch { return { ok: true, skipped: 'unreadable' }; }
|
|
241
|
+
const items = openClarifications(body);
|
|
242
|
+
if (!items.length) return { ok: true, items };
|
|
243
|
+
const list = items.map((i) => `第${i.lineNo}行「${i.text}」`).join(';');
|
|
244
|
+
return {
|
|
245
|
+
ok: false,
|
|
246
|
+
items,
|
|
247
|
+
reason: `文档含 ${items.length} 处未澄清项——${list}`,
|
|
248
|
+
hint: '逐条解决后把行首「待澄清」改为「已澄清」(保留行,便于事后对质曾不清楚过),或直接删行;确认门不接受挂着未澄清项的文档',
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
207
252
|
// confirmGateFor:确认门总判(confirm-doc 用;docRel = workflow/specs/... 或 workflow/plans/...)
|
|
253
|
+
// ⚠️ 只判**前置深度**(入口/spec 是否已确认)。待澄清项判定是**同一确认门上的另一条独立判据**,
|
|
254
|
+
// 由 clarifyGateFor 单独承担——两者不合并:合并后拒绝原因会指向「前置」,作者找错地方。
|
|
208
255
|
export function confirmGateFor(root, docRel) {
|
|
209
256
|
const base = path.basename(docRel).replace(/\.md$/, '');
|
|
210
257
|
const kind = docRel.startsWith('workflow/specs/') ? 'spec' : 'plan';
|
|
@@ -276,4 +323,4 @@ export function resolveWorkspace(outputPath, rootOverride) {
|
|
|
276
323
|
return null;
|
|
277
324
|
}
|
|
278
325
|
|
|
279
|
-
export default { entryConfirmed, specConfirmed, draftGateFor, confirmGateFor, doneGateFor, gitReady, approvedTraceHit, resolveWorkspace, readFm, readLedger };
|
|
326
|
+
export default { entryConfirmed, specConfirmed, draftGateFor, confirmGateFor, clarifyGateFor, openClarifications, doneGateFor, gitReady, approvedTraceHit, resolveWorkspace, readFm, readLedger };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// stage-gates.test.mjs — 逐阶段前置门 + 待澄清门的纯函数直测(2026-10-12 needs-clarification)
|
|
3
|
+
// 为什么单建本套件(stage-gates.mjs 此前登记 scripts-test-exempt,靠 check-loop.test 间接覆盖):
|
|
4
|
+
// 本批给该模块加了两条**此前不存在的判据族**(clarifyGateFor / openClarifications),
|
|
5
|
+
// 而这两条**没有 check-loop 侧的对应检查**——check-loop 不消费它们。间接覆盖天然盖不到
|
|
6
|
+
// 「确认门拒绝未澄清项」这条行为,纯函数直测是唯一能钉住它的位置(check 20 的豁免登记
|
|
7
|
+
// 在此更新为「同名直测 + 间接覆盖并有」)。
|
|
8
|
+
// 覆盖:CLARIFY_RE 形态矩阵(正例 5 / 负例 7)+ HTML 注释不计 + 已澄清前缀不计
|
|
9
|
+
// + clarifyGateFor 三态(无 / 有 / 不可读)+ 行号点名。
|
|
10
|
+
// 用法:node templates/_agents/scripts/stage-gates.test.mjs(npm test 随跑)
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { openClarifications, clarifyGateFor } from './stage-gates.mjs';
|
|
15
|
+
|
|
16
|
+
let pass = 0;
|
|
17
|
+
let fail = 0;
|
|
18
|
+
const check = (desc, cond, detail = '') => {
|
|
19
|
+
if (cond) { pass++; console.log(`PASS ${desc}`); }
|
|
20
|
+
else { fail++; console.log(`FAIL ${desc}${detail ? `\n${detail}` : ''}`); }
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// ---- T1 正例形态矩阵(5 个写法都应抽出,行号正确)----
|
|
24
|
+
{
|
|
25
|
+
const body = [
|
|
26
|
+
'# S', // 1
|
|
27
|
+
'', // 2
|
|
28
|
+
'- 待澄清:错误率判据?', // 3 ← 行首列表符 + 全角冒号
|
|
29
|
+
'* 需澄清: 依赖版本未定', // 4 ← 星号 + 半角冒号 + 同义前缀
|
|
30
|
+
' - 待确认细节:用户没说要不要兼容旧数据', // 5 ← 前导空格 + 第三同义前缀
|
|
31
|
+
'正文里提到「待澄清」二字不算(不是行首列表条目)', // 6 ← 负例
|
|
32
|
+
'', // 7
|
|
33
|
+
'- 待澄清了:已解决的说法不该被抽', // 8 ← 负例(词后跟了别的字符)
|
|
34
|
+
'待澄清:无列表符的行不算', // 9 ← 负例
|
|
35
|
+
'- 已澄清:这是已解决的', // 10 ← 负例(前缀已澄清)
|
|
36
|
+
].join('\n');
|
|
37
|
+
const items = openClarifications(body);
|
|
38
|
+
check('T1 三个同义前缀 + 列表符变体 + 全/半角冒号全命中(5 个写法)', items.length === 3, JSON.stringify(items));
|
|
39
|
+
check('T1 行号正确(3 / 4 / 5)',
|
|
40
|
+
items.map((i) => i.lineNo).join(',') === '3,4,5', JSON.stringify(items.map((i) => i.lineNo)));
|
|
41
|
+
check('T1 抽出的是标记后的正文(不含前缀与冒号)',
|
|
42
|
+
items[0].text === '错误率判据?' && items[1].text === '依赖版本未定' && items[2].text === '用户没说要不要兼容旧数据',
|
|
43
|
+
JSON.stringify(items.map((i) => i.text)));
|
|
44
|
+
check('T1 负例四类全不误抽(正文提及 / 前缀粘连 / 无列表符 / 已澄清)',
|
|
45
|
+
!items.some((i) => /待澄清了|无列表符|已解决/.test(i.text)), JSON.stringify(items));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---- T2 HTML 注释行不计(模板自带说明写在注释里,不该被判成作者的未澄清项)----
|
|
49
|
+
{
|
|
50
|
+
const body = [
|
|
51
|
+
'# S',
|
|
52
|
+
'<!-- 起草时照实写一行 - 待澄清:<你卡在哪>,不要猜 -->', // 整行注释
|
|
53
|
+
'- 待澄清:真问题',
|
|
54
|
+
'<!-- - 待澄清:注释里的第二处 -->', // 整行注释
|
|
55
|
+
'行尾注释不算:- 待澄清:真的吗 <!-- 附注 -->', // 行尾注释
|
|
56
|
+
].join('\n');
|
|
57
|
+
const items = openClarifications(body);
|
|
58
|
+
check('T2 整行 HTML 注释里的标记不计(只 1 处命中)', items.length === 1 && items[0].lineNo === 3, JSON.stringify(items));
|
|
59
|
+
check('T2 行尾注释形态不计(模板说明常这么写)', !items.some((i) => /真的吗/.test(i.text)), JSON.stringify(items));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---- T2b 已知边界(跨行 HTML 注释块)——如实钉住,不假装覆盖 ----
|
|
63
|
+
// 逐行过滤只认「整行注释」与「行尾注释」;跨行注释块 <!--\n - 待澄清:x\n--> 的中间行会被抽出。
|
|
64
|
+
// 为何不修:跨行注释在 workflow 文档里零先例(模板说明一律单行注释),为不存在的形态加状态机
|
|
65
|
+
// 是过度工程。**方向是保守的**(多抽一处 → 确认门拒一次,作者看到就改),不会漏放。
|
|
66
|
+
{
|
|
67
|
+
const items = openClarifications('<!--\n- 待澄清:跨行注释块中间行\n-->');
|
|
68
|
+
check('T2b 已知边界:跨行注释块中间行会被抽出(保守方向——多拒不漏放)',
|
|
69
|
+
items.length === 1 && items[0].lineNo === 2, JSON.stringify(items));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---- T3 CRLF 与空输入 ----
|
|
73
|
+
{
|
|
74
|
+
check('T3 CRLF 归一后形态不变(行号按 \\n 计)',
|
|
75
|
+
openClarifications('- a\r\n\r\n- 待澄清:x\r\n').length === 1);
|
|
76
|
+
check('T3 空 / undefined 输入 → 空数组(不抛)',
|
|
77
|
+
openClarifications('').length === 0 && openClarifications(undefined).length === 0 && openClarifications(null).length === 0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---- T4 clarifyGateFor 三态 ----
|
|
81
|
+
{
|
|
82
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'stage-gates-test-'));
|
|
83
|
+
const p = path.join(tmp, 'doc.md');
|
|
84
|
+
fs.writeFileSync(p, '---\n状态: draft\n---\n# S\n- 待澄清:甲\n- 待澄清:乙\n');
|
|
85
|
+
const g = clarifyGateFor(p);
|
|
86
|
+
check('T4 挂未澄清项 → ok=false 且 items 含两处', !g.ok && g.items.length === 2, JSON.stringify(g));
|
|
87
|
+
check('T4 reason 逐条点名行号与内容(作者不必回去逐行找)',
|
|
88
|
+
/第5行「甲」/.test(g.reason) && /第6行「乙」/.test(g.reason), g.reason);
|
|
89
|
+
check('T4 reason 给出条数 + hint 给两条解决路径(改前缀 / 删行)',
|
|
90
|
+
/2 处未澄清项/.test(g.reason) && /已澄清/.test(g.hint) && /删行/.test(g.hint), JSON.stringify(g));
|
|
91
|
+
check('T4 ok=false 时必然带 reason 与 hint(出账完整,勿半截)', !!g.reason && !!g.hint);
|
|
92
|
+
|
|
93
|
+
fs.writeFileSync(p, '---\n状态: draft\n---\n# S\n- 已澄清:都解决了\n');
|
|
94
|
+
const g2 = clarifyGateFor(p);
|
|
95
|
+
check('T4 全部已澄清 → ok=true', g2.ok && g2.items.length === 0, JSON.stringify(g2));
|
|
96
|
+
|
|
97
|
+
const missing = clarifyGateFor(path.join(tmp, 'nope.md'));
|
|
98
|
+
check('T4 文件不可读 → ok=true + skipped=unreadable(fail-open,不因读不到而拒确认)',
|
|
99
|
+
missing.ok && missing.skipped === 'unreadable', JSON.stringify(missing));
|
|
100
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
|
|
104
|
+
if (fail) { console.log('\n用法:node templates/_agents/scripts/stage-gates.test.mjs'); process.exit(1); }
|
|
105
|
+
process.exit(0);
|
|
@@ -209,6 +209,40 @@ for (const m2 of html.matchAll(/href="([^"]+)"/g)) {
|
|
|
209
209
|
}
|
|
210
210
|
if (deadLinks.length) problems.push(`看板链接指向不存在的路径(相对 wiki/ 解析): ${deadLinks.join(';')}`);
|
|
211
211
|
|
|
212
|
+
// ---- 9. 来源血缘(B3,2026-10-10):wiki frontmatter「来源: workflow/<类型>/<文件>」指回 workflow 归档 ----
|
|
213
|
+
// 与 intent/incident 的正向「沉淀:」字段对称闭环——双向链接任一端指向不存在的文件即断链。
|
|
214
|
+
// 校验面:活跃层知识文档(.md/.html,含子目录递归)。无 frontmatter / 无来源键的老文件零影响。
|
|
215
|
+
// drafts-archive 草稿的来源校验降级为提示(草稿是中间态,来源 intent 可能尚未在仓库——B2 落的草稿
|
|
216
|
+
// 在 intent done 前生成,此时断链属预期),不进 problems。
|
|
217
|
+
// 注意:activeTree 只含活跃主题目录(drafts-archive 在枚举处被排除),草稿须另行扫描归档树。
|
|
218
|
+
const fmGet = (text, key) => {
|
|
219
|
+
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
220
|
+
if (!m) return null;
|
|
221
|
+
const kv = m[1].split(/\r?\n/).map((l) => l.match(/^(\S+):\s*(.*)$/)).find((x) => x && x[1] === key);
|
|
222
|
+
return kv ? kv[2].trim() : null;
|
|
223
|
+
};
|
|
224
|
+
let brokenSources = 0;
|
|
225
|
+
const checkSource = (f, isDraft) => {
|
|
226
|
+
let text;
|
|
227
|
+
try { text = fs.readFileSync(path.join(WIKI, f), 'utf8'); } catch { return; }
|
|
228
|
+
const src = fmGet(text, '来源');
|
|
229
|
+
if (!src) return;
|
|
230
|
+
if (fs.existsSync(src)) return;
|
|
231
|
+
if (isDraft) notes.push(`草稿 ${f} 来源未落地(草稿中间态,归类前允许):${src}`);
|
|
232
|
+
else { problems.push(`来源断链:${f} frontmatter「来源: ${src}」指向的文件不存在`); brokenSources++; }
|
|
233
|
+
};
|
|
234
|
+
for (const f of activeTree.files) {
|
|
235
|
+
if (!/\.(md|html)$/i.test(f)) continue;
|
|
236
|
+
checkSource(f, false);
|
|
237
|
+
}
|
|
238
|
+
if (fs.existsSync(ARCHIVE_DIR)) {
|
|
239
|
+
for (const f of walkTree(ARCHIVE_DIR).files) {
|
|
240
|
+
if (!/\.(md|html)$/i.test(f)) continue;
|
|
241
|
+
checkSource(`drafts-archive/${f}`, true);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (brokenSources === 0) notes.push('来源血缘有效:活跃层 wiki 文件的「来源」均指向存在的 workflow 归档');
|
|
245
|
+
|
|
212
246
|
// ---- 输出 ----
|
|
213
247
|
for (const n of notes) console.log('ℹ️ ' + n);
|
|
214
248
|
if (problems.length) {
|