sillyspec 3.28.6 → 3.28.8

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-execute/SKILL.md +224 -222
  2. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  3. package/.claude/skills/sillyspec-quick/SKILL.md +117 -116
  4. package/.husky/pre-push +3 -1
  5. package/.patch16-tmp.mjs +169 -0
  6. package/attachments/2ab2fd87cfa568ca6acadbb934a651d293a0ad8f3227c66a70b56dcf53a0ea3d.pptx +0 -0
  7. package/attachments/dc4c2b314ee0c295e1fe5243073eb5c7df7e429074ad7c14424523fbe86e6193.pptx +0 -0
  8. package/meta.json +11 -0
  9. package/package.json +1 -1
  10. package/src/agent-session-log.js +446 -35
  11. package/src/config-cat.js +6 -3
  12. package/src/config-schema.js +13 -0
  13. package/src/constants.js +3 -0
  14. package/src/db.js +12 -3
  15. package/src/decision-distill.js +6 -2
  16. package/src/dispatch/probe.js +8 -1
  17. package/src/docs-check.js +137 -12
  18. package/src/docs-gate.js +3 -0
  19. package/src/doctor-diagnostics.js +19 -7
  20. package/src/friction-tally.js +7 -2
  21. package/src/git-helper.js +6 -4
  22. package/src/hooks/worktree-guard.js +59 -29
  23. package/src/index.js +168 -64
  24. package/src/knowledge-match.js +90 -6
  25. package/src/local-detect.js +30 -15
  26. package/src/local-register.js +13 -4
  27. package/src/mcp-server.js +38 -8
  28. package/src/progress/change-registry.js +58 -26
  29. package/src/progress/consistency-doctor.js +11 -4
  30. package/src/progress/stage-machine.js +3 -1
  31. package/src/progress/step-store.js +30 -17
  32. package/src/progress.js +26 -0
  33. package/src/quicklog.js +44 -18
  34. package/src/review-dispatch.js +7 -1
  35. package/src/run/command.js +17 -3
  36. package/src/run/complete-handlers.js +40 -10
  37. package/src/run/complete.js +48 -17
  38. package/src/run/concurrent-detect.js +1 -1
  39. package/src/run/gate-snapshot.js +115 -5
  40. package/src/run/gates.js +35 -5
  41. package/src/run/prompt.js +61 -1
  42. package/src/run/quick-audit.js +80 -4
  43. package/src/run/shared.js +98 -10
  44. package/src/run/verify-quality-scan.js +14 -3
  45. package/src/scope-audit.js +89 -14
  46. package/src/semantic-guard.js +207 -0
  47. package/src/sillyhub-mcp/client.js +14 -0
  48. package/src/spec-sync.js +19 -14
  49. package/src/stage-contract-spec.js +4 -3
  50. package/src/stage-review.js +6 -5
  51. package/src/stages/brainstorm.js +1 -1
  52. package/src/stages/execute.js +6 -1
  53. package/src/stages/plan-postcheck.js +57 -12
  54. package/src/sync.js +66 -4
  55. package/src/task-review.js +41 -6
  56. package/src/verify-facts-schema.js +10 -3
  57. package/src/verify-postcheck.js +21 -6
  58. package/src/verify-probes.js +8 -3
  59. package/src/worktree-apply.js +56 -18
  60. package/src/worktree-cross.js +12 -18
  61. package/src/worktree.js +138 -25
  62. package/src/wt-commit.js +128 -0
@@ -5,6 +5,8 @@
5
5
  * 路由行发现——路由行由 decision-distill 幂等写入,本引擎只消费不写)——命中路由行的
6
6
  * 决策文件解析为 decisionHits(rejected 优先),供 brainstorm Step2 防复潮注入(task-04,FR-05)。
7
7
  * decisions 库不存在时(INDEX 无 Decisions 段/文件不存在)一切行为与无 decisions 库时一致。
8
+ * 另导出 matchDecisionsByFiles(task-02,FR-02):决策条目按代码文件路径反查——条目
9
+ * 「文件:」字段精确命中 ∪「锚点:」路径形态 token 提取兜底(quick 语义护栏的文件键引擎)。
8
10
  */
9
11
 
10
12
  import { existsSync, readFileSync } from 'fs'
@@ -59,19 +61,57 @@ function isDecisionRoute(entry) {
59
61
  }
60
62
 
61
63
  const DECISION_HEADER_RE = /^##\s+(D-\d+@v\d+)\s*(.*)$/
