sillyspec 3.33.7 → 3.33.9

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.
Files changed (41) hide show
  1. package/AGENTS.md +3 -2
  2. package/eval/knowledge/README.md +119 -0
  3. package/eval/knowledge/baseline.json +907 -0
  4. package/eval/knowledge/datasets/organic.jsonl +79 -0
  5. package/eval/knowledge/fixtures/index.mjs +132 -0
  6. package/eval/knowledge/lib/dataset.mjs +171 -0
  7. package/eval/knowledge/lib/engine.mjs +136 -0
  8. package/eval/knowledge/lib/faces.mjs +88 -0
  9. package/eval/knowledge/lib/metrics.mjs +56 -0
  10. package/eval/knowledge/lib/probes.mjs +98 -0
  11. package/eval/knowledge/lib/report.mjs +82 -0
  12. package/eval/knowledge/lib/repos.mjs +41 -0
  13. package/eval/knowledge/lib/snapshot.mjs +55 -0
  14. package/eval/knowledge/run.mjs +206 -0
  15. package/package.json +3 -2
  16. package/src/change-delete.js +24 -1
  17. package/src/checkpoint.js +258 -0
  18. package/src/config-schema.js +36 -0
  19. package/src/flow-parity.js +10 -50
  20. package/src/flow-review.js +4 -0
  21. package/src/flow.js +44 -10
  22. package/src/foreign-declared.js +2 -1
  23. package/src/index.js +100 -46
  24. package/src/knowledge-graph.js +243 -25
  25. package/src/knowledge-match.js +130 -21
  26. package/src/knowledge-stats.js +3 -0
  27. package/src/knowledge-vector.js +29 -4
  28. package/src/overlap-detect.js +68 -0
  29. package/src/redomain-unmapped.js +163 -2
  30. package/src/run/complete.js +20 -1
  31. package/src/run/gate-snapshot.js +3 -1
  32. package/src/run/prompt.js +67 -37
  33. package/src/run/quick-audit.js +58 -10
  34. package/src/run/shared.js +7 -1
  35. package/src/run/verify-quality-scan.js +52 -7
  36. package/src/stages/execute.js +64 -16
  37. package/src/stages/knowledge.js +57 -28
  38. package/src/task-tick.js +16 -0
  39. package/src/verify-postcheck.js +330 -3
  40. package/templates/agents-instruction.md +1 -1
  41. package/src/knowledge-quicklog.js +0 -261
