sillyspec 3.28.11 → 3.28.12
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/.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 +27 -5
- package/src/commit-guard.js +119 -0
- package/src/config-schema.js +2 -0
- package/src/diagnostic-codes.js +95 -0
- package/src/doctor-diagnostics.js +141 -1
- package/src/machine-interface.js +37 -0
- package/src/run/verify-quality-scan.js +151 -5
- package/src/stage-contract.js +426 -2
- package/src/stage-review-checklist.js +13 -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 +447 -8
package/.husky/pre-push
CHANGED
|
@@ -19,4 +19,7 @@ if [ $? -ne 0 ]; then
|
|
|
19
19
|
echo "❌ pre-push: docs gate failed, aborting push"
|
|
20
20
|
exit 1
|
|
21
21
|
fi
|
|
22
|
+
# archive_integrity advisory(2026-09-17 D14 收尾裁决第一段):零阻断只露出——
|
|
23
|
+
# 欠账清零并稳定后另裁第二段(镜像 docs-gate 的 ratchet 棘轮,只拦新增劣化)。
|
|
24
|
+
node bin/sillyspec.js doctor --json 2>/dev/null | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{const j=JSON.parse(s);const d=(j.dimensions||[]).find(x=>x&&x.name==='archive_integrity');if(d&&d.pass===false){console.log('⚠️ [advisory·不阻断] archive_integrity: '+String(d.evidence).split(';')[0]);}}catch(e){/* doctor JSON 异常不干扰 push */}})"
|
|
22
25
|
echo "✅ pre-push: lint + test + docs gate passed"
|
package/AGENTS.md
CHANGED
|
@@ -22,5 +22,5 @@
|
|
|
22
22
|
15. **quicklog 结构化落盘**:末步 `--done` 用四参数 `--req/--cause/--solution/--result`(CLI 合成结构化 output 并自动提取标题/四段分行);文件括注用 `--file-notes "path::注 || path2"`。骨架由 CLI 接管,`--done` 后按需核对即可,勿手拼模板。
|
|
23
23
|
16. **代码可能随时在修改**(多 agent 并行),Edit 前重跑 + 查最新态;破坏性 git op 前先备份。
|
|
24
24
|
17. **不奉承用户**,禁止"你说得对"类话术,直接给结论、依据、方案。
|
|
25
|
-
18. **禁止目录级 git add / git add -A**(多会话共享仓):.sillyspec/ 等目录里有并行会话的进行中文件与已提交文档,目录级暂存会夹带他者改动甚至误删已提交文件(2026-09-10 实证)。提交一律用显式 pathspec
|
|
25
|
+
18. **禁止目录级 git add / git add -A**(多会话共享仓):.sillyspec/ 等目录里有并行会话的进行中文件与已提交文档,目录级暂存会夹带他者改动甚至误删已提交文件(2026-09-10 实证)。提交一律用显式 pathspec,且 **add 与 commit 用同一份清单**:`git commit -m "..." -- 文件1 文件2`——带 pathspec 的 commit 只提交这些路径,他侧已 staged 的条目既不被带走、也原样留给对方;裸 `git commit` 提交的是整个共享暂存区,他会话 add 过的文件会被一并扫入(2026-09-17 实证:add 带 pathspec 但 commit 裸跑,并行会话 5 个 staged 文件被带入)。核对暂存面固定用 `git diff --cached --name-only` 全量读取,禁用 `git status | grep -v` 过滤式核对(过滤正是致盲原因);核对与提交分开两条命令执行,不得链在同一命令里(链行没有拦截点)。`.husky/pre-commit` 的 commit-guard 会警告超声明面的 staged 文件(只警告不阻断,见 src/commit-guard.js)。
|
|
26
26
|
19. **会话启动先立身份**:agent 会话启动时 `export SILLYSPEC_SESSION_ID=<唯一标识>`(如 agent 名+任务名)——多会话并行时 change 所有权判定(apply / cleanup / assess 自动 apply / 归档对他会话活跃变更的接管拒绝)依赖此标识;缺省降级为 `anon@<主机名>` 机器级标识(只拦他机,同机并行不设防)。部分 harness 的 Bash 工具 shell 状态不持久(env 随命令丢失),此时接管类命令每条显式带 flag 回退:`sillyspec worktree apply <change> --session <唯一标识>`(`--session` 优先级高于 env)。
|
package/package.json
CHANGED
|
@@ -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,6 +312,15 @@ 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
|
+
//
|
|
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 一词致口径分裂)。
|
|
315
324
|
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?://',
|
|
@@ -321,7 +330,10 @@ const RECEIPT_SOURCE_CROSS_LAYER_RE = new RegExp([
|
|
|
321
330
|
'\\b(?:nc|netcat|telnet|socat)\\b',
|
|
322
331
|
].join('|'), 'i')
|
|
323
332
|
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
|
-
|
|
333
|
+
/** CLI 机器段来源标记(与 verify-probes.js SMOKE_RECEIPT_SOURCE_MARK / task-01 记录 source 同值,双侧同步) */
|
|
334
|
+
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 用)──
|
|
@@ -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
|
+
}
|
|
@@ -1125,6 +1125,144 @@ export function detectSelfMaintenanceTax(cwd, authoritySpecDir) {
|
|
|
1125
1125
|
}
|
|
1126
1126
|
}
|
|
1127
1127
|
|
|
1128
|
+
/**
|
|
1129
|
+
* D14 归档完整性重扫(archive_integrity,2026-09-17 OpenSpec validate --archived 对标)。
|
|
1130
|
+
*
|
|
1131
|
+
* 为什么需要:归档前的完成门禁很严(review.json 驱动勾选 / Wave 断言 / verify 对账),
|
|
1132
|
+
* 但目录搬进 changes/archive/ 后**没有任何机制回头看**——手工挪目录绕流程的自愈只在
|
|
1133
|
+
* 下次 CLI 触碰时生效,归档后手改 tasks.md / 删 plan.md 无人发现。本维度补事后重扫:
|
|
1134
|
+
* ① 任务 checkbox 全勾(tasks.md 优先,回退 plan.md——读侧兼容同 D5/progress 口径;
|
|
1135
|
+
* 归档后 runtime 工件已 prune,review.json 不可用,checkbox 是归档目录里唯一持久的完成面)
|
|
1136
|
+
* ② plan.md 在场(archive 自愈 findAlreadyArchivedDir 的基准文件)
|
|
1137
|
+
* ③ 注册表文件存在但读不了 → finding(OpenSpec #205 教训:读不了 ≠ 没任务,
|
|
1138
|
+
* 检查不许在什么都没看的情况下静默通过)
|
|
1139
|
+
* 语义:WARNING advisory 不阻断、只读无修复(钩子/门禁挂载是后续独立策略裁决);
|
|
1140
|
+
* 无 archive 目录 / 无 task 行 → pass(无任务视为完备,同 OpenSpec "nothing to complete")。
|
|
1141
|
+
*/
|
|
1142
|
+
function detectArchiveIntegrity(cwd, authoritySpecDir) {
|
|
1143
|
+
const base = { name: 'archive_integrity', label: '归档完整性重扫', safe_actions: [] };
|
|
1144
|
+
if (!authoritySpecDir) {
|
|
1145
|
+
return { ...base, pass: true, severity: null, findings: ['无权威 specDir,跳过'] };
|
|
1146
|
+
}
|
|
1147
|
+
const archiveDir = join(authoritySpecDir, 'changes', 'archive');
|
|
1148
|
+
if (!existsSync(archiveDir)) {
|
|
1149
|
+
return { ...base, pass: true, severity: null, findings: ['无 changes/archive 目录(未归档/新仓),跳过'] };
|
|
1150
|
+
}
|
|
1151
|
+
let entries;
|
|
1152
|
+
try {
|
|
1153
|
+
entries = readdirSync(archiveDir, { withFileTypes: true });
|
|
1154
|
+
} catch (e) {
|
|
1155
|
+
return { ...base, pass: false, severity: CHECK_SEVERITY.WARNING, findings: [`changes/archive 目录不可读(${e?.message || e})——人工排查,不静默跳过`] };
|
|
1156
|
+
}
|
|
1157
|
+
const names = entries
|
|
1158
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith('.'))
|
|
1159
|
+
.map((d) => d.name)
|
|
1160
|
+
.sort();
|
|
1161
|
+
if (names.length === 0) {
|
|
1162
|
+
return { ...base, pass: true, severity: null, findings: ['changes/archive 为空(无已归档变更)'], archive_count: 0, offenders: [] };
|
|
1163
|
+
}
|
|
1164
|
+
const offenders = [];
|
|
1165
|
+
for (const name of names) {
|
|
1166
|
+
const dir = join(archiveDir, name);
|
|
1167
|
+
const reasons = [];
|
|
1168
|
+
// ② plan.md 在场性
|
|
1169
|
+
const planPath = join(dir, 'plan.md');
|
|
1170
|
+
const hasPlan = existsSync(planPath);
|
|
1171
|
+
if (!hasPlan) reasons.push('plan.md 缺失(归档自愈基准文件)');
|
|
1172
|
+
// ①③ 任务注册表:tasks.md 优先回退 plan.md;存在但不可读必须显式报(区分「缺席」与「读不了」)。
|
|
1173
|
+
// 2026-08-20-task-truth-unify 之前的旧归档完成态勾在 plan.md(tasks.md 是留给后人的未勾任务清单)——
|
|
1174
|
+
// tasks.md 有 checkbox 行但 0 勾、而 plan.md 有 ≥1 勾时,完成证据在 plan.md,切 plan.md 为完成源
|
|
1175
|
+
// (真实仓首扫实证:2026-05-31-sqlite-migration tasks 0/15 + plan 15/15,43 份 offenders 大半为此误报)。
|
|
1176
|
+
const tasksPath = join(dir, 'tasks.md');
|
|
1177
|
+
const readRegistry = (p) => {
|
|
1178
|
+
const content = readFileSync(p, 'utf8');
|
|
1179
|
+
// 与 progress.js readPlanCheckboxStatus 同正则(task- 前缀锚定,x/X 计勾)
|
|
1180
|
+
const re = /^\s*[-*]\s+\[([ xX])\]\s+task-\d+/gm;
|
|
1181
|
+
let t = 0;
|
|
1182
|
+
let c = 0;
|
|
1183
|
+
let m;
|
|
1184
|
+
while ((m = re.exec(content)) !== null) {
|
|
1185
|
+
t++;
|
|
1186
|
+
if (m[1] !== ' ') c++;
|
|
1187
|
+
}
|
|
1188
|
+
return { total: t, checked: c };
|
|
1189
|
+
};
|
|
1190
|
+
const regPath = existsSync(tasksPath) ? tasksPath : hasPlan ? planPath : null;
|
|
1191
|
+
if (regPath) {
|
|
1192
|
+
try {
|
|
1193
|
+
let stat = readRegistry(regPath);
|
|
1194
|
+
if (regPath === tasksPath && stat.total > 0 && stat.checked === 0 && hasPlan) {
|
|
1195
|
+
try {
|
|
1196
|
+
const planStat = readRegistry(planPath);
|
|
1197
|
+
if (planStat.total > 0 && planStat.checked > 0) stat = planStat;
|
|
1198
|
+
} catch { /* plan.md 不可读 → 维持 tasks.md 0 勾口径(本就报未勾) */ }
|
|
1199
|
+
}
|
|
1200
|
+
if (stat.total > 0 && stat.checked < stat.total) {
|
|
1201
|
+
reasons.push(`${stat.total - stat.checked} 个任务未勾(${stat.checked}/${stat.total})`);
|
|
1202
|
+
}
|
|
1203
|
+
} catch (e) {
|
|
1204
|
+
reasons.push(`任务注册表不可读(${relative(archiveDir, regPath)}:${e?.code || e?.message || e})`);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
if (reasons.length > 0) offenders.push({ name, reasons });
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// 豁免账本(2026-09-17 收尾裁决):老归档历史形态(早于 plan.md 流程的手工归档/远古命名)逐份豁免——
|
|
1211
|
+
// 照 docs-check skip 清单先例(39 处历史快照冻结),不伪造 plan.md 篡改历史;账本外继续亮红;
|
|
1212
|
+
// 纪律:新欠账禁止入账(入账须变更流程裁决);账本解析失败按无豁免 fail-safe。
|
|
1213
|
+
const exemptPath = join(authoritySpecDir, 'archive-integrity-exempt.yaml');
|
|
1214
|
+
let exemptNames = new Set();
|
|
1215
|
+
const exemptNotes = [];
|
|
1216
|
+
if (existsSync(exemptPath)) {
|
|
1217
|
+
try {
|
|
1218
|
+
const doc = jsYaml.load(readFileSync(exemptPath, 'utf8'));
|
|
1219
|
+
const entries = Array.isArray(doc && doc.entries) ? doc.entries : [];
|
|
1220
|
+
exemptNames = new Set(entries.filter((e) => e && typeof e.name === 'string').map((e) => e.name));
|
|
1221
|
+
} catch (e) {
|
|
1222
|
+
exemptNotes.push(`豁免账本解析失败(${e?.message || e})——按无豁免处理,红灯保持`);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
const exempted = offenders.filter((o) => exemptNames.has(o.name));
|
|
1226
|
+
const effective = offenders.filter((o) => !exemptNames.has(o.name));
|
|
1227
|
+
const staleExemptions = [...exemptNames].filter((n) => !offenders.some((o) => o.name === n));
|
|
1228
|
+
if (exempted.length > 0) {
|
|
1229
|
+
exemptNotes.push(`${exempted.length} 份豁免在案(历史形态,账本 archive-integrity-exempt.yaml)`);
|
|
1230
|
+
}
|
|
1231
|
+
if (staleExemptions.length > 0) {
|
|
1232
|
+
exemptNotes.push(`${staleExemptions.length} 条豁免已失效(对应归档不再欠账)可清理:${staleExemptions.slice(0, 5).join(', ')}${staleExemptions.length > 5 ? ' …' : ''}`);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
if (effective.length === 0) {
|
|
1236
|
+
const findings = [`${names.length} 份归档完整(任务全勾 + plan.md 在场${exempted.length > 0 ? `,另有 ${exempted.length} 份豁免在案` : ''})`];
|
|
1237
|
+
findings.push(...exemptNotes);
|
|
1238
|
+
return {
|
|
1239
|
+
...base,
|
|
1240
|
+
pass: true,
|
|
1241
|
+
severity: null,
|
|
1242
|
+
findings,
|
|
1243
|
+
archive_count: names.length,
|
|
1244
|
+
offenders: [],
|
|
1245
|
+
exempted_count: exempted.length,
|
|
1246
|
+
stale_exemptions: staleExemptions,
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
const findings = effective.slice(0, 10).map((o) => `${o.name}:${o.reasons.join(';')}`);
|
|
1250
|
+
if (effective.length > 10) findings.push(`…还有 ${effective.length - 10} 份`);
|
|
1251
|
+
findings.push(...exemptNotes);
|
|
1252
|
+
findings.push('语义:归档=全勾终态;未勾/缺文件多为归档后手改或手工搬目录绕流程——git log 定位改动来源,doctor 只读不自动修');
|
|
1253
|
+
return {
|
|
1254
|
+
...base,
|
|
1255
|
+
pass: false,
|
|
1256
|
+
severity: CHECK_SEVERITY.WARNING,
|
|
1257
|
+
findings,
|
|
1258
|
+
safe_actions: [{ dimension: 'archive_integrity', action: 'manual_inspect', risk: 'manual_edit', rationale: '归档完整性破损', next_step: '逐份核对 offenders:git log --follow .sillyspec/changes/archive/<name>/tasks.md 定位改动者与意图,确属历史遗留走变更流程裁决入豁免账本(新欠账禁止入账),勿改语义' }],
|
|
1259
|
+
archive_count: names.length,
|
|
1260
|
+
offenders: effective,
|
|
1261
|
+
exempted_count: exempted.length,
|
|
1262
|
+
stale_exemptions: staleExemptions,
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1128
1266
|
/**
|
|
1129
1267
|
* renderDoctorSummary(FR-01,全新输出契约——2026-09-09-doctor-noai):逐维
|
|
1130
1268
|
* ✅/⚠️/❌ + label + findings 首行 + safe_actions 提示行。顶层非 --json 命令与
|
|
@@ -1177,7 +1315,9 @@ export async function runDoctorDiagnostics({ cwd }) {
|
|
|
1177
1315
|
const applyManifestDrift = detectApplyManifestDrift(cwd, authoritySpecDir)
|
|
1178
1316
|
// task-02(2026-09-15-tax-governance):自维护税面(活跃 tally 列示 + 台账聚合 + 税重阈值,只读)
|
|
1179
1317
|
const selfMaintenanceTax = detectSelfMaintenanceTax(cwd, authoritySpecDir)
|
|
1180
|
-
|
|
1318
|
+
// D14(2026-09-17 archive-rescan,OpenSpec validate --archived 对标):归档完整性事后重扫(只读 WARNING)
|
|
1319
|
+
const archiveIntegrity = detectArchiveIntegrity(cwd, authoritySpecDir)
|
|
1320
|
+
const dimensions = [multiDb, pointerHealth, changesSplit, changeDb, executeMismatch, docBloat, repoNativeChain, lifecycleDoc, worktreeHealth, buildEnv, mcpEndpoints, applyManifestDrift, selfMaintenanceTax, archiveIntegrity];
|
|
1181
1321
|
|
|
1182
1322
|
return {
|
|
1183
1323
|
dimensions,
|