sillyspec 3.28.7 → 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/package.json +1 -1
- package/src/agent-session-log.js +446 -35
- package/src/config-cat.js +6 -3
- package/src/constants.js +3 -0
- package/src/db.js +12 -3
- 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/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 +41 -35
- 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 +23 -0
- package/src/quicklog.js +44 -18
- package/src/run/command.js +17 -3
- package/src/run/complete-handlers.js +3 -3
- package/src/run/complete.js +28 -9
- 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 +9 -1
- package/src/run/quick-audit.js +25 -2
- package/src/run/shared.js +98 -10
- package/src/run/verify-quality-scan.js +14 -3
- 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/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
|
|
|
@@ -76,15 +76,21 @@ export class ChangeRegistry {
|
|
|
76
76
|
const sqlDb = db.getDb();
|
|
77
77
|
const ch = sqlDb.prepare(`SELECT id FROM changes WHERE name = ?`).get(changeName);
|
|
78
78
|
if (!ch) return null;
|
|
79
|
-
const
|
|
80
|
-
`SELECT
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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();
|
|
88
94
|
} catch { return null; }
|
|
89
95
|
}
|
|
90
96
|
|
|
@@ -309,44 +315,44 @@ export class ChangeRegistry {
|
|
|
309
315
|
// 注:新名日期前缀门禁只在 CLI 边界强制(index.js change-rename 入口)——renameChange 是
|
|
310
316
|
// 库函数,测试/平台工具合法用任意名(2026-09-11 决策)。
|
|
311
317
|
const db = this.pm._ensureDB(cwd);
|
|
312
|
-
//
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
const row = sqlDb.prepare(`SELECT name, status FROM changes WHERE name = ?`).get(oldName);
|
|
316
|
-
if (row === undefined) return null;
|
|
317
|
-
return { name: row.name, status: row.status };
|
|
318
|
-
});
|
|
319
|
-
if (!existing) {
|
|
320
|
-
console.error(`❌ 变更 ${oldName} 不存在`);
|
|
321
|
-
return;
|
|
322
|
-
}
|
|
323
|
-
// 检查新名是否已存在
|
|
324
|
-
const conflict = db.transaction(() => {
|
|
325
|
-
const sqlDb = db.getDb();
|
|
326
|
-
const row = sqlDb.prepare(`SELECT name FROM changes WHERE name = ?`).get(newName);
|
|
327
|
-
return row !== undefined;
|
|
328
|
-
});
|
|
329
|
-
if (conflict) {
|
|
330
|
-
console.error(`❌ 变更 ${newName} 已存在`);
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
333
|
-
// 先更新 DB,再重命名目录;FS 失败则回滚 DB,避免"目录已改名但 DB 旧名"的孤儿
|
|
334
|
-
// (旧实现 FS-first 无补偿:renameSync 成功后 DB transaction 抛 EPERM/EBUSY 会让
|
|
335
|
-
// 目录已是 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/目录分裂(空目录假成功)。
|
|
336
321
|
const oldDir = this.pm._changePath(cwd, oldName);
|
|
337
322
|
const newDir = this.pm._changePath(cwd, newName);
|
|
338
323
|
const now = new Date().toISOString();
|
|
324
|
+
let outcome;
|
|
339
325
|
try {
|
|
340
|
-
db.transaction(() => {
|
|
326
|
+
outcome = db.transaction(() => {
|
|
341
327
|
const sqlDb = db.getDb();
|
|
342
|
-
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 改走
|
|
343
333
|
// 本地脏度(D-013 / task-04):重命名也是本地推进(标新名)
|
|
344
334
|
this.pm._touchLocalModified(cwd, newName, now);
|
|
335
|
+
return 'ok';
|
|
345
336
|
});
|
|
346
337
|
} catch (e) {
|
|
347
338
|
console.error(`❌ 重命名失败:更新数据库时出错(${e.message})`);
|
|
348
339
|
return;
|
|
349
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(两名) 都失联且无自动恢复)。
|
|
350
356
|
let renamed = true;
|
|
351
357
|
if (existsSync(oldDir)) {
|
|
352
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();
|
package/src/quicklog.js
CHANGED
|
@@ -67,6 +67,13 @@ export async function withFileLock(lockPath, fn, opts = {}) {
|
|
|
67
67
|
if (!restored) { try { unlinkSync(claim) } catch {} }
|
|
68
68
|
}
|
|
69
69
|
} catch {}
|
|
70
|
+
// 偷锁重试前同样受超时与退避约束(坑 quicklog-lock-steal-spin,2026-09-12 审查批 A-③):
|
|
71
|
+
// 旧版 continue 直接跳过下方检查——锁文件被 AV/索引器长期占用、renameSync 持续失败
|
|
72
|
+
// (每次被外层 catch 吞掉)时忙等自旋:不检查 timeoutMs、不让出 CPU,命令挂死 + 100% CPU。
|
|
73
|
+
if (Date.now() - start > timeoutMs) {
|
|
74
|
+
throw new Error(`文件锁超时(${timeoutMs}ms): ${lockPath}`)
|
|
75
|
+
}
|
|
76
|
+
await sleep(retryMs)
|
|
70
77
|
continue
|
|
71
78
|
}
|
|
72
79
|
} catch {}
|
|
@@ -80,7 +87,13 @@ export async function withFileLock(lockPath, fn, opts = {}) {
|
|
|
80
87
|
return await fn()
|
|
81
88
|
} finally {
|
|
82
89
|
try { closeSync(fd) } catch {}
|
|
83
|
-
|
|
90
|
+
// 释放前校验持有者身份(坑 quicklog-lock-release-mismatch,2026-09-12 审查批 A-③):
|
|
91
|
+
// 本进程临界区超 staleMs 被他进程偷锁后,lockPath 上可能是**他人新建**的锁——无条件
|
|
92
|
+
// unlink 误删它会让第三方抢锁成功,形成双进程并发临界区。仅内容仍为本会话 myId 才删
|
|
93
|
+
// (读失败=锁已不在/被偷,容忍——偷锁路径自会回收旧 claim)。
|
|
94
|
+
try {
|
|
95
|
+
if (readFileSync(lockPath, 'utf8') === myId) unlinkSync(lockPath)
|
|
96
|
+
} catch {}
|
|
84
97
|
}
|
|
85
98
|
}
|
|
86
99
|
|
|
@@ -718,7 +731,12 @@ export async function allocateQuicklogEntry(specBase, gitUser, { description, li
|
|
|
718
731
|
const files = Array.isArray(allowedFiles) ? allowedFiles : []
|
|
719
732
|
const today = todayStamp()
|
|
720
733
|
|
|
721
|
-
|
|
734
|
+
// 平台推送在锁外执行(2026-09-11 审查性能包①):锁内 5s 网络推送 + 逐变更 tasks 锁
|
|
735
|
+
// (最坏各 10s)可使临界区突破 30s stale 偷锁阈值——他进程偷锁后双写者并发,正是本模块
|
|
736
|
+
// 头声明要根治的并发丢更新被重新打开。推送 best-effort 不回写本地,锁外语义等同(原注释
|
|
737
|
+
// 自证),仅放弃「分配即推送」的顺序一致性(平台为 best-effort 面板,乱序窗口极小)。
|
|
738
|
+
let pushPayload = null
|
|
739
|
+
const { qlId } = await withFileLock(lockPath, async () => {
|
|
722
740
|
const { maxSeq, usedSuffix } = scanExisting(quicklogDir, today)
|
|
723
741
|
const nextSeq = maxSeq + 1
|
|
724
742
|
// XXXX 4 位 hex 随机后缀(消歧;NNN 已在锁内顺序分配保证唯一,此处仅 belt-and-suspenders)
|
|
@@ -744,9 +762,7 @@ export async function allocateQuicklogEntry(specBase, gitUser, { description, li
|
|
|
744
762
|
|
|
745
763
|
for (const c of linked) await appendTaskCheckbox(specBase, c, qlId, desc)
|
|
746
764
|
|
|
747
|
-
|
|
748
|
-
// 保证「分配即推送」顺序一致;best-effort 失败不影响返回。
|
|
749
|
-
await pushQuicklogEntryToPlatform(specBase, {
|
|
765
|
+
pushPayload = {
|
|
750
766
|
ql_id: qlId,
|
|
751
767
|
timestamp: nowDatetime(),
|
|
752
768
|
title: desc,
|
|
@@ -758,10 +774,13 @@ export async function allocateQuicklogEntry(specBase, gitUser, { description, li
|
|
|
758
774
|
files: files.map(f => ({ path: f, note: null })),
|
|
759
775
|
body_sections: {},
|
|
760
776
|
raw_block: entry,
|
|
761
|
-
}
|
|
762
|
-
|
|
777
|
+
}
|
|
763
778
|
return { qlId }
|
|
764
779
|
})
|
|
780
|
+
if (pushPayload) {
|
|
781
|
+
await pushQuicklogEntryToPlatform(specBase, pushPayload).catch(() => {}) // 自身已吞错;双保险防未来改动破坏 best-effort 契约
|
|
782
|
+
}
|
|
783
|
+
return { qlId }
|
|
765
784
|
}
|
|
766
785
|
|
|
767
786
|
/**
|
|
@@ -785,9 +804,11 @@ export async function appendQuicklogEntryWithId(specBase, gitUser, qlId, { descr
|
|
|
785
804
|
const linked = Array.isArray(linkedChanges) ? linkedChanges : []
|
|
786
805
|
const files = Array.isArray(allowedFiles) ? allowedFiles : []
|
|
787
806
|
|
|
788
|
-
|
|
807
|
+
// 推送出锁(同 allocateQuicklogEntry 性能包①:临界区只留本地写 + tasks 挂载)
|
|
808
|
+
let pushPayload = null
|
|
809
|
+
const { existed } = await withFileLock(lockPath, async () => {
|
|
789
810
|
// 双检(锁内):并发兜底同 ID 只落一条
|
|
790
|
-
if (findQuicklogEntry(specBase, gitUser, qlId)) return {
|
|
811
|
+
if (findQuicklogEntry(specBase, gitUser, qlId)) return { existed: true }
|
|
791
812
|
await rotateIfNeeded(userFile, user)
|
|
792
813
|
const entry = [
|
|
793
814
|
'',
|
|
@@ -799,7 +820,7 @@ export async function appendQuicklogEntryWithId(specBase, gitUser, qlId, { descr
|
|
|
799
820
|
].join('\n')
|
|
800
821
|
appendFileSync(userFile, entry)
|
|
801
822
|
for (const c of linked) await appendTaskCheckbox(specBase, c, qlId, desc)
|
|
802
|
-
|
|
823
|
+
pushPayload = {
|
|
803
824
|
ql_id: qlId,
|
|
804
825
|
timestamp: nowDatetime(),
|
|
805
826
|
title: desc,
|
|
@@ -810,9 +831,13 @@ export async function appendQuicklogEntryWithId(specBase, gitUser, qlId, { descr
|
|
|
810
831
|
files: files.map(f => ({ path: f, note: null })),
|
|
811
832
|
body_sections: {},
|
|
812
833
|
raw_block: entry,
|
|
813
|
-
}
|
|
814
|
-
return {
|
|
834
|
+
}
|
|
835
|
+
return { existed: false }
|
|
815
836
|
})
|
|
837
|
+
if (pushPayload && !existed) {
|
|
838
|
+
await pushQuicklogEntryToPlatform(specBase, pushPayload).catch(() => {})
|
|
839
|
+
}
|
|
840
|
+
return { qlId, existed }
|
|
816
841
|
}
|
|
817
842
|
|
|
818
843
|
/**
|
|
@@ -837,10 +862,12 @@ export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultTex
|
|
|
837
862
|
? softFiles.filter(Boolean).map((f) => String(f).replace(/\\/g, '/'))
|
|
838
863
|
: []
|
|
839
864
|
|
|
865
|
+
// 推送出锁(性能包①):终态 payload 以锁内落盘结果组装,锁外 best-effort 推送 + sidecar 持久化
|
|
866
|
+
// (sidecar 与推送同源同解析器,同移锁外不改变「以落盘终态为准」语义)
|
|
867
|
+
let finalPayload = null
|
|
840
868
|
await withFileLock(lockPath, async () => {
|
|
841
869
|
// 条目可能在主文件或轮转归档中
|
|
842
870
|
let updatedContent = null
|
|
843
|
-
let updatedFile = null
|
|
844
871
|
for (const f of listQuicklogFiles(quicklogDir)) {
|
|
845
872
|
const filePath = join(quicklogDir, f)
|
|
846
873
|
let content = ''
|
|
@@ -850,7 +877,6 @@ export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultTex
|
|
|
850
877
|
if (updated !== null) {
|
|
851
878
|
await writeAtomic(filePath, updated) // 命中处原子落盘(只改含目标条目的那一个文件)
|
|
852
879
|
updatedContent = updated
|
|
853
|
-
updatedFile = filePath
|
|
854
880
|
break
|
|
855
881
|
}
|
|
856
882
|
}
|
|
@@ -860,16 +886,16 @@ export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultTex
|
|
|
860
886
|
// (design §5.3:翻完成时标题行被 extractTitleFromResult 刷新,推送须与落盘一致,
|
|
861
887
|
// 不能用入参拼——标题/结果块/文件行都会在 flipEntryInContent 中重写)。
|
|
862
888
|
const rawBlock = extractRawBlock(updatedContent, qlId)
|
|
863
|
-
|
|
889
|
+
finalPayload = buildPushPayloadFromRaw(rawBlock, {
|
|
864
890
|
ql_id: qlId,
|
|
865
891
|
author_raw: user,
|
|
866
892
|
status: 'completed',
|
|
867
893
|
linked_changes: linked,
|
|
868
894
|
})
|
|
869
|
-
// P1-4:终态结构 sidecar 持久化(同一解析器产出,md/sidecar 恒一致;fail-soft)
|
|
870
|
-
writeQuicklogSidecar(specBase, finalPayload)
|
|
871
|
-
await pushQuicklogEntryToPlatform(specBase, finalPayload).catch(() => {})
|
|
872
895
|
})
|
|
896
|
+
// P1-4:终态结构 sidecar 持久化(同一解析器产出,md/sidecar 恒一致;fail-soft)
|
|
897
|
+
writeQuicklogSidecar(specBase, finalPayload)
|
|
898
|
+
await pushQuicklogEntryToPlatform(specBase, finalPayload).catch(() => {})
|
|
873
899
|
}
|
|
874
900
|
|
|
875
901
|
/**
|