sillyspec 3.26.7 → 3.26.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 (62) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +1 -2
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +1 -2
  3. package/.claude/skills/sillyspec-doctor/SKILL.md +2 -0
  4. package/.claude/skills/sillyspec-execute/SKILL.md +10 -2
  5. package/.claude/skills/sillyspec-explore/SKILL.md +0 -1
  6. package/.claude/skills/sillyspec-plan/SKILL.md +1 -2
  7. package/.claude/skills/sillyspec-quick/SKILL.md +1 -2
  8. package/.claude/skills/sillyspec-scan/SKILL.md +2 -2
  9. package/.claude/skills/sillyspec-status/SKILL.md +0 -1
  10. package/.claude/skills/sillyspec-verify/SKILL.md +1 -2
  11. package/.husky/pre-push +9 -2
  12. package/README.md +5 -9
  13. package/bin/sillyspec.js +1 -1
  14. package/package.json +2 -2
  15. package/packages/dashboard/server/index.js +14 -8
  16. package/src/change-risk-profile.js +5 -207
  17. package/src/config-schema.js +310 -287
  18. package/src/constants.js +97 -106
  19. package/src/db-engine.js +1 -1
  20. package/src/docs-check.js +362 -0
  21. package/src/docs-debt.js +226 -0
  22. package/src/docs-gate.js +106 -0
  23. package/src/doctor-diagnostics.js +23 -2
  24. package/src/git-helper.js +2 -2
  25. package/src/hooks/worktree-guard.js +1 -1
  26. package/src/index.js +1963 -1787
  27. package/src/init.js +95 -17
  28. package/src/machine-interface.js +7 -4
  29. package/src/modules.js +490 -486
  30. package/src/progress/stage-machine.js +3 -0
  31. package/src/progress.js +37 -13
  32. package/src/quicklog.js +182 -1
  33. package/src/run/command.js +154 -26
  34. package/src/run/complete-handlers.js +1254 -1128
  35. package/src/run/complete.js +9 -33
  36. package/src/run/gates.js +85 -1
  37. package/src/run/prompt.js +82 -1
  38. package/src/run/quick-audit.js +23 -0
  39. package/src/run/shared.js +273 -5
  40. package/src/run/stage.js +17 -1
  41. package/src/scan-diff.js +388 -0
  42. package/src/scan-staleness.js +95 -0
  43. package/src/stage-contract-spec.js +16 -1
  44. package/src/stage-contract.js +8 -5
  45. package/src/stage-review.js +1 -1
  46. package/src/stages/brainstorm-auto.js +0 -1
  47. package/src/stages/brainstorm.js +3 -3
  48. package/src/stages/execute.js +35 -11
  49. package/src/stages/plan-postcheck.js +93 -36
  50. package/src/stages/plan.js +22 -7
  51. package/src/stages/quick.js +1 -1
  52. package/src/stages/scan.js +2 -2
  53. package/src/stages/verify.js +11 -12
  54. package/src/sync.js +39 -17
  55. package/src/task-review.js +14 -5
  56. package/src/workflow.js +8 -185
  57. package/src/worktree-apply.js +128 -3
  58. package/src/worktree.js +2 -2
  59. package/templates/claude-instruction.md +1 -1
  60. package/templates/prompts/taskcard-rules.md +1 -0
  61. package/templates/prompts/verify-probes.md +2 -2
  62. package/templates/skills/sillyspec-onboard/SKILL.md +0 -92
