sillyspec 3.28.11 → 3.28.13
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/.claude/skills/sillyspec-execute/SKILL.md +222 -223
- package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
- package/.claude/skills/sillyspec-quick/SKILL.md +8 -8
- package/.claude/skills/sillyspec-verify/SKILL.md +87 -91
- package/.husky/pre-commit +4 -0
- package/.husky/pre-push +3 -0
- package/AGENTS.md +1 -1
- package/package.json +1 -1
- package/src/change-risk-profile.js +29 -7
- package/src/commit-guard.js +119 -0
- package/src/config-schema.js +2 -0
- package/src/decision-distill.js +32 -25
- package/src/diagnostic-codes.js +95 -0
- package/src/doctor-diagnostics.js +170 -1
- package/src/fr-index.js +359 -0
- package/src/knowledge-stats.js +99 -2
- package/src/machine-interface.js +37 -0
- package/src/run/archive-distill.js +29 -0
- package/src/run/prompt.js +32 -1
- package/src/run/verify-quality-scan.js +151 -5
- package/src/stage-contract.js +496 -19
- package/src/stage-review-checklist.js +13 -1
- package/src/stages/brainstorm.js +6 -1
- package/src/stages/verify.js +6 -2
- package/src/verify-facts-schema.js +21 -5
- package/src/verify-postcheck.js +86 -2
- package/src/verify-probes.js +1293 -27
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* integration-critical / deployment-critical),产出门控验证需求。
|
|
7
7
|
*/
|
|
8
8
|
import { readFileSync, statSync } from 'fs'
|
|
9
|
-
import { join } from 'path'
|
|
9
|
+
import { join, isAbsolute } from 'path'
|
|
10
10
|
|
|
11
11
|
// ============ 向后兼容:旧的 INTEGRATION_CRITICAL_PATTERNS ============
|
|
12
12
|
|
|
@@ -312,7 +312,16 @@ export function detectChangeRisk({ designContent = '', planContent = '', changed
|
|
|
312
312
|
// classifyReceiptCommandSource(facts.integrationRan 判定)与本分类同族口径,两处正则族
|
|
313
313
|
// 保持同步演进)。smoke 冒烟命令族计 cross-layer(D-006 退役判据:批次 C commands.smoke
|
|
314
314
|
// 落地后由 smoke 回执一票判定「集成实测已跑」——起服务冒烟即跨层实测,不得默认 build 误拦金路径)。
|
|
315
|
-
|
|
315
|
+
//
|
|
316
|
+
// 来源标记直判(2026-09-17-api-coverage-smoke task-02 / Grill B-1 修正,FR-03):条目 source
|
|
317
|
+
// 标记 'cli-noai-smoke'(verify-probes ensureSmokeReceiptSection 注入的机器段行尾注,
|
|
318
|
+
// parseEvidenceSlots additive 解析回填)→ 直判 cross-layer,**优先于命令词正则分类**——
|
|
319
|
+
// 修正点在标记识别而非命令词增补:node scripts/smoke.mjs / bash smoke.sh / python smoke.py
|
|
320
|
+
// 等脚本形态在既有正则下全判 build 会误拦金路径,且脚本形态词表不可枚举(正则族两文件零词
|
|
321
|
+
// 变动)。对齐目标:verify-probes.js classifyReceiptCommandSource(judgeIntegrationRan 消费)
|
|
322
|
+
// 同款 sourceMark 参数与标记族——**任一侧增改标记族必须双侧同步**(G-3 先例:producer 侧
|
|
323
|
+
// 曾漏 smoke 一词致口径分裂)。
|
|
324
|
+
export const RECEIPT_SOURCE_CROSS_LAYER_RE = new RegExp([
|
|
316
325
|
'\\bcurl\\b', '\\bwget\\b', '\\bhttpie\\b', '\\bInvoke-WebRequest\\b', '\\bInvoke-RestMethod\\b',
|
|
317
326
|
'\\biwr\\b', '\\birm\\b', 'https?://',
|
|
318
327
|
'spring-boot:run', '\\bjava\\b[^|\\n]*\\s-jar\\b',
|
|
@@ -320,8 +329,11 @@ const RECEIPT_SOURCE_CROSS_LAYER_RE = new RegExp([
|
|
|
320
329
|
'\\bdotnet\\s+run\\b', '\\bflask\\s+run\\b', '\\buvicorn\\b', '\\bgunicorn\\b',
|
|
321
330
|
'\\b(?:nc|netcat|telnet|socat)\\b',
|
|
322
331
|
].join('|'), 'i')
|
|
323
|
-
const RECEIPT_SOURCE_UNIT_RE = /\bJUnitCore\b|\bnode\s+--test\b|\bmocha\b|\bjest\b|\bvitest\b|\bpytest\b|\bphpunit\b|\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test\b/i
|
|
324
|
-
|
|
332
|
+
export const RECEIPT_SOURCE_UNIT_RE = /\bJUnitCore\b|\bnode\s+--test\b|\bmocha\b|\bjest\b|\bvitest\b|\bpytest\b|\bphpunit\b|\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test\b/i
|
|
333
|
+
/** CLI 机器段来源标记(与 verify-probes.js SMOKE_RECEIPT_SOURCE_MARK / task-01 记录 source 同值,双侧同步) */
|
|
334
|
+
export const CLI_SMOKE_SOURCE_MARK = 'cli-noai-smoke'
|
|
335
|
+
function classifyReceiptSourceTag(command, sourceMark) {
|
|
336
|
+
if (sourceMark === CLI_SMOKE_SOURCE_MARK) return 'cross-layer'
|
|
325
337
|
const cmd = String(command || '')
|
|
326
338
|
if (RECEIPT_SOURCE_CROSS_LAYER_RE.test(cmd)) return 'cross-layer'
|
|
327
339
|
if (RECEIPT_SOURCE_UNIT_RE.test(cmd)) return 'unit'
|
|
@@ -430,18 +442,28 @@ export function checkIntegrationEvidence(verifyContent, requiredVerification, op
|
|
|
430
442
|
* 缺省按回执 command 来源就地分类(classifyReceiptSourceTag),未定类默认 'build'(向后
|
|
431
443
|
* 兼容的 fail-closed 缺省);sourceTag ∈ {build, unit} 的回执不作集成实测绿判据(四条件
|
|
432
444
|
* 全过也只到 non-green,reason 点名来源)——verify-postcheck 不在本调用链上,不受影响。
|
|
445
|
+
*
|
|
446
|
+
* r.source(2026-09-17-api-coverage-smoke task-02 / FR-03,Grill B-1):条目级来源标记
|
|
447
|
+
* (parseEvidenceSlots additive 第五字段,CLI 机器段行 'cli-noai-smoke' 尾注回填)透传给
|
|
448
|
+
* classifyReceiptSourceTag 直判 cross-layer——优先级在调用方 opts.sourceTag 声明源之下、
|
|
449
|
+
* 命令词正则分类之上;无标记条目分类路径逐字节不变。
|
|
433
450
|
*/
|
|
434
451
|
function auditRuntimeReceipt(r, opts) {
|
|
435
452
|
const out = { claim: r && r.claim, logPath: r && r.logPath, sourceTag: null, green: false, logExists: null, mtimeInWindow: null, failSignatures: 0, exitCode: r && typeof r.exitCode === 'number' ? r.exitCode : null, reason: '' }
|
|
436
453
|
// 打标依据 = 命令来源声明(不解析日志内容,X-10):opts.sourceTag 调用方声明源优先,
|
|
454
|
+
// 其次条目 source 标记直判(task-02 Grill B-1——'cli-noai-smoke' 直判 cross-layer),
|
|
437
455
|
// 否则按回执 command 分类;非法/缺省走分类,未定类默认 build。
|
|
438
456
|
const declaredSource = opts && typeof opts.sourceTag === 'string'
|
|
439
457
|
&& ['cross-layer', 'build', 'unit'].includes(opts.sourceTag) ? opts.sourceTag : null
|
|
440
|
-
out.sourceTag = declaredSource || classifyReceiptSourceTag(r && r.command)
|
|
458
|
+
out.sourceTag = declaredSource || classifyReceiptSourceTag(r && r.command, r && r.source)
|
|
441
459
|
if (!r || !r.logPath) { out.reason = '回执缺 logPath'; return out }
|
|
460
|
+
// 绝对路径直读(2026-09-17-api-coverage-smoke task-02):CLI 机器段(source=cli-noai-smoke)
|
|
461
|
+
// 的 logPath 是 quality-scan 记录的绝对路径实录——join(cwd, 绝对路径) 会拼出破损路径
|
|
462
|
+
// 误报「日志不存在」,isAbsolute 时跳过 join(verify-postcheck 测试输出解析同款口径)。
|
|
463
|
+
const resolveLog = (p) => (opts.cwd && !isAbsolute(String(p)) ? join(opts.cwd, p) : p)
|
|
442
464
|
let content = null
|
|
443
465
|
try {
|
|
444
|
-
const abs =
|
|
466
|
+
const abs = resolveLog(r.logPath)
|
|
445
467
|
content = readFileSync(abs, 'utf8')
|
|
446
468
|
out.logExists = true
|
|
447
469
|
} catch {
|
|
@@ -452,7 +474,7 @@ function auditRuntimeReceipt(r, opts) {
|
|
|
452
474
|
if (opts.verifyStartAt) {
|
|
453
475
|
try {
|
|
454
476
|
const startAt = new Date(opts.verifyStartAt).getTime()
|
|
455
|
-
out.mtimeInWindow = statSync(
|
|
477
|
+
out.mtimeInWindow = statSync(resolveLog(r.logPath)).mtimeMs >= startAt - 60_000
|
|
456
478
|
if (!out.mtimeInWindow) { out.reason = `日志 mtime 早于 verifyStartAt(${r.logPath})`; return out }
|
|
457
479
|
} catch { out.mtimeInWindow = null }
|
|
458
480
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commit-guard.js — pre-commit 提交边界守卫(fail-open,只警告不阻断)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要(2026-09-17 实证):多会话共享仓里 git 暂存区是共享状态——他会话 git add 过的
|
|
5
|
+
* 文件会被本会话的裸 git commit 一并带走(当日事故:add 带 pathspec 但 commit 裸跑,并行
|
|
6
|
+
* 会话 5 个 staged 文件被扫入 fb5bc11,事后 reset --soft + pathspec 重提交外科手术拆回)。
|
|
7
|
+
* AGENTS.md 第 18 条已加厚为「add/commit 同清单 pathspec」,本守卫是同一约束的工具化兜底:
|
|
8
|
+
* 纪理会漏,钩子卡点不会漏(与 write-audit / concurrent-detect 同族——确定性的事 CLI 兜)。
|
|
9
|
+
*
|
|
10
|
+
* 信号(只警告,exit 恒 0):
|
|
11
|
+
* S1 quick 声明面对账:staged 含 .sillyspec/quicklog/patches/ql-<id>.json 时,读其
|
|
12
|
+
* rows[].declared 声明面——staged 中超出声明面的文件(quicklog 账本/补丁自身除外)
|
|
13
|
+
* 即警告「疑似扫入他者文件」。
|
|
14
|
+
* S2 跨变更目录:staged 里 .sillyspec/changes/<name>/(含 archive/)命中 ≥2 个不同
|
|
15
|
+
* name → 警告「正常提交只携带一个变更的面」。
|
|
16
|
+
* 命中即 stderr 警告 + appendWriteAudit 留痕(via: 'commit-guard')。
|
|
17
|
+
*
|
|
18
|
+
* fail-open 硬约束(钩子挂在所有会话共享的 .husky/ 上,守卫自身绝不拦任何人的提交):
|
|
19
|
+
* 非 git 仓 / git 失败 / ql json 损坏 / 任何异常 → 静默 exit 0;钩子侧再垫 || true 双保险。
|
|
20
|
+
*/
|
|
21
|
+
import { execFileSync } from 'child_process'
|
|
22
|
+
import { readFileSync, existsSync } from 'fs'
|
|
23
|
+
import { join } from 'path'
|
|
24
|
+
import { pathToFileURL } from 'url'
|
|
25
|
+
import { appendWriteAudit } from './write-audit.js'
|
|
26
|
+
|
|
27
|
+
const QUICKLOG_PREFIX = '.sillyspec/quicklog/'
|
|
28
|
+
const QL_PATCH_JSON_RE = /^\.sillyspec\/quicklog\/patches\/ql-[^/]+\.json$/
|
|
29
|
+
const CHANGES_RE = /^\.sillyspec\/changes\/(?:archive\/)?([^/]+)\//
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* S1+S2 纯函数:staged 面 + ql patch json 映射 → 警告数组(无 git/文件系统依赖,可直测)。
|
|
33
|
+
* @param {string[]} stagedFiles staged 相对路径
|
|
34
|
+
* @param {Record<string, any>} qlPatches { json路径 → 解析结果 }(损坏的已由调用方跳过)
|
|
35
|
+
* @returns {{signal: string, files: string[], message: string}[]}
|
|
36
|
+
*/
|
|
37
|
+
export function analyzeStagedFace(stagedFiles, qlPatches) {
|
|
38
|
+
const staged = Array.isArray(stagedFiles) ? stagedFiles.filter((f) => typeof f === 'string' && f) : []
|
|
39
|
+
const warnings = []
|
|
40
|
+
// S1 quick 声明面对账
|
|
41
|
+
const qlJsons = staged.filter((f) => QL_PATCH_JSON_RE.test(f))
|
|
42
|
+
if (qlJsons.length > 0) {
|
|
43
|
+
const declared = new Set()
|
|
44
|
+
for (const j of qlJsons) {
|
|
45
|
+
const parsed = (qlPatches || {})[j]
|
|
46
|
+
if (!parsed || !Array.isArray(parsed.rows)) continue
|
|
47
|
+
for (const r of parsed.rows) {
|
|
48
|
+
if (r && r.declared && typeof r.path === 'string') declared.add(r.path)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (declared.size > 0) {
|
|
52
|
+
const offending = staged.filter((f) => !f.startsWith(QUICKLOG_PREFIX) && !declared.has(f))
|
|
53
|
+
if (offending.length > 0) {
|
|
54
|
+
warnings.push({
|
|
55
|
+
signal: 'quick_declared_face_exceeded',
|
|
56
|
+
files: offending,
|
|
57
|
+
message: `staged 超出 quick 声明面(${qlJsons.map((f) => f.split('/').pop()).join(', ')}):${offending.join('、')}——疑似扫入他者文件,核对是否该随本提交带走`,
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// S2 跨变更目录
|
|
63
|
+
const changeNames = new Set()
|
|
64
|
+
for (const f of staged) {
|
|
65
|
+
const m = f.match(CHANGES_RE)
|
|
66
|
+
if (m) changeNames.add(m[1])
|
|
67
|
+
}
|
|
68
|
+
if (changeNames.size >= 2) {
|
|
69
|
+
warnings.push({
|
|
70
|
+
signal: 'multi_change_dirs_staged',
|
|
71
|
+
files: staged.filter((f) => CHANGES_RE.test(f)),
|
|
72
|
+
message: `staged 同时含 ${changeNames.size} 个变更目录(${[...changeNames].join('、')})——正常提交只携带一个变更的面,疑似夹带并行会话产物`,
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
return warnings
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function main() {
|
|
79
|
+
const cwd = process.cwd()
|
|
80
|
+
let staged = []
|
|
81
|
+
try {
|
|
82
|
+
staged = execFileSync('git', ['diff', '--cached', '--name-only'], {
|
|
83
|
+
cwd,
|
|
84
|
+
encoding: 'utf8',
|
|
85
|
+
// stderr 必须管道化:execFileSync 默认把子进程 stderr 透传给父进程——非 git 目录下
|
|
86
|
+
// git 落 --no-index 模式报 unknown option 的 usage 长文会漏进守卫 stderr(噪声)。
|
|
87
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
88
|
+
})
|
|
89
|
+
.split(/\r?\n/)
|
|
90
|
+
.map((s) => s.trim())
|
|
91
|
+
.filter(Boolean)
|
|
92
|
+
} catch {
|
|
93
|
+
return 0 // 非 git 仓 / git 异常 → 放行
|
|
94
|
+
}
|
|
95
|
+
if (staged.length === 0) return 0
|
|
96
|
+
const qlPatches = {}
|
|
97
|
+
for (const f of staged) {
|
|
98
|
+
if (!QL_PATCH_JSON_RE.test(f)) continue
|
|
99
|
+
try {
|
|
100
|
+
qlPatches[f] = JSON.parse(readFileSync(join(cwd, f), 'utf8'))
|
|
101
|
+
} catch { /* 单个 json 损坏 → 跳过该信号源,不连坐 */ }
|
|
102
|
+
}
|
|
103
|
+
const warnings = analyzeStagedFace(staged, qlPatches)
|
|
104
|
+
if (warnings.length === 0) return 0
|
|
105
|
+
for (const w of warnings) process.stderr.write(`⚠️ [commit-guard] ${w.message}\n`)
|
|
106
|
+
const specDir = join(cwd, '.sillyspec')
|
|
107
|
+
if (existsSync(specDir)) {
|
|
108
|
+
appendWriteAudit(specDir, {
|
|
109
|
+
via: 'commit-guard',
|
|
110
|
+
files: [...new Set(warnings.flatMap((w) => w.files))].slice(0, 20),
|
|
111
|
+
detail: { signals: warnings.map((w) => ({ signal: w.signal, files: w.files.slice(0, 20) })) },
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
return 0
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 直接执行(node src/commit-guard.js)才跑 main;被 import 时不跑
|
|
118
|
+
const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
|
|
119
|
+
if (invokedDirectly) process.exit(main())
|
package/src/config-schema.js
CHANGED
|
@@ -70,6 +70,7 @@ export const LOCAL_YAML_SCHEMA = {
|
|
|
70
70
|
{ path: 'commands.build', type: 'string', optional: true, status: 'live', readers: ['detectLocalYaml (src/local-detect.js)', 'validateCommands (src/scan-postcheck.js)'], desc: '构建命令。', example: 'npm run build' },
|
|
71
71
|
{ path: 'commands.test', type: 'string', optional: true, status: 'live', readers: ['extractTestCommand (src/verify-postcheck.js)', 'runQuickTestLintGate (src/run/quick-audit.js)', 'validateCommands (src/scan-postcheck.js)'], desc: '测试命令——verify 阶段 CLI 亲自执行此命令与 verify-result.md 对账,实测失败即阻断;quick --done 触及 src/test 时同款实测(P0-2 门禁)。', example: 'npm test' },
|
|
72
72
|
{ path: 'commands.lint', type: 'string', optional: true, status: 'live', readers: ['detectLocalYaml (src/local-detect.js)', 'runQuickTestLintGate (src/run/quick-audit.js)', 'validateCommands (src/scan-postcheck.js)'], desc: 'lint 命令——quick --done 触及 src/test 时 CLI 亲自执行(P0-2 门禁,失败阻断完成)。', example: 'npm run lint' },
|
|
73
|
+
{ path: 'commands.smoke', type: 'string', optional: true, status: 'live', readers: ['executeVerifyQualityScan (src/run/verify-quality-scan.js)'], desc: '接口冒烟命令——verify 阶段 CLI 亲跑(脚本自理服务生命周期:后台起服+轮询就绪+finally 杀),300s 超时帽,失败/超时只记失败态不阻断本步、触发 PASS 封顶(D-002);指纹含此键,未变不重跑。', example: 'node scripts/smoke.mjs' },
|
|
73
74
|
{ path: 'commands.install', type: 'string', optional: true, status: 'live', readers: ['extractInstallCommand (src/worktree-deps.js)'], desc: '依赖安装命令(worktree 依赖 provisioning 用)。', example: 'npm install' },
|
|
74
75
|
],
|
|
75
76
|
},
|
|
@@ -316,6 +317,7 @@ commands:
|
|
|
316
317
|
build: npm run build
|
|
317
318
|
test: npm test
|
|
318
319
|
lint: npm run lint
|
|
320
|
+
# smoke: node scripts/smoke.mjs # verify 冒烟命令(可选;脚本自理服务生命周期,失败只封顶 PASS 不阻断)
|
|
319
321
|
# install: npm install # worktree 依赖安装命令
|
|
320
322
|
|
|
321
323
|
# ── SillyHub MCP 客户端凭据(派发到 worker 用)──
|
package/src/decision-distill.js
CHANGED
|
@@ -211,8 +211,9 @@ function parseModulePathsSubset(content) {
|
|
|
211
211
|
/** 未注入 moduleIndex 时的尽力发现:knowledgeRoot 同级 docs/<项目>/modules/_module-map.yaml。
|
|
212
212
|
* 多子项目仓(如本仓 sillyspec+dashboard)各项目有自己的 map——dogfood 实证(2026-08-24):
|
|
213
213
|
* 只取首个命中会把主项目条目全部误落 unmapped(dashboard 的 map 按字母序先命中)。
|
|
214
|
-
* 改为合并全部项目的 map(同名模块合并 paths/core_files 去重),失败归 null → 全部 unmapped。
|
|
215
|
-
|
|
214
|
+
* 改为合并全部项目的 map(同名模块合并 paths/core_files 去重),失败归 null → 全部 unmapped。
|
|
215
|
+
* 2026-09-18-fr-index-l1:导出供 fr-index 域解析复用。 */
|
|
216
|
+
export function discoverModuleIndex(knowledgeRoot) {
|
|
216
217
|
try {
|
|
217
218
|
const docsDir = join(dirname(knowledgeRoot), 'docs')
|
|
218
219
|
if (!existsSync(docsDir)) return null
|
|
@@ -319,15 +320,19 @@ function renderBlockLines(entry, headHash, supersedesNote, changeName) {
|
|
|
319
320
|
return lines
|
|
320
321
|
}
|
|
321
322
|
|
|
322
|
-
/** 知识文件切段:preamble 行 +
|
|
323
|
+
/** 知识文件切段:preamble 行 + 条目段(段边界 = 下一节头)。
|
|
323
324
|
* 段的变更归属取段内 `变更:<name>` 行;无该行的历史条目 → change=null(legacy:
|
|
324
|
-
* 升级前落库的条目,不参与新条目的同号匹配/supersede——只共存不误删)。
|
|
325
|
-
|
|
325
|
+
* 升级前落库的条目,不参与新条目的同号匹配/supersede——只共存不误删)。
|
|
326
|
+
* 2026-09-18-fr-index-l1 参数化:sectionRegex/buildId 经参(decisions 缺省不变;fr 侧传 FR 节头
|
|
327
|
+
* 正则与恒等 id),decisions 调用点零改动。 */
|
|
328
|
+
export function splitKnowledgeSections(content, opts = {}) {
|
|
329
|
+
const sectionRegex = opts.sectionRegex || /^## (D-\d+)@v(\d+)\s*(.*)$/
|
|
330
|
+
const buildId = opts.buildId || ((n, v) => `${n}@v${v}`)
|
|
326
331
|
const sections = []
|
|
327
332
|
const preamble = []
|
|
328
333
|
let cur = null
|
|
329
334
|
for (const line of content.replace(/\r\n/g, '\n').split('\n')) {
|
|
330
|
-
const m = line.match(
|
|
335
|
+
const m = line.match(sectionRegex)
|
|
331
336
|
if (m) {
|
|
332
337
|
if (cur) sections.push(cur)
|
|
333
338
|
cur = { number: m[1], version: parseInt(m[2], 10), title: m[3].trim(), lines: [line] }
|
|
@@ -338,7 +343,7 @@ function splitKnowledgeSections(content) {
|
|
|
338
343
|
}
|
|
339
344
|
if (cur) sections.push(cur)
|
|
340
345
|
for (const s of sections) {
|
|
341
|
-
s.id =
|
|
346
|
+
s.id = buildId(s.number, s.version)
|
|
342
347
|
const cm = s.lines.find(l => /^变更[::]/.test(l))
|
|
343
348
|
s.change = cm ? cm.replace(/^变更[::]\s*/, '').trim() || null : null
|
|
344
349
|
}
|
|
@@ -346,7 +351,7 @@ function splitKnowledgeSections(content) {
|
|
|
346
351
|
}
|
|
347
352
|
|
|
348
353
|
/** 段落重组落盘文本(幂等归一:段间恰好一个空行 + 文件单一尾换行) */
|
|
349
|
-
function joinKnowledgeFile(preamble, sections) {
|
|
354
|
+
export function joinKnowledgeFile(preamble, sections) {
|
|
350
355
|
const parts = []
|
|
351
356
|
const pre = preamble.join('\n').replace(/\s+$/, '')
|
|
352
357
|
if (pre) parts.push(pre)
|
|
@@ -394,19 +399,21 @@ function applyEntryToSections(sections, entry, headHash) {
|
|
|
394
399
|
// INDEX.md decisions 路由行幂等维护(FR-05:写入责任在本模块;只动 ## Decisions 段内指向 decisions/ 的行)
|
|
395
400
|
// ---------------------------------------------------------------------------
|
|
396
401
|
|
|
397
|
-
/**
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
const
|
|
402
|
+
/**
|
|
403
|
+
* 域文件实况 ↔ INDEX 段路由行对账:缺行补、失效行删、重复行去重;不动其他类别行。
|
|
404
|
+
* 2026-09-18-fr-index-l1 参数化:section/subdir/makeLine/matchTarget 经参(decisions 缺省不变),
|
|
405
|
+
* fr 侧传 'FR 需求索引' 段与 fr/ 子目录——两类路由行各守各段互不干扰。
|
|
406
|
+
*/
|
|
407
|
+
export function syncIndexRoutingLines(knowledgeRoot, opts = {}) {
|
|
408
|
+
const sectionName = opts.section || 'Decisions'
|
|
409
|
+
const subdir = opts.subdir || 'decisions'
|
|
410
|
+
const makeLine = opts.makeLine || ((d) => `- ${d}|decision|决策 → [${subdir}/${d}.md](${subdir}/${d}.md)`)
|
|
411
|
+
const esc = subdir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
412
|
+
const targetRe = opts.matchTarget || new RegExp(`^-\\s+.*→\\s*\\[[^\\]]*\\]\\(${esc}/([^)#]+?)(?:\\.md)?(?:#[^)]*)?\\)\\s*$`)
|
|
413
|
+
const domainDir = join(knowledgeRoot, subdir)
|
|
407
414
|
let domains
|
|
408
415
|
try {
|
|
409
|
-
domains = readdirSync(
|
|
416
|
+
domains = readdirSync(domainDir).filter(f => f.endsWith('.md')).map(f => f.replace(/\.md$/, ''))
|
|
410
417
|
} catch { return false }
|
|
411
418
|
if (domains.length === 0) return false
|
|
412
419
|
|
|
@@ -417,29 +424,29 @@ function syncIndexRoutingLines(knowledgeRoot) {
|
|
|
417
424
|
}
|
|
418
425
|
const lines = original ? original.replace(/\r\n/g, '\n').split('\n') : ['# Knowledge Index', '']
|
|
419
426
|
|
|
420
|
-
//
|
|
427
|
+
// 定位段边界(参数化段名)
|
|
421
428
|
let secStart = -1
|
|
422
429
|
let secEnd = lines.length
|
|
423
430
|
for (let i = 0; i < lines.length; i++) {
|
|
424
431
|
const h = lines[i].match(/^##\s+(.+?)\s*$/)
|
|
425
432
|
if (!h) continue
|
|
426
433
|
if (secStart === -1) {
|
|
427
|
-
if (h[1] ===
|
|
434
|
+
if (h[1] === sectionName) secStart = i + 1
|
|
428
435
|
} else { secEnd = i; break }
|
|
429
436
|
}
|
|
430
437
|
|
|
431
|
-
const desired = domains.map(
|
|
438
|
+
const desired = domains.map(makeLine)
|
|
432
439
|
if (secStart === -1) {
|
|
433
440
|
// 无段则追加(保留原文件其余内容)
|
|
434
441
|
while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop()
|
|
435
|
-
lines.push('',
|
|
442
|
+
lines.push('', `## ${sectionName}`, ...desired)
|
|
436
443
|
} else {
|
|
437
444
|
const body = lines.slice(secStart, secEnd)
|
|
438
445
|
const kept = []
|
|
439
446
|
const seen = new Set()
|
|
440
447
|
const domainSet = new Set(domains)
|
|
441
448
|
for (const l of body) {
|
|
442
|
-
const m = l.match(
|
|
449
|
+
const m = l.match(targetRe)
|
|
443
450
|
if (!m) { kept.push(l); continue } // 非路由行(注释/空行)原样保留
|
|
444
451
|
// 目标域文件仍存在的路由行原样保留(关键词可能被人工扩充);失效行(文件已不存在)与重复行删
|
|
445
452
|
if (domainSet.has(m[1]) && !seen.has(m[1])) { seen.add(m[1]); kept.push(l) }
|
|
@@ -448,7 +455,7 @@ function syncIndexRoutingLines(knowledgeRoot) {
|
|
|
448
455
|
const tail = []
|
|
449
456
|
while (kept.length > 0 && kept[kept.length - 1].trim() === '') tail.unshift(kept.pop())
|
|
450
457
|
for (const d of desired) {
|
|
451
|
-
const m = d.match(
|
|
458
|
+
const m = d.match(targetRe)
|
|
452
459
|
if (m && !seen.has(m[1])) kept.push(d) // 缺行补
|
|
453
460
|
}
|
|
454
461
|
kept.push(...tail)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* diagnostic-codes.js — machine-interface 族诊断码表单一源
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要:信封 errors/warnings 是自由文本中文句子,程序化消费方(SillyHub/未来 MCP
|
|
5
|
+
* 消费者)无法按错误类型稳定分支——提示语改一个字就是一次静默破坏性变更。本表给
|
|
6
|
+
* gate/derive/progress show 三面的自产错误配稳定 snake_case 身份码(2026-09-17
|
|
7
|
+
* OpenSpec 源码对比三轮评审②号项,change 2026-09-17-mi-diagnostic-codes)。
|
|
8
|
+
*
|
|
9
|
+
* 纪律(D-002@v1 硬边界):
|
|
10
|
+
* - 恰 10 码:信封级 4(exit 2 面)+ check 级 6(gate/derive 失败面)。
|
|
11
|
+
* - 只码 machine-interface 族自产错误——stage-contract 校验器散文、doctor/validate/
|
|
12
|
+
* scope-audit 面首期不碰(码标识失败面,不标识每条消息)。
|
|
13
|
+
* - 扩码走变更流程追加;本表是码身份唯一源——契约文档诊断码目录(interface-contract.md)
|
|
14
|
+
* 与运行时发射不得出现表外码,test/diagnostic-codes-parity.test.mjs 双向钉死
|
|
15
|
+
* (OpenSpec 的 agent-contract 无 parity 测试,此为反超点)。
|
|
16
|
+
*
|
|
17
|
+
* 零依赖叶子模块(不 import 仓内任何其他 src 模块,防环)。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 码 → { surface: 消费命令面, exit: 该失败面的退出码语义, trigger: 触发条件 }。
|
|
22
|
+
* surface 取值 ∈ gate / derive / progress show(CLI 子命令视角)。
|
|
23
|
+
*/
|
|
24
|
+
export const DIAGNOSTIC_CODES = Object.freeze({
|
|
25
|
+
// ── 信封级(无法核验,exit 2)──
|
|
26
|
+
db_missing: {
|
|
27
|
+
surface: ['gate', 'derive', 'progress show'],
|
|
28
|
+
exit: 2,
|
|
29
|
+
trigger: '进度库不存在(只读契约:不为其建库,fail-closed)',
|
|
30
|
+
},
|
|
31
|
+
change_not_found: {
|
|
32
|
+
surface: ['gate', 'derive'],
|
|
33
|
+
exit: 2,
|
|
34
|
+
trigger: '变更名在进度库无记录',
|
|
35
|
+
},
|
|
36
|
+
unknown_facet: {
|
|
37
|
+
surface: ['derive'],
|
|
38
|
+
exit: 2,
|
|
39
|
+
trigger: 'facet ∉ FACETS 白名单(用法错)',
|
|
40
|
+
},
|
|
41
|
+
internal_error: {
|
|
42
|
+
surface: ['gate', 'derive', 'progress show'],
|
|
43
|
+
exit: 2,
|
|
44
|
+
trigger: '内部异常兜底(stdout 仍为合法 JSON)',
|
|
45
|
+
},
|
|
46
|
+
// ── check 级(事实性阻断,exit 1)──
|
|
47
|
+
artifacts_invalid: {
|
|
48
|
+
surface: ['gate', 'derive'],
|
|
49
|
+
exit: 1,
|
|
50
|
+
trigger: 'runValidators 失败(gate artifacts check 与 derive artifacts facet 同源)',
|
|
51
|
+
},
|
|
52
|
+
design_file_ref_invalid: {
|
|
53
|
+
surface: ['gate'],
|
|
54
|
+
exit: 1,
|
|
55
|
+
trigger: 'design 文件清单行级核验失败(直承 design-facts.js 既有稳定码)',
|
|
56
|
+
},
|
|
57
|
+
transition_blocked: {
|
|
58
|
+
surface: ['gate'],
|
|
59
|
+
exit: 1,
|
|
60
|
+
trigger: 'checkTransition 不允许(含 failed_post_check 门控)',
|
|
61
|
+
},
|
|
62
|
+
execute_evidence_unchanged: {
|
|
63
|
+
surface: ['gate', 'derive'],
|
|
64
|
+
exit: 1,
|
|
65
|
+
trigger: 'base..head 无代码变更(checkbox ≠ implementation)',
|
|
66
|
+
},
|
|
67
|
+
task_reviews_invalid: {
|
|
68
|
+
surface: ['gate', 'derive'],
|
|
69
|
+
exit: 1,
|
|
70
|
+
trigger: 'validateTaskReviews 失败(review.json 缺失/schema/verdict=fail)',
|
|
71
|
+
},
|
|
72
|
+
verify_test_failed: {
|
|
73
|
+
surface: ['gate', 'derive'],
|
|
74
|
+
exit: 1,
|
|
75
|
+
trigger: 'commands.test 实测失败',
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/** gate check id → 失败码(六 check 全覆盖;表外 id 返回 undefined,调用方不挂键)。 */
|
|
80
|
+
const CHECK_ID_TO_CODE = Object.freeze({
|
|
81
|
+
artifacts: 'artifacts_invalid',
|
|
82
|
+
'design-file-list': 'design_file_ref_invalid',
|
|
83
|
+
transition: 'transition_blocked',
|
|
84
|
+
'execute-evidence': 'execute_evidence_unchanged',
|
|
85
|
+
'task-reviews': 'task_reviews_invalid',
|
|
86
|
+
'verify-test': 'verify_test_failed',
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {string} checkId - gate check id(artifacts/design-file-list/transition/execute-evidence/task-reviews/verify-test)
|
|
91
|
+
* @returns {string|undefined} 对应失败码;表外 id 返回 undefined(信封不挂 code 键)
|
|
92
|
+
*/
|
|
93
|
+
export function checkCode(checkId) {
|
|
94
|
+
return CHECK_ID_TO_CODE[checkId];
|
|
95
|
+
}
|
|
@@ -33,6 +33,9 @@ import { pruneTimestampedEntries } from './runtime-hygiene.js';
|
|
|
33
33
|
import { CHECK_SEVERITY } from './constants.js';
|
|
34
34
|
import { checkPlatformManaged, isSelfReferentialSpecRoot, PLATFORM_MANAGED_FILENAME, QUICK_SID_RE, resolveRuntimeRoot } from './run/shared.js';
|
|
35
35
|
import { readFrictionLedger } from './friction-ledger.js';
|
|
36
|
+
// FR 索引消费(2026-09-18-fr-index-l1 L1,D-007):D14 第四检查——epoch 后归档的索引在场+取代完整。
|
|
37
|
+
// fr-index 是纯 fs 叶子链(仅 decision-distill 底座),doctor 静态引入无环。
|
|
38
|
+
import { FR_INDEX_EPOCH, scanFrIndex, parseChangeRequirements } from './fr-index.js';
|
|
36
39
|
|
|
37
40
|
// db 角色标签
|
|
38
41
|
const DB_ROLE = {
|
|
@@ -1125,6 +1128,170 @@ export function detectSelfMaintenanceTax(cwd, authoritySpecDir) {
|
|
|
1125
1128
|
}
|
|
1126
1129
|
}
|
|
1127
1130
|
|
|
1131
|
+
/**
|
|
1132
|
+
* D14 归档完整性重扫(archive_integrity,2026-09-17 OpenSpec validate --archived 对标)。
|
|
1133
|
+
*
|
|
1134
|
+
* 为什么需要:归档前的完成门禁很严(review.json 驱动勾选 / Wave 断言 / verify 对账),
|
|
1135
|
+
* 但目录搬进 changes/archive/ 后**没有任何机制回头看**——手工挪目录绕流程的自愈只在
|
|
1136
|
+
* 下次 CLI 触碰时生效,归档后手改 tasks.md / 删 plan.md 无人发现。本维度补事后重扫:
|
|
1137
|
+
* ① 任务 checkbox 全勾(tasks.md 优先,回退 plan.md——读侧兼容同 D5/progress 口径;
|
|
1138
|
+
* 归档后 runtime 工件已 prune,review.json 不可用,checkbox 是归档目录里唯一持久的完成面)
|
|
1139
|
+
* ② plan.md 在场(archive 自愈 findAlreadyArchivedDir 的基准文件)
|
|
1140
|
+
* ③ 注册表文件存在但读不了 → finding(OpenSpec #205 教训:读不了 ≠ 没任务,
|
|
1141
|
+
* 检查不许在什么都没看的情况下静默通过)
|
|
1142
|
+
* 语义:WARNING advisory 不阻断、只读无修复(钩子/门禁挂载是后续独立策略裁决);
|
|
1143
|
+
* 无 archive 目录 / 无 task 行 → pass(无任务视为完备,同 OpenSpec "nothing to complete")。
|
|
1144
|
+
*/
|
|
1145
|
+
function detectArchiveIntegrity(cwd, authoritySpecDir) {
|
|
1146
|
+
const base = { name: 'archive_integrity', label: '归档完整性重扫', safe_actions: [] };
|
|
1147
|
+
if (!authoritySpecDir) {
|
|
1148
|
+
return { ...base, pass: true, severity: null, findings: ['无权威 specDir,跳过'] };
|
|
1149
|
+
}
|
|
1150
|
+
const archiveDir = join(authoritySpecDir, 'changes', 'archive');
|
|
1151
|
+
if (!existsSync(archiveDir)) {
|
|
1152
|
+
return { ...base, pass: true, severity: null, findings: ['无 changes/archive 目录(未归档/新仓),跳过'] };
|
|
1153
|
+
}
|
|
1154
|
+
let entries;
|
|
1155
|
+
try {
|
|
1156
|
+
entries = readdirSync(archiveDir, { withFileTypes: true });
|
|
1157
|
+
} catch (e) {
|
|
1158
|
+
return { ...base, pass: false, severity: CHECK_SEVERITY.WARNING, findings: [`changes/archive 目录不可读(${e?.message || e})——人工排查,不静默跳过`] };
|
|
1159
|
+
}
|
|
1160
|
+
const names = entries
|
|
1161
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith('.'))
|
|
1162
|
+
.map((d) => d.name)
|
|
1163
|
+
.sort();
|
|
1164
|
+
if (names.length === 0) {
|
|
1165
|
+
return { ...base, pass: true, severity: null, findings: ['changes/archive 为空(无已归档变更)'], archive_count: 0, offenders: [] };
|
|
1166
|
+
}
|
|
1167
|
+
const offenders = [];
|
|
1168
|
+
// FR 索引面(D14 第四检查数据源,循环外一次扫描复用;fr/ 不存在=仓未启用,全跳不误报)
|
|
1169
|
+
const frKnowledgeRoot = join(authoritySpecDir, 'knowledge');
|
|
1170
|
+
const frEnabled = existsSync(join(frKnowledgeRoot, 'fr'));
|
|
1171
|
+
const frEntries = frEnabled ? scanFrIndex(frKnowledgeRoot) : [];
|
|
1172
|
+
for (const name of names) {
|
|
1173
|
+
const dir = join(archiveDir, name);
|
|
1174
|
+
const reasons = [];
|
|
1175
|
+
// ② plan.md 在场性
|
|
1176
|
+
const planPath = join(dir, 'plan.md');
|
|
1177
|
+
const hasPlan = existsSync(planPath);
|
|
1178
|
+
if (!hasPlan) reasons.push('plan.md 缺失(归档自愈基准文件)');
|
|
1179
|
+
// ①③ 任务注册表:tasks.md 优先回退 plan.md;存在但不可读必须显式报(区分「缺席」与「读不了」)。
|
|
1180
|
+
// 2026-08-20-task-truth-unify 之前的旧归档完成态勾在 plan.md(tasks.md 是留给后人的未勾任务清单)——
|
|
1181
|
+
// tasks.md 有 checkbox 行但 0 勾、而 plan.md 有 ≥1 勾时,完成证据在 plan.md,切 plan.md 为完成源
|
|
1182
|
+
// (真实仓首扫实证:2026-05-31-sqlite-migration tasks 0/15 + plan 15/15,43 份 offenders 大半为此误报)。
|
|
1183
|
+
const tasksPath = join(dir, 'tasks.md');
|
|
1184
|
+
const readRegistry = (p) => {
|
|
1185
|
+
const content = readFileSync(p, 'utf8');
|
|
1186
|
+
// 与 progress.js readPlanCheckboxStatus 同正则(task- 前缀锚定,x/X 计勾)
|
|
1187
|
+
const re = /^\s*[-*]\s+\[([ xX])\]\s+task-\d+/gm;
|
|
1188
|
+
let t = 0;
|
|
1189
|
+
let c = 0;
|
|
1190
|
+
let m;
|
|
1191
|
+
while ((m = re.exec(content)) !== null) {
|
|
1192
|
+
t++;
|
|
1193
|
+
if (m[1] !== ' ') c++;
|
|
1194
|
+
}
|
|
1195
|
+
return { total: t, checked: c };
|
|
1196
|
+
};
|
|
1197
|
+
const regPath = existsSync(tasksPath) ? tasksPath : hasPlan ? planPath : null;
|
|
1198
|
+
if (regPath) {
|
|
1199
|
+
try {
|
|
1200
|
+
let stat = readRegistry(regPath);
|
|
1201
|
+
if (regPath === tasksPath && stat.total > 0 && stat.checked === 0 && hasPlan) {
|
|
1202
|
+
try {
|
|
1203
|
+
const planStat = readRegistry(planPath);
|
|
1204
|
+
if (planStat.total > 0 && planStat.checked > 0) stat = planStat;
|
|
1205
|
+
} catch { /* plan.md 不可读 → 维持 tasks.md 0 勾口径(本就报未勾) */ }
|
|
1206
|
+
}
|
|
1207
|
+
if (stat.total > 0 && stat.checked < stat.total) {
|
|
1208
|
+
reasons.push(`${stat.total - stat.checked} 个任务未勾(${stat.checked}/${stat.total})`);
|
|
1209
|
+
}
|
|
1210
|
+
} catch (e) {
|
|
1211
|
+
reasons.push(`任务注册表不可读(${relative(archiveDir, regPath)}:${e?.code || e?.message || e})`);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
// ── ④ FR 索引在场与取代完整(2026-09-18-fr-index-l1 L1,D-007:epoch 分界第四检查)──
|
|
1215
|
+
// 豁免面:fr/ 目录不存在(仓未启用索引,全跳);无日期前缀/epoch 前归档(存量不回填);
|
|
1216
|
+
// quick-* 与无 requirements.md(quick/scale:small 无索引义务)。违者并入 offenders(豁免走既有账本)。
|
|
1217
|
+
if (frEnabled) {
|
|
1218
|
+
const dm = name.match(/^(\d{4}-\d{2}-\d{2})-/);
|
|
1219
|
+
if (dm && dm[1] >= FR_INDEX_EPOCH && existsSync(join(dir, 'requirements.md'))) {
|
|
1220
|
+
if (!frEntries.some((e) => e.change === name)) {
|
|
1221
|
+
reasons.push('FR 索引缺失(epoch 后归档应有索引条目——归档时 indexRequirements 未落,查 archive 步降级日志)');
|
|
1222
|
+
} else {
|
|
1223
|
+
try {
|
|
1224
|
+
const reqParsed = parseChangeRequirements(dir);
|
|
1225
|
+
const refIds = new Set();
|
|
1226
|
+
for (const fr of reqParsed.frs) for (const r of fr.supersedes) refIds.add(r);
|
|
1227
|
+
for (const refId of refIds) {
|
|
1228
|
+
if (!frEntries.some((e) => e.id === refId && e.supersededBy)) {
|
|
1229
|
+
reasons.push(`承接未翻取代(${refId} 被引用但索引中未标 superseded)`);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
} catch { /* requirements 解析失败归任务注册表口径,此处不重复报 */ }
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
if (reasons.length > 0) offenders.push({ name, reasons });
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// 豁免账本(2026-09-17 收尾裁决):老归档历史形态(早于 plan.md 流程的手工归档/远古命名)逐份豁免——
|
|
1240
|
+
// 照 docs-check skip 清单先例(39 处历史快照冻结),不伪造 plan.md 篡改历史;账本外继续亮红;
|
|
1241
|
+
// 纪律:新欠账禁止入账(入账须变更流程裁决);账本解析失败按无豁免 fail-safe。
|
|
1242
|
+
const exemptPath = join(authoritySpecDir, 'archive-integrity-exempt.yaml');
|
|
1243
|
+
let exemptNames = new Set();
|
|
1244
|
+
const exemptNotes = [];
|
|
1245
|
+
if (existsSync(exemptPath)) {
|
|
1246
|
+
try {
|
|
1247
|
+
const doc = jsYaml.load(readFileSync(exemptPath, 'utf8'));
|
|
1248
|
+
const entries = Array.isArray(doc && doc.entries) ? doc.entries : [];
|
|
1249
|
+
exemptNames = new Set(entries.filter((e) => e && typeof e.name === 'string').map((e) => e.name));
|
|
1250
|
+
} catch (e) {
|
|
1251
|
+
exemptNotes.push(`豁免账本解析失败(${e?.message || e})——按无豁免处理,红灯保持`);
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
const exempted = offenders.filter((o) => exemptNames.has(o.name));
|
|
1255
|
+
const effective = offenders.filter((o) => !exemptNames.has(o.name));
|
|
1256
|
+
const staleExemptions = [...exemptNames].filter((n) => !offenders.some((o) => o.name === n));
|
|
1257
|
+
if (exempted.length > 0) {
|
|
1258
|
+
exemptNotes.push(`${exempted.length} 份豁免在案(历史形态,账本 archive-integrity-exempt.yaml)`);
|
|
1259
|
+
}
|
|
1260
|
+
if (staleExemptions.length > 0) {
|
|
1261
|
+
exemptNotes.push(`${staleExemptions.length} 条豁免已失效(对应归档不再欠账)可清理:${staleExemptions.slice(0, 5).join(', ')}${staleExemptions.length > 5 ? ' …' : ''}`);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
if (effective.length === 0) {
|
|
1265
|
+
const findings = [`${names.length} 份归档完整(任务全勾 + plan.md 在场${exempted.length > 0 ? `,另有 ${exempted.length} 份豁免在案` : ''})`];
|
|
1266
|
+
findings.push(...exemptNotes);
|
|
1267
|
+
return {
|
|
1268
|
+
...base,
|
|
1269
|
+
pass: true,
|
|
1270
|
+
severity: null,
|
|
1271
|
+
findings,
|
|
1272
|
+
archive_count: names.length,
|
|
1273
|
+
offenders: [],
|
|
1274
|
+
exempted_count: exempted.length,
|
|
1275
|
+
stale_exemptions: staleExemptions,
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
const findings = effective.slice(0, 10).map((o) => `${o.name}:${o.reasons.join(';')}`);
|
|
1279
|
+
if (effective.length > 10) findings.push(`…还有 ${effective.length - 10} 份`);
|
|
1280
|
+
findings.push(...exemptNotes);
|
|
1281
|
+
findings.push('语义:归档=全勾终态;未勾/缺文件多为归档后手改或手工搬目录绕流程——git log 定位改动来源,doctor 只读不自动修');
|
|
1282
|
+
return {
|
|
1283
|
+
...base,
|
|
1284
|
+
pass: false,
|
|
1285
|
+
severity: CHECK_SEVERITY.WARNING,
|
|
1286
|
+
findings,
|
|
1287
|
+
safe_actions: [{ dimension: 'archive_integrity', action: 'manual_inspect', risk: 'manual_edit', rationale: '归档完整性破损', next_step: '逐份核对 offenders:git log --follow .sillyspec/changes/archive/<name>/tasks.md 定位改动者与意图,确属历史遗留走变更流程裁决入豁免账本(新欠账禁止入账),勿改语义' }],
|
|
1288
|
+
archive_count: names.length,
|
|
1289
|
+
offenders: effective,
|
|
1290
|
+
exempted_count: exempted.length,
|
|
1291
|
+
stale_exemptions: staleExemptions,
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1128
1295
|
/**
|
|
1129
1296
|
* renderDoctorSummary(FR-01,全新输出契约——2026-09-09-doctor-noai):逐维
|
|
1130
1297
|
* ✅/⚠️/❌ + label + findings 首行 + safe_actions 提示行。顶层非 --json 命令与
|
|
@@ -1177,7 +1344,9 @@ export async function runDoctorDiagnostics({ cwd }) {
|
|
|
1177
1344
|
const applyManifestDrift = detectApplyManifestDrift(cwd, authoritySpecDir)
|
|
1178
1345
|
// task-02(2026-09-15-tax-governance):自维护税面(活跃 tally 列示 + 台账聚合 + 税重阈值,只读)
|
|
1179
1346
|
const selfMaintenanceTax = detectSelfMaintenanceTax(cwd, authoritySpecDir)
|
|
1180
|
-
|
|
1347
|
+
// D14(2026-09-17 archive-rescan,OpenSpec validate --archived 对标):归档完整性事后重扫(只读 WARNING)
|
|
1348
|
+
const archiveIntegrity = detectArchiveIntegrity(cwd, authoritySpecDir)
|
|
1349
|
+
const dimensions = [multiDb, pointerHealth, changesSplit, changeDb, executeMismatch, docBloat, repoNativeChain, lifecycleDoc, worktreeHealth, buildEnv, mcpEndpoints, applyManifestDrift, selfMaintenanceTax, archiveIntegrity];
|
|
1181
1350
|
|
|
1182
1351
|
return {
|
|
1183
1352
|
dimensions,
|