62
- const DECISION_FIELD_RE = /^(状态|理由|否决理由|复潮条件)\s*[::]\s*(.*)$/
64
+ const DECISION_FIELD_RE = /^(状态|文件|理由|否决理由|复潮条件)\s*[::]\s*(.*)$/
65
+ // 「锚点:」单独标签精确匹配读入(仿 docs-check.js parseDecisionEntries 先例)——绝不并入
66
+ // DECISION_FIELD_RE:其 else-if 链末尾的 reason 回填(else if (!cur.reason))会把锚点值
67
+ // 误吞进 reason(Grill X-002 隐性回归)。
68
+ const DECISION_ANCHOR_RE = /^锚点\s*[::]\s*(.*)$/
69
+ // 变更:单独标签(变更名限定——D-xxx 是变更内局部序号,跨变更同号互不相干,去重键依赖它)
70
+ const DECISION_CHANGE_RE = /^变更\s*[::]\s*(.*)$/
71
+
72
+ /** 「文件:」值切分 + 逐项反斜杠归一 POSIX(与 decision-distill 的 entry.files 契约对齐:
73
+ * split /[,,、\s]+/ + replace(/\\/g,'/'))。 */
74
+ function splitDecisionFiles(value) {
75
+ return String(value || '')
76
+ .split(/[,,、\s]+/)
77
+ .map(s => s.replace(/\\/g, '/'))
78
+ .filter(Boolean)
79
+ }
80
+
81
+ // 锚点路径 token 提取:路径形态 token + 扩展名白名单(扩展名按长度降序排列——
82
+ // (js|…|jsx) 短前缀在前会把 foo.jsx 截断成 foo.js)。
83
+ const ANCHOR_FILE_TOKEN_RE = /[\w./-]+\.(?:mjs|cjs|jsx|tsx|js|ts|py|go|java|rs)/g
84
+
85
+ /**
86
+ * 锚点值 → 代码文件路径集(条目无「文件:」字段时的兜底命中源)。
87
+ * 真实先例:D-905 形态「锚点:src/quicklog.js:493」→ 提取 src/quicklog.js
88
+ * (token 字符类不含 : 故止于行号前,再防御性剥 :line/:line-line/:符号 后缀)。
89
+ * 「未记录」/无路径形态 token → []。
90
+ */
91
+ function anchorFilePaths(anchor) {
92
+ const a = String(anchor || '').trim().replace(/\\/g, '/')
93
+ if (!a || a === '未记录') return []
94
+ const out = new Set()
95
+ for (const token of a.match(ANCHOR_FILE_TOKEN_RE) || []) {
96
+ const p = token.replace(/:(?:\d+(?:-\d+)?|[A-Za-z_$][A-Za-z0-9_$]*)$/, '')
97
+ if (p) out.add(p)
98
+ }
99
+ return [...out]
100
+ }
63
101
 
64
102
  /**
65
103
  * 解析单个 decisions/<域>.md 的 D-xxx@vN 条目。
66
104
  * reason 语义:rejected 条目取「否决理由」(expects_from task-02 的 reject_reason),
67
105
  * 其余条目回退「理由」一句话;revisitWhen 仅 rejected 条目非空(复潮条件)。
106
+ * files =「文件:」字段切分归一(task-02,FR-02);anchor =「锚点:」行原值(单独标签读入,
107
+ * 不进 reason 回填链——Grill X-002)。
68
108
  * @param {string} filePath 决策文件绝对路径
69
109
  * @param {string} file INDEX 相对路径(decisions/<域>.md),原样进 decisionHits.file
70
- * @returns {{ file: string, id: string, title: string, status: string, reason: string, revisitWhen: string }[]}
110
+ * @returns {{ file: string, id: string, title: string, status: string, reason: string, revisitWhen: string, files: string[], anchor: string }[]}
71
111
  */
