sillyspec 3.28.7 → 3.28.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.claude/skills/sillyspec-quick/SKILL.md +2 -0
  2. package/.husky/pre-push +3 -1
  3. package/AGENTS.md +3 -2
  4. package/README.md +35 -11
  5. package/SKILL.md +24 -10
  6. package/SillySpec-/350/203/275/345/212/233/344/272/256/347/202/271/345/205/250/346/231/257-2026-09-14.pptx +0 -0
  7. package/package.json +1 -1
  8. package/src/agent-session-log.js +446 -35
  9. package/src/change-risk-profile.js +20 -0
  10. package/src/config-cat.js +6 -3
  11. package/src/config-schema.js +32 -0
  12. package/src/constants.js +3 -0
  13. package/src/db.js +23 -6
  14. package/src/decision-distill.js +15 -3
  15. package/src/design-facts.js +33 -3
  16. package/src/docs-check.js +137 -12
  17. package/src/docs-gate.js +108 -6
  18. package/src/doctor-diagnostics.js +243 -9
  19. package/src/friction-ledger.js +171 -0
  20. package/src/friction-tally.js +7 -2
  21. package/src/git-helper.js +6 -4
  22. package/src/hooks/worktree-guard.js +72 -30
  23. package/src/index.js +356 -81
  24. package/src/knowledge-classify.js +363 -0
  25. package/src/knowledge-hits.js +74 -0
  26. package/src/knowledge-stats.js +180 -0
  27. package/src/local-detect.js +30 -15
  28. package/src/local-register.js +13 -4
  29. package/src/mcp-server.js +38 -8
  30. package/src/progress/change-registry.js +224 -43
  31. package/src/progress/consistency-doctor.js +11 -4
  32. package/src/progress/shared.js +3 -2
  33. package/src/progress/stage-machine.js +3 -1
  34. package/src/progress/step-store.js +30 -17
  35. package/src/progress.js +123 -8
  36. package/src/quick-gate-profile.js +251 -0
  37. package/src/quicklog.js +199 -32
  38. package/src/review-dispatch.js +2 -1
  39. package/src/run/command.js +124 -15
  40. package/src/run/complete-handlers.js +514 -24
  41. package/src/run/complete.js +82 -25
  42. package/src/run/concurrent-detect.js +99 -1
  43. package/src/run/gate-snapshot.js +115 -5
  44. package/src/run/gates.js +85 -14
  45. package/src/run/prompt.js +143 -6
  46. package/src/run/quick-audit.js +136 -15
  47. package/src/run/shared.js +174 -20
  48. package/src/run/stage.js +6 -0
  49. package/src/run/verify-quality-scan.js +14 -3
  50. package/src/scan-diff.js +61 -30
  51. package/src/scan-postcheck.js +74 -0
  52. package/src/scan-refresh.js +477 -0
  53. package/src/scan-staleness.js +25 -13
  54. package/src/scope-audit.js +365 -35
  55. package/src/spec-sync.js +19 -14
  56. package/src/stage-contract-spec.js +4 -3
  57. package/src/stage-contract.js +185 -2
  58. package/src/stage-review.js +29 -12
  59. package/src/stages/brainstorm.js +7 -1
  60. package/src/stages/execute.js +91 -4
  61. package/src/stages/knowledge.js +52 -3
  62. package/src/stages/plan-postcheck.js +57 -12
  63. package/src/stages/quick.js +1 -1
  64. package/src/stages/verify.js +1 -0
  65. package/src/sync-noise.js +114 -6
  66. package/src/sync.js +201 -11
  67. package/src/task-review.js +333 -27
  68. package/src/verify-facts-schema.js +10 -3
  69. package/src/verify-postcheck.js +41 -6
  70. package/src/verify-probes.js +348 -7
  71. package/src/worktree-apply.js +594 -62
  72. package/src/worktree-cross.js +12 -18
  73. package/src/worktree-deps.js +100 -2
  74. package/src/worktree.js +255 -30
  75. package/src/wt-commit.js +128 -0
  76. package/templates/agents-instruction.md +2 -2
  77. package/templates/prompts/testcase-design.md +2 -1
  78. package/templates/prompts/verify-probes.md +4 -1
  79. package/sa-rename-probe/src/new.js +0 -4
