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.
- package/.claude/skills/sillyspec-execute/SKILL.md +224 -222
- package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
- package/.claude/skills/sillyspec-quick/SKILL.md +117 -116
- package/.husky/pre-push +3 -1
- package/.patch16-tmp.mjs +169 -0
- package/attachments/2ab2fd87cfa568ca6acadbb934a651d293a0ad8f3227c66a70b56dcf53a0ea3d.pptx +0 -0
- package/attachments/dc4c2b314ee0c295e1fe5243073eb5c7df7e429074ad7c14424523fbe86e6193.pptx +0 -0
- package/meta.json +11 -0
- package/package.json +1 -1
- package/src/agent-session-log.js +446 -35
- package/src/config-cat.js +6 -3
- package/src/config-schema.js +13 -0
- package/src/constants.js +3 -0
- package/src/db.js +12 -3
- package/src/decision-distill.js +6 -2
- package/src/dispatch/probe.js +8 -1
- package/src/docs-check.js +137 -12
- package/src/docs-gate.js +3 -0
- package/src/doctor-diagnostics.js +19 -7
- package/src/friction-tally.js +7 -2
- package/src/git-helper.js +6 -4
- package/src/hooks/worktree-guard.js +59 -29
- package/src/index.js +168 -64
- package/src/knowledge-match.js +90 -6
- package/src/local-detect.js +30 -15
- package/src/local-register.js +13 -4
- package/src/mcp-server.js +38 -8
- package/src/progress/change-registry.js +58 -26
- package/src/progress/consistency-doctor.js +11 -4
- package/src/progress/stage-machine.js +3 -1
- package/src/progress/step-store.js +30 -17
- package/src/progress.js +26 -0
- package/src/quicklog.js +44 -18
- package/src/review-dispatch.js +7 -1
- package/src/run/command.js +17 -3
- package/src/run/complete-handlers.js +40 -10
- package/src/run/complete.js +48 -17
- package/src/run/concurrent-detect.js +1 -1
- package/src/run/gate-snapshot.js +115 -5
- package/src/run/gates.js +35 -5
- package/src/run/prompt.js +61 -1
- package/src/run/quick-audit.js +80 -4
- package/src/run/shared.js +98 -10
- package/src/run/verify-quality-scan.js +14 -3
- package/src/scope-audit.js +89 -14
- package/src/semantic-guard.js +207 -0
- package/src/sillyhub-mcp/client.js +14 -0
- package/src/spec-sync.js +19 -14
- package/src/stage-contract-spec.js +4 -3
- package/src/stage-review.js +6 -5
- package/src/stages/brainstorm.js +1 -1
- package/src/stages/execute.js +6 -1
- package/src/stages/plan-postcheck.js +57 -12
- package/src/sync.js +66 -4
- package/src/task-review.js +41 -6
- package/src/verify-facts-schema.js +10 -3
- package/src/verify-postcheck.js +21 -6
- package/src/verify-probes.js +8 -3
- package/src/worktree-apply.js +56 -18
- package/src/worktree-cross.js +12 -18
- package/src/worktree.js +138 -25
- package/src/wt-commit.js +128 -0
package/src/knowledge-match.js
CHANGED
|
@@ -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 = /^(
|
|
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, '
|
|
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
|
}
|
package/src/local-detect.js
CHANGED
|
@@ -15,26 +15,41 @@ import { join } from 'path'
|
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* 从 Makefile 文本提取 test 目标的命令(轻量正则,与 verify-postcheck 同风格)。
|
|
18
|
-
*
|
|
19
|
-
* -
|
|
20
|
-
* -
|
|
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
|
-
* -
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
package/src/local-register.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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) =>
|
|
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
|
-
|
|
288
|
-
|
|
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(`
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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); }
|