@@ -0,0 +1,226 @@
1
+ /**
2
+ * docs-debt — 模块文档欠账事实计算(change: 2026-08-15-docs-debt-inject,债单第六节)
3
+ *
4
+ * 原则:"CLI 算事实注入"——git 事实算出本变更触及模块的文档欠账,注入 Wave prompt 的是
5
+ * 结论不是要求(advisory 不阻断,无债零输出,D-005)。
6
+ *
7
+ * 归属三级(D-003):module.paths || module.core_files(读端口径同 prompt.js loadModuleContextIndex)
8
+ * → 模块卡 doc 内容中的文件路径字面量(v1 map 兼容)→ 未匹配归 unmapped。
9
+ * behind 口径(D-004):每模块两次 git log -1 --format="%h %ct" 取时间戳判方向,
10
+ * behind = rev-list --count docCommit..srcCommit;untracked 卡 behind=null 显式"卡片从未提交";
11
+ * git 失败/超时 5s → 该模块 behind=null + 降级注记,不抛。
12
+ */
13
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
14
+ import { join, relative } from 'node:path'
15
+ import { safeGit } from './git-helper.js'
16
+ import { parseModuleMapSimple } from './modules.js'
17
+ import { runDocsCheck } from './docs-check.js'
18
+
19
+ /** safeGit 超时(大仓 git log 慢时降级该模块,不挂起 Wave 渲染) */
20
+ const GIT_TIMEOUT_MS = 5000
21
+
22
+ /**
23
+ * 纯函数:把 changedFiles 归属到模块。
24
+ * @param {string[]} changedFiles 变更文件(POSIX 相对路径)
25
+ * @param {object} moduleIndex parseModuleMapSimple 返回值({ modules: { id: { paths?, core_files?, doc? } } })
26
+ * @param {{ cardsDir: string }} opts cardsDir = <specBase>/docs/<project>/modules
27
+ * @returns {{ byModule: Map<string, { doc: string, files: string[] }>, unmapped: string[] }}
28
+ */
29
+ export function matchFilesToModules(changedFiles, moduleIndex, opts = {}) {
30
+ const byModule = new Map()
31
+ const unmapped = []
32
+ // parseModuleMapSimple 返回扁平模块对象({ id: {paths?, core_files?, doc?} },无 .modules 包层)
33
+ const modules = moduleIndex && !moduleIndex.modules ? moduleIndex : (moduleIndex?.modules || {})
34
+ const cardsDir = opts.cardsDir
35
+
36
+ // 模块 → 候选路径集合(一级:paths || core_files;同时预载卡片内容供二级)
37
+ const modulePaths = new Map() // moduleId → string[]
38
+ const cardContents = new Map() // moduleId → string
39
+ for (const [id, m] of Object.entries(modules)) {
40
+ const ps = [...(Array.isArray(m.paths) ? m.paths : []), ...(Array.isArray(m.core_files) ? m.core_files : [])]
41
+ modulePaths.set(id, ps)
42
+ if (cardsDir && m.doc) {
43
+ const cardPath = join(cardsDir, m.doc.replace(/^modules\//, ''))
44
+ if (existsSync(cardPath)) {
45
+ try { cardContents.set(id, readFileSync(cardPath, 'utf8')) } catch { /* 降级二级 */ }
46
+ }
47
+ }
48
+ }
49
+
50
+ for (const f of changedFiles) {
51
+ let matched = null
52
+ // 一级:paths/core_files 前缀或字面量命中
53
+ for (const [id, ps] of modulePaths) {
54
+ if (ps.some(p => p === f || f.startsWith(p.replace(/\/$/, '') + '/') || p.startsWith(f.split('/')[0] + '/') && p === f)) {
55
+ matched = id; break
56
+ }
57
+ }
58
+ // 二级:卡片 doc 内容中的文件路径字面量(精确子串)
59
+ if (!matched) {
60
+ for (const [id, content] of cardContents) {
61
+ if (content && content.includes(f)) { matched = id; break }
62
+ }
63
+ }
64
+ // 二级b:裸文件名匹配——卡片引用习惯是裸名(`execute.js` 而非 `src/stages/execute.js`,
65
+ // 本仓 stages.md 实证),全路径 includes 对这类卡永远落空 → 归属 unmapped(假"无归属")。
66
+ // 规则:基名在卡内存在"独立出现"(两侧均非路径/标识符字符),防 a.js 误配 xa.js.txt 等。
67
+ // 全路径命中(上面循环)优先,裸名仅在未命中时兜底。
68
+ if (!matched) {
69
+ const base = f.slice(f.lastIndexOf('/') + 1)
70
+ if (base && !base.startsWith('.')) {
71
+ outer: for (const [id, content] of cardContents) {
72
+ if (!content) continue
73
+ let pos = content.indexOf(base)
74
+ while (pos !== -1) {
75
+ const before = pos > 0 ? content[pos - 1] : ''
76
+ const after = content[pos + base.length] || ''
77
+ const beforeOk = before === '' || !/[A-Za-z0-9_\/.\-]/.test(before)
78
+ const afterOk = after === '' || !/[A-Za-z0-9_\-./]/.test(after)
79
+ if (beforeOk && afterOk) { matched = id; break outer }
80
+ pos = content.indexOf(base, pos + 1)
81
+ }
82
+ }
83
+ }
84
+ }
85
+ if (!matched) { unmapped.push(f); continue }
86
+ const entry = byModule.get(matched) || { doc: modules[matched].doc || '', files: [] }
87
+ entry.files.push(f)
88
+ byModule.set(matched, entry)
89
+ }
90
+ return { byModule, unmapped }
91
+ }
92
+
93
+ /** 单模块欠账:双 commit 时间戳判方向 + rev-list 计数。全失败 → behind null + 注记。
94
+ * docGitPath:模块卡的 git 相对路径(specBase 相对 projectRoot 换算——specBase 在项目内时
95
+ * 是 .sillyspec/docs/<p>/modules/<id>.md;平台模式 specBase 在仓外时卡片不在 git 内 →
96
+ * docLog 恒失败 → 走"从未提交"形态,如实呈现)。 */
97
+ function moduleDebt(projectRoot, moduleId, entry, docGitPath, docGitRoot) {
98
+ const notes = []
99
+ const srcPaths = entry.files
100
+ const srcLog = safeGit(projectRoot, ['log', '-1', '--format=%h %ct', '--', ...srcPaths], { timeout: GIT_TIMEOUT_MS })
101
+ let behind = null
102
+ let srcCommit = null
103
+ let docCommit = null
104
+ let neverCommitted = false
105
+ if (!srcLog.error && srcLog.value && srcLog.value.trim()) {
106
+ const [h, ct] = srcLog.value.trim().split(' ')
107
+ srcCommit = { h, ct: parseInt(ct, 10) }
108
+ } else {
109
+ notes.push('源码 commit 历史不可得(新文件或 git 失败)')
110
+ }
111
+ if (docGitPath) {
112
+ // 卡片历史在卡片所在 git 仓查(docGitRoot;linked worktree 场景 = 主仓根),
113
+ // 源码历史在 projectRoot 查——两侧 commit 同属一个仓库历史可 rev-list 对账
114
+ const docGitCwd = docGitRoot || projectRoot
115
+ const docLog = safeGit(docGitCwd, ['log', '-1', '--format=%h %ct', '--', docGitPath], { timeout: GIT_TIMEOUT_MS })
116
+ if (!docLog.error && docLog.value && docLog.value.trim()) {
117
+ const [h, ct] = docLog.value.trim().split(' ')
118
+ docCommit = { h, ct: parseInt(ct, 10) }
119
+ } else {
120
+ neverCommitted = true
121
+ }
122
+ }
123
+ if (srcCommit && !docCommit) {
124
+ // 有源码历史但卡片从未提交 → 欠账最重形态
125
+ return { module: moduleId, ...entry, srcCommit: srcCommit.h, docCommit: null, behind: null, neverCommitted: true, notes }
126
+ }
127
+ if (srcCommit && docCommit) {
128
+ // ct 相等也进对账(同一秒内的提交——测试/快速序列场景),由 rev-list count 分晓
129
+ if (srcCommit.ct >= docCommit.ct) {
130
+ const cnt = safeGit(docGitRoot || projectRoot, ['rev-list', '--count', `${docCommit.h}..${srcCommit.h}`], { timeout: GIT_TIMEOUT_MS })
131
+ behind = cnt.error ? null : parseInt(cnt.value, 10)
132
+ if (behind === null) notes.push('rev-list 失败降级')
133
+ } else {
134
+ return { module: moduleId, ...entry, srcCommit: srcCommit.h, docCommit: docCommit.h, behind: 0, neverCommitted: false, notes }
135
+ }
136
+ }
137
+ return { module: moduleId, ...entry, srcCommit: srcCommit?.h || null, docCommit: docCommit?.h || null, behind, neverCommitted, notes }
138
+ }
139
+
140
+ /**
141
+ * IO 入口:算模块文档欠账事实。
142
+ * @param {{ projectRoot: string, specBase: string, projectName: string, changedFiles: string[] }} opts
143
+ * projectRoot:git 锚(worktree 根或 cwd);specBase/projectName:module-map 与卡片定位
144
+ * @returns {{ ok: boolean, facts: string, entries: object[], unmapped: string[] }}
145
+ * ok=false = 归属数据缺失(map 不存在/解析空)→ facts 为单行说明
146
+ * facts 空 = 无债(调用方零输出)
147
+ */
148
+ export function computeDocsDebt(opts) {
149
+ const { projectRoot, specBase, projectName, changedFiles = [] } = opts || {}
150
+ const cardsDir = specBase && projectName ? join(specBase, 'docs', projectName, 'modules') : null
151
+ const mapPath = cardsDir ? join(cardsDir, '_module-map.yaml') : null
152
+ if (!mapPath || !existsSync(mapPath)) {
153
+ return { ok: false, facts: '[docs-debt] 无 _module-map.yaml,模块归属数据缺失(跑 sillyspec modules rebuild 生成)', entries: [], unmapped: changedFiles }
154
+ }
155
+ let moduleIndex
156
+ try {
157
+ moduleIndex = parseModuleMapSimple(readFileSync(mapPath, 'utf8'))
158
+ } catch (e) {
159
+ return { ok: false, facts: `[docs-debt] module-map 解析失败(${e.message}),跳过欠账计算`, entries: [], unmapped: changedFiles }
160
+ }
161
+ if (!moduleIndex || Object.keys(moduleIndex.modules ? moduleIndex.modules : moduleIndex).length === 0) {
162
+ return { ok: false, facts: '[docs-debt] module-map 解析为空(0 模块),归属数据缺失', entries: [], unmapped: changedFiles }
163
+ }
164
+ const { byModule, unmapped } = matchFilesToModules(changedFiles, moduleIndex, { cardsDir })
165
+ if (byModule.size === 0) {
166
+ return { ok: true, facts: '', entries: [], unmapped }
167
+ }
168
+ const entries = []
169
+ // doc 的 git 锚定(execute 审查 FAIL-1 修复):卡片查询按 specBase 所在 git 仓根算相对路径,
170
+ // 不按 projectRoot——dogfood execute 场景 projectRoot=worktree 而 specBase=主仓/.sillyspec,
171
+ // linked worktree 共享 git 历史,git -C <worktree> log -- <主仓相对路径> 查得到卡片真实历史;
172
+ // 若按 projectRoot relative 判 null 会把"卡片在主仓 git 内"误报为"从未提交"(假事实)。
173
+ // 平台模式 specRoot 在仓外(git -C specBase 失败/无 git)→ docGitPath=null 走"从未提交"如实呈现。
174
+ let docGitRoot = null
175
+ let docPathPrefix = null
176
+ try {
177
+ const specTop = safeGit(specBase, ['rev-parse', '--show-toplevel'], { timeout: GIT_TIMEOUT_MS })
178
+ if (!specTop.error && specTop.value) {
179
+ docGitRoot = specTop.value.trim()
180
+ const rel = relative(docGitRoot, cardsDir)
181
+ if (rel && !rel.startsWith('..')) docPathPrefix = rel.replace(/\\/g, '/')
182
+ }
183
+ } catch { /* 保持 null */ }
184
+ for (const [id, entry] of byModule) {
185
+ const docGitPath = docPathPrefix && entry.doc ? `${docPathPrefix}/${entry.doc.replace(/^modules\//, '')}` : null
186
+ entries.push(moduleDebt(projectRoot, id, entry, docGitPath, docGitPath ? docGitRoot : null))
187
+ }
188
+ // 只报欠账(behind>0 / neverCommitted / behind=null 降级注记);behind=0 不出(无债零输出)
189
+ const debtEntries = entries.filter(e => e.neverCommitted || (e.behind !== null && e.behind > 0) || (e.behind === null && !e.neverCommitted))
190
+ if (debtEntries.length === 0) return { ok: true, facts: '', entries, unmapped }
191
+ const lines = ['[docs-debt] 本变更已触及以下模块(CLI 算,diff × _module-map.yaml,累计):']
192
+ for (const e of debtEntries) {
193
+ if (e.neverCommitted) {
194
+ lines.push(` - ${e.module}:模块卡 ${e.doc} 从未提交(欠账最重)`)
195
+ } else if (e.behind === null) {
196
+ lines.push(` - ${e.module}:欠账数不可得(${(e.notes || []).join(';') || 'git 降级'})`)
197
+ } else {
198
+ lines.push(` - ${e.module}:源码 ${e.behind} commit 未同步卡(卡停 ${e.docCommit},源码到 ${e.srcCommit})`)
199
+ }
200
+ // O-2(docs-signals-o12):内联卡片失效引用 + 建议行号——agent 从"知道欠账"到"知道改哪行"。
201
+ // 守卫 docGitPath && docGitRoot(D-004 同锚;平台模式仓外跳过);每模块上限 3 条(D-002);
202
+ // 异常整块降级跳过(advisory 链不因增强而脆)。
203
+ const inline = inlineCardInvalidRefs(e, docGitRoot, docPathPrefix)
204
+ if (inline) lines.push(inline)
205
+ lines.push(` 涉及文件:${e.files.join(', ')}`)
206
+ }
207
+ return { ok: true, facts: lines.join('\n'), entries, unmapped }
208
+ }
209
+
210
+ /** O-2:欠账模块卡片的失效引用内联行(docs check 层1 单文档)。无失效/不可跑 → null。 */
211
+ function inlineCardInvalidRefs(entry, docGitRoot, docPathPrefix) {
212
+ if (!docGitRoot || !docPathPrefix || !entry.doc) return null
213
+ try {
214
+ const docGitPath = `${docPathPrefix}/${entry.doc.replace(/^modules\//, '')}`
215
+ const check = runDocsCheck({ projectRoot: docGitRoot, docs: [docGitPath] })
216
+ if (check.ok || !Array.isArray(check.invalid) || check.invalid.length === 0) return null
217
+ const parts = check.invalid.slice(0, 3).map((inv) => {
218
+ const sug = Array.isArray(inv.suggest) && inv.suggest.length > 0 ? `→建议 L${inv.suggest.join('/L')}` : ''
219
+ return `${inv.ref || inv.doc}${sug}`
220
+ })
221
+ const more = check.invalid.length > 3 ? ` · 等 ${check.invalid.length} 处` : ''
222
+ return ` 卡内失效引用 ${check.invalid.length} 处:${parts.join(' · ')}${more}`
223
+ } catch {
224
+ return null
225
+ }
226
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * docs gate:docs check 的 ratchet 门(欠账只许减少不许增加)。
3
+ *
4
+ * 动机(doc-consistency-debt 第七节裁决):behind 计数是代理信号不能当阈值(源码活跃
5
+ * 不代表卡错,误报会让所有人学会忽略报警);docs check 失效数是直接信号(每条都是
6
+ * 具体的错),ratchet 语义 = 失效数 ≤ 基线即过、超基线拦——不管历史存量,只拦增量。
7
+ *
8
+ * 基线文件:.sillyspec/docs-check-baseline(纯数字一行,可手工改)。首次无基线时
9
+ * 必须显式 --init-baseline 生成(不悄悄合法化存量欠账——与 quick fail-closed 调性
10
+ * 一致);--init-baseline 幂等,重跑以当前实测数覆盖。
11
+ *
12
+ * exit code:0 过(≤基线)/ 1 拦(>基线)/ 2 配置或 IO 错误(含无基线)。
13
+ * 纯判定逻辑(evaluateRatchet)与 IO 面(runDocsGate)分离,前者可单测。
14
+ */
15
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { runDocsCheck, readDocsCheckConfig, DocsCheckConfigError } from './docs-check.js';
18
+
19
+ /** 基线文件名(相对 specBase/.sillyspec 根;specBase 由调用方传入完整路径锚) */
20
+ export const BASELINE_FILENAME = 'docs-check-baseline';
21
+
22
+ /** 展示用基线路径(文案用,POSIX 风格;join 防 specBase 带不带尾分隔符两种形态) */
23
+ function baselineDisplay(specBase) {
24
+ return [specBase.replace(/[\\/]+$/, ''), BASELINE_FILENAME].join('/').replace(/\\/g, '/');
25
+ }
26
+
27
+ /**
28
+ * 纯判定:ratchet 语义。
29
+ * @param {{ current: number, baseline: number }} r
30
+ * @returns {{ ok: boolean, delta: number, message: string }}
31
+ * delta = current - baseline;负值(清偿了)提示可下调基线但不强制。
32
+ */
33
+ export function evaluateRatchet({ current, baseline }) {
34
+ const delta = current - baseline;
35
+ if (delta <= 0) {
36
+ const hint = delta < 0
37
+ ? `✅ docs gate: ${current} 处失效 ≤ 基线 ${baseline}(清偿了 ${-delta} 处,可跑 --init-baseline 下调基线锁住成果)`
38
+ : `✅ docs gate: ${current} 处失效 = 基线 ${baseline},放行`;
39
+ return { ok: true, delta, message: hint };
40
+ }
41
+ return {
42
+ ok: false, delta,
43
+ message: `❌ docs gate: ${current} 处失效 > 基线 ${baseline}(新增 ${delta} 处),拦截。修掉新增引用或显式 --init-baseline 重置基线(需你确认存量合法)`,
44
+ };
45
+ }
46
+
47
+ /** 读基线:不存在返回 null(调用方决定 init 或报错);内容非数字行返回 NaN(按损坏报错)。 */
48
+ export function readBaseline(specBase) {
49
+ const p = join(specBase, BASELINE_FILENAME);
50
+ if (!existsSync(p)) return null;
51
+ const raw = readFileSync(p, 'utf8').trim();
52
+ const n = parseInt(raw, 10);
53
+ return Number.isInteger(n) && n >= 0 ? n : NaN;
54
+ }
55
+
56
+ /** 写基线(--init-baseline):以实测当前失效数覆盖。 */
57
+ export function writeBaseline(specBase, value) {
58
+ writeFileSync(join(specBase, BASELINE_FILENAME), `${value}\n`, 'utf8');
59
+ }
60
+
61
+ /**
62
+ * IO 入口:跑一次 gate。
63
+ * @param {{ projectRoot: string, specBase: string, initBaseline?: boolean }} opts
64
+ * projectRoot 源码仓根(docs check 锚);specBase .sillyspec 根(基线文件所在)
65
+ * @param {object} checkOpts 透传 runDocsCheck(paths/skip/keywordAssert 覆盖;缺省读 local.yaml)
66
+ * @returns {Promise<{ exitCode: 0|1|2, ok: boolean, current: number, baseline: number|null,
67
+ * delta: number|null, message: string, inited: boolean }>}
68
+ */
69
+ export async function runDocsGate(opts = {}, checkOpts = {}) {
70
+ const { projectRoot, specBase, initBaseline = false } = opts;
71
+ let result;
72
+ try {
73
+ const cfg = readDocsCheckConfig(projectRoot);
74
+ result = runDocsCheck({
75
+ projectRoot,
76
+ paths: checkOpts.paths || cfg.paths,
77
+ skip: checkOpts.skip || cfg.skip,
78
+ keywordAssert: checkOpts.keywordAssert ?? cfg.keywordAssert,
79
+ });
80
+ } catch (e) {
81
+ if (e instanceof DocsCheckConfigError) {
82
+ return { exitCode: 2, ok: false, current: null, baseline: null, delta: null, message: `docs gate 配置错误:${e.message}`, inited: false };
83
+ }
84
+ throw e;
85
+ }
86
+ const current = result.invalid.length;
87
+
88
+ if (initBaseline) {
89
+ writeBaseline(specBase, current);
90
+ return { exitCode: 0, ok: true, current, baseline: current, delta: 0, message: `📌 docs gate: 基线已初始化为当前实测 ${current} 处失效(${baselineDisplay(specBase)})`, inited: true };
91
+ }
92
+
93
+ const baseline = readBaseline(specBase);
94
+ if (baseline === null) {
95
+ return {
96
+ exitCode: 2, ok: false, current, baseline: null, delta: null,
97
+ message: `❌ docs gate: 无基线文件(${baselineDisplay(specBase)})。首次使用先跑 sillyspec docs gate --init-baseline(以当前实测数立基线,存量既往不咎只拦增量)`,
98
+ inited: false,
99
+ };
100
+ }
101
+ if (Number.isNaN(baseline)) {
102
+ return { exitCode: 2, ok: false, current, baseline: null, delta: null, message: `❌ docs gate: 基线文件损坏(非非负整数),手工修正或 --init-baseline 重置`, inited: false };
103
+ }
104
+ const v = evaluateRatchet({ current, baseline });
105
+ return { exitCode: v.ok ? 0 : 1, ok: v.ok, current, baseline, delta: v.delta, message: v.message, inited: false };
106
+ }
@@ -24,6 +24,7 @@ import { openDatabase, pluckGet, pluckAll } from './db-engine.js';
24
24
  import { existsSync, statSync, readFileSync, readdirSync, mkdirSync, writeFileSync, unlinkSync } from 'fs';
25
25
  import { join } from 'path';
26
26
  import { CHECK_SEVERITY } from './constants.js';
27
+ import { checkPlatformManaged } from './run/shared.js';
27
28
 
28
29
  // db 角色标签
29
30
  const DB_ROLE = {
@@ -216,8 +217,28 @@ function detectMultiDb(cwd, pointer) {
216
217
 
217
218
  // ── D2 pointer 健康 ───────────────────────────────────────────────────
218
219
 
219
- function detectPointerHealth(pointer) {
220
+ function detectPointerHealth(pointer, cwd = null) {
220
221
  if (!pointer.present) {
222
+ // FR-06:指针缺失但接管声明存在 → 报 pointer_missing_but_managed(warning 非阻断)。
223
+ // 此状态 resolvePlatformSpecDir / runCommand 恢复链会 fail-closed 拒绝静默落本地,
224
+ // doctor 只读诊断给出同款恢复引导(不自动执行任何修复)。
225
+ if (cwd) {
226
+ const decl = checkPlatformManaged(cwd);
227
+ if (decl) {
228
+ return {
229
+ name: 'pointer_health',
230
+ label: '平台指针健康',
231
+ pass: false,
232
+ severity: CHECK_SEVERITY.WARNING,
233
+ findings: [
234
+ `pointer_missing_but_managed: 平台接管声明存在但恢复指针缺失(原 specRoot: ${decl.specRoot || '(未记录)'})——` +
235
+ `裸调命令将 fail-closed 拒绝静默落本地。恢复:① 重跑平台 scan/init(带 --spec-root)重建指针;` +
236
+ `② sillyspec platform disconnect(删除接管声明,彻底脱离平台);③ 显式 --spec-dir <路径> 临时指定。`,
237
+ ],
238
+ safe_actions: [{ dimension: 'pointer_health', action: 'recreate_pointer', risk: 'confirm_required', rationale: '接管声明存在但指针缺失,需重建指针或显式脱离平台', next_step: '重跑平台 scan(带 --spec-root)或 sillyspec platform disconnect' }],
239
+ };
240
+ }
241
+ }
221
242
  return {
222
243
  name: 'pointer_health',
223
244
  label: '平台指针健康',
@@ -489,7 +510,7 @@ function detectExecuteProgressPlanMismatch(authoritySpecRoot, authDb) {
489
510
  export async function runDoctorDiagnostics({ cwd }) {
490
511
  const pointer = resolvePointer(cwd);
491
512
  const multiDb = detectMultiDb(cwd, pointer);
492
- const pointerHealth = detectPointerHealth(pointer);
513
+ const pointerHealth = detectPointerHealth(pointer, cwd);
493
514
  const changesSplit = detectChangesSplit(cwd, pointer);
494
515
  const changeDb = detectChangeDbConsistency(cwd, pointer, multiDb);
495
516
 
package/src/git-helper.js CHANGED
@@ -34,7 +34,7 @@ export function safeGit(cwd, args, opts = {}) {
34
34
  // 格式化(取首行)推迟到最终返回,避免重试分支重复格式化,并保留原始 code 供重试判定。
35
35
  const attempt = (t) => {
36
36
  try {
37
- let value = execFileSync('git', fullArgs, { encoding: 'utf8', timeout: t })
37
+ let value = execFileSync('git', fullArgs, { encoding: 'utf8', timeout: t, stdio: ['ignore', 'pipe', 'pipe'] })
38
38
  if (trim) value = value.trim()
39
39
  return { value, errorObj: null }
40
40
  } catch (e) {
@@ -59,7 +59,7 @@ export function safeGit(cwd, args, opts = {}) {
59
59
  export function git(cwd, args, opts = {}) {
60
60
  const { trim = true, timeout = 5000 } = opts
61
61
  const fullArgs = ['-c', `safe.directory=${cwd}`, '-C', cwd, ...args]
62
- const value = execFileSync('git', fullArgs, { encoding: 'utf8', timeout })
62
+ const value = execFileSync('git', fullArgs, { encoding: 'utf8', timeout, stdio: ['ignore', 'pipe', 'pipe'] })
63
63
  return trim ? value.trim() : value
64
64
  }
65
65
 
@@ -748,7 +748,7 @@ export function shouldBlockWrite(filePath, cwd) {
748
748
  * @param {string} cwd - 当前工作目录
749
749
  * @returns {{ blocked: boolean, reason?: string }}
750
750
  */
751
- export function shouldBlockBash(command, cwd) {
751
+ function shouldBlockBash(command, cwd) {
752
752
  if (!command || !command.trim()) return { blocked: false }
753
753
 
754
754
  const callerCwd = cwd || process.cwd()