@@ -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
 
@@ -1,7 +1,45 @@
1
1
  // W6 Step9b: 变更注册表(changes 表生命周期)—— 注册/注销/重命名/隔离状态/平台同步戳/审批状态。
2
2
  // 从 src/progress.js 单体 ProgressManager 抽出。持有 pm 引用(构造注入),调 pm._ensureDB /
3
3
  // pm._changePath(persistence-core 留 facade 本体)。无共享常量依赖。
4
- import { existsSync, mkdirSync, renameSync } from 'fs';
4
+ import { existsSync, mkdirSync, readFileSync, renameSync } from 'fs';
5
+ import { join } from 'path';
6
+
7
+ // 所有权活跃心跳窗缺省(2026-09-14-change-ownership-guards task-02 / FR-01):owner 非本会话
8
+ // 且 now-last_active < 窗口 → 拒绝接管;窗口外 → 自动接管。local.yaml change-ownership.
9
+ // heartbeat_minutes 可调(resolveHeartbeatMs),缺省 15 分钟(config-schema 已登记 live 读者=本文件)。
10
+ const DEFAULT_HEARTBEAT_MINUTES = 15;
11
+
12
+ /**
13
+ * 读 local.yaml 的 change-ownership.heartbeat_minutes → 毫秒窗(2026-09-14-change-ownership-guards
14
+ * task-02)。路径口径与 sync.js readLocalYaml / worktree-cross.js 一致:cwd/.sillyspec/local.yaml。
15
+ * 定向行扫描(只找 change-ownership: 顶层段下的 heartbeat_minutes),不引 YAML 解析依赖、
16
+ * 字节级容错(CRLF 去 \r、注释行天然不匹配)。缺文件/缺段/缺键/值非正整数(含 0——误配把
17
+ * 活跃窗清零会让护栏形同虚设)→ 缺省 15 分钟。纯读不抛。模块私有(唯一消费者=
18
+ * assertChangeOwnership 心跳窗缺省解析;config-schema 登记的读者即本文件)。
19
+ * @param {string} cwd - 项目根目录
20
+ * @returns {number} 心跳窗口毫秒
21
+ */
22
+ function resolveHeartbeatMs(cwd) {
23
+ try {
24
+ const p = join(cwd || process.cwd(), '.sillyspec', 'local.yaml');
25
+ if (!existsSync(p)) return DEFAULT_HEARTBEAT_MINUTES * 60 * 1000;
26
+ let inSection = false;
27
+ for (const rawLine of readFileSync(p, 'utf8').split('\n')) {
28
+ const line = rawLine.replace(/\r$/, ''); // CRLF 兼容(CLAUDE.md #13)
29
+ if (/^\S/.test(line)) { // 顶层 key 行:进/出 change-ownership 段
30
+ inSection = line.startsWith('change-ownership:');
31
+ continue;
32
+ }
33
+ if (!inSection) continue;
34
+ const m = line.match(/^\s*heartbeat_minutes\s*:\s*(\d+)\s*$/);
35
+ if (m) {
36
+ const minutes = parseInt(m[1], 10);
37
+ return (Number.isFinite(minutes) && minutes >= 1) ? minutes * 60 * 1000 : DEFAULT_HEARTBEAT_MINUTES * 60 * 1000;
38
+ }
39
+ }
40
+ } catch { /* 读失败 → 缺省(缺省是安全下界,不是语义分支) */ }
41
+ return DEFAULT_HEARTBEAT_MINUTES * 60 * 1000;
42
+ }
5
43
 
