sillyspec 3.29.4 → 3.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/sillyspec-auto/SKILL.md +1 -1
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +2 -2
- package/.claude/skills/sillyspec-commit/SKILL.md +1 -1
- package/.claude/skills/sillyspec-flow/SKILL.md +57 -0
- package/.claude/skills/sillyspec-quick/SKILL.md +7 -105
- package/AGENTS.md +38 -20
- package/SKILL.md +9 -3
- package/assets/command-cards/flow.md +32 -0
- package/assets/command-cards/run-archive.md +26 -0
- package/assets/command-cards/run-brainstorm.md +33 -0
- package/assets/command-cards/run-execute.md +29 -0
- package/assets/command-cards/run-plan.md +27 -0
- package/assets/command-cards/run-quick.md +17 -0
- package/assets/command-cards/run-verify.md +29 -0
- package/assets/command-cards/status.md +27 -0
- package/package.json +2 -1
- package/packages/dashboard/server/executor.js +2 -1
- package/packages/dashboard/server/parser.js +7 -7
- package/r7-test-env/README-R7-TEST.md +93 -0
- package/r7-test-env/demo-project/lib/calc.js +6 -0
- package/r7-test-env/demo-project/package.json +1 -0
- package/r7-test-env/demo-project/test.js +10 -0
- package/r7-test-env/prompt-R7-SMOKE.md +41 -0
- package/r7-test-env/sillyspec-r7.cmd +3 -0
- package/round4/account.mjs +98 -0
- package/round4/b1-analyze-task08.mjs +40 -0
- package/round4/b1-b1-target-validation.md +32 -0
- package/round4/b3a-prompt-audit-result.md +66 -0
- package/round4/b3a-prompt-audit.mjs +89 -0
- package/round4/brief-autocompact.md +34 -0
- package/round4/brief-session-replay.md +50 -0
- package/round4/deep-read-synthesis.md +38 -0
- package/round4/extract-full.mjs +38 -0
- package/round4/extract-timeline.mjs +43 -0
- package/round4/find-sessions.mjs +13 -0
- package/round4/full-r4-l.md +4707 -0
- package/round4/full-r4-o-l.md +4300 -0
- package/round4/full-r4-os.md +1681 -0
- package/round4/full-r4-sf.md +4255 -0
- package/round4/full-r4-sq.md +1734 -0
- package/round4/gate-audit-relay-receipt.md +30 -0
- package/round4/gate-value-audit.md +52 -0
- package/round4/optimization-plan.md +165 -0
- package/round4/prereg.md +67 -0
- package/round4/prompt-R4-L.md +63 -0
- package/round4/prompt-R4-O-L.md +59 -0
- package/round4/prompt-R4-O-S.md +56 -0
- package/round4/prompt-R4-S-F.md +60 -0
- package/round4/prompt-R4-S-Q.md +66 -0
- package/round4/r4-final-report.html +347 -0
- package/round4/r4-os-timeline.txt +297 -0
- package/round4/r4-sf-timeline.txt +738 -0
- package/round4/r4-sq-timeline.txt +322 -0
- package/round4/r4l-timeline.txt +793 -0
- package/round4/r4ol-timeline.txt +729 -0
- package/round4/r4sf-timeline.txt +852 -0
- package/round4/r5-scenario-matrix.md +46 -0
- package/round4/result-S.md +171 -0
- package/round4/result-final.md +46 -0
- package/round4/result-quality.md +63 -0
- package/src/change-list.js +9 -0
- package/src/classify-change.js +6 -0
- package/src/command-cards.js +74 -0
- package/src/commit-guard.js +2 -0
- package/src/config-schema.js +64 -12
- package/src/contract-matrix.js +26 -5
- package/src/db.js +35 -4
- package/src/decision-distill.js +9 -3
- package/src/diagnostic-codes.js +2 -1
- package/src/docs-check.js +1 -1
- package/src/doctor-diagnostics.js +44 -2
- package/src/flow-draft.js +748 -0
- package/src/flow-parity.js +236 -0
- package/src/flow-review.js +184 -0
- package/src/flow.js +1492 -0
- package/src/foreign-declared.js +17 -1
- package/src/fr-index.js +396 -22
- package/src/fs-atomic.js +5 -1
- package/src/git-helper.js +2 -2
- package/src/greenfield-bootstrap.js +98 -0
- package/src/handoff.js +120 -7
- package/src/hunk-attribution.js +191 -0
- package/src/index.js +602 -21
- package/src/init.js +25 -8
- package/src/knowledge-classify.js +47 -1
- package/src/knowledge-digest.js +162 -0
- package/src/knowledge-quicklog.js +246 -0
- package/src/machine-draft.js +128 -0
- package/src/machine-interface.js +194 -7
- package/src/mcp-server.js +1 -0
- package/src/module-impact.js +51 -22
- package/src/module-resolve.js +3 -1
- package/src/preview-progress.js +160 -0
- package/src/progress/change-registry.js +11 -3
- package/src/progress/shared.js +3 -2
- package/src/progress.js +56 -13
- package/src/quicklog.js +44 -6
- package/src/redomain.js +147 -0
- package/src/review-material-pack.js +204 -1
- package/src/run/archive-distill.js +4 -2
- package/src/run/bg-sync.js +67 -0
- package/src/run/command.js +127 -10
- package/src/run/complete-handlers.js +363 -73
- package/src/run/complete.js +356 -74
- package/src/run/gate-snapshot-ledger.js +213 -0
- package/src/run/gate-snapshot.js +260 -38
- package/src/run/gates.js +482 -268
- package/src/run/green-cache.js +123 -0
- package/src/run/junction-rm.js +70 -0
- package/src/run/next.js +14 -0
- package/src/run/prompt.js +185 -55
- package/src/run/quick-audit.js +213 -14
- package/src/run/shared.js +123 -4
- package/src/run/stage.js +182 -216
- package/src/run/test-ledger.js +264 -0
- package/src/run/verify-quality-scan.js +84 -7
- package/src/scope-audit.js +6 -1
- package/src/sentinel-assertions.js +114 -0
- package/src/setup.js +3 -3
- package/src/spec-sync.js +41 -1
- package/src/stage-contract.js +26 -8
- package/src/stage-review-checklist.js +2 -0
- package/src/stage-review.js +7 -2
- package/src/stage-templates.js +157 -0
- package/src/stages/archive.js +2 -0
- package/src/stages/brainstorm.js +24 -106
- package/src/stages/execute.js +222 -85
- package/src/stages/knowledge.js +74 -1
- package/src/stages/plan-postcheck.js +385 -20
- package/src/stages/plan.js +23 -1
- package/src/stages/quick.js +10 -2
- package/src/stages/verify.js +4 -2
- package/src/sync.js +81 -23
- package/src/task-done.js +181 -0
- package/src/task-review.js +8 -55
- package/src/taskcard-frontmatter.js +57 -0
- package/src/test-bindings.js +549 -0
- package/src/timeline.js +218 -0
- package/src/ui-visual.js +175 -0
- package/src/verify-draft.js +214 -0
- package/src/verify-postcheck.js +919 -159
- package/src/verify-probes.js +103 -25
- package/src/watcher.js +1031 -0
- package/src/worktree-apply.js +44 -4
- package/src/worktree-deps.js +22 -8
- package/src/worktree-policy.js +42 -0
- package/src/worktree.js +102 -15
- package/templates/agents-instruction.md +37 -18
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* test-ledger.js — 测试结果三键指纹账本(P2 / FR-02 / D-001@v1,2026-09-21-r5-efficiency-batch3)
|
|
3
|
+
*
|
|
4
|
+
* 背景(batch2 归档实证):全量测试 ~100s/次,一个收尾事务跑 7 次(必要 2-3 次);agent 因怕被
|
|
5
|
+
* --done 拦而恐惧性预跑。本模块按「三键全等才复用」提供 fail-closed 结果账本,消费点=gate
|
|
6
|
+
* verify 的 verify-test / quick --done 实测门 / gate --full(P1)。
|
|
7
|
+
*
|
|
8
|
+
* **fail-closed 三层(红线,D-001@v1)**:
|
|
9
|
+
* ① 键分量不可得(git 失败/测试面读不到/配置身份不可解析)→ 不记不复用,永远真跑;
|
|
10
|
+
* ② 失败结果永不缓存——只有通过的结果才落账本(失败走真跑修复路径,缓存失败=假绿源);
|
|
11
|
+
* ③ 严格全等——key 为三键分量的 sha256 串比对,无模糊匹配、无时间窗放宽。
|
|
12
|
+
*
|
|
13
|
+
* **三键定义**:
|
|
14
|
+
* - codeFingerprint:{ head, dirtyDigest }——HEAD commit + porcelain 未提交摘要(路径+内容
|
|
15
|
+
* hash 归一)。与 verify-quality-scan 的代码指纹同思想(P0-1 先例),本模块独立实现不耦合
|
|
16
|
+
* (quality-scan 是 noAI 步→verify 门单向,本账本多消费点)。
|
|
17
|
+
* - testSet(**v2**,P0 修正——fr-test-binding 方案 §3.5 立项前置):{ runPlan, configDigest,
|
|
18
|
+
* testFaceDigest }——runPlan=[{ runner, files }](runner=实解命令,resolveTestSetIdentity
|
|
19
|
+
* 经 extractTestCommand 单一解析源解析,调用方不再传字面量;files=段级选中集合,residual
|
|
20
|
+
* adapter 落地后入键,现一律 null=整目录摘要兜底);configDigest=local.yaml 原文摘要
|
|
21
|
+
* (local.yaml 是 gitignore 文件——不进 HEAD 也不进 porcelain,v1 键只含调用方硬编码
|
|
22
|
+
* 'npm test' 字面量,改 commands.test / test_strategy / modules / known_failures 均**不换键**
|
|
23
|
+
* =两个不同实际执行映射同一键、跨命令误复用旧绿;v2 起配置任何字节变化都换键——宁可
|
|
24
|
+
* 多跑一次,不复用错绿);testFaceDigest=测试面(整目录或选中集合)内容摘要。测试文件
|
|
25
|
+
* 本身也是代码,改测试=改被测面,必须换键。
|
|
26
|
+
* - envProfile:结构化探针(布尔/枚举,**禁裸路径入键**)。探针清单不是拍脑袋列举——
|
|
27
|
+
* cwdInsideWorktree 用 detectCwdInsideWorktree 同源判定(batch2 实证:13 个 worktree-cwd
|
|
28
|
+
* 环境族测试在主 worktree 全挂、在隔离快照全过,其判定信号就是「cwd 路径含
|
|
29
|
+
* .sillyspec/.runtime/worktrees/<change> 段」——探针必须捕获该信号,worktree 与主仓天然
|
|
30
|
+
* 分键);另收 node 版本/平台名/行为开关 env 集(SILLYSPEC_STEP_GUIDE 等,P4 翻默认后
|
|
31
|
+
* 直接影响输出形态族)/本地 spec 与平台指针存在性(resolveRuntimeRoot 分流信号)。
|
|
32
|
+
*
|
|
33
|
+
* 账本形态:.runtime/test-ledger-<changeName>.json 单条覆盖写(最近一次通过结果;
|
|
34
|
+
* schemaVersion: 2——v1 旧账 consult 即失效【自然失效,不迁移】,首次通过后按 v2 重建)——
|
|
35
|
+
* quick 会话名=quick-<hex> 天然 per-change 隔离,无并发互写面;写入走 writeAtomicSync
|
|
36
|
+
* (meta.json 并发读先例同款约束)。
|
|
37
|
+
*/
|
|
38
|
+
import { createHash } from 'crypto'
|
|
39
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
|
|
40
|
+
import { join, relative, isAbsolute } from 'path'
|
|
41
|
+
import { gitQuiet } from '../git-helper.js'
|
|
42
|
+
import { detectCwdInsideWorktree } from './shared.js'
|
|
43
|
+
import { writeAtomicSync } from '../fs-atomic.js'
|
|
44
|
+
import { extractTestCommand } from '../verify-postcheck.js'
|
|
45
|
+
|
|
46
|
+
const sha256 = (s) => createHash('sha256').update(s).digest('hex')
|
|
47
|
+
|
|
48
|
+
/** 行为开关 env 集(影响测试结果的 SILLYSPEC_* 键——P4 翻默认后 STEP_GUIDE 直接改 stdout 形态) */
|
|
49
|
+
const BEHAVIOR_ENV_KEYS = [
|
|
50
|
+
'SILLYSPEC_STEP_GUIDE',
|
|
51
|
+
'SILLYSPEC_VERIFY_GATE_SNAPSHOT_OFF',
|
|
52
|
+
'SILLYSPEC_QUICK_TEST_GATE',
|
|
53
|
+
'SILLYSPEC_VERIFY_QUALITY_SCAN_RERUN',
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 环境探针(键分量③)。任何探针异常 → 整体 null(fail-closed 层①)。
|
|
58
|
+
* @returns {object|null} 结构化探针(JSON 稳定序参与 key)
|
|
59
|
+
*/
|
|
60
|
+
export function computeEnvProfile({ cwd = process.cwd(), env = process.env } = {}) {
|
|
61
|
+
try {
|
|
62
|
+
const profile = {
|
|
63
|
+
cwdInsideWorktree: detectCwdInsideWorktree(cwd) !== null, // 13 环境族测试的真实判定信号(同源)
|
|
64
|
+
hasLocalSpec: existsSync(join(cwd, '.sillyspec')),
|
|
65
|
+
hasPlatformPointer: existsSync(join(cwd, '.sillyspec-platform.json')),
|
|
66
|
+
nodeVersion: process.version,
|
|
67
|
+
platform: process.platform,
|
|
68
|
+
behaviorEnv: BEHAVIOR_ENV_KEYS.reduce((acc, k) => {
|
|
69
|
+
acc[k] = env[k] === undefined ? null : String(env[k])
|
|
70
|
+
return acc
|
|
71
|
+
}, {}),
|
|
72
|
+
}
|
|
73
|
+
return profile
|
|
74
|
+
} catch { return null }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 代码指纹(键分量①):{ head, dirtyDigest }。git 不可达/HEAD 解析失败 → null(fail-closed)。
|
|
79
|
+
* dirtyDigest = porcelain(含 untracked)行集的排序摘要——任何未提交变化换键。
|
|
80
|
+
* ignoreFiles:账本自身路径(runtimeRoot 在仓内时)——**排除自污染**:记账写文件会改
|
|
81
|
+
* porcelain,若不排除,落账瞬间键即漂移、下次查询永 mismatch(自毁键,测试 L1 实证)。
|
|
82
|
+
* @returns {{ head: string, dirtyDigest: string }|null}
|
|
83
|
+
*/
|
|
84
|
+
export function computeCodeFingerprint(projectRoot, { ignoreFiles = [] } = {}) {
|
|
85
|
+
try {
|
|
86
|
+
const head = gitQuiet(projectRoot, ['rev-parse', 'HEAD'])
|
|
87
|
+
if (!head) return null
|
|
88
|
+
const status = gitQuiet(projectRoot, ['status', '--porcelain'])
|
|
89
|
+
if (status === null) return null
|
|
90
|
+
const ignore = new Set(ignoreFiles)
|
|
91
|
+
const lines = status.split('\n').filter(Boolean)
|
|
92
|
+
.filter(l => {
|
|
93
|
+
const p = l.slice(3).split(' -> ').pop().replace(/^"|"$/g, '')
|
|
94
|
+
// 两种命中:精确路径(展开的 untracked/已跟踪文件)与目录前缀(git 折叠整棵未跟踪
|
|
95
|
+
// 目录成 `?? dir/` 一行——排除须向下匹配;账本自污染实测形态,测试 L1)
|
|
96
|
+
if (ignore.has(p)) return false
|
|
97
|
+
for (const f of ignore) { if (p.endsWith('/') && f.startsWith(p)) return false }
|
|
98
|
+
return true
|
|
99
|
+
})
|
|
100
|
+
.sort()
|
|
101
|
+
const dirtyDigest = sha256(lines.join('\n') || '(clean)')
|
|
102
|
+
return { head: head.trim(), dirtyDigest }
|
|
103
|
+
} catch { return null }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** runtimeRoot 相对 projectRoot 的账本路径(仓外 runtimeRoot → null,无需排除) */
|
|
107
|
+
function ledgerIgnorePath(runtimeRoot, changeName, projectRoot) {
|
|
108
|
+
try {
|
|
109
|
+
const abs = testLedgerPath(runtimeRoot, changeName)
|
|
110
|
+
const rel = relative(projectRoot, abs)
|
|
111
|
+
return rel && !rel.startsWith('..') && !isAbsolute(rel) ? rel.split('\\').join('/') : null
|
|
112
|
+
} catch { return null }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 测试面摘要(键分量②的输入):test/ 目录(含一级子目录)文件清单+每文件内容摘要。
|
|
117
|
+
* 深度帽 2 层、文件数帽 2000(run-tests.mjs 同量级),超帽/读失败 → null(fail-closed——
|
|
118
|
+
* 测试面不可全知时复用无意义)。
|
|
119
|
+
* @returns {string|null}
|
|
120
|
+
*/
|
|
121
|
+
export function computeTestFaceDigest(testRoot, depth = 0) {
|
|
122
|
+
try {
|
|
123
|
+
if (!existsSync(testRoot) || depth > 2) return null
|
|
124
|
+
const names = readdirSync(testRoot).filter(n => !n.startsWith('.')).sort()
|
|
125
|
+
const parts = []
|
|
126
|
+
let count = 0
|
|
127
|
+
for (const n of names) {
|
|
128
|
+
const p = join(testRoot, n)
|
|
129
|
+
const st = statSync(p)
|
|
130
|
+
if (st.isDirectory()) {
|
|
131
|
+
const sub = computeTestFaceDigest(p, depth + 1)
|
|
132
|
+
if (sub === null) return null
|
|
133
|
+
parts.push(n + '/:' + sub)
|
|
134
|
+
} else if (/\.(mjs|js|cjs|json)$/.test(n)) {
|
|
135
|
+
if (++count > 2000) return null
|
|
136
|
+
parts.push(n + ':' + sha256(readFileSync(p)))
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return sha256(parts.join('|'))
|
|
140
|
+
} catch { return null }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 键②身份解析(v2,P0 修正——fr-test-binding 方案 §3.5 立项前置):runPlan.runner =
|
|
145
|
+
* 实解命令(extractTestCommand 单一解析源,未配置/unavailable 回退 'npm test'——与
|
|
146
|
+
* runVerifyTestCheck 主路径同口径);configDigest = local.yaml 原文摘要(换行归一,EOL-only
|
|
147
|
+
* 重写不换键)。specBase 未提供/读取异常 → null(fail-closed 层①:身份不可知则键不可得,
|
|
148
|
+
* 调用方不得回退传字面量命令)。
|
|
149
|
+
* @returns {{ runPlan: Array<{runner: string, files: string[]|null}>, configDigest: string }|null}
|
|
150
|
+
*/
|
|
151
|
+
export function resolveTestSetIdentity({ specBase } = {}) {
|
|
152
|
+
try {
|
|
153
|
+
if (!specBase) return null
|
|
154
|
+
const yamlPath = join(specBase, 'local.yaml')
|
|
155
|
+
const text = existsSync(yamlPath) ? readFileSync(yamlPath, 'utf8') : null
|
|
156
|
+
const runner = extractTestCommand(text) || 'npm test'
|
|
157
|
+
return {
|
|
158
|
+
// files 段级选中集合:residual adapter 落地后入键;现一律 null = 整目录摘要兜底(保守向)
|
|
159
|
+
runPlan: [{ runner, files: null }],
|
|
160
|
+
configDigest: sha256((text ?? '(no-local.yaml)').replace(/\r\n?/g, '\n')),
|
|
161
|
+
}
|
|
162
|
+
} catch { return null }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 选中集合摘要(v2 结构位,P0 第三行):files 非空 → 仅摘要选中文件内容(未选中测试文件
|
|
167
|
+
* 改动不再整目录连坐换键);任一文件缺失/读失败 → null(fail-closed——选中面不可全知时
|
|
168
|
+
* 复用无意义)。files 空/非数组 → null(调用方走整目录 computeTestFaceDigest 兜底)。
|
|
169
|
+
* @param {string} testRoot
|
|
170
|
+
* @param {string[]|null} files 相对 testRoot 的路径
|
|
171
|
+
* @returns {string|null}
|
|
172
|
+
*/
|
|
173
|
+
export function computeSelectedTestFaceDigest(testRoot, files) {
|
|
174
|
+
try {
|
|
175
|
+
if (!Array.isArray(files) || files.length === 0) return null
|
|
176
|
+
const parts = []
|
|
177
|
+
for (const f of [...files].sort()) {
|
|
178
|
+
parts.push(f + ':' + sha256(readFileSync(join(testRoot, f))))
|
|
179
|
+
}
|
|
180
|
+
return sha256(parts.join('|'))
|
|
181
|
+
} catch { return null }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 三键合成(v2):全部分量就绪才出键;任一 null → null。
|
|
186
|
+
* testSet = { runPlan, configDigest, testFaceDigest }(resolveTestSetIdentity + 面摘要合成)。
|
|
187
|
+
* @returns {string|null} sha256 key
|
|
188
|
+
*/
|
|
189
|
+
export function computeTestLedgerKey({ projectRoot, testSet, cwd, env, ignoreFiles = [] }) {
|
|
190
|
+
const code = computeCodeFingerprint(projectRoot, { ignoreFiles })
|
|
191
|
+
const profile = computeEnvProfile({ cwd, env })
|
|
192
|
+
if (!code || !testSet || !testSet.testFaceDigest || !profile) return null
|
|
193
|
+
return sha256(JSON.stringify({ code, testSet, env: profile }))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** consult/record 共用键计算(同源防两端口径漂移) */
|
|
197
|
+
function ledgerKeyFor({ runtimeRoot, changeName, projectRoot, testRoot, specBase, cwd, env }) {
|
|
198
|
+
const selfIgnore = ledgerIgnorePath(runtimeRoot, changeName, projectRoot)
|
|
199
|
+
const identity = resolveTestSetIdentity({ specBase })
|
|
200
|
+
if (!identity) return { identity: null, key: null }
|
|
201
|
+
const face = computeSelectedTestFaceDigest(testRoot, identity.runPlan[0]?.files) || computeTestFaceDigest(testRoot)
|
|
202
|
+
const key = computeTestLedgerKey({
|
|
203
|
+
projectRoot,
|
|
204
|
+
testSet: { runPlan: identity.runPlan, configDigest: identity.configDigest, testFaceDigest: face },
|
|
205
|
+
cwd, env, ignoreFiles: selfIgnore ? [selfIgnore] : [],
|
|
206
|
+
})
|
|
207
|
+
return { identity, key }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 账本路径(per-change 单文件) */
|
|
211
|
+
export function testLedgerPath(runtimeRoot, changeName) {
|
|
212
|
+
return join(runtimeRoot, `test-ledger-${changeName}.json`)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 查询:三键全等且有通过记录 → 复用。任一分量不可得/键不等/账本缺失损坏/版本不符 → 不复用
|
|
217
|
+
* (fail-closed)。runPlan 随命中回放(供消费方如实展示实际命令)。
|
|
218
|
+
* @returns {{ reuse: boolean, result?: object, runPlan?: Array<object>|null, reason: string, key?: string }}
|
|
219
|
+
*/
|
|
220
|
+
export function consultTestLedger({ runtimeRoot, changeName, projectRoot, testRoot, specBase, cwd, env }) {
|
|
221
|
+
const { key } = ledgerKeyFor({ runtimeRoot, changeName, projectRoot, testRoot, specBase, cwd, env })
|
|
222
|
+
if (!key) return { reuse: false, reason: 'fail-closed: 键分量不可得(git/测试面/环境探针/配置身份任一失败)' }
|
|
223
|
+
try {
|
|
224
|
+
const p = testLedgerPath(runtimeRoot, changeName)
|
|
225
|
+
if (!existsSync(p)) return { reuse: false, reason: 'no-ledger', key }
|
|
226
|
+
const j = JSON.parse(readFileSync(p, 'utf8'))
|
|
227
|
+
if (!j || j.schemaVersion !== 2 || !j.key || !j.result || j.result.pass !== true) {
|
|
228
|
+
return { reuse: false, reason: 'ledger-invalid-or-failed-entry', key }
|
|
229
|
+
}
|
|
230
|
+
if (j.key !== key) return { reuse: false, reason: 'key-mismatch', key }
|
|
231
|
+
return { reuse: true, result: j.result, runPlan: Array.isArray(j.runPlan) ? j.runPlan : null, reason: 'match', key }
|
|
232
|
+
} catch {
|
|
233
|
+
return { reuse: false, reason: 'ledger-read-error', key }
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* 记账:只有通过结果落账(fail-closed 层②——失败永不缓存)。键不可得 → 不记(下次仍真跑)。
|
|
239
|
+
* 返回是否落账。
|
|
240
|
+
*/
|
|
241
|
+
export function recordTestLedger({ runtimeRoot, changeName, projectRoot, testRoot, specBase, cwd, env, result }) {
|
|
242
|
+
if (!result || result.pass !== true) return false
|
|
243
|
+
const { identity, key } = ledgerKeyFor({ runtimeRoot, changeName, projectRoot, testRoot, specBase, cwd, env })
|
|
244
|
+
if (!identity || !key) return false
|
|
245
|
+
try {
|
|
246
|
+
writeAtomicSync(testLedgerPath(runtimeRoot, changeName), JSON.stringify({
|
|
247
|
+
schemaVersion: 2,
|
|
248
|
+
change: changeName,
|
|
249
|
+
key,
|
|
250
|
+
// runPlan 回放(审计面:账本声称复用的实际执行身份;不参与键——键已含其摘要)
|
|
251
|
+
runPlan: identity.runPlan,
|
|
252
|
+
result: {
|
|
253
|
+
pass: true,
|
|
254
|
+
total: result.total ?? null,
|
|
255
|
+
failed: result.failed ?? [],
|
|
256
|
+
failedFiles: result.failedFiles ?? [],
|
|
257
|
+
durationMs: result.durationMs ?? null,
|
|
258
|
+
strategy: result.strategy ?? null,
|
|
259
|
+
ranAt: new Date().toISOString(),
|
|
260
|
+
},
|
|
261
|
+
}, null, 2) + '\n')
|
|
262
|
+
return true
|
|
263
|
+
} catch { return false }
|
|
264
|
+
}
|
|
@@ -33,6 +33,7 @@ import { join, dirname } from 'node:path'
|
|
|
33
33
|
import { writeAtomicSync } from '../fs-atomic.js'
|
|
34
34
|
import { gitQuiet } from '../git-helper.js'
|
|
35
35
|
import { resolveRuntimeRoot, parsePorcelainPath } from './shared.js'
|
|
36
|
+
import { computeEnvProfile, computeTestFaceDigest } from './test-ledger.js'
|
|
36
37
|
import { resolveChangeRisk, extractExplicitRiskLevel } from '../change-risk-profile.js'
|
|
37
38
|
import { loadBlastDeclarationsAllProjects } from '../blast-surface.js'
|
|
38
39
|
import { parseFileChangeListDetailed } from '../change-list.js'
|
|
@@ -103,6 +104,7 @@ export function storeQualityScan({ specBase, cwd, changeName, testResult, lintRe
|
|
|
103
104
|
const path = qualityScanRecordPath(specBase, changeName)
|
|
104
105
|
if (!path) return null
|
|
105
106
|
const fingerprint = computeQualityScanFingerprint({ cwd, specBase })
|
|
107
|
+
const dedupKey = computeQualityScanDedupKey({ cwd, specBase, fingerprint })
|
|
106
108
|
try {
|
|
107
109
|
mkdirSync(dirname(path), { recursive: true })
|
|
108
110
|
writeAtomicSync(path, JSON.stringify({
|
|
@@ -112,7 +114,10 @@ export function storeQualityScan({ specBase, cwd, changeName, testResult, lintRe
|
|
|
112
114
|
fingerprint,
|
|
113
115
|
// 失败签名去重面(ql-20260920-010 修复一 d):additive 字段,存量记录无这两键 →
|
|
114
116
|
// shouldReuseLastFailedScan 判 no-dedup-key 保守重跑一次,之后新记录即携带
|
|
115
|
-
dedupKey
|
|
117
|
+
dedupKey,
|
|
118
|
+
// RERUN 失败签名(r5l 方案 4):dedupKey × 测试面 × 环境探针;同样 additive——存量
|
|
119
|
+
// 记录无此键 → RERUN 签名闸判无签名放行一次(同 no-dedup-key 先例口径)
|
|
120
|
+
rerunSignature: computeRerunSignature({ cwd, specBase, dedupKey, env: process.env }),
|
|
116
121
|
usedSnapshot: Boolean(usedSnapshot),
|
|
117
122
|
ranAt: new Date().toISOString(),
|
|
118
123
|
testResult,
|
|
@@ -173,6 +178,36 @@ export function computeQualityScanDedupKey({ cwd, specBase, fingerprint = null }
|
|
|
173
178
|
return createHash('sha256').update(fp + '\n##known-failures##\n' + kf).digest('hex')
|
|
174
179
|
}
|
|
175
180
|
|
|
181
|
+
/**
|
|
182
|
+
* RERUN 失败签名(r5l-forensic-verdict.md 方案 4 / 评审护栏#6):dedupKey(代码指纹 ×
|
|
183
|
+
* known_failures 豁免面)× 测试面 × 环境探针 三路合成,供 SILLYSPEC_VERIFY_QUALITY_SCAN_RERUN=1
|
|
184
|
+
* 生效前对账——签名未变则拒绝重跑(重跑结果注定相同,R5-L 两次强制重扫各烧 ~8M+10m 的根治)。
|
|
185
|
+
*
|
|
186
|
+
* 环境必须入键(护栏#6 五态之「环境变放行」):环境真变(探针换值)签名才失配,RERUN=1 才
|
|
187
|
+
* 不会被假拒漏真拦。探针复用 test-ledger computeEnvProfile 同源实现,但剔除
|
|
188
|
+
* SILLYSPEC_VERIFY_QUALITY_SCAN_RERUN 自身——被测旋钮不是环境(原样入键 = RERUN=1 与历史
|
|
189
|
+
* 记录恒失配,闸门永不触发)。测试面同理复用 computeTestFaceDigest(test/ 读不到按
|
|
190
|
+
* 'unavailable' 入键——代码指纹的 HEAD+非文档脏集仍覆盖已提交/未提交的测试文件变更)。
|
|
191
|
+
*
|
|
192
|
+
* 任一 fail 分量不可得 → null:签名闸 fail-open(拒跑是危险方向——算不出签名就放行重跑,
|
|
193
|
+
* 宁可多跑一次不可漏真拦;与复用判定的 fail-closed 方向刻意相反)。
|
|
194
|
+
*/
|
|
195
|
+
export function computeRerunSignature({ cwd, specBase, dedupKey = null, env = process.env }) {
|
|
196
|
+
const dedup = dedupKey ?? computeQualityScanDedupKey({ cwd, specBase })
|
|
197
|
+
if (!dedup) return null
|
|
198
|
+
let testFace = 'unavailable'
|
|
199
|
+
try {
|
|
200
|
+
const face = computeTestFaceDigest(join(cwd, 'test'))
|
|
201
|
+
if (face !== null) testFace = face
|
|
202
|
+
} catch { /* 测试面读不到 → unavailable 兜底入键 */ }
|
|
203
|
+
const profile = computeEnvProfile({ cwd, env })
|
|
204
|
+
if (!profile) return null
|
|
205
|
+
if (profile.behaviorEnv) delete profile.behaviorEnv.SILLYSPEC_VERIFY_QUALITY_SCAN_RERUN
|
|
206
|
+
return createHash('sha256')
|
|
207
|
+
.update(dedup + '\n##test-face##\n' + testFace + '\n##env-probe##\n' + JSON.stringify(profile))
|
|
208
|
+
.digest('hex')
|
|
209
|
+
}
|
|
210
|
+
|
|
176
211
|
/**
|
|
177
212
|
* 读回最近一条扫描记录(不论成败)——失败去重重放用。loadReusableQualityScan 拒绝
|
|
178
213
|
* failed 记录(复用是优化不是正确性依赖),去重恰恰要消费 failed 态;schema/source/
|
|
@@ -297,7 +332,7 @@ export async function runCoverageExistenceCheck({ cwd, specBase, changeName }) {
|
|
|
297
332
|
const artifactRel = (yamlText.match(/^\s*coverage_artifact:\s*["']?([^"'\n#]+?)["']?\s*(?:#.*)?$/m) || [])[1] || 'coverage/lcov.info'
|
|
298
333
|
let runNote = ''
|
|
299
334
|
try {
|
|
300
|
-
execSync(covCmd.trim(), { cwd, encoding: 'utf8', timeout: 10 * 60 * 1000, maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
335
|
+
execSync(covCmd.trim(), { cwd, encoding: 'utf8', timeout: 10 * 60 * 1000, maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
|
|
301
336
|
runNote = '命令退出码 0'
|
|
302
337
|
} catch (e) {
|
|
303
338
|
runNote = `命令退出非 0(${e.status ?? '?'})——产物若已生成仍按产物判定`
|
|
@@ -407,7 +442,7 @@ function runSmokeCheck({ gateCwd, mainCwd, specBase, changeName, inSnapshot }) {
|
|
|
407
442
|
let r = null
|
|
408
443
|
let spawnError = null
|
|
409
444
|
try {
|
|
410
|
-
r = spawnSync(command, { shell: true, cwd: gateCwd, timeout: SMOKE_TIMEOUT_MS, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 })
|
|
445
|
+
r = spawnSync(command, { shell: true, cwd: gateCwd, timeout: SMOKE_TIMEOUT_MS, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, windowsHide: true })
|
|
411
446
|
} catch (e) { spawnError = e }
|
|
412
447
|
let timedOut = isSyncTimeout(r)
|
|
413
448
|
let fallbackMainRepo = false
|
|
@@ -417,7 +452,7 @@ function runSmokeCheck({ gateCwd, mainCwd, specBase, changeName, inSnapshot }) {
|
|
|
417
452
|
fallbackMainRepo = true
|
|
418
453
|
console.warn('⚠️ 快照 smoke 超时(node_modules junction I/O 慢)→ 主仓复跑 smoke(并行噪声可能混入——失败先做归属鉴定)')
|
|
419
454
|
try {
|
|
420
|
-
r = spawnSync(command, { shell: true, cwd: mainCwd, timeout: SMOKE_TIMEOUT_MS, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 })
|
|
455
|
+
r = spawnSync(command, { shell: true, cwd: mainCwd, timeout: SMOKE_TIMEOUT_MS, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, windowsHide: true })
|
|
421
456
|
spawnError = null
|
|
422
457
|
ranCwd = mainCwd
|
|
423
458
|
timedOut = isSyncTimeout(r)
|
|
@@ -493,12 +528,43 @@ function warnIfMainRepoDirtyForGate(cwd) {
|
|
|
493
528
|
*/
|
|
494
529
|
export async function executeVerifyQualityScan({ cwd, specBase, changeName, platformOpts }) {
|
|
495
530
|
const { runVerifyTestCheck, printVerifyTestCheck, runVerifyLintCheck, printVerifyLintCheck, shouldBlockVerifyLint, classifyTestFailureArtifact } = await import('../verify-postcheck.js')
|
|
531
|
+
// ── RERUN 失败签名闸(r5l-forensic-verdict.md 方案 4 / 评审护栏#6 五态)──
|
|
532
|
+
// R5-L 实证:RERUN=1 两次强制重扫各烧 ~8M 重发 + 10m/6.8m 等待,而失败签名未变——重跑
|
|
533
|
+
// 结果注定相同。故 RERUN=1 生效前先对账失败签名(dedupKey 已含代码指纹×豁免面,再叠测试
|
|
534
|
+
// 面×环境探针):未变 → 拒绝重跑(throw=退出码非 0);环境真变(探针入键)/代码/豁免/测试
|
|
535
|
+
// 面变化 → 签名失配自动放行;RERUN=force 无条件放行(真·逃生阀,旧 RERUN=1 的裸语义移到
|
|
536
|
+
// 此档)。RERUN 未设时本闸不参与——下方既有 dedup 复用判定与 fail-closed 语义一行不动。
|
|
537
|
+
const rerunMode = process.env.SILLYSPEC_VERIFY_QUALITY_SCAN_RERUN
|
|
538
|
+
if (rerunMode === 'force') {
|
|
539
|
+
console.log('ℹ️ SILLYSPEC_VERIFY_QUALITY_SCAN_RERUN=force——无条件强制重跑(RERUN 签名闸与失败签名去重均旁路)')
|
|
540
|
+
} else if (rerunMode === '1') {
|
|
541
|
+
const lastRecord = loadLastQualityScanRecord({ specBase, changeName })
|
|
542
|
+
const lastSig = lastRecord ? lastRecord.rerunSignature : null
|
|
543
|
+
const lastFailed = lastRecord && lastRecord.testResult && lastRecord.testResult.status === 'failed'
|
|
544
|
+
if (lastFailed && lastSig) {
|
|
545
|
+
const sig = computeRerunSignature({ cwd, specBase })
|
|
546
|
+
if (sig && sig === lastSig) {
|
|
547
|
+
const t = lastRecord.testResult
|
|
548
|
+
console.error(`\n🛑 RERUN 签名闸:上次质量扫描失败且失败签名未变(代码指纹 / known_failures 豁免面 / 测试面 / 环境探针均未变${lastRecord.ranAt ? ',上次运行 ' + lastRecord.ranAt : ''})——重跑结果注定相同,拒绝重跑:`)
|
|
549
|
+
console.error(` 上次实测:\`${t.command}\` — ${(t.reason || '').split('\n')[0]}`)
|
|
550
|
+
if (t.outputTail) {
|
|
551
|
+
for (const line of t.outputTail.split('\n').slice(-8)) console.error(` | ${line}`)
|
|
552
|
+
}
|
|
553
|
+
console.error(' 出路(按序):① 修代码(指纹即失配,下次自动重测)② 补 known_failures 豁免(豁免面即失配)③ 换快照口径(设/删 SILLYSPEC_VERIFY_GATE_SNAPSHOT_OFF,口径变化即失配)④ 环境确已修好后强制重跑:SILLYSPEC_VERIFY_QUALITY_SCAN_RERUN=1(环境探针已入失败签名——环境真变即自动放行;仍被拒=探针未见变化,确要无条件重跑用 SILLYSPEC_VERIFY_QUALITY_SCAN_RERUN=force)')
|
|
554
|
+
throw new Error(`RERUN 签名闸拒绝重跑(失败签名未变:代码指纹×测试面×环境探针全等)——${(t.reason || '测试失败').split('\n')[0]}。按上方出路处置;确要无条件重跑设 SILLYSPEC_VERIFY_QUALITY_SCAN_RERUN=force。`)
|
|
555
|
+
}
|
|
556
|
+
} else if (lastFailed) {
|
|
557
|
+
console.log('ℹ️ RERUN=1:上次失败记录无 rerunSignature(存量记录)——签名闸放行一次,本次重跑后新记录即携带签名')
|
|
558
|
+
} else {
|
|
559
|
+
console.log('ℹ️ SILLYSPEC_VERIFY_QUALITY_SCAN_RERUN=1——强制重跑(上次无失败记录在案,签名闸无事可对)')
|
|
560
|
+
}
|
|
561
|
+
}
|
|
496
562
|
// ── 失败签名去重(ql-20260920-010 修复一 d)──
|
|
497
563
|
// 复入本步前先对账上次失败:代码指纹 + known_failures 豁免面 + 快照口径都没变 → 失败
|
|
498
564
|
// 必然原样复现,重跑是纯等待(对撞三轮三连 8~10 分钟假红重试的根治)。复用只消费
|
|
499
565
|
// failed 态记录(passed 态 --done 复用归 loadReusableQualityScan),逃生阀
|
|
500
|
-
// SILLYSPEC_VERIFY_QUALITY_SCAN_RERUN=1
|
|
501
|
-
if (
|
|
566
|
+
// SILLYSPEC_VERIFY_QUALITY_SCAN_RERUN=1(闸门见上方签名闸)与 =force(无条件旁路)。
|
|
567
|
+
if (rerunMode !== '1' && rerunMode !== 'force') {
|
|
502
568
|
const lastRecord = loadLastQualityScanRecord({ specBase, changeName })
|
|
503
569
|
const verdict = shouldReuseLastFailedScan({
|
|
504
570
|
lastRecord,
|
|
@@ -519,7 +585,7 @@ export async function executeVerifyQualityScan({ cwd, specBase, changeName, plat
|
|
|
519
585
|
console.error(` 🔬 失败签名命中伪影形态(${replayTriage.kind}):${replayTriage.advice}`)
|
|
520
586
|
console.error(' (上次运行早于分诊机制——本次重跑会自动做主树口径对照;如需立即重跑设 SILLYSPEC_VERIFY_QUALITY_SCAN_RERUN=1)')
|
|
521
587
|
}
|
|
522
|
-
console.error(' 出路(按序):① 修代码(指纹即失配,下次自动重测)② 补 known_failures 豁免(豁免面即失配)③ 换快照口径(设/删 SILLYSPEC_VERIFY_GATE_SNAPSHOT_OFF,口径变化即失配)④ 环境确已修好后强制重跑:SILLYSPEC_VERIFY_QUALITY_SCAN_RERUN=1')
|
|
588
|
+
console.error(' 出路(按序):① 修代码(指纹即失配,下次自动重测)② 补 known_failures 豁免(豁免面即失配)③ 换快照口径(设/删 SILLYSPEC_VERIFY_GATE_SNAPSHOT_OFF,口径变化即失配)④ 环境确已修好后强制重跑:SILLYSPEC_VERIFY_QUALITY_SCAN_RERUN=1(环境探针已入失败签名——环境真变即放行;仍被拒用 SILLYSPEC_VERIFY_QUALITY_SCAN_RERUN=force)')
|
|
523
589
|
throw new Error(`noAI 质量扫描复用上次失败结果(失败签名未变,跳过重跑)——${(t.reason || '测试失败').split('\n')[0]}。修复后重跑 sillyspec run verify${changeName ? ` --change ${changeName}` : ''},或按上方出路处置。`)
|
|
524
590
|
}
|
|
525
591
|
}
|
|
@@ -530,6 +596,16 @@ export async function executeVerifyQualityScan({ cwd, specBase, changeName, plat
|
|
|
530
596
|
// ⚠️ 口径可见。指纹(storeQualityScan)仍按主仓 cwd 算——--done 对账复用的指纹匹配口径不变。
|
|
531
597
|
let snap = null
|
|
532
598
|
if (!process.env.SILLYSPEC_VERIFY_GATE_SNAPSHOT_OFF) {
|
|
599
|
+
// worktree 会话跳快照(2026-09-23 R9/R10 根治):worktree 已是会话独占隔离——快照只剩
|
|
600
|
+
// venv junction 冻结/慢 I/O 纯成本(R9 3MB/0CPU 冻结、R10 15min 慢跑实证),直接工作树实测。
|
|
601
|
+
let skipForWorktree = false
|
|
602
|
+
try {
|
|
603
|
+
const { shouldSkipGateSnapshotForWorktree } = await import('./gate-snapshot.js')
|
|
604
|
+
skipForWorktree = shouldSkipGateSnapshotForWorktree(cwd)
|
|
605
|
+
} catch { /* 判定异常按不跳走原路 */ }
|
|
606
|
+
if (skipForWorktree) {
|
|
607
|
+
console.log('🔀 cwd 是会话专属 worktree——隔离快照冗余(并行会话不在场;快照 venv junction 冻结/慢 I/O 面 R9/R10 双实证),直接在 worktree 实测')
|
|
608
|
+
} else {
|
|
533
609
|
try {
|
|
534
610
|
const { createVerifyGateSnapshot } = await import('./gate-snapshot.js')
|
|
535
611
|
snap = await createVerifyGateSnapshot({ cwd, changeName, specBase, platformOpts })
|
|
@@ -537,6 +613,7 @@ export async function executeVerifyQualityScan({ cwd, specBase, changeName, plat
|
|
|
537
613
|
console.log(`🧪 noAI 扫描隔离快照(根:${snap.snapshotRoot}):HEAD + 本变更 ${snap.changeFileCount} 个文件${snap.sourceRoot ? '(overlay 自 worktree——本变更分支内容定向跑)' : ''}(并行会话脏文件不参与判定)`)
|
|
538
614
|
}
|
|
539
615
|
} catch { /* 快照链路异常 → 主仓现行为 */ }
|
|
616
|
+
}
|
|
540
617
|
}
|
|
541
618
|
const gateCwd = snap ? snap.snapshotRoot : cwd
|
|
542
619
|
const gateSpecBase = snap ? join(snap.snapshotRoot, '.sillyspec') : specBase
|
package/src/scope-audit.js
CHANGED
|
@@ -321,7 +321,12 @@ export function buildFrozenPatch(root, files, { baseRef } = {}) {
|
|
|
321
321
|
const parts = []
|
|
322
322
|
try {
|
|
323
323
|
const r = safeGit(root, ['diff', '--no-color', baseRef], { timeout: 60 * 1000 })
|
|
324
|
-
|
|
324
|
+
// diff 采集失败 fail-closed(2026-09-27-thin-module-scope-persist 评审 P1 清偿:此前失败静默
|
|
325
|
+
// 跳过 tracked 段,untracked 自拼 hunk 仍拼出非空 patch → patchStatus ok 的伪完整件——实证
|
|
326
|
+
// core.bare 被误写 true 时 `git diff <ref>` 报「must be run in a work tree」,change.patch
|
|
327
|
+
// 只剩 proposal.md 一个 hunk 却标 ok。失败即 null(jsdoc 契约:调用方 fail-soft 不落伪 patch))
|
|
328
|
+
if (r.error) return null
|
|
329
|
+
const filtered = filterPatchForFiles(r.value, wanted)
|
|
325
330
|
if (filtered) parts.push(filtered)
|
|
326
331
|
const trackedOut = safeGit(root, ['ls-files'], { timeout: 30 * 1000 })
|
|
327
332
|
const trackedSet = new Set(String(trackedOut.value || '').split('\n').map(toPosix).filter(Boolean))
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sentinel-assertions.js — L0 收口侧纯断言(2026-09-23-sentinel-rules / FR-07 / D-002@v1)。
|
|
3
|
+
*
|
|
4
|
+
* 与 watcher 侧哨兵规则(src/watcher.js R1 假勾选)同判据的**硬门版**纯函数:--done 收口
|
|
5
|
+
* 侧调用拒收「全勾但零完成证据」的假完成主张(L0 升级通道——watcher 恒 advisory 人判,
|
|
6
|
+
* 收口是唯一拒绝点,符合「守卫必须是验收侧的机制,永远不是生成侧的劝说」设计原则)。
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ 本批(task-03)只交付函数+单测,**无任何仓内调用方**——收口接线留下批(避免与并行
|
|
9
|
+
* 会话改同文件);测试文件是当前唯一消费方。
|
|
10
|
+
*
|
|
11
|
+
* 纯度口径:无副作用(零写盘/零 db),输出由参数+只读盘面(review.json 在场探测)决定;
|
|
12
|
+
* 盘面探测可经 opts.listReviewsImpl 注入替身(单测零真 fs)。证据判据与 R1 同口径:
|
|
13
|
+
* commit 消息(标题或正文——2026-09-25 起收口侧取 %B 整条消息)含完整 token task-NN
|
|
14
|
+
* (负向前瞻,task-01 不证 task-010)或对应 review.json 在场(.runtime/execute-runs
|
|
15
|
+
* 任意 run 的 tasks 目录下对应任务子目录)。
|
|
16
|
+
*/
|
|
17
|
+
import { join, dirname } from 'path';
|
|
18
|
+
import { existsSync, readdirSync } from 'fs';
|
|
19
|
+
|
|
20
|
+
/** 判集行锚:`- [x] task-01: …`(id 行才是可验证主张;无 id 勾选行不入判——不可验证不拒收)。 */
|
|
21
|
+
const CHECKED_TASK_LINE_RE = /^[-*] \[( |x|X)\] (task-\d+)/;
|
|
22
|
+
|
|
23
|
+
/** task id 完整 token 匹配(词边界,与 watcher R1 同口径)。 */
|
|
24
|
+
function taskTokenRe(id) {
|
|
25
|
+
return new RegExp(`${id}(?!\\d)`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* review.json 在场证据清单(execute-runs 两级遍历;异常/缺失 → [],commit 证据照判)。
|
|
30
|
+
*/
|
|
31
|
+
function listReviewEvidence(changeDir, opts) {
|
|
32
|
+
if (typeof opts.listReviewsImpl === 'function') {
|
|
33
|
+
try { return opts.listReviewsImpl(changeDir) || []; } catch { return []; }
|
|
34
|
+
}
|
|
35
|
+
if (!changeDir) return [];
|
|
36
|
+
try {
|
|
37
|
+
const runsRoot = join(dirname(changeDir), '.runtime', 'execute-runs');
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const run of readdirSync(runsRoot)) {
|
|
40
|
+
let names = [];
|
|
41
|
+
try { names = readdirSync(join(runsRoot, run, 'tasks')); } catch { continue; }
|
|
42
|
+
for (const t of names) {
|
|
43
|
+
if (existsSync(join(runsRoot, run, 'tasks', t, 'review.json'))) {
|
|
44
|
+
out.push(`execute-runs/${run}/tasks/${t}/review.json`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
} catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 假勾选三态判定(L0 收口拒收支点,下批接线)。
|
|
56
|
+
*
|
|
57
|
+
* @param {object} args
|
|
58
|
+
* @param {string|null} args.changeDir 变更目录(推导 specBase 定位 execute-runs;null/探测
|
|
59
|
+
* 失败按无 review 证据,commit 证据照判)
|
|
60
|
+
* @param {string|null} args.tasksMd tasks.md 全文(判集=行首 checkbox+task-NN id 行)
|
|
61
|
+
* @param {Array<string|{message:string}|{subject:string}>} args.commits 提交组(消息含
|
|
62
|
+
* task-NN 即该任务证据)
|
|
63
|
+
* @param {{listReviewsImpl?: Function}} [args.opts] 注入面(测试替身)
|
|
64
|
+
* @returns {{status:'complete'|'fake'|'none', claimTotal:number, checked:number, missing:string[]}}
|
|
65
|
+
* none=无完成主张(判集空或未全勾);complete=全勾且零 missing;fake=全勾且有零证据
|
|
66
|
+
* 任务(missing 列其 id,收口侧拒收依据)。
|
|
67
|
+
*/
|
|
68
|
+
export function detectFakeCheckCompletion({ changeDir, tasksMd, commits, opts = {} } = {}) {
|
|
69
|
+
const entries = [];
|
|
70
|
+
for (const line of String(tasksMd || '').split(/\r?\n/)) {
|
|
71
|
+
const m = line.match(CHECKED_TASK_LINE_RE);
|
|
72
|
+
if (m) entries.push({ id: m[2], checked: m[1].toLowerCase() === 'x' });
|
|
73
|
+
}
|
|
74
|
+
const claimTotal = entries.length;
|
|
75
|
+
const checked = entries.filter((e) => e.checked).length;
|
|
76
|
+
if (claimTotal === 0 || checked < claimTotal) {
|
|
77
|
+
return { status: 'none', claimTotal, checked, missing: [] };
|
|
78
|
+
}
|
|
79
|
+
const messages = (Array.isArray(commits) ? commits : [])
|
|
80
|
+
.map((c) => (typeof c === 'string' ? c : String((c && (c.message ?? c.subject)) || '')));
|
|
81
|
+
const reviews = listReviewEvidence(changeDir, opts);
|
|
82
|
+
const missing = entries
|
|
83
|
+
.filter((e) => {
|
|
84
|
+
const re = taskTokenRe(e.id);
|
|
85
|
+
const byCommit = messages.some((msg) => re.test(msg));
|
|
86
|
+
const byReview = reviews.some((p) => p.includes(`/tasks/${e.id}/`));
|
|
87
|
+
return !(byCommit || byReview);
|
|
88
|
+
})
|
|
89
|
+
.map((e) => e.id);
|
|
90
|
+
return { status: missing.length === 0 ? 'complete' : 'fake', claimTotal, checked, missing };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 勾选节奏检测(2026-09-26-thin-check-cadence):watcher 事件流里 task-done 单拍跳 ≥2 格 =
|
|
95
|
+
* 一把全勾(未按工作单元逐个勾)——thin-agent-tasks 纪律的收口侧 advisory 判定面(warn 不
|
|
96
|
+
* 阻断,接线在 flow done ledger 子步;节奏是习惯问题非造假主张,L0 硬门另有其人)。
|
|
97
|
+
* 纯函数:事件清单→最大跳格记录;无多格跳返回 null。detail 与 watcher inferEvents 生成
|
|
98
|
+
* 格式成对(`checked N→M`);解析失配(格式漂移/坏行/非 task-done)按无证据静默——fail-open。
|
|
99
|
+
*/
|
|
100
|
+
export function detectBatchCheckCadence(events) {
|
|
101
|
+
let worst = null;
|
|
102
|
+
for (const e of events || []) {
|
|
103
|
+
if (!e || e.kind !== 'task-done') continue;
|
|
104
|
+
const m = /^checked (\d+)→(\d+)$/.exec(String(e.detail || ''));
|
|
105
|
+
if (!m) continue;
|
|
106
|
+
const from = Number(m[1]);
|
|
107
|
+
const to = Number(m[2]);
|
|
108
|
+
if (to - from < 2) continue;
|
|
109
|
+
if (!worst || to - from > worst.to - worst.from) worst = { from, to, detail: e.detail, ts: e.ts };
|
|
110
|
+
}
|
|
111
|
+
return worst;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export default { detectFakeCheckCompletion, detectBatchCheckCadence };
|
package/src/setup.js
CHANGED
|
@@ -171,7 +171,7 @@ export async function cmdSetup(dir, options = {}) {
|
|
|
171
171
|
results['全局工具'] = {};
|
|
172
172
|
for (const g of GLOBAL_TOOLS) {
|
|
173
173
|
try {
|
|
174
|
-
execSync(g.checkCommand, { stdio: 'pipe', encoding: 'utf8' });
|
|
174
|
+
execSync(g.checkCommand, { stdio: 'pipe', encoding: 'utf8', windowsHide: true });
|
|
175
175
|
results['全局工具'][g.id] = true;
|
|
176
176
|
} catch {
|
|
177
177
|
results['全局工具'][g.id] = false;
|
|
@@ -252,7 +252,7 @@ export async function cmdSetup(dir, options = {}) {
|
|
|
252
252
|
const globalInstalled = new Set();
|
|
253
253
|
for (const tool of GLOBAL_TOOLS) {
|
|
254
254
|
try {
|
|
255
|
-
execSync(tool.checkCommand, { stdio: 'pipe', encoding: 'utf8' });
|
|
255
|
+
execSync(tool.checkCommand, { stdio: 'pipe', encoding: 'utf8', windowsHide: true });
|
|
256
256
|
globalInstalled.add(tool.id);
|
|
257
257
|
} catch {}
|
|
258
258
|
}
|
|
@@ -379,7 +379,7 @@ export async function cmdSetup(dir, options = {}) {
|
|
|
379
379
|
for (const tool of selectedGlobal) {
|
|
380
380
|
const spinner = ora(`安装 ${tool.name}...`).start();
|
|
381
381
|
try {
|
|
382
|
-
execSync(tool.installCommand, { stdio: 'pipe', encoding: 'utf8', timeout: 120000 });
|
|
382
|
+
execSync(tool.installCommand, { stdio: 'pipe', encoding: 'utf8', timeout: 120000, windowsHide: true });
|
|
383
383
|
spinner.succeed(`${tool.name} 安装完成`);
|
|
384
384
|
} catch (err) {
|
|
385
385
|
spinner.fail(`${tool.name} 安装失败: ${err.message}`);
|