sillyspec 3.26.14 → 3.27.1
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-archive/SKILL.md +2 -0
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +1 -1
- package/.claude/skills/sillyspec-commit/SKILL.md +18 -60
- package/.claude/skills/sillyspec-continue/SKILL.md +2 -0
- package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
- package/.claude/skills/sillyspec-explore/SKILL.md +10 -3
- package/.claude/skills/sillyspec-plan/SKILL.md +10 -2
- package/.claude/skills/sillyspec-quick/SKILL.md +2 -2
- package/.claude/skills/sillyspec-resume/SKILL.md +2 -0
- package/.claude/skills/sillyspec-verify/SKILL.md +4 -0
- package/.claude/skills/sillyspec-workspace/SKILL.md +7 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/packages/dashboard/server/index.js +22 -6
- package/src/change-risk-profile.js +21 -6
- package/src/commit-suggest.js +202 -0
- package/src/config-cat.js +88 -0
- package/src/db.js +14 -3
- package/src/docs-check.js +598 -534
- package/src/doctor-diagnostics.js +102 -1
- package/src/endpoint-extractor.js +21 -2
- package/src/fs-atomic.js +4 -2
- package/src/git-helper.js +73 -5
- package/src/hooks/claude-pre-tool-use.cjs +10 -5
- package/src/hooks/worktree-guard.js +42 -4
- package/src/index.js +557 -12
- package/src/init.js +1 -1
- package/src/local-register.js +98 -0
- package/src/module-impact.js +166 -0
- package/src/module-resolve.js +211 -0
- package/src/modules.js +82 -2
- package/src/progress/change-registry.js +43 -4
- package/src/progress/stage-machine.js +10 -5
- package/src/progress/step-store.js +12 -8
- package/src/progress.js +19 -3
- package/src/quicklog.js +106 -1
- package/src/run/command.js +95 -1
- package/src/run/complete-handlers.js +73 -5
- package/src/run/complete.js +47 -14
- package/src/run/gates.js +144 -17
- package/src/run/multi-repo-context.js +3 -1
- package/src/run/next.js +128 -0
- package/src/run/prompt.js +36 -4
- package/src/run/quick-audit.js +5 -2
- package/src/run/shared.js +48 -20
- package/src/run/stage.js +37 -3
- package/src/scan-postcheck.js +142 -2
- package/src/stage-contract-spec.js +1 -1
- package/src/stage-contract.js +14 -1
- package/src/stage-review.js +15 -6
- package/src/stages/archive.js +4 -2
- package/src/stages/brainstorm.js +1 -0
- package/src/stages/execute.js +275 -44
- package/src/stages/explore.js +11 -2
- package/src/stages/knowledge.js +3 -2
- package/src/stages/quick.js +18 -9
- package/src/stages/scan.js +1 -0
- package/src/stages/verify.js +16 -51
- package/src/sync.js +44 -3
- package/src/task-review.js +273 -11
- package/src/taskcard.js +52 -22
- package/src/verify-postcheck.js +223 -6
- package/src/verify-probes.js +264 -0
- package/src/workspace.js +125 -0
- package/src/worktree-apply.js +43 -11
- package/src/worktree-deps.js +59 -5
- package/src/worktree.js +128 -14
- package/templates/claude-instruction.md +1 -1
- package/templates/prompts/taskcard-rules.md +1 -1
- package/templates/prompts/verify-probes.md +13 -47
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commit-suggest.js — `sillyspec commit` 的语义收集 + commit message 建议生成
|
|
3
|
+
* (2026-08-21 agent-手工产出审计第二批 G1-G3)。
|
|
4
|
+
*
|
|
5
|
+
* commit skill 此前让 agent 手工收集语义:取 LAST_COMMIT_TIME、cat 多处 QUICKLOG、
|
|
6
|
+
* 扫变更目录、按时间戳过滤已勾 task、按路径模式识别来源阶段——进度库/quicklog/
|
|
7
|
+
* git 全在 CLI 手里,这是「CLI 有数据却让 agent 重新收集」的最典型案例。
|
|
8
|
+
*
|
|
9
|
+
* 本模块一条命令产出全部上下文 + conventional commits 建议 message + 可照抄的
|
|
10
|
+
* git commit 命令。**只建议不执行**(对齐 skill 绝对规则「不要自动提交」——确认权在人)。
|
|
11
|
+
*
|
|
12
|
+
* 语义来源(与 skill 手工流程同源):
|
|
13
|
+
* A. QUICKLOG:specBase/quicklog/QUICKLOG-<user>.md + changes/<change>/quicklog/ 下的归属条目,
|
|
14
|
+
* 条目时间 > 上次 commit 时间者(首次提交全收)
|
|
15
|
+
* B. execute 已勾 task:活跃变更 tasks.md 的 [x] 行(tasks.md mtime > 上次 commit 才计入,
|
|
16
|
+
* 防旧变更全勾 task 永久污染建议)
|
|
17
|
+
* C. 阶段产出:changed paths 按路径模式归 scan/brainstorm/plan/execute/archive/knowledge
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
20
|
+
import { join } from 'path'
|
|
21
|
+
import { gitQuiet } from './git-helper.js'
|
|
22
|
+
import { resolveSpecDir } from './run/shared.js'
|
|
23
|
+
|
|
24
|
+
function listActiveChanges(specBase) {
|
|
25
|
+
try {
|
|
26
|
+
return readdirSync(join(specBase, 'changes'), { withFileTypes: true })
|
|
27
|
+
.filter(e => e.isDirectory() && e.name !== 'archive')
|
|
28
|
+
.map(e => e.name)
|
|
29
|
+
.sort()
|
|
30
|
+
} catch {
|
|
31
|
+
return []
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 解析 QUICKLOG 文件里的条目(## ql-xxx | time | title + 状态:行)
|
|
37
|
+
*/
|
|
38
|
+
function parseQuicklogEntries(file) {
|
|
39
|
+
let content
|
|
40
|
+
try {
|
|
41
|
+
content = readFileSync(file, 'utf8')
|
|
42
|
+
} catch {
|
|
43
|
+
return []
|
|
44
|
+
}
|
|
45
|
+
const entries = []
|
|
46
|
+
const lines = content.split('\n')
|
|
47
|
+
let cur = null
|
|
48
|
+
for (const line of lines) {
|
|
49
|
+
const h = line.match(/^## (ql-\S+) \| ([0-9: -]+?) \| (.*)$/)
|
|
50
|
+
if (h) {
|
|
51
|
+
if (cur) entries.push(cur)
|
|
52
|
+
cur = { qlId: h[1], time: h[2].trim(), title: h[3].trim(), status: '未知' }
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
if (cur) {
|
|
56
|
+
const s = line.match(/^状态[::]\s*(\S+)/)
|
|
57
|
+
if (s) cur.status = s[1]
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (cur) entries.push(cur)
|
|
61
|
+
return entries
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function classifyStagePath(p) {
|
|
65
|
+
const posix = p.replace(/\\/g, '/')
|
|
66
|
+
if (/^\.sillyspec\/docs\/[^/]+\/scan\//.test(posix)) return 'scan'
|
|
67
|
+
if (/^\.sillyspec\/changes\/archive\//.test(posix)) return 'archive'
|
|
68
|
+
if (/^\.sillyspec\/changes\/[^/]+\/(proposal|design)\.md/.test(posix)) return 'brainstorm'
|
|
69
|
+
if (/^\.sillyspec\/changes\/[^/]+\/(tasks|plan)\.md/.test(posix)) return 'plan'
|
|
70
|
+
if (/^\.sillyspec\/knowledge\//.test(posix)) return 'knowledge'
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 收集提交上下文并生成建议 message。
|
|
76
|
+
* @param {{ cwd: string, specDir?: string|null }} opts
|
|
77
|
+
* @returns {{
|
|
78
|
+
* lastCommitTime: string|null, hasChanges: boolean, changedCount: number, stat: string,
|
|
79
|
+
* quickEntries: Array<{qlId,time,title,status,file}>, checkedTasks: Array<{change,item}>,
|
|
80
|
+
* stageArtifacts: string[], suggestion: {type, subject, body}|null
|
|
81
|
+
* }}
|
|
82
|
+
*/
|
|
83
|
+
export function collectCommitContext({ cwd, specDir = null }) {
|
|
84
|
+
const specBase = resolveSpecDir(cwd, { specDir })
|
|
85
|
+
const lastCommitTime = gitQuiet(cwd, ['log', '-1', '--format=%ci'])
|
|
86
|
+
const lastCommitKey = lastCommitTime ? lastCommitTime.slice(0, 19) : null
|
|
87
|
+
|
|
88
|
+
// -uall:未跟踪目录不折叠(.sillyspec/ 整体未跟踪时默认 porcelain 只给一行 `?? .sillyspec/`,
|
|
89
|
+
// 路径模式分类会瞎;-uall 逐文件列出,scan/brainstorm/plan 分类才有输入)
|
|
90
|
+
const porcelain = gitQuiet(cwd, ['status', '--porcelain', '-uall']) || ''
|
|
91
|
+
const changedLines = porcelain.split('\n').filter(Boolean)
|
|
92
|
+
const changedPaths = changedLines.map(l => l.slice(3).trim()).filter(Boolean)
|
|
93
|
+
const untrackedCount = changedLines.filter(l => l.startsWith('??')).length
|
|
94
|
+
const stat = gitQuiet(cwd, ['diff', 'HEAD', '--stat']) || (changedPaths.length > 0 ? `(${changedPaths.length} 个文件待首提交)` : '')
|
|
95
|
+
|
|
96
|
+
// ── 来源 A:QUICKLOG(主 quicklog + 各活跃变更归属 quicklog)──
|
|
97
|
+
const quickFiles = []
|
|
98
|
+
try {
|
|
99
|
+
for (const f of readdirSync(join(specBase, 'quicklog'))) {
|
|
100
|
+
if (/^QUICKLOG.*\.md$/.test(f)) quickFiles.push(join(specBase, 'quicklog', f))
|
|
101
|
+
}
|
|
102
|
+
} catch {}
|
|
103
|
+
for (const c of listActiveChanges(specBase)) {
|
|
104
|
+
try {
|
|
105
|
+
for (const f of readdirSync(join(specBase, 'changes', c, 'quicklog'))) {
|
|
106
|
+
if (f.endsWith('.md')) quickFiles.push(join(specBase, 'changes', c, 'quicklog', f))
|
|
107
|
+
}
|
|
108
|
+
} catch {}
|
|
109
|
+
}
|
|
110
|
+
const quickEntries = quickFiles
|
|
111
|
+
.flatMap(file => parseQuicklogEntries(file).map(e => ({ ...e, file })))
|
|
112
|
+
.filter(e => !lastCommitKey || e.time > lastCommitKey)
|
|
113
|
+
.sort((a, b) => (a.time < b.time ? -1 : 1))
|
|
114
|
+
const doneQuick = quickEntries.filter(e => e.status === '已完成')
|
|
115
|
+
|
|
116
|
+
// ── 来源 B:活跃变更已勾 task(tasks.md mtime 晚于上次 commit 才计入)──
|
|
117
|
+
const lastCommitMs = lastCommitTime ? Date.parse(lastCommitTime) : null
|
|
118
|
+
const checkedTasks = []
|
|
119
|
+
for (const c of listActiveChanges(specBase)) {
|
|
120
|
+
const tasksPath = join(specBase, 'changes', c, 'tasks.md')
|
|
121
|
+
if (!existsSync(tasksPath)) continue
|
|
122
|
+
if (lastCommitMs !== null && !Number.isNaN(lastCommitMs)) {
|
|
123
|
+
try {
|
|
124
|
+
// epoch 对比(非字符串):git %ci 带本地时区偏移,Date.parse 归一;字符串对比会因
|
|
125
|
+
// UTC/本地时差把刚改过的 tasks.md 误判为早于上次 commit 而漏收
|
|
126
|
+
if (statSync(tasksPath).mtime.getTime() <= lastCommitMs) continue
|
|
127
|
+
} catch { continue }
|
|
128
|
+
}
|
|
129
|
+
for (const line of readFileSync(tasksPath, 'utf8').split('\n')) {
|
|
130
|
+
const m = line.match(/^[-*]\s*\[[xX]\]\s*(task-\d+)\s*[::]?\s*(.*)/)
|
|
131
|
+
if (m) checkedTasks.push({ change: c, item: `${m[1]}: ${m[2].trim()}` })
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── 来源 C:阶段产出(changed paths 按模式归类去重)──
|
|
136
|
+
const stageSet = new Set()
|
|
137
|
+
for (const p of changedPaths) {
|
|
138
|
+
const stage = classifyStagePath(p)
|
|
139
|
+
if (stage) stageSet.add(stage)
|
|
140
|
+
}
|
|
141
|
+
const stageArtifacts = [...stageSet]
|
|
142
|
+
|
|
143
|
+
// ── 建议 message(对齐 commit skill 的 type 表)──
|
|
144
|
+
let suggestion = null
|
|
145
|
+
const bodyLines = []
|
|
146
|
+
const hasQuick = doneQuick.length > 0
|
|
147
|
+
const hasTasks = checkedTasks.length > 0
|
|
148
|
+
const hasStages = stageArtifacts.length > 0
|
|
149
|
+
|
|
150
|
+
if (hasQuick || hasTasks || hasStages) {
|
|
151
|
+
let type
|
|
152
|
+
let subject
|
|
153
|
+
if (hasTasks && !hasQuick) {
|
|
154
|
+
type = 'feat'
|
|
155
|
+
const byChange = new Map()
|
|
156
|
+
for (const t of checkedTasks) {
|
|
157
|
+
if (!byChange.has(t.change)) byChange.set(t.change, [])
|
|
158
|
+
byChange.get(t.change).push(t.item)
|
|
159
|
+
}
|
|
160
|
+
const [firstChange, items] = [...byChange][0]
|
|
161
|
+
subject = `feat: ${firstChange} 完成 task ×${items.length}`
|
|
162
|
+
} else if (hasQuick && !hasTasks) {
|
|
163
|
+
type = 'fix'
|
|
164
|
+
if (doneQuick.length === 1) {
|
|
165
|
+
subject = `fix: ${doneQuick[0].title}`
|
|
166
|
+
} else if (doneQuick.length === 2) {
|
|
167
|
+
subject = `fix: ${doneQuick[0].title};${doneQuick[1].title}`
|
|
168
|
+
} else {
|
|
169
|
+
subject = `fix: quick 修复 ${doneQuick.length} 项`
|
|
170
|
+
}
|
|
171
|
+
} else if (hasTasks) {
|
|
172
|
+
type = 'feat'
|
|
173
|
+
subject = `feat: 变更交付 + quick 修复 ${doneQuick.length} 项`
|
|
174
|
+
} else {
|
|
175
|
+
type = 'fix'
|
|
176
|
+
subject = `fix: quick 修复 + 阶段产出`
|
|
177
|
+
}
|
|
178
|
+
if (doneQuick.length >= 2 || hasTasks || hasStages) {
|
|
179
|
+
for (const q of doneQuick) bodyLines.push(`- quick ${q.qlId}: ${q.title}`)
|
|
180
|
+
}
|
|
181
|
+
for (const t of checkedTasks) bodyLines.push(`- ${t.change} ${t.item}`)
|
|
182
|
+
for (const s of stageArtifacts) bodyLines.push(`- 阶段产出: ${s}`)
|
|
183
|
+
if (!hasStages && !hasTasks && doneQuick.length === 1) bodyLines.length = 0
|
|
184
|
+
suggestion = {
|
|
185
|
+
type,
|
|
186
|
+
subject: subject.length > 100 ? subject.slice(0, 100) + '…' : subject,
|
|
187
|
+
body: bodyLines.length > 0 ? bodyLines.join('\n') : '',
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
lastCommitTime,
|
|
193
|
+
hasChanges: changedPaths.length > 0,
|
|
194
|
+
changedCount: changedPaths.length,
|
|
195
|
+
untrackedCount,
|
|
196
|
+
stat,
|
|
197
|
+
quickEntries,
|
|
198
|
+
checkedTasks,
|
|
199
|
+
stageArtifacts,
|
|
200
|
+
suggestion,
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config-cat.js — local.yaml 实际值读取的权威路径解析器(`sillyspec config cat` 数据源)。
|
|
3
|
+
*
|
|
4
|
+
* 背景(2026-08-21 root-local-yaml 治理):`config schema` 只列键不含值,agent 拿实际配置
|
|
5
|
+
* 只能翻文件——worktree 内 `.sillyspec/` 是 checkout 副本,gitignored 的 local.yaml 不随
|
|
6
|
+
* checkout 出现,agent 找不到就漂去项目根乱找甚至乱建。cat 按「文件存在」解析真实路径,
|
|
7
|
+
* agent 不需要知道文件在哪。
|
|
8
|
+
*
|
|
9
|
+
* 候选链(首个「文件存在」者胜):
|
|
10
|
+
* 1. specBase(--spec-dir / 平台 pointer / resolveSpecDir 祖先链首个 .sillyspec 目录)——
|
|
11
|
+
* resolveSpecDir 按「目录存在」命中,worktree 内会停在副本目录,文件并不在那里;
|
|
12
|
+
* 2. cwd 祖先链逐级 <dir>/.sillyspec/local.yaml——worktree 副本无此文件会继续向上命中主仓
|
|
13
|
+
* (sillyspec worktree 恒挂在 <主仓>/.sillyspec/.runtime/worktrees/ 下,祖先链必达);
|
|
14
|
+
* 3. git common-dir 反推主仓根——用户手动 `git worktree add` 到任意位置时的兜底。
|
|
15
|
+
*
|
|
16
|
+
* 候选链不含项目根 <dir>/local.yaml——那里从来没有这个文件(hook 同步拦写)。
|
|
17
|
+
* 纯只读;平台 pointer 异常由调用方吞掉走祖先链(真实 reader 各自 fail-closed,cat 是
|
|
18
|
+
* 只读便利口,不必加重)。
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync } from 'fs'
|
|
21
|
+
import { join, resolve, dirname } from 'path'
|
|
22
|
+
import { gitQuiet } from './git-helper.js'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 解析真实 local.yaml 路径。
|
|
26
|
+
* @param {string} dir 起点目录(CLI --dir / cwd)
|
|
27
|
+
* @param {{ specBase?: string|null }} [opts] specBase = resolvePlatformSpecDir 预解析结果(可 null)
|
|
28
|
+
* @returns {{ exists: boolean, path: string|null, source: string|null, searched: string[] }}
|
|
29
|
+
* searched 为候选链前几个路径(未命中时供人看「找过哪里」,最多 6 条)
|
|
30
|
+
*/
|
|
31
|
+
export function resolveLocalYaml(dir, opts = {}) {
|
|
32
|
+
const start = resolve(dir || process.cwd())
|
|
33
|
+
const seen = new Set()
|
|
34
|
+
const candidates = []
|
|
35
|
+
const push = (p, source) => {
|
|
36
|
+
const key = resolve(p)
|
|
37
|
+
if (seen.has(key)) return
|
|
38
|
+
seen.add(key)
|
|
39
|
+
candidates.push({ path: key, source })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (opts.specBase) push(join(opts.specBase, 'local.yaml'), 'spec 根(--spec-dir/平台 pointer/最近 .sillyspec)')
|
|
43
|
+
for (let d = start; ; d = dirname(d)) {
|
|
44
|
+
push(join(d, '.sillyspec', 'local.yaml'), 'cwd 祖先链(worktree 内向上命中主仓)')
|
|
45
|
+
const parent = dirname(d)
|
|
46
|
+
if (parent === d) break
|
|
47
|
+
}
|
|
48
|
+
// git 兜底:linked worktree 内 --git-common-dir 指向主仓共享 .git,dirname 即主仓根
|
|
49
|
+
const commonDir = gitQuiet(start, ['rev-parse', '--git-common-dir'])
|
|
50
|
+
if (commonDir) {
|
|
51
|
+
const mainRoot = dirname(resolve(start, String(commonDir).trim()))
|
|
52
|
+
push(join(mainRoot, '.sillyspec', 'local.yaml'), 'git common-dir 反推主仓根')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const hit = candidates.find(c => existsSync(c.path))
|
|
56
|
+
return {
|
|
57
|
+
exists: !!hit,
|
|
58
|
+
path: hit ? hit.path : null,
|
|
59
|
+
source: hit ? hit.source : null,
|
|
60
|
+
searched: candidates.slice(0, 6).map(c => c.path),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 写侧目标解析(`sillyspec local register-repo` 用):已存在 local.yaml → 就地更新该文件
|
|
66
|
+
* (含 worktree → 主仓解析);全不存在 → 取 specBase(或 cwd 链)候选,但候选落在 worktree
|
|
67
|
+
* 副本下(<主仓>/.sillyspec/.runtime/worktrees/ 内)时重定向 git common-dir 主仓根——
|
|
68
|
+
* local.yaml 是 gitignored 不随 worktree checkout,写进副本目录是死配置。
|
|
69
|
+
*
|
|
70
|
+
* @param {string} dir 起点目录
|
|
71
|
+
* @param {{ specBase?: string|null }} [opts]
|
|
72
|
+
* @returns {{ path: string, existed: boolean }}
|
|
73
|
+
*/
|
|
74
|
+
export function resolveLocalYamlWriteTarget(dir, opts = {}) {
|
|
75
|
+
const r = resolveLocalYaml(dir, opts)
|
|
76
|
+
if (r.exists) return { path: r.path, existed: true }
|
|
77
|
+
const start = resolve(dir || process.cwd())
|
|
78
|
+
const preferred = opts.specBase ? join(opts.specBase, 'local.yaml') : join(start, '.sillyspec', 'local.yaml')
|
|
79
|
+
const posix = resolve(preferred).replace(/\\/g, '/')
|
|
80
|
+
if (posix.includes('/.sillyspec/.runtime/worktrees/')) {
|
|
81
|
+
const commonDir = gitQuiet(start, ['rev-parse', '--git-common-dir'])
|
|
82
|
+
if (commonDir) {
|
|
83
|
+
const mainRoot = dirname(resolve(start, String(commonDir).trim()))
|
|
84
|
+
return { path: join(mainRoot, '.sillyspec', 'local.yaml'), existed: false }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { path: resolve(preferred), existed: false }
|
|
88
|
+
}
|
package/src/db.js
CHANGED
|
@@ -105,13 +105,19 @@ export class DB {
|
|
|
105
105
|
if (!existsSync(p)) return null;
|
|
106
106
|
return statSync(p).size === 0 ? 'empty' : 'ok';
|
|
107
107
|
};
|
|
108
|
-
// tryOpen: openDatabase(path) + prepare 探测双保险(防打开成功但内容非 SQLite
|
|
108
|
+
// tryOpen: openDatabase(path) + prepare 探测双保险(防打开成功但内容非 SQLite)。
|
|
109
|
+
// 探测前先设 busy_timeout(2026-08-21 审查 BUG-7):init() 的 applyPragmas 在 _openWithFallback
|
|
110
|
+
// 之后才跑,探测 SELECT 此前以 busy_timeout=0(SQLite 默认)执行——他者进程 CHECKPOINT 改写
|
|
111
|
+
// 主库时探测立即 SQLITE_BUSY,3 次重试总窗口仅 350ms,大 WAL 下 checkpoint 秒级 → 健康库
|
|
112
|
+
// 被误判"损坏"硬抛错。先设 busy_timeout 让探测自身等待锁,重试只剩消化瞬时抖动。
|
|
109
113
|
const tryOpen = (p) => {
|
|
110
114
|
let db;
|
|
111
115
|
try { db = openDatabase(p); }
|
|
112
116
|
catch { return null; }
|
|
113
|
-
try {
|
|
114
|
-
|
|
117
|
+
try {
|
|
118
|
+
db.exec('PRAGMA busy_timeout = 5000');
|
|
119
|
+
db.prepare('SELECT count(*) FROM sqlite_master').get(); return db;
|
|
120
|
+
} catch { try { db.close(); } catch { /* 关闭失败忽略 */ } return null; }
|
|
115
121
|
};
|
|
116
122
|
|
|
117
123
|
// 1. 主库(0 字节=截断信号,必须走回退,故门禁要求 'ok' 有内容)
|
|
@@ -144,6 +150,11 @@ export class DB {
|
|
|
144
150
|
: 'sillyspec.db 损坏';
|
|
145
151
|
console.warn(`⚠️ ${reason},已从 .bak 备份恢复。`);
|
|
146
152
|
bakDb.close();
|
|
153
|
+
// 覆盖前救援快照(2026-08-21 审查 BUG-7):主库有内容但打不开("损坏")时,其中可能
|
|
154
|
+
// 含比 .bak 更新的数据——静默覆盖即静默丢。best-effort 留一份 .corrupt-<ts> 副本供人工恢复。
|
|
155
|
+
if (primaryRaw === 'ok') {
|
|
156
|
+
try { copyFileSync(this.dbPath, `${this.dbPath}.corrupt-${Date.now()}`); } catch { /* 救援失败不阻断恢复 */ }
|
|
157
|
+
}
|
|
147
158
|
// node:sqlite 绑定路径,无 _save:把 .bak 内容 copy 回主库,统一用 dbPath。
|
|
148
159
|
// 体检 BUG-18:主库可能正被其他进程(daemon/另一 CLI)打开——Windows 覆盖他人
|
|
149
160
|
// 打开的文件抛 EPERM/EBUSY,短暂退避重试(持有者通常是瞬时句柄),仍失败 fail-loud
|