@@ -0,0 +1,258 @@
1
+ /**
2
+ * checkpoint.js — 任务级影子提交存档(tick 即快照,2026-10-28-shadow-checkpoint)
3
+ *
4
+ * 定位:断点恢复从「进度级」(tasks.md 记做到哪)升级到「内容级」(工作区长什么样)——
5
+ * task tick 成功翻格后落一个 git 影子提交到 refs/sillyspec/checkpoints/<change>,agent
6
+ * 跑歪/改崩时 git restore --source 一键读档。git 对象库本身是内容寻址存储:增量成本=
7
+ * 只哈希变化文件,相同内容全局一份——零新依赖即得 CAS 快照性质。
8
+ *
9
+ * 机制(多 agent 共享仓安全是第一约束):
10
+ * - 临时索引 GIT_INDEX_FILE=.git/sillyspec-cp-index-<session>(会话隔离)——read-tree HEAD
11
+ * 起底 → add -A(硬排除清单)→ write-tree → commit-tree 成链(父=上一存档,首档父=
12
+ * 当时 HEAD)→ update-ref。共享 index 全程零读写(staged 面不可能被卷走,规则 11 纪律)。
13
+ * - 无变化 no-op:write-tree 结果与现 ref 树 SHA 相同即跳过(零对象零 ref 翻转)。
14
+ * - fail-open 铁律:本模块任何失败只返回 skipped(reason 留痕),绝不抛出阻断 tick。
15
+ *
16
+ * 边界:worktree 会话跳过(v1 主仓形态——git-dir ≠ common-dir 判定);未忽略 untracked
17
+ * 总量超阈值(缺省 200MB,checkpoint.maxUntrackedMB)早停跳过;.sillyspec/.runtime 与
18
+ * 常见产物目录/.env* 硬排除不进快照(gitignore 之外的第二道防线)。
19
+ */
20
+ import { existsSync, readFileSync, rmSync, statSync } from 'fs'
21
+ import { join, resolve as resolvePath } from 'path'
22
+ import { git, gitQuiet } from './git-helper.js'
23
+
24
+ const REF_PREFIX = 'refs/sillyspec/checkpoints/'
25
+ const DEFAULT_MAX_UNTRACKED_MB = 200
26
+ const SUBJECT_PREFIX = 'sillyspec-cp: '
27
+ const NOOP_GUARD_TREE_TIMEOUT_MS = 60000
28
+
29
+ /** 硬排除清单(add 后在临时索引上 reset 的路径)。目录用 glob 双星任意深度形态
30
+ * (:(glob) 魔法 + 前置双星斜杠 + 尾部斜杠双星——实证 2.45:缺尾部 /** 时零匹配,
31
+ * 根级与嵌套 packages/x/node_modules 一并排除,2026-10-28-cp-review-debt FR-01);
32
+ * .env* 与 .sillyspec/.runtime 维持根级语义。 */
33
+ const EXCLUDE_DIRS = ['node_modules', 'dist', 'build', 'target', '.venv', 'venv', '__pycache__', '.next', '.cache', 'coverage']
34
+ const RESET_SPECS = [...EXCLUDE_DIRS.map((d) => `:(glob)**/${d}/**`), '.env', '.env.*', '.sillyspec/.runtime']
35
+
36
+ /** 与 EXCLUDE_SPECS 同口径的 JS 判定(体积护栏用——只计真会进快照的文件)。 */
37
+ function isHardExcluded(p) {
38
+ const segs = p.split('/')
39
+ if (segs.some((s) => EXCLUDE_DIRS.includes(s))) return true
40
+ const root = segs[0]
41
+ if (root === '.env' || /^\.env\..+$/.test(root)) return true
42
+ if (p === '.sillyspec/.runtime' || p.startsWith('.sillyspec/.runtime/')) return true
43
+ return false
44
+ }
45
+
46
+ const SAFE_CHANGE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
47
+
48
+ /**
49
+ * 读 .sillyspec/local.yaml 的 checkpoint 块(手解析单一键路径,沿 worktree supplyFiles
50
+ * 先例零 yaml 依赖)。fail-open:文件缺失/解析异常一律返回缺省(enabled=true)。
51
+ * @returns {{enabled: boolean, maxUntrackedMB: number}}
52
+ */
53
+ export function readCheckpointConfig(repoRoot) {
54
+ const defaults = { enabled: true, maxUntrackedMB: DEFAULT_MAX_UNTRACKED_MB }
55
+ try {
56
+ const p = join(repoRoot, '.sillyspec', 'local.yaml')
57
+ if (!existsSync(p)) return defaults
58
+ const vals = {}
59
+ let inBlock = false
60
+ for (const rawLine of readFileSync(p, 'utf8').split(/\r?\n/)) {
61
+ const line = rawLine.replace(/\t/g, ' ')
62
+ if (/^checkpoint:\s*(#.*)?$/.test(line)) { inBlock = true; continue }
63
+ if (!inBlock) continue
64
+ if (/^\S/.test(line)) break // 出块:下一个顶层键
65
+ const m = line.match(/^\s+([\w.-]+):\s*(.*?)\s*(#.*)?$/)
66
+ if (m && m[2] !== '') vals[m[1]] = m[2]
67
+ }
68
+ const enabled = vals.enabled === undefined ? true : !/^(false|off|no|0)$/i.test(String(vals.enabled))
69
+ const mb = Number(vals.maxUntrackedMB)
70
+ return { enabled, maxUntrackedMB: Number.isFinite(mb) && mb > 0 ? mb : DEFAULT_MAX_UNTRACKED_MB }
71
+ } catch {
72
+ return defaults
73
+ }
74
+ }
75
+
76
+ /** 主仓判定:worktree 会话(git-dir ≠ common-dir)v1 跳过存档。 */
77
+ function isWorktreeSession(repoRoot) {
78
+ const gitDir = gitQuiet(repoRoot, ['rev-parse', '--absolute-git-dir'])
79
+ const commonDir = gitQuiet(repoRoot, ['rev-parse', '--git-common-dir'])
80
+ if (!gitDir || !commonDir) return false // 探测失败不拦(留给后续 git 调用自然 fail-open)
81
+ if (commonDir === '.git') return false // 主仓:两者同指
82
+ try {
83
+ return resolvePath(repoRoot, gitDir) !== resolvePath(repoRoot, commonDir)
84
+ } catch {
85
+ return gitDir !== commonDir
86
+ }
87
+ }
88
+
89
+ /**
90
+ * 落一个影子提交(fail-open:任何失败返回 {kind:'skipped', reason},绝不抛出)。
91
+ * @param {{repoRoot: string, changeName: string, session?: string, taskId?: string}} opts
92
+ * @returns {Promise<{kind:'created'|'noop'|'skipped', sha?: string, treeSha?: string,
93
+ * parent?: string, files?: number, reason?: string, detail?: string}>}
94
+ */
95
+ export async function createCheckpoint({ repoRoot, changeName, session, taskId }) {
96
+ const name = String(changeName || '')
97
+ if (!SAFE_CHANGE_NAME_RE.test(name)) return { kind: 'skipped', reason: 'invalid-change-name', detail: name }
98
+ const cfg = readCheckpointConfig(repoRoot)
99
+ if (!cfg.enabled) return { kind: 'skipped', reason: 'checkpoint-disabled' }
100
+ if (isWorktreeSession(repoRoot)) return { kind: 'skipped', reason: 'worktree-session' }
101
+
102
+ const ref = REF_PREFIX + name
103
+ const gitDir = gitQuiet(repoRoot, ['rev-parse', '--absolute-git-dir'])
104
+ if (!gitDir) return { kind: 'skipped', reason: 'not-a-git-repo' }
105
+ const sessionId = String(session || process.env.SILLYSPEC_SESSION_ID || 'anon').replace(/[^A-Za-z0-9._-]/g, '_')
106
+ const tmpIndex = join(gitDir, `sillyspec-cp-index-${sessionId}`)
107
+ // env 展开铁律(git-helper 终批-③):裸替换丢 SystemRoot/TEMP 在 Windows 必炸
108
+ const idxEnv = { ...process.env, GIT_INDEX_FILE: tmpIndex }
109
+ const head = gitQuiet(repoRoot, ['rev-parse', 'HEAD'])
110
+ if (!head) return { kind: 'skipped', reason: 'no-head-commit' }
111
+
112
+ try {
113
+ rmSync(tmpIndex, { force: true }) // 清上次中断残留(同会话同路径幂等)
114
+ git(repoRoot, ['read-tree', 'HEAD'], { env: idxEnv })
115
+
116
+ // 体积护栏:只计真会进快照的 untracked(硬排除同口径),累计超限早停
117
+ const maxBytes = cfg.maxUntrackedMB * 1024 * 1024
118
+ const othersRaw = gitQuiet(repoRoot, ['ls-files', '--others', '--exclude-standard', '-z'])
119
+ if (othersRaw) {
120
+ let bytes = 0
121
+ for (const p of othersRaw.split('\0')) {
122
+ if (!p || isHardExcluded(p)) continue
123
+ try {
124
+ bytes += statSync(join(repoRoot, p)).size
125
+ } catch { /* 文件瞬失:计 0 不拦 */}
126
+ if (bytes > maxBytes) return { kind: 'skipped', reason: 'untracked-size-over-limit', detail: `${Math.round(bytes / 1048576)}MB > ${cfg.maxUntrackedMB}MB` }
127
+ }
128
+ }
129
+
130
+ // 裸 add -A(无 pathspec):ignored 文件静默跳过——显式 pathspec 形态遇 gitignored
131
+ // 候选会整体报错拒加("paths are ignored by .gitignore",.sillyspec/ 被忽略的仓必炸)。
132
+ // 硬排除随后以 reset 收窄:临时索引上把排除路径退回 HEAD 态(不在 HEAD 的即整条移出)。
133
+ git(repoRoot, ['add', '-A'], { env: idxEnv, timeout: NOOP_GUARD_TREE_TIMEOUT_MS })
134
+ git(repoRoot, ['reset', '-q', '--', ...RESET_SPECS], { env: idxEnv })
135
+ const treeSha = git(repoRoot, ['write-tree'], { env: idxEnv })
136
+
137
+ const current = gitQuiet(repoRoot, ['rev-parse', '--verify', ref])
138
+ if (current) {
139
+ const currentTree = gitQuiet(repoRoot, ['rev-parse', `${ref}^{tree}`])
140
+ if (currentTree === treeSha) return { kind: 'noop', treeSha } // 无变化:零对象零翻转
141
+ }
142
+
143
+ const parent = current || head
144
+ const subject = `${SUBJECT_PREFIX}${name} ${taskId || '-'} ${new Date().toISOString()}`
145
+ const message = `${subject}\n\nshadow checkpoint for ${name}\ntask: ${taskId || '-'}\nsession: ${sessionId}\n`
146
+ // 身份兜底(沿 worktree baseline checkpoint 先例):未配置 user.email 时注入临时身份,
147
+ // 已配置则用真实身份——存档提交可归属
148
+ const cpEnv = { ...process.env }
149
+ if (!gitQuiet(repoRoot, ['config', 'user.email'])) {
150
+ cpEnv.GIT_AUTHOR_NAME = 'sillyspec-checkpoint'
151
+ cpEnv.GIT_AUTHOR_EMAIL = 'checkpoint@sillyspec.local'
152
+ cpEnv.GIT_COMMITTER_NAME = 'sillyspec-checkpoint'
153
+ cpEnv.GIT_COMMITTER_EMAIL = 'checkpoint@sillyspec.local'
154
+ }
155
+ const sha = git(repoRoot, ['commit-tree', treeSha, '-p', parent, '-F', '-'], { input: message, env: cpEnv })
156
+ git(repoRoot, ['update-ref', ref, sha])
157
+
158
+ let files = 0
159
+ const changed = gitQuiet(repoRoot, ['diff-tree', '--no-commit-id', '--name-only', '-r', sha])
160
+ if (changed) files = changed.split('\n').filter(Boolean).length
161
+ return { kind: 'created', sha, treeSha, parent, files }
162
+ } catch (e) {
163
+ return { kind: 'skipped', reason: 'git-error', detail: e && e.message ? e.message : String(e) }
164
+ } finally {
165
+ try { rmSync(tmpIndex, { force: true }) } catch { /* 清理失败:同会话下次覆盖 */ }
166
+ }
167
+ }
168
+
169
+ /**
170
+ * 列出变更的存档链(按时间倒序;链尾混入主历史,按 subject 前缀过滤)。
171
+ * @returns {Promise<Array<{sha: string, taskId: string|null, ts: number, files: number}>>}
172
+ */
173
+ export async function listCheckpoints({ repoRoot, changeName }) {
174
+ const name = String(changeName || '')
175
+ if (!SAFE_CHANGE_NAME_RE.test(name)) return []
176
+ const ref = REF_PREFIX + name
177
+ const log = gitQuiet(repoRoot, ['log', '--format=%H%x09%at%x09%s', '-n', '200', ref])
178
+ if (!log) return []
179
+ const out = []
180
+ for (const line of log.split('\n')) {
181
+ if (!line) continue
182
+ const [sha, at, subject] = line.split('\t')
183
+ if (!subject || !subject.startsWith(SUBJECT_PREFIX)) continue // 链尾以下的主历史止步
184
+ const m = /^sillyspec-cp: \S+ (task-\d+|-) /.exec(subject)
185
+ const files = gitQuiet(repoRoot, ['diff-tree', '--no-commit-id', '--name-only', '-r', sha])
186
+ out.push({ sha, taskId: m && m[1] !== '-' ? m[1] : null, ts: Number(at) * 1000, files: files ? files.split('\n').filter(Boolean).length : 0 })
187
+ }
188
+ return out
189
+ }
190
+
191
+ /**
192
+ * 恢复预览:工作区 vs 存档点的 diff(patch 与 stat 各一份)。
193
+ * @returns {Promise<{stat: string, patch: string}>}
194
+ */
195
+ export async function previewRestore({ repoRoot, sha, paths = [] }) {
196
+ const spec = ['--', ...(paths && paths.length ? paths : ['.'])]
197
+ const stat = git(repoRoot, ['diff', '--stat', sha, ...spec])
198
+ const patch = git(repoRoot, ['diff', sha, ...spec])
199
+ return { stat, patch }
200
+ }
201
+
202
+ /**
203
+ * 链内校验(无上限):subject 前缀过滤全链——不复用 listCheckpoints 的 -n 200 显示帽
204
+ * (截断解耦,2026-10-28-cp-review-debt FR-03:超帽旧档同样可读,主历史普通提交仍拒)。
205
+ */
206
+ function isCheckpointInChain(repoRoot, ref, sha) {
207
+ const subjects = gitQuiet(repoRoot, ['log', '--format=%H %s', ref])
208
+ if (!subjects) return false
209
+ for (const line of subjects.split('\n')) {
210
+ if (!line) continue
211
+ const commitSha = line.slice(0, 40)
212
+ const subject = line.slice(41)
213
+ if (!subject.startsWith(SUBJECT_PREFIX)) break // 链尾以下为主历史,止步
214
+ if (commitSha === sha) return true
215
+ }
216
+ return false
217
+ }
218
+
219
+ /**
220
+ * 读档:git restore --source(仅写工作区,零触碰 index)。调用方负责 --yes 确认纪律。
221
+ * @param {{repoRoot: string, sha: string, paths?: string[], all?: boolean}} opts
222
+ * @returns {Promise<{ok: boolean, reason?: string, restored: string[]}>}
223
+ */
224
+ export async function restoreCheckpoint({ repoRoot, sha, changeName, paths = [], all = false }) {
225
+ const name = String(changeName || '')
226
+ const ref = REF_PREFIX + name
227
+ if (!SAFE_CHANGE_NAME_RE.test(name) || !isCheckpointInChain(repoRoot, ref, sha)) {
228
+ return { ok: false, reason: 'sha 不在该变更存档链中(用 checkpoint --change 列出可用 SHA)', restored: [] }
229
+ }
230
+ const target = (all || !paths.length) ? ['.'] : paths
231
+ try {
232
+ git(repoRoot, ['restore', `--source=${sha}`, '--', ...target])
233
+ return { ok: true, restored: target }
234
+ } catch (e) {
235
+ return { ok: false, reason: e && e.message ? e.message : String(e), restored: [] }
236
+ }
237
+ }
238
+
239
+ /** 删除变更的存档 ref(change-delete 清理步用;best-effort 不抛出)。 */
240
+ export function deleteCheckpointRef(repoRoot, changeName) {
241
+ const name = String(changeName || '')
242
+ if (!SAFE_CHANGE_NAME_RE.test(name)) return false
243
+ try {
244
+ git(repoRoot, ['update-ref', '-d', REF_PREFIX + name])
245
+ return true
246
+ } catch {
247
+ return false
248
+ }
249
+ }
250
+
251
+ /** ref 存在性探测(dry-run 报告面用)。 */
252
+ export function hasCheckpointRef(repoRoot, changeName) {
253
+ const name = String(changeName || '')
254
+ if (!SAFE_CHANGE_NAME_RE.test(name)) return false
255
+ return Boolean(gitQuiet(repoRoot, ['rev-parse', '--verify', REF_PREFIX + name]))
256
+ }
257
+
258
+ export default { createCheckpoint, listCheckpoints, previewRestore, restoreCheckpoint, deleteCheckpointRef, hasCheckpointRef, readCheckpointConfig }
@@ -72,12 +72,33 @@ export const LOCAL_YAML_SCHEMA = {
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
73
  { path: 'commands.test_timeout_sec', type: 'number', optional: true, status: 'live', readers: ['resolveTestTimeoutMs (src/verify-postcheck.js)'], desc: 'commands.test 超时秒数(R9 实证 2026-09-23:全量套件实测可达 27min,固定 600s 帽必杀)——优先级 local.yaml 本键 > env SILLYSPEC_TEST_TIMEOUT_MS > 缺省 600。慢仓在此提帽,防真慢套件被当超时失败。', example: '2400' },
74
74
  { path: 'commands.lint_timeout_sec', type: 'number', optional: true, status: 'live', readers: ['runVerifyLintCheck (src/verify-postcheck.js)'], desc: 'commands.lint 超时秒数——优先级 显式调用参 > 本键 > env SILLYSPEC_LINT_TIMEOUT_MS > 缺省 180(快照内 junction I/O 慢,快照路径调用侧显式传 300)。', example: '600' },
75
+ { path: 'commands.metrics', type: 'string', optional: true, status: 'live', readers: ['runVerifyMetricsCheck (src/verify-postcheck.js)', 'runQuickTestLintGate (src/run/quick-audit.js)'], desc: '【2026-10-29-open-metrics-gate】可选指标门命令——收口实测在隔离快照内执行,非零退出=门红阻断收口;未配置或标 unavailable 跳过留痕(fail-open)。适合复杂度上限/重复率/死代码/覆盖率阈值类秒级检查:命令开放不绑定工具,生态/构建工具检查任务(npx 类零安装、mvn/gradle 检查目标等)或仓内自写脚本均可;执行时注入 SILLYSPEC_CHANGE_FILES(本变更文件清单,POSIX 相对路径换行分隔)供大仓增量门只扫变更面(历史存量债不拦新变更);变异测试等慢工具建议提帽或放 pre-push/CI 层。', example: 'npx jscpd src' },
76
+ { path: 'commands.metrics_timeout_sec', type: 'number', optional: true, status: 'live', readers: ['runVerifyMetricsCheck (src/verify-postcheck.js)'], desc: 'commands.metrics 超时秒数——优先级 显式调用参 > 本键 > env SILLYSPEC_METRICS_TIMEOUT_MS(毫秒) > 缺省 180(快照内 junction I/O 慢,快照路径调用侧显式传 300)。', example: '600' },
75
77
  { 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' },
76
78
  { path: 'commands.install', type: 'string', optional: true, status: 'live', readers: ['extractInstallCommand (src/worktree-deps.js)'], desc: '依赖安装命令(worktree 依赖 provisioning 用)。', example: 'npm install' },
77
79
  { path: 'commands.knowledge-gate', type: 'boolean', optional: true, status: 'live', readers: ['completeStep (src/run/complete.js)'], desc: '【2026-09-28-unclear-req-to-brainstorm FR-03/D-004】brainstorm 方案步(提出 2-3 种方案)--done 知识检索命中回显开关——缺省 true=开(对 --output 与 decisions.md 条目跑既有检索匹配器,命中 warn 回显「须 evidence 回应或说明不复潮」不阻断);false(或 off)关回显,无命中时输出与现状一致。', example: 'false' },
78
80
  { path: 'commands.scan:check', type: 'string', optional: true, status: 'live', readers: ['scan 漂移检查(scripts/scan-drift-check.py 经 scan 流程调用)'], desc: '【2026-10-23-schema-registration-debt 平台仓 dogfood 补登】scan 产物漂移自检命令。', example: 'python scripts/scan-drift-check.py' },
79
81
  ],
80
82
  },
83
+ {
84
+ id: 'metrics_routes',
85
+ title: '指标门路由(多语言/monorepo)',
86
+ note: '【2026-10-29-metrics-routes】与 commands.metrics 单命令门共存:按变更文件面 ∩ gate.paths 路径前缀路由,仅执行命中 gate(串行);语言中立(路径集合,不枚举语言/框架)。段未配置=零变化。',
87
+ keys: [
88
+ { path: 'metrics_routes.<name>.paths', type: 'array', optional: true, status: 'live', readers: ['extractMetricsRoutes (src/verify-postcheck.js)'], desc: 'gate 的路径前缀集合(仓根相对)——段全等匹配:frontend/ 不命中 frontend-v2/;引号数组 `["a/", "b/"]` 或逗分裸串均可。', example: '["frontend/", "packages/web/"]' },
89
+ { path: 'metrics_routes.<name>.command', type: 'string', optional: true, status: 'live', readers: ['runVerifyMetricsRoutesCheck (src/verify-postcheck.js)'], desc: 'gate 的指标门命令——gate 命中(变更文件面 ∩ paths 非空)才执行,非零退出=门红;gate 子进程注入 SILLYSPEC_CHANGE_FILES=该 gate 命中子集(gate 内工具可增量扫)。', example: 'npx --prefix frontend eslint . --max-warnings 0' },
90
+ { path: 'metrics_routes.<name>.timeout_sec', type: 'number', optional: true, status: 'live', readers: ['runVerifyMetricsRoutesCheck (src/verify-postcheck.js)'], desc: 'gate 级超时秒数——优先级 本键 > 调用侧快照帽 > commands.metrics_timeout_sec > env SILLYSPEC_METRICS_TIMEOUT_MS(毫秒)> 缺省 180。', example: '300' },
91
+ ],
92
+ },
93
+ {
94
+ id: 'checkpoint',
95
+ title: '影子存档(task tick 快照)',
96
+ note: '机制先落代码缺省(fail-open,缺省零回归)——键只用于仓主显式调优(2026-10-28-shadow-checkpoint)。',
97
+ keys: [
98
+ { path: 'checkpoint.enabled', type: 'boolean', optional: true, status: 'live', readers: ['readCheckpointConfig (src/checkpoint.js)'], desc: '影子存档开关——task tick 成功翻格后落 git 影子提交到 refs/sillyspec/checkpoints/<change>(临时索引零触碰共享暂存区,断点恢复从进度级升到内容级)。缺省 true;false 关闭(tick 照常)。解析失败 fail-open 取缺省。', example: 'false' },
99
+ { path: 'checkpoint.maxUntrackedMB', type: 'number', optional: true, status: 'live', readers: ['readCheckpointConfig (src/checkpoint.js)'], desc: '体积护栏——未忽略 untracked 总量超过该 MB 数时跳过本次存档并留痕(早停判定,不遍历全树;硬排除目录不计入)。缺省 200。', example: '50' },
100
+ ],
101
+ },
81
102
  {
82
103
  id: 'knowledge',
83
104
  title: '知识检索(平台向量语义召回)',
@@ -423,8 +444,23 @@ commands:
423
444
  # install: npm install # worktree 依赖安装命令
424
445
  # test_timeout_sec: 2400 # commands.test 超时秒数(慢仓提帽;缺省 600,env SILLYSPEC_TEST_TIMEOUT_MS 同效)
425
446
  # lint_timeout_sec: 600 # commands.lint 超时秒数(缺省 180)
447
+ # metrics: npx jscpd src # 可选指标门:收口实测非零退出阻断(复杂度/重复/死代码/覆盖率阈值类秒级检查;执行注入 SILLYSPEC_CHANGE_FILES=本变更文件清单可只扫变更面;变异测试类慢工具建议 pre-push/CI)
448
+ # metrics_timeout_sec: 600 # commands.metrics 超时秒数(缺省 180)
449
+ # metrics_routes: # 多语言/monorepo 路由门(与上面 metrics 单命令可共存;仅跑变更命中的 gate)
450
+ # web:
451
+ # paths: ["frontend/", "packages/web/"] # 路径前缀集合(段全等:frontend/ 不蹭 frontend-v2/)
452
+ # command: npx --prefix frontend eslint . --max-warnings 0
453
+ # jvm:
454
+ # paths: ["backend/"]
455
+ # command: mvn -B -f backend/pom.xml pmd:check
456
+ # timeout_sec: 300 # gate 级超时(缺省 180);gate 子进程注入 SILLYSPEC_CHANGE_FILES=命中子集
426
457
  # knowledge-gate: false # 方案步 --done 知识检索命中回显开关(缺省 true 开;false 关回显)
427
458
 
459
+ # ── 影子存档(task tick 快照;缺省开启零配置,仅在显式调优时写)──
460
+ # checkpoint:
461
+ # enabled: true # 影子存档开关(false 关闭,tick 照常)
462
+ # maxUntrackedMB: 200 # 体积护栏——未忽略 untracked 总量超此 MB 数跳过存档留痕
463
+
428
464
  # ── 知识检索(平台向量语义召回)──
429
465
  # 缺省(未写整段)跟随平台能力自动探测:端点在场自动启用、未部署自动跳过(部署后自动
430
466
  # 启用,零配置)。仅显式逃生阀时才需要写:
@@ -305,60 +305,20 @@ export function renderVerifyReceipt({ change, baseline, head, gateSummary, revie
305
305
  }
306
306
 
307
307
  /**
308
- * design 槽4(风险与死路)实质作答收割 → decisions.md(已有不覆盖)。
309
- * @returns {{harvested: boolean, reason?: string}}
308
+ * design 槽4(风险与死路)收割——已下线(2026-11-02-slot4-harvest-retire)。
309
+ *
310
+ * 历史动机(2026-09-25-thin-parity-assets):轻量变更决策产出从零到一,合成底稿条目随蒸馏
311
+ * 链进 knowledge。4A 数据对账实证产物全是模板底稿(本仓 47 条、平台 173 条),标题模板化、
312
+ * 无真否决语义——底稿资格门(series-final-cleanup)已解决行为面(不占防复潮席位),本变更
313
+ * 根治数据面:不再合成伪决策条目。风险分析内容保留在 design.md 归档冻结件(信息零丢失)。
314
+ *
315
+ * 函数保留签名与导出(flow.js 调用点已摘除,anchorBackfillCandidate 同文件导出不能删)。
316
+ * @returns {{harvested: boolean, reason: string}} 恒 {harvested:false}
310
317
  */
311
318
  export function harvestSlot4Decision({ changeDir, change }) {
312
319
  const decPath = join(changeDir, 'decisions.md')
313
320
  if (existsSync(decPath)) return { harvested: false, reason: 'decisions.md 已在场(不覆盖)' }
314
- let dText
315
- try { dText = readFileSync(join(changeDir, 'design.md'), 'utf8') } catch { return { harvested: false, reason: '无 design.md' } }
316
- const lines = dText.replace(/\r\n/g, '\n').split('\n')
317
- let inSlot = false
318
- const buf = []
319
- for (const line of lines) {
320
- if (/^<!--\s*AGENT:槽4/.test(line)) { inSlot = true; continue }
321
- if (inSlot && (/^<!--/.test(line) || /^#{1,6}\s/.test(line))) break
322
- if (inSlot) buf.push(line)
323
- }
324
- let answer = buf.join('\n').trim()
325
- // v2 纯 markdown(2026-10-04-thin-docs-v2):无槽标记时改读「风险与死路」节正文(复用
326
- // flow-review 的 v2 节读取——剥离问题原文行;单一源 DESIGN_QUESTIONS 防镜像漂移)
327
- if (!inSlot) {
328
- const risksSec = DESIGN_QUESTIONS.sections.find((s) => s.key === 'risks')
329
- answer = readV2SectionAnswer(dText, risksSec.heading, DESIGN_QUESTIONS)
330
- }
331
- if (!answer || /^不适用/.test(answer)) return { harvested: false, reason: '槽4 空/不适用' }
332
- // 锚点自动回填(2026-10-15-decision-anchor-backfill 收割端):风险段作答文本恰含一个
333
- // 路径 token → 写入 - 锚点:行(源 decisions.md 是真相源,蒸馏自然带上、人可改);
334
- // 0/多候选不写(蒸馏端渲染时还会再兜底一次同款判定)。单一源 anchorBackfillCandidate。
335
- const anchorCand = anchorBackfillCandidate(answer)
336
- const anchorLine = anchorCand ? [`- 锚点:${anchorCand}`] : []
337
- const text = [
338
- '---',
339
- `author: flow-machine-draft`,
340
- `created_at: ${new Date().toISOString()}`,
341
- '---',
342
- `# 决策记录(Decisions)— ${change}`,
343
- '',
344
- // 标题自解释(2026-10-15-harvest-title-clarify):读者面是 decisions 文档与 knowledge,
345
- // 机制出处(槽4 收割)由 frontmatter author: flow-machine-draft 承载,标题只说内容与性质。
346
- `## D-001@v1: 设计期风险与放弃方案(底稿)`,
347
- `- 类型:process`,
348
- `- 状态:confirmed`,
349
- ...anchorLine,
350
- // 类型/状态字段(2026-09-26-slot4-distill-fix):蒸馏链入选要 status∈{confirmed|accepted|rejected}
351
- // 且 type∈七类白名单,字段解析只认 `- 字段:值` 列表行形态——此前收割条目零字段致永不入选,
352
- // 「随蒸馏链进 knowledge」断链(thin-agent-tasks 教训留档归档而 knowledge 零落地的实证)。
353
- // 槽4 是定案的流程/方法论取舍,process+confirmed 语义成立。
354
- `- 答案:${answer.replace(/\s*\n+\s*/g, ' ')}`,
355
- // 正文用「答案」标签(同修):distill 落盘「理由:」行取 entry.answer——此前「- 决策:」非白名单
356
- // 字段,正文不随条目进 knowledge(骨架落盘内容丢失)。一行展平(评审 P1 清偿):字段解析是
357
- // 单行契约,多行槽4 作答经 \n 续行会静默丢后续行——收割端压平为单行(空格分隔)保全文。
358
- '',
359
- ].join('\n')
360
- writeAtomicSync(decPath, text)
361
- return { harvested: true }
321
+ return { harvested: false, reason: '底稿收割已下线(2026-11-02-slot4-harvest-retire)——风险分析保留在 design.md 归档件,不再合成伪决策条目' }
362
322
  }
363
323
 
364
324
  export default { reconcileModuleDocs, renderVerifyReceipt, harvestSlot4Decision, collectFreezeFiles, backfillGateSummary, motiveFromInput }
@@ -303,6 +303,10 @@ export function renderReviewerTaskbook({ change, changeDir, head = null, probeLi
303
303
  ` 没有任何声明边界时写「无声明边界」。未裁决=未审。`,
304
304
  ` 4. 作答中的防护声明(如有):声称的防护机制在代码里真实存在且生效?`,
305
305
  ` 5. 生成物同步(如涉及):类型/契约文件与实现对齐?`,
306
+ // 测试有效性与根因性(2026-10-29-open-metrics-gate task-04,ub-mode「Test Behavior, Not
307
+ // Implementation」/「Fix Root Causes」转写):从「测试存在且绿」升级到「测试有效」——空实现
308
+ // 判据给评审员可操作的假测试检验法,根因判据拦「吞崩溃 guard 冒充修复」。
309
+ ` 6. 测试有效性与根因性(假测试判据两问):空实现判据——把绑定测试所测的实现改成全部返回空的空操作,若测试仍可能全绿则测试无效(断言须测可观察行为,mock 一切零断言的摆设测试按无效报);根因判据——diff 中的修复是否只是吞崩溃/藏失败的 guard(兜底掩盖≠根因消除,须指出并按严重度裁决)。`,
306
310
  // 项目评审探针(2026-10-12-review-probe-injection):local.yaml review_probes 数据驱动注入——
307
311
  // 检查单生产权收归 CLI 的项目特有面(dogfood 实证:主代理手写清单没想到的面无人查)。
308
312
  // 未配置零注入——本段缺省时任务书与历史形态逐字一致(零回归)。
package/src/flow.js CHANGED
@@ -31,6 +31,7 @@ import { join, relative, dirname } from 'node:path'
31
31
  import { DatabaseSync } from 'node:sqlite'
32
32
  import yaml from 'js-yaml'
33
33
  import { git, gitQuiet } from './git-helper.js'
34
+ import { scopeTelemetryRefs } from './knowledge-graph.js'
34
35
  import { writeAtomicSync } from './fs-atomic.js'
35
36
  import { resolveRuntimeRoot, triggerSync, triggerSyncTerminalStrict, assertDatedChangeName } from './run/shared.js'
36
37
  import { detectUiTouch, buildUiGuidanceLines, runUiVisualProbe, readUiVisualGate, UI_EVIDENCE_FILENAME } from './ui-visual.js'
@@ -308,6 +309,7 @@ export async function flowKnowledgeDigest({ specBase, change, changeDir, input,
308
309
  const alwaysRepo = [...scoped.always, ...scoped.repo]
309
310
  summary.scopedEntries = alwaysRepo.length
310
311
  scopedLines.push(' 🧭 工具中立性(内置规约 00,每变更恒注入):不枚举定义开放世界、不锁死开发语言/框架——配置与机制用 CLI 自有分类(批类别/路径集合),新增语言只加探测适配。')
312
+ scopedLines.push(' 🧭 反例(任务词汇——违规现场词面相似即命中):评测工具缺省写死两个仓的路径即违规——每条路径必须来自 --repo 参数或缺省当前仓;出现具体项目名/项目特定 env/硬编码仓表即违规(2026-11-02 用户原话纠正实证)。')
311
313
  for (const e of alwaysRepo.slice(0, 5)) {
312
314
  scopedLines.push(` 📌 [${e.scope}] ${e.id}:${e.summary}`)
313
315
  }
@@ -366,13 +368,42 @@ export async function flowKnowledgeDigest({ specBase, change, changeDir, input,
366
368
  const src = km.entries.slice(0, 3).map((e) => (e.anchor ? `${e.file}#${e.anchor}` : e.file)).join(';')
367
369
  lines.push(` 📚 知识命中(INDEX 关键词,按需 Read):${src}`)
368
370
  }
371
+ // routeHits 指针行(FR-04,2026-11-02-knowledge-rank-denoise):entries 移出 fr/decisions
372
+ // 路由行后,implemented 决策与 FR 的可见性由这里承载(只指路不注入正文)。
373
+ if (km.matched && (km.routeHits || []).length > 0) {
374
+ const src = km.routeHits.slice(0, 3).map((e) => (e.anchor ? `${e.file}#${e.anchor}` : e.file)).join(';')
375
+ lines.push(` 🧭 决策/FR 路由命中(专用通道,按需 Read):${src}`)
376
+ }
377
+ // 融合增量(2026-11-02-knowledge-fusion FR-03):scope 否决独立小节——路由 rejected top-5
378
+ // 不被热文件历史否决挤占(独立语义:触碰过的文件曾被否决/记死路)
379
+ const scopeRejected = (km.scopeDecisionHits || []).filter((h) => h.deathPath || h.status === 'rejected').slice(0, 3)
380
+ if (scopeRejected.length > 0) {
381
+ lines.push(` 🧭 触达文件关联否决(scope 视角——触碰过的文件曾被否决/记死路,防复潮——除非复潮条件满足勿重提):`)
382
+ for (const h of scopeRejected) {
383
+ const note = h.status === 'rejected' ? (h.reason || '(未记录)') : `死路——${deathPathNote(h.reason)}`
384
+ lines.push(` - ${h.id} ${h.title}(${h.file}|${decisionChangeQualifier(h.change)}|${h.via === 'two-hop' ? '两跳' : '直连'})${h.status === 'rejected' ? '否决理由' : '⚰️'}:${note}`)
385
+ }
386
+ }
387
+ const scopeEntries = km.scopeEntries || []
388
+ if (scopeEntries.length > 0) {
389
+ const src = scopeEntries.slice(0, 3).map((e) => (e.anchor ? `${e.file}#${e.anchor}` : e.file)).join(';')
390
+ lines.push(` 📚 触达文件关联手工条目(按需 Read):${src}`)
391
+ }
369
392
  // 遥测导线(2026-10-15-thin-inject-telemetry):轻量道注入此前不落 hits.jsonl——thin 常态化后
370
393
  // inject 事件流断流(本仓 10-09 后归零实证),knowledge stats 从未命中判定被盲区污染。
371
394
  // 分级字段(A2-lite):injectedFiles=正文实际注入(thin 面恒空——只出按需 Read 行)、
372
395
  // mentionedFiles=显式展示给 agent 的锚点(📚 行 top-3)。既有 matchedFiles 口径不变
373
396
  // (knowledge-stats 消费面零回归)。仅命中面非空才落盘;fail-soft(R-04)不阻断注入。
374
- const thinMatchedFiles = km.matched ? (km.entries || []).map((e) => (e.anchor ? `${e.file}#${e.anchor}` : e.file)) : []
375
- if (thinMatchedFiles.length > 0 || rejected.length > 0) {
397
+ const thinMatchedFiles = km.matched ? [...(km.entries || []), ...(km.routeHits || [])].map((e) => (e.anchor ? `${e.file}#${e.anchor}` : e.file)) : []
398
+ // scope 展示面遥测(FR-02):独立字段 scopeMatchedFiles(不并入 matchedFiles——防 neverHit 失真);
399
+ // mentionedFiles 按实际展示集合(📚 top3 + 🧭 top3 + scope top3+3,去重)
400
+ const scopeMatchedFiles = km.matched ? scopeTelemetryRefs(km) : []
401
+ const mentionedFiles = [...new Set([
402
+ ...(km.entries || []).slice(0, 3).map((e) => (e.anchor ? `${e.file}#${e.anchor}` : e.file)),
403
+ ...(km.routeHits || []).slice(0, 3).map((e) => (e.anchor ? `${e.file}#${e.anchor}` : e.file)),
404
+ ...scopeMatchedFiles,
405
+ ])].slice(0, 12)
406
+ if (thinMatchedFiles.length > 0 || rejected.length > 0 || scopeMatchedFiles.length > 0) {
376
407
  try {
377
408
  const { appendKnowledgeHit } = await import('./knowledge-hits.js')
378
409
  appendKnowledgeHit(join(specBase, '.runtime'), {
@@ -381,7 +412,8 @@ export async function flowKnowledgeDigest({ specBase, change, changeDir, input,
381
412
  query: kmQuery,
382
413
  matchedFiles: thinMatchedFiles,
383
414
  injectedFiles: [],
384
- mentionedFiles: thinMatchedFiles.slice(0, 3),
415
+ mentionedFiles,
416
+ ...(scopeMatchedFiles.length > 0 ? { scopeMatchedFiles } : {}),
385
417
  rejectedCount: rejected.length,
386
418
  source: 'thin-start',
387
419
  })
@@ -2710,13 +2742,8 @@ export async function cmdFlowDone({ change, cwd, specBase, runtimeRootOpt = null
2710
2742
  // requirements 永不进 knowledge/fr,知识复利在新默认道断流;轻量变更无 design.md,域路由
2711
2743
  // 以基线以来交付 diff 供 deliverableFiles,伪域回退同口径)
2712
2744
  if (st.substeps?.distill === 'done') { skip('distill') } else {
2713
- // 槽4收割(2026-09-25-thin-parity-assets:design「风险与死路」实质作答合成 decisions.md——
2714
- // 轻量变更决策产出从零到一;已有 decisions 不覆盖)
2715
- try {
2716
- const { harvestSlot4Decision } = await import('./flow-parity.js')
2717
- const h = harvestSlot4Decision({ changeDir, change })
2718
- if (h.harvested) console.log(`🌱 槽4(风险与死路)收割 → decisions.md(底稿——作答里的放弃方案请拆成 rejected 条目再收口;随蒸馏链进 knowledge)`)
2719
- } catch { /* 收割 best-effort */ }
2745
+ // 槽4收割已下线(2026-11-02-slot4-harvest-retire):design 风险段不再合成伪决策条目——
2746
+ // 风险分析保留在 design.md 归档件(信息零丢失),底稿条目 4A 实证为纯模板噪声
2720
2747
  // 模块变更索引机械回写(2026-10-12-thin-module-gate-and-feed 二档):命中模块的卡/sidecar
2721
2748
  // 追加一行本变更条目——零语义判断(日期/变更名/动机首行全已知),split-changelog 契约 sidecar
2722
2749
  // 优先、卡内「## 变更索引」节次之、变更名在场即跳过(断点续跑幂等)。moduleScope 由上方
@@ -2942,6 +2969,13 @@ async function cmdFlowVerifyDrafts({ change, cwd, specBase, runtimeRootOpt = nul
2942
2969
  for (const a of r.advisories) console.warn(`⚠️ [门柱漂移 advisory] ${a}(不阻断批准)`)
2943
2970
  if (r.degraded) console.log(`ℹ️ 自查降级:draft-ledger 不在此机(.runtime 机本位/存量变更)——空槽兜底校验通过(v2 纯 markdown 无法对锚校验,如实降级)`)
2944
2971
  console.log(`✅ spec 面自查通过(0 violations)——通过≠批准:请把摘要给用户看,用户确认后 sillyspec flow approve --change ${change}(批准留痕后动码)`)
2972
+ // 重叠路由 advisory(2026-10-28-overlap-route FR-04):批准前最后一个高频触点——当前
2973
+ // 变更声明与他人活跃变更相交时一行分流提示。fail-open 静默:检测异常不拦批准不影响退出码。
2974
+ try {
2975
+ const { overlapAdvisoryFor } = await import('./overlap-detect.js')
2976
+ const line = overlapAdvisoryFor(change, { cwd, specBase })
2977
+ if (line) console.warn(line)
2978
+ } catch { /* advisory 静默:检测失败不拦批准 */ }
2945
2979
  } else {
2946
2980
  for (const a of r.advisories) console.warn(`⚠️ [门柱漂移 advisory] ${a}(不阻断批准)`)
2947
2981
  if (hasViolations) {
@@ -174,7 +174,8 @@ export function collectForeignDeclaredFiles(cwd, currentChangeName, opts = {}) {
174
174
  * (退回旧 foreign-first 行为,fail-closed 不放大放行面)。
175
175
  * @returns {Set<string>} 正斜杠归一后的声明路径集
176
176
  */
177
- function loadOwnDeclaredSet(specBase, runtimeRoot, currentChangeName) {
177
+ // export 补齐(2026-10-28-overlap-route):重叠检测复用声明加载单一源,防口径漂移
178
+ export function loadOwnDeclaredSet(specBase, runtimeRoot, currentChangeName) {
178
179
  const set = new Set()
179
180
  if (!currentChangeName) return set
180
181
  const norm = (p) => String(p || '').replace(/\\/g, '/')