6
44
  export class ChangeRegistry {
7
45
  constructor(pm) {
@@ -76,15 +114,21 @@ export class ChangeRegistry {
76
114
  const sqlDb = db.getDb();
77
115
  const ch = sqlDb.prepare(`SELECT id FROM changes WHERE name = ?`).get(changeName);
78
116
  if (!ch) return null;
79
- const row = sqlDb.prepare(
80
- `SELECT MAX(ts) AS latest FROM (
81
- SELECT MAX(completed_at) AS ts FROM stages WHERE change_id = ?
82
- UNION ALL
83
- SELECT MAX(st.completed_at) AS ts FROM steps st
84
- JOIN stages s ON st.stage_id = s.id WHERE s.change_id = ?
85
- )`
86
- ).get(ch.id, ch.id);
87
- return row && row.latest ? row.latest : null;
117
+ const rows = sqlDb.prepare(
118
+ `SELECT completed_at AS ts FROM stages WHERE change_id = ? AND completed_at IS NOT NULL
119
+ UNION ALL
120
+ SELECT st.completed_at AS ts FROM steps st
121
+ JOIN stages s ON st.stage_id = s.id WHERE s.change_id = ? AND st.completed_at IS NOT NULL`
122
+ ).all(ch.id, ch.id);
123
+ // JS 侧按解析后的时间取最新(归一 ISO 返回):SQL 字符串 MAX 在 zh-CN('/')与 ISO('-')
124
+ // 混存时恒取 zh-CN('/'0x2F>'-'0x2D)——存量 zh-CN 陈旧值会盖过新 ISO 致时近性闸取错
125
+ // 时间戳(活跃变更被误归档方向,2026-09-11 审查)。不可解析串跳过。
126
+ let latestMs = -Infinity;
127
+ for (const r of rows) {
128
+ const ms = new Date(r.ts).getTime();
129
+ if (Number.isFinite(ms) && ms > latestMs) latestMs = ms;
130
+ }
131
+ return latestMs === -Infinity ? null : new Date(latestMs).toISOString();
88
132
  } catch { return null; }
89
133
  }
90
134
 
@@ -132,8 +176,13 @@ export class ChangeRegistry {
132
176
  /**
133
177
  * 注册变更到活跃列表
134
178
  * SQL: INSERT OR IGNORE → 若已 archived 则 UPDATE status='active'
179
+ * @param {string} cwd
180
+ * @param {string} changeName
181
+ * @param {{ ownerSession?: string|null }} [opts] - owner_session(2026-09-14-change-ownership-guards
182
+ * task-01:首建者获得所有权;INSERT OR IGNORE 语义天然「已有行不覆盖」——已存在行(含他人
183
+ * 活跃 change)owner 不动。会话标识三级方案的接线在 task-02,缺省 undefined=不写(列默认 NULL=无主))
135
184
  */
136
- registerChange(cwd, changeName) {
185
+ registerChange(cwd, changeName, opts = {}) {
137
186
  if (!changeName) {
138
187
  console.warn('⚠️ registerChange: changeName 为空,跳过');
139
188
  return;
@@ -142,11 +191,11 @@ export class ChangeRegistry {
142
191
  db.transaction(() => {
143
192
  const sqlDb = db.getDb();
144
193
  const now = new Date().toISOString();
145
- // 尝试插入新行
194
+ // 尝试插入新行(带 owner_session——首次创建者获得所有权,已有值不覆盖)
146
195
  const ins = sqlDb.prepare(
147
- `INSERT OR IGNORE INTO changes (name, created_at, last_active)
148
- VALUES (?, ?, ?)`
149
- ).run(changeName, now, now);
196
+ `INSERT OR IGNORE INTO changes (name, created_at, last_active, owner_session)
197
+ VALUES (?, ?, ?, ?)`
198
+ ).run(changeName, now, now, opts.ownerSession || null);
150
199
  // 只在真正创建 change 时标脏(D-013/task-04);已存在行不标(读路径/重复调用不误判脏)
151
200
  if (ins.changes > 0) this.pm._touchLocalModified(cwd, changeName, now);
152
201
  // 注意:不复活已归档的变更——归档是不可逆操作
@@ -154,6 +203,131 @@ export class ChangeRegistry {
154
203
  });
155
204
  }
156
205
 
206
+ /**
207
+ * 读变更所有者会话标识(2026-09-14-change-ownership-guards task-01 数据载体,D-001/D-005@v1)。
208
+ * 只读不抛(与 getQuicklogId 同族容错):行缺失/未登记/读取失败 → null。
209
+ * NULL=无主(存量行 v6 迁移后即此态,任何会话可接管——判定/接管语义在 assertChangeOwnership,task-02)。
210
+ * @param {string} cwd
211
+ * @param {string} changeName
212
+ * @returns {string|null} owner 会话标识,无主/无行/读失败返回 null
213
+ */
214
+ getChangeOwner(cwd, changeName) {
215
+ if (!changeName) return null;
216
+ try {
217
+ const db = this.pm._ensureDB(cwd);
218
+ const row = db.getDb().prepare('SELECT owner_session FROM changes WHERE name = ?').get(changeName);
219
+ return (row && typeof row.owner_session === 'string' && row.owner_session) || null;
220
+ } catch {
221
+ return null;
222
+ }
223
+ }
224
+
225
+ /**
226
+ * 认领变更所有权(2026-09-14-change-ownership-guards task-01 数据载体):
227
+ * - 行不存在 → 创建并写 owner=session(首建者获得);
228
+ * - 行存在且 owner 非空 → 不覆盖,返回既有 owner(调用方对比可知认领未成功);
229
+ * - 行存在且 owner 为 NULL(存量无主行)→ 认领写入。
230
+ * 单事务内「INSERT OR IGNORE + 守卫 UPDATE + 读回」,并发两个会话同时首认领时靠
231
+ * SQLite 单写者串行化——后者命中守卫(owner 已非 NULL)不覆盖,返回先到者。
232
+ * 实际变更(首建/认领)标本地脏度(owner 随同步 payload 上行,D-013 同族)。
233
+ * @param {string} cwd
234
+ * @param {string} changeName
235
+ * @param {string|null} session - 本会话标识(空值视为无主认领,只建行不写 owner)
236
+ * @returns {string|null} 实际 owner(首建/认领成功=session;他人已持有=其值;行刚建未写=null)
237
+ */
238
+ claimChangeOwner(cwd, changeName, session) {
239
+ if (!changeName) return null;
240
+ const db = this.pm._ensureDB(cwd);
241
+ const now = new Date().toISOString();
242
+ return db.transaction(() => {
243
+ const sqlDb = db.getDb();
244
+ // 1) 首建:INSERT OR IGNORE 写 own(行已存在则零行、既有 owner 不动)
245
+ const ins = sqlDb.prepare(
246
+ `INSERT OR IGNORE INTO changes (name, created_at, last_active, owner_session)
247
+ VALUES (?, ?, ?, ?)`
248
+ ).run(changeName, now, now, session || null);
249
+ // 2) 存量无主行(owner NULL/空串)补认领;WHERE 守卫保证已有值(他人)绝不覆盖
250
+ let claimed = 0;
251
+ if (session) {
252
+ claimed = sqlDb.prepare(
253
+ `UPDATE changes SET owner_session = ? WHERE name = ? AND (owner_session IS NULL OR owner_session = '')`
254
+ ).run(session, changeName).changes;
255
+ }
256
+ // 3) 实际变更(首建/认领命中)才标脏;纯读回(他人持有)不扰动同步脏度
257
+ if (ins.changes > 0 || claimed > 0) this.pm._touchLocalModified(cwd, changeName, now);
258
+ // 4) 读回实际 owner
259
+ const row = sqlDb.prepare('SELECT owner_session FROM changes WHERE name = ?').get(changeName);
260
+ return (row && row.owner_session) || null;
261
+ });
262
+ }
263
+
264
+ /**
265
+ * 所有权判定(2026-09-14-change-ownership-guards task-02,FR-01 / D-001@v1):
266
+ * **读 changes 行不写库**——接管写库归调用方(setChangeOwner / claimChangeOwner),
267
+ * 判定与写分离让接线点可在锁内先判后写(判定-执行无 TOCTOU 由调用方锁保证)。
268
+ * 分支(self 判定 = 精确字符串等值):
269
+ * - opts.forced(--takeover) → takeover-forced 放行(无条件接管,调用方重写 owner 留痕)
270
+ * - owner 为空(无行/NULL) → no-owner 放行(存量无主行,任何会话可接管)
271
+ * - owner === selfSession → self 放行(本会话自有 change 零路径变化)
272
+ * - owner 他人 且 now-last_active ≥ 窗口 → takeover-stale 放行(窗口外自动接管重写)
273
+ * - owner 他人 且活跃窗内 → blocked-active-owner 拒绝(allowed:false,调用方打结构化错误)
274
+ * last_active 缺失/不可解析按陈旧处理:无法证明新鲜即不拦——护栏拦「可证活跃」,不因时间戳
275
+ * 损坏把 change 锁死到无人能接管(逃生通道优先,与 getLatestActivityAt 族 fail-open 同哲学)。
276
+ * 心跳即 last_active:每次 CLI 写操作既有刷新(updateChangeIsolation/registerChange 等族)。
277
+ * @param {string} cwd
278
+ * @param {string} changeName
279
+ * @param {{ selfSession?: string|null, nowMs?: number, heartbeatMs?: number, forced?: boolean }} [opts]
280
+ * @returns {{allowed: boolean, action: 'self'|'takeover-stale'|'takeover-forced'|'no-owner'|'blocked-active-owner', owner: string|null, lastActive: string|null, heartbeatMs: number}}
281
+ */
282
+ assertChangeOwnership(cwd, changeName, opts = {}) {
283
+ const heartbeatMs = (Number.isFinite(opts.heartbeatMs) && opts.heartbeatMs > 0)
284
+ ? opts.heartbeatMs
285
+ : resolveHeartbeatMs(cwd);
286
+ const deny = (action, owner, lastActive) => ({ allowed: false, action, owner, lastActive, heartbeatMs });
287
+ if (!changeName) return { allowed: true, action: 'no-owner', owner: null, lastActive: null, heartbeatMs };
288
+ const row = this.pm._ensureDB(cwd).getDb()
289
+ .prepare('SELECT owner_session, last_active FROM changes WHERE name = ?').get(changeName);
290
+ const owner = (row && typeof row.owner_session === 'string' && row.owner_session) || null;
291
+ const lastActive = (row && typeof row.last_active === 'string' && row.last_active) || null;
292
+ if (opts.forced) return { allowed: true, action: 'takeover-forced', owner, lastActive, heartbeatMs };
293
+ if (owner === null) return { allowed: true, action: 'no-owner', owner: null, lastActive, heartbeatMs };
294
+ if (owner === opts.selfSession) return { allowed: true, action: 'self', owner, lastActive, heartbeatMs };
295
+ // owner 他人:新鲜度决定拒绝 / 自动接管
296
+ const lastMs = lastActive ? new Date(lastActive).getTime() : NaN;
297
+ const nowMs = Number.isFinite(opts.nowMs) ? opts.nowMs : Date.now();
298
+ const stale = !Number.isFinite(lastMs) || (nowMs - lastMs) >= heartbeatMs;
299
+ return stale
300
+ ? { allowed: true, action: 'takeover-stale', owner, lastActive, heartbeatMs }
301
+ : deny('blocked-active-owner', owner, lastActive);
302
+ }
303
+
304
+ /**
305
+ * 强制重写所有者(task-02 接管写库侧;claimChangeOwner 的对照——那把守卫「已有值不覆盖」,
306
+ * 本方法无条件写):assertChangeOwnership 放行 no-owner/takeover-stale/takeover-forced 后由
307
+ * 接线调用。同事务刷新 last_active——新 owner 的心跳从接管时刻起算,否则下次校验仍读到
308
+ * 旧 owner 的陈旧时间戳,接管形同没发生(立刻又可被他人 takeover-stale 抢走)。行缺失先建
309
+ * (INSERT OR IGNORE)。实际变更标本地脏度(owner_session 随同步 payload 上行,D-013 同族)。
310
+ * @param {string} cwd
311
+ * @param {string} changeName
312
+ * @param {string|null} session - 接管者的会话标识
313
+ */
314
+ setChangeOwner(cwd, changeName, session) {
315
+ if (!changeName) return;
316
+ const db = this.pm._ensureDB(cwd);
317
+ const now = new Date().toISOString();
318
+ db.transaction(() => {
319
+ const sqlDb = db.getDb();
320
+ const ins = sqlDb.prepare(
321
+ `INSERT OR IGNORE INTO changes (name, created_at, last_active, owner_session)
322
+ VALUES (?, ?, ?, ?)`
323
+ ).run(changeName, now, now, session || null);
324
+ const upd = sqlDb.prepare(
325
+ 'UPDATE changes SET owner_session = ?, last_active = ? WHERE name = ?'
326
+ ).run(session || null, now, changeName);
327
+ if (ins.changes > 0 || upd.changes > 0) this.pm._touchLocalModified(cwd, changeName, now);
328
+ });
329
+ }
330
+
157
331
  /**
158
332
  * 更新变更的隔离状态
159
333
  * @param {string} cwd - 项目根目录
@@ -257,9 +431,16 @@ export class ChangeRegistry {
257
431
  // 永不携带(乐观锁失效)、本地脏度恒 false、platform status behind 恒跳过。值优先
258
432
  // 平台回执 last_pushed_at,缺省回退本次 X-SillySpec-Pushed-At(后端 _apply 存的就是
259
433
  // 该 header 原值,回写与服务器精确一致)。COALESCE 保旧值:无 syncedTs 只推进展示列。
434
+ // ql-20260914-001:COALESCE 直写改 MAX 单调推进(坑 sync-base-ts-out-of-order-backfill)——
435
+ // 同机多进程(CLI 会话 ×N + 步进链)并发推送时,A(t1)/B(t2) 乱序回填会让迟到的旧回执
436
+ // 覆盖已推进的 base(multi-agent-platform 实证:6 个假冲突的 base 全部停在上一轮值),
437
+ // 回声窗重开 → 下次 push 409/pull 假冲突。MAX 只升不降:base ≥ 回执恒安全(平台 409
438
+ // 判据是 stored > base,base 偏高只会让推送被接受,绝不产生假 409);仅当回执比现存
439
+ // base 旧(乱序/平台侧回退)时改变行为——正是要防的覆写。与 resolve/自愈路径的
440
+ // MAX 写法(sync.js 四处)对齐,消除本仓最后一个直写点。
260
441
  sqlDb.prepare(
261
- 'UPDATE changes SET platform_last_sync = ?, platform_sync_enabled = 1, last_synced_platform_ts = COALESCE(?, last_synced_platform_ts) WHERE name = ?'
262
- ).run(new Date().toISOString(), syncedTs, changeName);
442
+ 'UPDATE changes SET platform_last_sync = ?, platform_sync_enabled = 1, last_synced_platform_ts = MAX(COALESCE(?, last_synced_platform_ts), COALESCE(last_synced_platform_ts, ?)) WHERE name = ?'
443
+ ).run(new Date().toISOString(), syncedTs, syncedTs, changeName);
263
444
  });
264
445
  }
265
446
 
@@ -309,44 +490,44 @@ export class ChangeRegistry {
309
490
  // 注:新名日期前缀门禁只在 CLI 边界强制(index.js change-rename 入口)——renameChange 是
310
491
  // 库函数,测试/平台工具合法用任意名(2026-09-11 决策)。
311
492
  const db = this.pm._ensureDB(cwd);
312
- // 检查旧名是否存在
313
- const existing = db.transaction(() => {
314
- const sqlDb = db.getDb();
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(两名) 都失联且无自动恢复)。
493
+ // 存在性/冲突检查与 UPDATE 收进同一事务 + 校验影响行数(坑 rename-concurrent-split,
494
+ // 2026-09-12 审查批 C-①):旧三段独立事务是 check-then-act——并发重命名 X→Y 与 X→Z 时,
495
+ // 后者的 UPDATE 命中 0 行不报错,继续搬目录造成 DB/目录分裂(空目录假成功)。
336
496
  const oldDir = this.pm._changePath(cwd, oldName);
337
497
  const newDir = this.pm._changePath(cwd, newName);
338
498
  const now = new Date().toISOString();
499
+ let outcome;
339
500
  try {
340
- db.transaction(() => {
501
+ outcome = db.transaction(() => {
341
502
  const sqlDb = db.getDb();
342
- sqlDb.prepare(`UPDATE changes SET name = ?, last_active = ? WHERE name = ?`).run(newName, now, oldName);
503
+ const row = sqlDb.prepare(`SELECT name FROM changes WHERE name = ?`).get(oldName);
504
+ if (row === undefined) return 'missing';
505
+ if (sqlDb.prepare(`SELECT 1 FROM changes WHERE name = ?`).get(newName) !== undefined) return 'conflict';
506
+ const r = sqlDb.prepare(`UPDATE changes SET name = ?, last_active = ? WHERE name = ?`).run(newName, now, oldName);
507
+ if (r.changes !== 1) return 'stolen'; // 并发窗口:检查后他进程已把 oldName 改走
343
508
  // 本地脏度(D-013 / task-04):重命名也是本地推进(标新名)
344
509
  this.pm._touchLocalModified(cwd, newName, now);
510
+ return 'ok';
345
511
  });
346
512
  } catch (e) {
347
513
  console.error(`❌ 重命名失败:更新数据库时出错(${e.message})`);
348
514
  return;
349
515
  }
516
+ if (outcome === 'missing') {
517
+ console.error(`❌ 变更 ${oldName} 不存在`);
518
+ return;
519
+ }
520
+ if (outcome === 'conflict') {
521
+ console.error(`❌ 变更 ${newName} 已存在`);
522
+ return;
523
+ }
524
+ if (outcome === 'stolen') {
525
+ console.error(`❌ 变更 ${oldName} 刚被并发操作重命名/删除(0 行更新),本次跳过——请重查后重试`);
526
+ return;
527
+ }
528
+ // 先更新 DB,再重命名目录;FS 失败则回滚 DB,避免"目录已改名但 DB 旧名"的孤儿
529
+ // (旧实现 FS-first 无补偿:renameSync 成功后 DB transaction 抛 EPERM/EBUSY 会让
530
+ // 目录已是 newName、DB 仍是 oldName,read(两名) 都失联且无自动恢复)。
350
531
  let renamed = true;
351
532
  if (existsSync(oldDir)) {
352
533
  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];
@@ -26,8 +26,9 @@ export const STAGE_ORDER = ['scan', 'brainstorm', 'plan', 'execute', 'verify', '
26
26
  // (consistency-doctor 坑 + prompt-control-debt plan-c 同根因)。
27
27
  export const MAIN_FLOW_ORDER = ['brainstorm', 'plan', 'execute', 'verify', 'archive'];
28
28
 
29
- // progress 数据版本(v3 = SQLite;v4 = changes 加 base_ts / 本地脏度两列,platform-sync §8)
30
- export const CURRENT_VERSION = 5;
29
+ // progress 数据版本(v3 = SQLite;v4 = changes 加 base_ts / 本地脏度两列,platform-sync §8;
30
+ // v6 = changes 加 owner_session 所有权列,2026-09-14-change-ownership-guards D-005@v1)
31
+ export const CURRENT_VERSION = 6;
31
32
 
32
33
  // 滞留提示阈值(天,2026-08-30 用户反馈②:7 个「代码全落地但流程没收口」的变更挂 38 天
33
34
  // 无人发现):活跃变更 last_active 距今超该天数且流程未收口(archive 未完成)时,progress
@@ -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