72
112
  function parseDecisionFile(filePath, file) {
73
113
  let content
74
- try { content = readFileSync(filePath, 'utf8') } catch { return [] }
114
+ try { content = readFileSync(filePath, 'utf-8') } catch { return [] }
75
115
  const hits = []
76
116
  let cur = null
77
117
  const flush = () => {
@@ -82,14 +122,22 @@ function parseDecisionFile(filePath, file) {
82
122
  const h = line.match(DECISION_HEADER_RE)
83
123
  if (h) {
84
124
  flush()
85
- cur = { file, id: h[1], title: h[2].trim(), status: '', reason: '', revisitWhen: '' }
125
+ cur = { file, id: h[1], title: h[2].trim(), status: '', reason: '', revisitWhen: '', files: [], anchor: '', change: '' }
86
126
  continue
87
127
  }
88
128
  if (!cur) continue
129
+ // 锚点:单独标签先于字段链匹配(不在 DECISION_FIELD_RE 内,见常量处注释)
130
+ const a = line.match(DECISION_ANCHOR_RE)
131
+ if (a) { cur.anchor = a[1].trim(); continue }
132
+ // 变更:单独标签读入(坑 decision-cross-change-id-shadow,2026-09-11 语义护栏 dogfood 首跑实证:
133
+ // 域文件跨变更同号条目常见(D-xxx 是变更内局部序号),去重键须含变更名否则先到条目遮蔽后来者)
134
+ const c = line.match(DECISION_CHANGE_RE)
135
+ if (c) { cur.change = c[1].trim(); continue }
89
136
  const f = line.match(DECISION_FIELD_RE)
90
137
  if (!f) continue
91
138
  const value = f[2].trim()
92
139
  if (f[1] === '状态') cur.status = value.toLowerCase()
140
+ else if (f[1] === '文件') cur.files = splitDecisionFiles(value)
93
141
  else if (f[1] === '否决理由') cur.reason = value
94
142
  else if (f[1] === '复潮条件') cur.revisitWhen = value
95
143
  else if (!cur.reason) cur.reason = value // 理由:仅作 implemented(或否决理由缺失)时的回填
@@ -115,16 +163,52 @@ export function parseDecisionEntries(indexDir, indexEntries = null) {
115
163
  if (!existsSync(filePath)) continue // 路由行失效(域文件已删)→ 跳过不阻断
116
164
  hits.push(...parseDecisionFile(filePath, route.file))
117
165
  }
118
- // 多条路由行指向同一域文件时按 file+id 去重
166
+ // 多条路由行指向同一域文件时按 file+id+变更 去重(完全重复折叠);键含变更名——
167
+ // 跨变更同号条目(D-xxx 是变更内局部序号,域文件内同 id 不同变更常见)不再被先到者
168
+ // 遮蔽(坑 decision-cross-change-id-shadow,2026-09-11 dogfood 实证:runtime.md 七个
169
+ // D-001@v1 分属七变更,旧键只放行首个——同号 rejected 决策在 {DECISION_HITS} 防复潮
170
+ // 注入同样被遮蔽)。无「变更:」行的 legacy 条目 change='' 与同号 legacy 条目互折叠
171
+ // (维持旧行为,不放大历史条目数)。
119
172
  const seen = new Set()
120
173
  return hits.filter(h => {
121
- const key = `${h.file}#${h.id}`
174
+ const key = `${h.file}#${h.id}#${h.change || ''}`
122
175
  if (seen.has(key)) return false
123
176
  seen.add(key)
124
177
  return true
125
178
  })
126
179
  }
127
180
 
181
+ /**
182
+ * 决策条目按代码文件路径反查(task-02,FR-02)——quick 语义护栏(task-03/05/06)的
183
+ * 文件键引擎。条目命中源 = 「文件:」字段(cur.files)∪「锚点:」路径形态 token 提取
184
+ * (anchorFilePaths 兜底);查询侧与条目侧路径统一 POSIX 归一。
185
+ * @param {string} indexDir knowledge 目录路径
186
+ * @param {string[]} files 查询代码文件路径列表
187
+ * @returns {{ [file: string]: Array<{ id: string, title: string, status: string, reason: string, file: string }> }}
188
+ * 键 = 归一后的查询路径(结果键 ⊆ 查询集);条目 file 字段沿 parseDecisionFile 既有
189
+ * 口径(INDEX 相对 decisions/<域>.md 路径);rejected 条目同样进结果(status 透传,
190
+ * 消费方按需过滤);无 decisions 库 / 路由失效 / 空查询 → {}。
191
+ */
192
+ export function matchDecisionsByFiles(indexDir, files) {
193
+ const queries = [...new Set(
194
+ (Array.isArray(files) ? files : [files])
195
+ .map(f => String(f || '').trim().replace(/\\/g, '/'))
196
+ .filter(Boolean)
197
+ )]
198
+ if (queries.length === 0) return {}
199
+ const querySet = new Set(queries)
200
+ const result = {}
201
+ for (const hit of parseDecisionEntries(indexDir)) {
202
+ const sources = new Set([...(hit.files || []), ...anchorFilePaths(hit.anchor)])
203
+ for (const src of sources) {
204
+ if (!querySet.has(src)) continue
205
+ if (!result[src]) result[src] = []
206
+ result[src].push({ id: hit.id, title: hit.title, status: hit.status, reason: hit.reason, file: hit.file })
207
+ }
208
+ }
209
+ return result
210
+ }
211
+
128
212
  function escapeRegex(s) {
129
213
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
130
214
  }
@@ -15,26 +15,41 @@ import { join } from 'path'
15
15
 
16
16
  /**
17
17
  * 从 Makefile 文本提取 test 目标的命令(轻量正则,与 verify-postcheck 同风格)。
18
- * 支持两种常见写法:
19
- * - 同行命令:`test: pytest` → 'pytest'
20
- * - tab 续行:`test:\n\tpytest\n` → 'pytest'(命令在下一 tab 开头行)
18
+ * 支持的写法(按 make 语义):
19
+ * - tab/空格 缩行配方:`test:\n\tpytest\n` → 'pytest'
20
+ * - `;` 同行配方:`test: ; pytest` → 'pytest'
21
+ * - 行内其他非空内容是 prerequisite(`test: unit integration`),不是命令——配方照常
22
+ * 向下找缩行;无配方回退 'make test'(make 会先跑依赖目标,语义正确)
21
23
  * 边界:
22
- * - 纯空目标(`test:` 无同行命令、无 tab 续行)→ 回退 'make test'
23
- * - 命令行内 `#` 注释截断;取第一个非空命令行
24
+ * - 配方行以「下一个非缩进非空行」为界——空目标后跳进后续目标的配方是旧版缺陷
25
+ * (ql-20260911-029 复现:`test:\nbuild:\n\tnpm run build` 旧正则返回 'npm run build')
26
+ * - `test := x` 变量赋值不匹配 test 目标;命令行内 `#` 注释截断
24
27
  * @param {string} makefileText
25
28
  * @returns {string}
26
29
  */
27
- function parseMakefileTestCommand(makefileText) {
30
+ export function parseMakefileTestCommand(makefileText) {
28
31
  if (!makefileText) return 'make test'
29
- // 定位 `test:` 行;捕获 `:` 后同行内容(同行命令或空)
30
- const head = makefileText.match(/^test:\s*([^\n#]*?)\s*(?:#.*)?$/m)
31
- if (!head) return 'make test'
32
- const inline = (head[1] || '').trim()
33
- if (inline) return inline
34
- // 同行为空 → 找该行之后第一个 tab 续行命令(`\tcmd` 或 ` cmd`)
35
- const after = makefileText.slice(head.index + head[0].length)
36
- const cont = after.match(/^[ \t]+([^\n#]+?)\s*(?:#.*)?$/m)
37
- if (cont && cont[1]) return cont[1].trim()
32
+ const lines = String(makefileText).split(/\r?\n/)
33
+ // (?!=) 排除 `test :=` 变量赋值形态
34
+ const testIdx = lines.findIndex(l => /^test:(?!=)/.test(l))
35
+ if (testIdx === -1) return 'make test'
36
+ const inline = lines[testIdx].slice(lines[testIdx].indexOf('test:') + 'test:'.length).trim()
37
+ const semicolonRecipe = inline.match(/^;\s*(.+)$/)
38
+ if (semicolonRecipe) {
39
+ const cmd = semicolonRecipe[1].replace(/\s+#.*$/, '').trim()
40
+ if (cmd) return cmd
41
+ }
42
+ // 向下扫配方:第一条缩进行即 test 的配方(tab 或空格缩进);遇非缩进非空行(下一
43
+ // 目标/变量/指令)即边界——空行与列 0 注释不构成边界
44
+ for (let i = testIdx + 1; i < lines.length; i++) {
45
+ const line = lines[i]
46
+ if (line.trim() === '' || line.startsWith('#')) continue
47
+ if (/^[ \t]+\S/.test(line)) {
48
+ const cmd = line.replace(/^[ \t]+/, '').replace(/\s+#.*$/, '').trim()
49
+ return cmd || 'make test'
50
+ }
51
+ return 'make test'
52
+ }
38
53
  return 'make test'
39
54
  }
40
55
 
@@ -16,8 +16,9 @@
16
16
  * (tmp+rename,防多会话并发写半截,与 local detect 写入口径一致)。
17
17
  */
18
18
  import { existsSync, readFileSync, mkdirSync } from 'fs'
19
- import { dirname } from 'path'
19
+ import { dirname, join } from 'path'
20
20
  import { writeAtomicSync } from './fs-atomic.js'
21
+ import { withFileLock } from './quicklog.js'
21
22
 
22
23
  function escapeRe(s) {
23
24
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
@@ -26,13 +27,18 @@ function escapeRe(s) {
26
27
  /**
27
28
  * 把 `<key>: <path>` 注册进 local.yaml 的 repos: 段(幂等,已有该 key 则改值)。
28
29
  *
30
+ * 读-改-写整段持 .local.yaml.lock(2026-09-12 审查批 C-②):writeAtomicSync 只保证单次
31
+ * 写不撕裂,不串行化 RMW——多 agent 并行 register-repo(不同 key)或与 local detect /
32
+ * platform connect 的写入口并发时,后写者整体覆盖前写者、先注册条目静默丢失
33
+ * (quicklog 侧同场景用 withFileLock,此处对齐)。async 化:调用方须 await。
34
+ *
29
35
  * @param {string} yamlPath - local.yaml 绝对路径(不存在则新建)
30
36
  * @param {string} key - repo key(限 [A-Za-z0-9_.\-]+,'main' 隐式禁止注册)
31
37
  * @param {string} repoPath - 跨仓仓根绝对路径(写入时统一正斜杠,与示例格式一致)
32
- * @returns {{ fileCreated: boolean, sectionCreated: boolean, replaced: boolean }}
38
+ * @returns {Promise<{ fileCreated: boolean, sectionCreated: boolean, replaced: boolean }>}
33
39
  * @throws {Error} key 非法 / key='main' / repoPath 空 / 读写失败
34
40
  */
35
- export function registerRepoInLocalYaml(yamlPath, key, repoPath) {
41
+ export async function registerRepoInLocalYaml(yamlPath, key, repoPath) {
36
42
  if (!yamlPath) throw new Error('registerRepoInLocalYaml: yamlPath 不能为空')
37
43
  if (!key || !/^[A-Za-z0-9_.\-]+$/.test(key)) {
38
44
  throw new Error(`repo key 非法: "${key}"(限字母数字 . _ -,与 parseRepoRegistry 读侧口径一致)`)
@@ -42,7 +48,9 @@ export function registerRepoInLocalYaml(yamlPath, key, repoPath) {
42
48
  }
43
49
  if (!repoPath) throw new Error('registerRepoInLocalYaml: repoPath 不能为空')
44
50
 
45
- const existed = existsSync(yamlPath)
51
+ // 锁文件与 yaml 同目录(与 QUICKLOG/tasks 锁同机制;锁内读-判定-写,后写者不再覆盖前写者)
52
+ return await withFileLock(join(dirname(yamlPath), '.local.yaml.lock'), async () => {
53
+ const existed = existsSync(yamlPath)
46
54
  // CRLF 归一后按 LF 写回(同 task 卡锚点写入的 CRLF 坑;local.yaml 各 CLI 写入口径均 LF)
47
55
  const raw0 = existed ? readFileSync(yamlPath, 'utf8') : ''
48
56
  const hadCr = raw0.includes('\r') // 磁盘原文带 CRLF → 幂等路径也要落盘治愈(见下方幂等分支)
@@ -101,4 +109,5 @@ export function registerRepoInLocalYaml(yamlPath, key, repoPath) {
101
109
  lines.splice(insertAt, 0, entryLine)
102
110
  write(lines.join('\n'))
103
111
  return { fileCreated: false, sectionCreated: false, replaced: false }
112
+ })
104
113
  }
package/src/mcp-server.js CHANGED
@@ -64,8 +64,6 @@ const TOOLS = [
64
64
 
65
65
  /** tools/call 路由:子进程 --json(stdout 纪律隔离;CLI 的 stderr 透传进 text 供诊断) */
66
66
  async function callTool(name, args) {
67
- const argv = ['--meta'] // 占位防误配:--meta 是 run 的 flag,工具不走 run
68
- argv.length = 0
69
67
  const specDir = args && typeof args.spec_dir === 'string' && args.spec_dir ? ['--spec-dir', args.spec_dir] : []
70
68
  switch (name) {
71
69
  case 'sillyspec_next':
@@ -81,6 +79,14 @@ async function callTool(name, args) {
81
79
  }
82
80
  }
83
81
 
82
+ // 子进程挂死兜底(2026-09-12 审查批 A-②):gate/derive 会真实跑测试与 git 全家,测试进程
83
+ // 不退/git 等锁时 tools/call 若无超时则永久挂起(stdio MCP 无请求级超时,host 侧表现为工具
84
+ // 卡死)。默认 300s(全量测试套件 ~90s 的余量),env 可调;输出封顶防 stderr 刷屏型无界累积。
85
+ const CALL_TIMEOUT_MS = Number(process.env.SILLYSPEC_MCP_CALL_TIMEOUT_MS) > 0
86
+ ? Number(process.env.SILLYSPEC_MCP_CALL_TIMEOUT_MS)
87
+ : 300_000
88
+ const OUTPUT_CAP_BYTES = 8 * 1024 * 1024
89
+
84
90
  function runCli(cliArgs) {
85
91
  return new Promise((resolve) => {
86
92
  const p = spawn(process.execPath, [CLI_ENTRY, ...cliArgs], {
@@ -90,14 +96,38 @@ function runCli(cliArgs) {
90
96
  })
91
97
  let out = ''
92
98
  let err = ''
93
- p.stdout.on('data', (d) => { out += String(d) })
94
- p.stderr.on('data', (d) => { err += String(d) })
99
+ let outTrunc = false
100
+ let errTrunc = false
101
+ let settled = false
102
+ const finish = (r) => {
103
+ if (settled) return
104
+ settled = true
105
+ clearTimeout(timer)
106
+ resolve(r)
107
+ }
108
+ const timer = setTimeout(() => {
109
+ try { p.kill() } catch { /* 已退出 */ }
110
+ finish({ text: `CLI 子进程超时(${CALL_TIMEOUT_MS}ms)被终止: sillyspec ${cliArgs.join(' ')}(挂死兜底;可用 SILLYSPEC_MCP_CALL_TIMEOUT_MS 调整)`, isError: true, exitCode: null })
111
+ }, CALL_TIMEOUT_MS)
112
+ p.stdout.on('data', (d) => {
113
+ if (out.length < OUTPUT_CAP_BYTES) out += String(d)
114
+ else outTrunc = true
115
+ })
116
+ p.stderr.on('data', (d) => {
117
+ if (err.length < OUTPUT_CAP_BYTES) err += String(d)
118
+ else errTrunc = true
119
+ })
95
120
  p.on('close', (code) => {
96
- // envelope 原样透传(text = stdout JSON 全文;machine 侧自解);stderr 仅失败时附诊断
97
- const text = (out.trim() || err.trim().slice(-2000) || `(exit ${code},无输出)`)
98
- resolve({ text, isError: code !== null && code !== 0 && code !== 1, exitCode: code })
121
+ // envelope 原样透传(text = stdout JSON 全文;machine 侧自解);stderr 仅失败时附诊断。
122
+ // isError 口径(批 E-④):exit 0=正常;1=BLOCKED(合法裁决结果);2=EXIT_UNKNOWN
123
+ // (envelope 合法、含诊断信息)——三者都是机器可解析的合法结果,不标 isError;
124
+ // 部分 MCP host 把 isError 当「工具执行失败」处理而不向模型展示 text,exit 2 的
125
+ // 诊断信息会被吞。真失败(spawn error/超时)由 error/timeout 路径标 isError。
126
+ const truncNote = outTrunc || errTrunc ? `\n(输出超 ${OUTPUT_CAP_BYTES} 字节封顶截断)` : ''
127
+ const text = (out.trim() || err.trim().slice(-2000) || `(exit ${code},无输出)`) + truncNote
128
+ finish({ text, isError: code !== null && code !== 0 && code !== 1 && code !== 2, exitCode: code })
99
129
  })
100
- p.on('error', (e) => resolve({ text: `CLI 子进程失败: ${e.message}`, isError: true }))
130
+ p.on('error', (e) => finish({ text: `CLI 子进程失败: ${e.message}`, isError: true }))
101
131
  })
102
132
  }
103
133
 
@@ -62,6 +62,38 @@ export class ChangeRegistry {
62
62
  } catch { return null; }
63
63
  }
64
64
 
65
+ /**
66
+ * 读某变更最近一次进度活动时刻(quick-done-autoarchive-misfire 缺陷②,D-002@v1 复潮
67
+ * 2026-09-11 实证:stage 阶段态区分不了「活跃在途」与「启动后弃单」——brainstorm
68
+ * in_progress + tasks.md 仅含他者 quick 行时「全勾」是空洞真值)。取该变更全部
69
+ * stages/steps completed_at 的最大值:活跃会话分钟级在推进(最近活动必新);僵尸的
70
+ * 最后一步完成时刻必然陈旧。只读不抛:无变更/无完成行/读失败返回 null(调用方按
71
+ * 无近期活动放行,僵尸逃生通道语义不变——与 getStageCompletedAt 同族容错)。
72
+ */
73
+ getLatestActivityAt(cwd, changeName) {
74
+ try {
75
+ const db = this.pm._ensureDB(cwd);
76
+ const sqlDb = db.getDb();
77
+ const ch = sqlDb.prepare(`SELECT id FROM changes WHERE name = ?`).get(changeName);
78
+ if (!ch) return null;
79
+ const rows = sqlDb.prepare(
80
+ `SELECT completed_at AS ts FROM stages WHERE change_id = ? AND completed_at IS NOT NULL
81
+ UNION ALL
82
+ SELECT st.completed_at AS ts FROM steps st
83
+ JOIN stages s ON st.stage_id = s.id WHERE s.change_id = ? AND st.completed_at IS NOT NULL`
84
+ ).all(ch.id, ch.id);
85
+ // JS 侧按解析后的时间取最新(归一 ISO 返回):SQL 字符串 MAX 在 zh-CN('/')与 ISO('-')
86
+ // 混存时恒取 zh-CN('/'0x2F>'-'0x2D)——存量 zh-CN 陈旧值会盖过新 ISO 致时近性闸取错
87
+ // 时间戳(活跃变更被误归档方向,2026-09-11 审查)。不可解析串跳过。
88
+ let latestMs = -Infinity;
89
+ for (const r of rows) {
90
+ const ms = new Date(r.ts).getTime();
91
+ if (Number.isFinite(ms) && ms > latestMs) latestMs = ms;
92
+ }
93
+ return latestMs === -Infinity ? null : new Date(latestMs).toISOString();
94
+ } catch { return null; }
95
+ }
96
+
65
97
  /**
66
98
  * 读某变更某阶段的 started_at(2026-09-10 用户反馈②:evidence mtime 窗口锚点从
67
99
  * execute completed_at 放宽到 started_at——证据合法产自 execute 或 verify 两窗口,
@@ -283,44 +315,44 @@ export class ChangeRegistry {
283
315
  // 注:新名日期前缀门禁只在 CLI 边界强制(index.js change-rename 入口)——renameChange 是
284
316
  // 库函数,测试/平台工具合法用任意名(2026-09-11 决策)。
285
317
  const db = this.pm._ensureDB(cwd);
286
- // 检查旧名是否存在
287
- const existing = db.transaction(() => {
288
- const sqlDb = db.getDb();
289
- const row = sqlDb.prepare(`SELECT name, status FROM changes WHERE name = ?`).get(oldName);
290
- if (row === undefined) return null;
291
- return { name: row.name, status: row.status };
292
- });
293
- if (!existing) {
294
- console.error(`❌ 变更 ${oldName} 不存在`);
295
- return;
296
- }
297
- // 检查新名是否已存在
298
- const conflict = db.transaction(() => {
299
- const sqlDb = db.getDb();
300
- const row = sqlDb.prepare(`SELECT name FROM changes WHERE name = ?`).get(newName);
301
- return row !== undefined;
302
- });
303
- if (conflict) {
304
- console.error(`❌ 变更 ${newName} 已存在`);
305
- return;
306
- }
307
- // 先更新 DB,再重命名目录;FS 失败则回滚 DB,避免"目录已改名但 DB 旧名"的孤儿
308
- // (旧实现 FS-first 无补偿:renameSync 成功后 DB transaction 抛 EPERM/EBUSY 会让
309
- // 目录已是 newName、DB 仍是 oldName,read(两名) 都失联且无自动恢复)。
318
+ // 存在性/冲突检查与 UPDATE 收进同一事务 + 校验影响行数(坑 rename-concurrent-split,
319
+ // 2026-09-12 审查批 C-①):旧三段独立事务是 check-then-act——并发重命名 X→Y 与 X→Z 时,
320
+ // 后者的 UPDATE 命中 0 行不报错,继续搬目录造成 DB/目录分裂(空目录假成功)。
310
321
  const oldDir = this.pm._changePath(cwd, oldName);
311
322
  const newDir = this.pm._changePath(cwd, newName);
312
323
  const now = new Date().toISOString();
324
+ let outcome;
313
325
  try {
314
- db.transaction(() => {
326
+ outcome = db.transaction(() => {
315
327
  const sqlDb = db.getDb();
316
- sqlDb.prepare(`UPDATE changes SET name = ?, last_active = ? WHERE name = ?`).run(newName, now, oldName);
328
+ const row = sqlDb.prepare(`SELECT name FROM changes WHERE name = ?`).get(oldName);
329
+ if (row === undefined) return 'missing';
330
+ if (sqlDb.prepare(`SELECT 1 FROM changes WHERE name = ?`).get(newName) !== undefined) return 'conflict';
331
+ const r = sqlDb.prepare(`UPDATE changes SET name = ?, last_active = ? WHERE name = ?`).run(newName, now, oldName);
332
+ if (r.changes !== 1) return 'stolen'; // 并发窗口:检查后他进程已把 oldName 改走
317
333
  // 本地脏度(D-013 / task-04):重命名也是本地推进(标新名)
318
334
  this.pm._touchLocalModified(cwd, newName, now);
335
+ return 'ok';
319
336
  });
320
337
  } catch (e) {
321
338
  console.error(`❌ 重命名失败:更新数据库时出错(${e.message})`);
322
339
  return;
323
340
  }
341
+ if (outcome === 'missing') {
342
+ console.error(`❌ 变更 ${oldName} 不存在`);
343
+ return;
344
+ }
345
+ if (outcome === 'conflict') {
346
+ console.error(`❌ 变更 ${newName} 已存在`);
347
+ return;
348
+ }
349
+ if (outcome === 'stolen') {
350
+ console.error(`❌ 变更 ${oldName} 刚被并发操作重命名/删除(0 行更新),本次跳过——请重查后重试`);
351
+ return;
352
+ }
353
+ // 先更新 DB,再重命名目录;FS 失败则回滚 DB,避免"目录已改名但 DB 旧名"的孤儿
354
+ // (旧实现 FS-first 无补偿:renameSync 成功后 DB transaction 抛 EPERM/EBUSY 会让
355
+ // 目录已是 newName、DB 仍是 oldName,read(两名) 都失联且无自动恢复)。
324
356
  let renamed = true;
325
357
  if (existsSync(oldDir)) {
326
358
  try {
@@ -4,6 +4,7 @@
4
4
  import { appendFileSync, existsSync, readFileSync, readdirSync } from 'fs';
5
5
  import { join, resolve } from 'path';
6
6
  import { DB } from '../db.js';
7
+ import { openDatabase } from '../db-engine.js';
7
8
  import { summarizeTaskCompletion } from '../task-review.js';
8
9
  import { STAGE_ORDER, MAIN_FLOW_ORDER } from './shared.js';
9
10
 
@@ -101,11 +102,15 @@ export class ConsistencyDoctor {
101
102
  if (!existsSync(dbPath)) return null;
102
103
  let db = null;
103
104
  try {
104
- db = new DB(dbPath);
105
- db.init();
106
- const rows = db.getDb().prepare("SELECT name FROM changes WHERE status = 'active' ORDER BY name").all();
105
+ // 真只读(坑 read-active-quiet-write-open,2026-09-12 审查批 C-⑤):旧 new DB().init()
106
+ // 会跑 WAL PRAGMA(创建 -wal/-shm 侧车)且 schema 戳不匹配时执行 DDL 迁移——「只读探测」
107
+ // 实际改写被探测库。readOnly 打开零副作用;打开失败(损坏/权限)→ unreadable 降级不变。
108
+ db = openDatabase(dbPath, { readOnly: true });
109
+ const rows = db.prepare("SELECT name FROM changes WHERE status = 'active' ORDER BY name").all();
110
+ db.close();
107
111
  return { activeChanges: rows.map(r => r.name) };
108
112
  } catch (e) {
113
+ try { if (db) db.close(); } catch { /* 已关闭/打开失败 */ }
109
114
  return { activeChanges: [], unreadable: true, error: e.message };
110
115
  }
111
116
  }
@@ -314,7 +319,9 @@ export class ConsistencyDoctor {
314
319
  const fixable = []; // { stage, action, description, apply: (data) => void }
315
320
  const manual = [...splitManual]; // string
316
321
 
317
- const now = new Date().toLocaleString('zh-CN', { hour12: false });
322
+ // ISO(Fix d 的 reopenedAt / Fix e 的 completedAt 均落库):zh-CN 串的 '/' 在字符串 MAX 中
323
+ // 恒盖过 ISO 值,会污染 getLatestActivityAt 时近性闸与平台同步(2026-09-11 审查)
324
+ const now = new Date().toISOString();
318
325
 
319
326
  for (const stageName of STAGE_ORDER) {
320
327
  const sd = data.stages[stageName];
@@ -632,7 +632,9 @@ export class StageMachine {
632
632
  // 执行重开操作
633
633
  const newRevision = (stageData.revision || 0) + 1;
634
634
  const fromStepName = steps[fromIdx].name;
635
- const now = new Date().toLocaleString('zh-CN', { hour12: false });
635
+ // ISO(落库字段):toLocaleString('zh-CN') 的 '/' 在字符串 MAX 中恒大于 ISO 的 '-',污染
636
+ // getLatestActivityAt 等取最大值比较与平台 pydantic 解析(2026-09-11 审查)
637
+ const now = new Date().toISOString();
636
638
 
637
639
  // 更新步骤状态:fromStep 之前的保持 completed,fromStep 变 pending,之后的变 stale
638
640
  for (let i = 0; i < steps.length; i++) {
@@ -134,24 +134,21 @@ export class StepStore {
134
134
  return;
135
135
  }
136
136
 
137
- // 查找 step_id:通过 changes → stages → steps JOIN 查询
138
- const sqlDb = db.getDb();
139
- const stepRow = sqlDb.prepare(
140
- `SELECT st.id, st.status FROM steps st
141
- JOIN stages sg ON st.stage_id = sg.id
142
- JOIN changes c ON sg.change_id = c.id
143
- WHERE c.name = ? AND sg.stage = ? AND st.name = ?`
144
- ).get(cn, stage, stepName);
145
- if (stepRow === undefined) {
146
- console.log(`❌ 步骤不存在: ${stage}/${stepName}`);
147
- return;
148
- }
149
- const stepId = stepRow.id;
150
-
151
- // UPDATE steps
137
+ // UPDATE steps(id 查找收进同一事务:事务外查询拿到的 id,可能在 UPDATE 前被他进程 _write 的
138
+ // steps DELETE+重插换成新自增 id——UPDATE 命中 0 行静默丢写且照常打印成功,2026-09-11 审查 P1)
152
139
  let stageCompletionCandidateId = null;
140
+ let stepFound = false;
153
141
  db.transaction(() => {
154
142
  const tDb = db.getDb();
143
+ const stepRow = tDb.prepare(
144
+ `SELECT st.id FROM steps st
145
+ JOIN stages sg ON st.stage_id = sg.id
146
+ JOIN changes c ON sg.change_id = c.id
147
+ WHERE c.name = ? AND sg.stage = ? AND st.name = ?`
148
+ ).get(cn, stage, stepName);
149
+ if (stepRow === undefined) return;
150
+ stepFound = true;
151
+ const stepId = stepRow.id;
155
152
  const now = new Date().toISOString();
156
153
  if (status) {
157
154
  tDb.prepare('UPDATE steps SET status = ?, completed_at = ? WHERE id = ? AND name = ?').run(status, status === 'completed' ? now : null, stepId, stepName);
@@ -179,6 +176,11 @@ export class StepStore {
179
176
  this.pm._touchLocalModified(cwd, cn, now);
180
177
  });
181
178
 
179
+ if (!stepFound) {
180
+ console.log(`❌ 步骤不存在: ${stage}/${stepName}`);
181
+ return;
182
+ }
183
+
182
184
  if (stageCompletionCandidateId !== null) {
183
185
  const { force = false } = options;
184
186
  const validation = this.pm._validateStageArtifacts(cwd, stage, cn);
@@ -199,11 +201,22 @@ export class StepStore {
199
201
  validationErrors: validation.errors,
200
202
  });
201
203
  }
204
+ let stageMarkedCompleted = false;
202
205
  db.transaction(() => {
203
206
  const tDb = db.getDb();
204
- tDb.prepare(`UPDATE stages SET status = 'completed', completed_at = ? WHERE id = ?`).run(new Date().toISOString(), stageCompletionCandidateId);
207
+ // 提交前复查:validator 窗口(execute 校验可 spawn git 最长 15s+)内他进程 addStep 新增
208
+ // pending 步或 reset 本阶段——按候选 id 盲写会把未完成阶段错标 completed(2026-09-11 审查)
209
+ const recheck = tDb.prepare("SELECT COUNT(*) AS cnt FROM steps WHERE stage_id = ? AND status NOT IN ('completed','skipped')").get(stageCompletionCandidateId);
210
+ if (recheck.cnt === 0) {
211
+ tDb.prepare(`UPDATE stages SET status = 'completed', completed_at = ? WHERE id = ?`).run(new Date().toISOString(), stageCompletionCandidateId);
212
+ stageMarkedCompleted = true;
213
+ }
205
214
  });
206
- console.log(`✅ 阶段 ${stage} 所有步骤已完成,阶段已标记为 completed`);
215
+ if (stageMarkedCompleted) {
216
+ console.log(`✅ 阶段 ${stage} 所有步骤已完成,阶段已标记为 completed`);
217
+ } else {
218
+ console.log(`ℹ️ 阶段 ${stage} 在校验窗口内出现新的未完成步骤,本次不标记 completed`);
219
+ }
207
220
  }
208
221
  }
209
222
 
package/src/progress.js CHANGED
@@ -806,6 +806,22 @@ export class ProgressManager {
806
806
  * 3. 单进程内调用串行(design §9),事务窗口短(毫秒级),全量写正确性远高于 diff 的边际收益。
807
807
  * 结论:全量 UPSERT 的简单正确性 > 持锁窗口优化的边际收益,不强行改 diff。
808
808
  */
809
+ /** .bak 快照节流间隔(坑2②):5 分钟——损坏回退最坏丢 5 分钟进度,换取每次写零额外负担 */
810
+ static SNAPSHOT_INTERVAL_MS = 5 * 60_000;
811
+
812
+ _maybeSnapshotBak(cwd) {
813
+ try {
814
+ const dbPath = this._runtimePath(cwd, 'sillyspec.db');
815
+ const bakPath = dbPath + '.bak';
816
+ const st = existsSync(bakPath) ? statSync(bakPath) : null;
817
+ if (st && Date.now() - st.mtimeMs < ProgressManager.SNAPSHOT_INTERVAL_MS) return;
818
+ const tmpPath = bakPath + '.tmp';
819
+ try { unlinkSync(tmpPath) } catch { /* 不存在即无事 */ }
820
+ this._ensureDB(cwd).getDb().exec(`VACUUM INTO '${tmpPath.replace(/'/g, "''")}'`);
821
+ renameSync(tmpPath, bakPath);
822
+ } catch { /* best-effort:快照失败只损失恢复源新鲜度,不影响写入主流程 */ }
823
+ }
824
+
809
825
  _write(cwd, data, changeName = null) {
810
826
  const cn = changeName || data.currentChange;
811
827
  if (!cn) {
@@ -813,6 +829,13 @@ export class ProgressManager {
813
829
  return;
814
830
  }
815
831
 
832
+ // 坑 platform-sync-progress-rollback-and-db-corruption 坑2②(2026-09-08 实证):并发写
833
+ // 把主库打成 0 字节时 .bak 全空——fail-loud 有其设计正当性但无恢复出路,只能删库重放。
834
+ // 定期 .bak 快照(5 分钟龄控)让 db.js _openWithFallback 的既有回退链获得新鲜恢复源:
835
+ // 损坏最坏回退到 ≤5min 前的进度,而不是从零重放。VACUUM INTO 产出一致性快照(对 WAL
836
+ // 并发写安全;比裸 copyFileSync 正确),best-effort 失败不影响写入主流程。
837
+ this._maybeSnapshotBak(cwd);
838
+
816
839
  const db = this._ensureDB(cwd);
817
840
  db.transaction(() => {
818
841
  const sqlDb = db.getDb();
@@ -908,6 +931,9 @@ export class ProgressManager {
908
931
  // verifyStartAt 基准只读(2026-09-08-ir-verify-facts):execute 行 completed_at;无行 null 走 R-05 fallback
909
932
  getStageCompletedAt(cwd, changeName, stage) { return this._changeRegistry.getStageCompletedAt(cwd, changeName, stage); }
910
933
 
934
+ // 最近进度活动时刻只读(D-002@v1 复潮):quick --done 自动归档闸的时近性信号
935
+ getLatestActivityAt(cwd, changeName) { return this._changeRegistry.getLatestActivityAt(cwd, changeName); }
936
+
911
937
  // evidence mtime 窗口锚点(2026-09-10 用户反馈②):execute 行 started_at——证据合法产自 execute
912
938
  // 或 verify 两窗口,锚「完成时刻」会把 execute 期间产的证据判旧(时序两难);无行 null 走 completed_at → R-05
913
939
  getStageStartedAt(cwd, changeName, stage) { return this._changeRegistry.getStageStartedAt(cwd, changeName, stage); }