sillyspec 3.26.11 → 3.26.13

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 (58) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +1 -1
  2. package/.claude/skills/sillyspec-execute/SKILL.md +1 -1
  3. package/.claude/skills/sillyspec-knowledge/SKILL.md +1 -1
  4. package/.claude/skills/sillyspec-plan/SKILL.md +5 -5
  5. package/.claude/skills/sillyspec-quick/SKILL.md +5 -1
  6. package/README.md +7 -5
  7. package/SKILL.md +5 -4
  8. package/package.json +1 -1
  9. package/packages/dashboard/server/executor.js +10 -1
  10. package/packages/dashboard/server/index.js +108 -46
  11. package/src/config-schema.js +3 -0
  12. package/src/contract-matrix.js +22 -13
  13. package/src/db.js +24 -4
  14. package/src/dispatch/probe.js +99 -63
  15. package/src/docs-check.js +58 -13
  16. package/src/docs-debt.js +6 -2
  17. package/src/docs-gate.js +1 -0
  18. package/src/doctor-diagnostics.js +208 -21
  19. package/src/fs-atomic.js +3 -1
  20. package/src/git-helper.js +7 -4
  21. package/src/hooks/worktree-guard.js +8 -2
  22. package/src/index.js +161 -12
  23. package/src/init.js +3 -2
  24. package/src/local-detect.js +9 -1
  25. package/src/modules.js +7 -5
  26. package/src/progress/stage-machine.js +589 -578
  27. package/src/progress/step-store.js +1 -1
  28. package/src/progress.js +184 -30
  29. package/src/quicklog.js +47 -16
  30. package/src/run/command.js +31 -8
  31. package/src/run/complete-handlers.js +21 -14
  32. package/src/run/complete.js +1181 -1121
  33. package/src/run/gates.js +148 -25
  34. package/src/run/prompt.js +9 -5
  35. package/src/run/shared.js +138 -58
  36. package/src/run/stage.js +41 -1
  37. package/src/setup.js +8 -10
  38. package/src/sillyhub-mcp/client.js +74 -6
  39. package/src/spec-dir-typo.js +3 -24
  40. package/src/spec-sync.js +43 -8
  41. package/src/stage-contract.js +12 -38
  42. package/src/stages/archive.js +4 -3
  43. package/src/stages/brainstorm-auto.js +1 -1
  44. package/src/stages/brainstorm.js +2 -1
  45. package/src/stages/doctor.js +28 -13
  46. package/src/stages/execute.js +164 -153
  47. package/src/stages/plan-postcheck.js +49 -28
  48. package/src/stages/plan.js +57 -40
  49. package/src/stages/quick.js +3 -1
  50. package/src/stages/verify.js +1 -1
  51. package/src/sync.js +323 -221
  52. package/src/task-review.js +246 -61
  53. package/src/taskcard.js +178 -0
  54. package/src/verify-postcheck.js +48 -35
  55. package/src/worktree-apply.js +58 -34
  56. package/src/worktree-deps.js +120 -20
  57. package/src/worktree.js +55 -9
  58. package/.npmrc-publish +0 -2
@@ -150,7 +150,7 @@ export class StepStore {
150
150
  const tDb = db.getDb();
151
151
  const now = new Date().toISOString();
152
152
  if (status) {
153
- tDb.prepare('UPDATE steps SET status = ?, completed_at = ? WHERE id = ? AND name = ?').run(status, now, stepId, stepName);
153
+ tDb.prepare('UPDATE steps SET status = ?, completed_at = ? WHERE id = ? AND name = ?').run(status, status === 'completed' ? now : null, stepId, stepName);
154
154
  }
155
155
  if (output !== undefined) {
156
156
  tDb.prepare('UPDATE steps SET output = ? WHERE id = ? AND name = ?').run(output, stepId, stepName);
package/src/progress.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * worktree-guard hook 直读 sillyspec.db,不再有 gate-status.json 缓存双源(task-10 废除)。
11
11
  */
12
12
 
13
- import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, appendFileSync, copyFileSync } from 'fs';
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, appendFileSync, copyFileSync, readdirSync, statSync } from 'fs';
14
14
  import { join, basename, dirname, resolve, sep } from 'path';
15
15
  import { tmpdir } from 'os';
16
16
  import { DB } from './db.js';
@@ -148,28 +148,13 @@ const CHANGES_SUBDIR = 'changes';
148
148
  // VALID_STAGES → ./progress/shared.js(W6 Step9c)
149
149
  // VALID_STATUSES → ./progress/step-store.js(updateStep 专用,本地化)
150
150
 
151
- // Stage statuses (superset of step statuses)
152
- const VALID_STAGE_STATUSES = ['pending', 'in-progress', 'completed', 'failed', 'blocked', 'revising', 'stale'];
153
-
154
- // STAGE_ORDER / MAIN_FLOW_ORDER → ./progress/shared.js(W6 Step9a:facade + 子模块共用)
155
-
156
- // STAGE_LABELS → ./progress/shared.js(W6 Step9c)
157
-
158
- // emptyStage → ./progress/shared.js(W6 Step9d)
159
-
160
- function makeInitialProgress(project) {
161
- const stages = {};
162
- for (const s of VALID_STAGES) stages[s] = emptyStage();
163
- return { _version: CURRENT_VERSION, project: project || '', currentStage: '', currentChange: null, stages, lastActive: null };
164
- }
165
-
166
- function makeInitialGlobal(project) {
167
- return { _version: CURRENT_VERSION, project: project || '', activeChanges: [] };
168
- }
169
151
 
170
152
  // ── ProgressManager ──
171
153
 
172
154
  export class ProgressManager {
155
+ /** 模块级 DB 连接池:Map<dbPath, DB>(SEC-09/PERF-10,见 _ensureDB 注释) */
156
+ static _dbPool = new Map();
157
+
173
158
  /**
174
159
  * @param {object} [opts]
175
160
  * @param {string} [opts.specDir] - 规范目录绝对路径(默认 cwd/.sillyspec)
@@ -215,11 +200,26 @@ export class ProgressManager {
215
200
  }
216
201
  }
217
202
 
218
- /** 懒初始化 DB 连接,缓存在实例上(better-sqlite3 同步 API,init 无 async) */
203
+ /**
204
+ * 懒初始化 DB 连接,缓存在实例上(better-sqlite3 同步 API,init 无 async)。
205
+ *
206
+ * SEC-09/PERF-10(2026-08-20 体检):连接额外进模块级 Map<dbPath, DB> 单例——
207
+ * 一条平台命令此前会 new 多个 ProgressManager(triggerPull / sync / import 各建),
208
+ * 同一 sillyspec.db 被 open 3-5 次,每次重跑 PRAGMA + schema 探测,且多连接
209
+ * 提升 SQLITE_BUSY 概率、Windows 残留句柄影响文件替换。CLI 短进程生命周期内
210
+ * dbPath 不变,进程退出由 OS 回收句柄(node:sqlite 无显式泄漏);进程内共享
211
+ * 单连接后 WAL 写竞争面反而缩小。
212
+ */
219
213
  _ensureDB(cwd) {
220
214
  if (!this._db) {
221
- this._db = new DB(this._runtimePath(cwd, 'sillyspec.db'));
222
- this._db.init();
215
+ const dbPath = this._runtimePath(cwd, 'sillyspec.db');
216
+ let db = ProgressManager._dbPool.get(dbPath);
217
+ if (!db) {
218
+ db = new DB(dbPath);
219
+ db.init();
220
+ ProgressManager._dbPool.set(dbPath, db);
221
+ }
222
+ this._db = db;
223
223
  }
224
224
  return this._db;
225
225
  }
@@ -288,7 +288,7 @@ export class ProgressManager {
288
288
  for (const row of stageRows) {
289
289
  const { id: sId, stage, status, started_at: startedAt, completed_at: completedAt, revision, reopened_from_step: reopenedFromStep, reopened_at: reopenedAt, stale_reason: staleReason } = row;
290
290
  stageMap[stage] = { _dbId: sId, status, startedAt, completedAt,
291
- ...(revision ? { revision } : {}),
291
+ ...(revision != null ? { revision } : {}),
292
292
  ...(reopenedFromStep ? { reopenedFromStep } : {}),
293
293
  ...(reopenedAt ? { reopenedAt } : {}),
294
294
  ...(staleReason ? { staleReason } : {}),
@@ -309,7 +309,7 @@ export class ProgressManager {
309
309
  if (!stepsByStage[stageId]) stepsByStage[stageId] = [];
310
310
  let waitAnswers = null;
311
311
  if (waitAnswersJson) {
312
- try { waitAnswers = JSON.parse(waitAnswersJson); } catch {}
312
+ try { waitAnswers = JSON.parse(waitAnswersJson); } catch (e) { console.warn(`[progress] waitAnswers JSON 损坏,已跳过: ${e.message}`); }
313
313
  }
314
314
  stepsByStage[stageId].push({
315
315
  name, status, output, completedAt,
@@ -564,7 +564,17 @@ export class ProgressManager {
564
564
  }
565
565
  const bakTs = new Date().toISOString().replace(/[:.]/g, '-');
566
566
  const bakPath = this._runtimePath(cwd, `sillyspec.db.pre-import-${bakTs}.bak`);
567
+ // 体检 BUG-18:WAL 模式下最近已提交事务可能仍在 -wal 侧车,直接 copy 主库文件会拿到
568
+ // 缺尾部提交的快照(恢复时静默回退进度)。先尽力 checkpoint(TRUNCATE) 把 WAL 合并回主库;
569
+ // 他进程持连接致 checkpoint 不完全时,把 -wal 侧车一并备份,恢复侧才有完整提交
570
+ try {
571
+ this._ensureDB(cwd).getDb().exec('PRAGMA wal_checkpoint(TRUNCATE)');
572
+ } catch { /* best-effort:失败退化为连 -wal 一起备份 */ }
567
573
  copyFileSync(dbPath, bakPath);
574
+ const walSidecar = `${dbPath}-wal`;
575
+ if (existsSync(walSidecar)) {
576
+ try { copyFileSync(walSidecar, `${bakPath}-wal`); } catch { /* 侧车消失=已 checkpoint,无碍 */ }
577
+ }
568
578
 
569
579
  const pushedAt = progressObj.pushed_at ?? null;
570
580
  const now = new Date().toISOString();
@@ -1002,22 +1012,25 @@ export class ProgressManager {
1002
1012
  // ── plan.md 对齐(doctor --align-execute-progress 入口)──
1003
1013
 
1004
1014
  /**
1005
- * 解析 changeDir/plan.md(回退 tasks.md)的 task checkbox 统计。
1015
+ * 解析 changeDir/tasks.md(任务注册表唯一真相)的 task checkbox 统计。
1016
+ * 2026-08-20-task-truth-unify:源从「plan.md 优先回退 tasks.md」改为 tasks.md 唯一源——
1017
+ * 旧顺序下新契约 plan.md(纯 ID 引用行)恒 total=0,doctor --align-execute-progress 会静默失效。
1018
+ * tasks.md 缺失时回退 plan.md(旧归档变更兼容读侧,不写侧迁移)。
1006
1019
  * 仅匹配 `- [ ] task-NN` / `- [x] task-NN` 形态的行(task- 前缀锚定,避免误捞非任务项)。
1007
- * @param {string} changeDir - 变更目录绝对路径(含 plan.md/tasks.md)
1020
+ * @param {string} changeDir - 变更目录绝对路径(含 tasks.md/plan.md)
1008
1021
  * @returns {{ total: number, checked: number }}
1009
1022
  */
1010
1023
  readPlanCheckboxStatus(changeDir) {
1011
1024
  if (!changeDir || typeof changeDir !== 'string') {
1012
1025
  throw new Error('changeDir 不能为空');
1013
1026
  }
1014
- const planPath = join(changeDir, 'plan.md');
1015
1027
  const tasksPath = join(changeDir, 'tasks.md');
1028
+ const planPath = join(changeDir, 'plan.md');
1016
1029
  let content = null;
1017
- if (existsSync(planPath)) {
1018
- content = readFileSync(planPath, 'utf8');
1019
- } else if (existsSync(tasksPath)) {
1030
+ if (existsSync(tasksPath)) {
1020
1031
  content = readFileSync(tasksPath, 'utf8');
1032
+ } else if (existsSync(planPath)) {
1033
+ content = readFileSync(planPath, 'utf8');
1021
1034
  } else {
1022
1035
  return { total: 0, checked: 0 };
1023
1036
  }
@@ -1128,4 +1141,145 @@ export class ProgressManager {
1128
1141
 
1129
1142
  return { ok: true, aligned, skipped, planTotal, planChecked };
1130
1143
  }
1144
+
1145
+ // ── 只读 dump(task-01/02/03:daemon 进度读取)──
1146
+
1147
+ /**
1148
+ * dump 输出的时间戳规范化:DB 内部两类形态(步骤推进时 JS Date 变量序列化
1149
+ * 的 ISO 形态、部分历史路径写入的 `YYYY/M/D H:mm:ss` 斜杠形态)统一转 ISO。
1150
+ * backend pydantic datetime 只吃 ISO(斜杠形态校验失败),斜杠 → new Date
1151
+ * 可解析;解析失败原样返回(backend 侧按无效丢弃)。
1152
+ */
1153
+ _dumpIso(ts) {
1154
+ if (!ts) return ts;
1155
+ if (typeof ts === 'string' && /^\d{4}-\d{2}-\d{2}/.test(ts)) return ts;
1156
+ const d = new Date(ts);
1157
+ if (Number.isNaN(d.getTime())) return ts;
1158
+ return d.toISOString();
1159
+ }
1160
+
1161
+ /**
1162
+ * 只读导出进度数据,供 daemon 通过 `sillyspec progress dump --json` 消费。
1163
+ *
1164
+ * 纯读路径:打开 DB 只读、读 user-inputs.md、列 artifacts 目录,不写任何状态。
1165
+ * 无活跃变更或 DB 不存在时返回 null(不抛异常,daemon 按 null 处理空数据)。
1166
+ *
1167
+ * 输出字段 snake_case + ISO 时间戳(2026-08-19-runtime-live-daemon-read
1168
+ * acceptance review P0 修复):契约消费端 backend RuntimeProgress pydantic
1169
+ * 是 snake_case(current_stage/last_active/started_at/size_bytes),
1170
+ * dump 早期误用内部 camelCase;跨端字段名断裂会让 pydantic 静默忽略
1171
+ * 未知字段导致前端核心字段全空。
1172
+ *
1173
+ * @param {string} cwd - specDir(`.sillyspec` 目录),由 CLI 层 --spec-dir 传入
1174
+ * @returns {object|null} 结构化进度数据,格式见 design.md §6.2
1175
+ */
1176
+ dump(cwd) {
1177
+ const specRoot = this._getSpecDir(cwd);
1178
+ const dbPath = this._runtimePath(cwd, 'sillyspec.db');
1179
+
1180
+ // DB 不存在 → 返回 null(项目未初始化进度)
1181
+ if (!existsSync(dbPath)) return null;
1182
+
1183
+ const db = this._ensureDB(cwd);
1184
+ const sqlDb = db.getDb();
1185
+
1186
+ // 1. project 行
1187
+ const projectRow = sqlDb.prepare('SELECT name FROM project WHERE id = 1').get();
1188
+ if (projectRow === undefined) return null;
1189
+ const projectName = projectRow.name;
1190
+
1191
+ // 2. 活跃变更(取第一个,或空)
1192
+ const changeRows = sqlDb.prepare("SELECT id, name, current_stage, last_active FROM changes WHERE status = 'active' ORDER BY name").all();
1193
+ if (changeRows.length === 0) {
1194
+ // 无活跃变更 → 返回仅含 project 的骨架
1195
+ return {
1196
+ project: projectName,
1197
+ current_stage: null,
1198
+ current_change: null,
1199
+ last_active: null,
1200
+ stages: {},
1201
+ user_inputs: this._readUserInputs(cwd),
1202
+ artifacts: this._listArtifacts(cwd),
1203
+ };
1204
+ }
1205
+
1206
+ const change = changeRows[0];
1207
+ const { id: changeId, name: changeName, current_stage: currentStage, last_active: lastActive } = change;
1208
+
1209
+ // 3. stages
1210
+ const stageRows = sqlDb.prepare(
1211
+ 'SELECT id, stage, status, started_at, completed_at, revision FROM stages WHERE change_id = ? ORDER BY id'
1212
+ ).all(changeId);
1213
+
1214
+ const stages = {};
1215
+ for (const s of VALID_STAGES) {
1216
+ stages[s] = { status: 'pending', steps: [] };
1217
+ }
1218
+ // PERF-06:一次 IN (...) 批量取全部 steps 再按 stage_id 分组(原逐 stage 查询是 N+1,
1219
+ // dump 是 daemon 轮询接口;与 read() :283-287 的批量读法对齐)
1220
+ const stepsByStage = new Map();
1221
+ if (stageRows.length > 0) {
1222
+ const placeholders = stageRows.map(() => '?').join(', ');
1223
+ const allStepRows = sqlDb.prepare(
1224
+ `SELECT stage_id, name, status, output, completed_at FROM steps WHERE stage_id IN (${placeholders}) ORDER BY stage_id, ordering`
1225
+ ).all(...stageRows.map(r => r.id));
1226
+ for (const sr of allStepRows) {
1227
+ if (!stepsByStage.has(sr.stage_id)) stepsByStage.set(sr.stage_id, []);
1228
+ stepsByStage.get(sr.stage_id).push(sr);
1229
+ }
1230
+ }
1231
+ for (const row of stageRows) {
1232
+ const stepRows = stepsByStage.get(row.id) || [];
1233
+ stages[row.stage] = {
1234
+ status: row.status,
1235
+ started_at: this._dumpIso(row.started_at) || undefined,
1236
+ completed_at: this._dumpIso(row.completed_at) || undefined,
1237
+ ...(row.revision ? { revision: row.revision } : {}),
1238
+ steps: stepRows.map(sr => ({
1239
+ name: sr.name,
1240
+ status: sr.status,
1241
+ ...(sr.output ? { output: sr.output } : {}),
1242
+ ...(sr.completed_at ? { completed_at: this._dumpIso(sr.completed_at) } : {}),
1243
+ })),
1244
+ };
1245
+ }
1246
+
1247
+ return {
1248
+ project: projectName,
1249
+ current_stage: currentStage || null,
1250
+ current_change: changeName,
1251
+ last_active: this._dumpIso(lastActive) || null,
1252
+ stages,
1253
+ user_inputs: this._readUserInputs(cwd),
1254
+ artifacts: this._listArtifacts(cwd),
1255
+ };
1256
+ }
1257
+
1258
+ /** 只读 user-inputs.md 内容(不存在返回 null) */
1259
+ _readUserInputs(cwd) {
1260
+ const p = this._runtimePath(cwd, 'user-inputs.md');
1261
+ if (!existsSync(p)) return null;
1262
+ return readFileSync(p, 'utf8');
1263
+ }
1264
+
1265
+ /** 列 artifacts 目录(不存在返回空数组) */
1266
+ _listArtifacts(cwd) {
1267
+ const dir = this._runtimePath(cwd, 'artifacts');
1268
+ if (!existsSync(dir)) return [];
1269
+ try {
1270
+ return readdirSync(dir, { withFileTypes: true })
1271
+ .filter(e => e.isFile())
1272
+ .map(e => {
1273
+ const fullPath = join(dir, e.name);
1274
+ const st = statSync(fullPath);
1275
+ return {
1276
+ filename: e.name,
1277
+ size_bytes: st.size,
1278
+ last_modified: st.mtime.toISOString(),
1279
+ };
1280
+ });
1281
+ } catch {
1282
+ return [];
1283
+ }
1284
+ }
1131
1285
  }
package/src/quicklog.js CHANGED
@@ -14,15 +14,22 @@ import { join, dirname, basename } from 'path'
14
14
  import { getRule } from './stage-contract-spec.js'
15
15
  import {
16
16
  openSync, closeSync, unlinkSync, statSync, mkdirSync, existsSync,
17
- readFileSync, writeFileSync, readdirSync, renameSync, appendFileSync,
17
+ readFileSync, writeFileSync, writeSync, readdirSync, renameSync, appendFileSync,
18
18
  } from 'fs'
19
19
  import { randomBytes } from 'crypto'
20
20
 
21
21
  const sleep = (ms) => new Promise(r => setTimeout(r, ms))
22
22
 
23
23
  /**
24
- * O_EXCL 文件锁。`openSync(lockPath,'wx')` 独占创建;已存在则按 mtime 判 stale 偷锁,
25
- * 否则退避重试至超时。CLI 短进程、低频写入,stale 阈值 30s 足够。
24
+ * O_EXCL 文件锁。`openSync(lockPath,'wx')` 独占创建并写入持有者唯一 id;已存在则按
25
+ * mtime 判 stale 偷锁(rename 原子抢占 + 内容校验),否则退避重试至超时。
26
+ * CLI 短进程、低频写入,stale 阈值 30s 足够。
27
+ *
28
+ * 偷锁为何用 rename 而非 unlink(2026-08-20 体检 BUG-02):两进程同偷一把 stale 锁时,
29
+ * 后到者的 unlink 会把先到者刚重建的新锁删掉(TOCTOU)→ 双进程同时进临界区。
30
+ * rename 到唯一名后比对内容:只有"观察时看到的那把锁"才允许删除;偷到并发者
31
+ * 新建的锁则原路放回。残余窗口仅剩"纳秒级三方精确竞争"(放回前 existsSync 检查
32
+ * 与 rename 之间),相比旧实现的必现双进已可忽略。
26
33
  * @param {string} lockPath 锁文件路径(建议与被保护文件同目录)
27
34
  * @param {() => (any|Promise<any>)} fn 临界区
28
35
  * @param {{staleMs?:number, timeoutMs?:number, retryMs?:number}} opts
@@ -31,11 +38,13 @@ export async function withFileLock(lockPath, fn, opts = {}) {
31
38
  const { staleMs = 30000, timeoutMs = 10000, retryMs = 50 } = opts
32
39
  mkdirSync(dirname(lockPath), { recursive: true })
33
40
  const start = Date.now()
41
+ const myId = `${process.pid}-${randomBytes(6).toString('hex')}`
34
42
  let fd = null
35
43
  // 抢锁循环
36
44
  while (true) {
37
45
  try {
38
46
  fd = openSync(lockPath, 'wx') // O_EXCL:已存在抛 EEXIST
47
+ writeSync(fd, myId)
39
48
  break
40
49
  } catch (e) {
41
50
  if (e.code !== 'EEXIST') throw e
@@ -43,7 +52,19 @@ export async function withFileLock(lockPath, fn, opts = {}) {
43
52
  try {
44
53
  const mtime = statSync(lockPath).mtimeMs
45
54
  if (Date.now() - mtime > staleMs) {
46
- try { unlinkSync(lockPath) } catch {} // 偷 stale 锁后重试
55
+ try {
56
+ const observed = readFileSync(lockPath, 'utf8') // 观察到的持有者 id
57
+ const claim = `${lockPath}.steal-${randomBytes(6).toString('hex')}`
58
+ renameSync(lockPath, claim) // 原子抢占:并发者只可能有一个 rename 成功
59
+ if (readFileSync(claim, 'utf8') === observed) {
60
+ try { unlinkSync(claim) } catch {} // 确实是那把 stale 锁 → 回收
61
+ } else {
62
+ // 偷到并发者新建的锁:放回(原位已被占则放弃,先占者保留)
63
+ let restored = false
64
+ try { if (!existsSync(lockPath)) { renameSync(claim, lockPath); restored = true } } catch {}
65
+ if (!restored) { try { unlinkSync(claim) } catch {} }
66
+ }
67
+ } catch {}
47
68
  continue
48
69
  }
49
70
  } catch {}
@@ -339,7 +360,7 @@ function listQuicklogFiles(quicklogDir) {
339
360
  // reader 句柄是瞬时的,故对占用类错误做异步重试+退避;非占用错误立即抛。
340
361
  async function writeAtomic(filePath, content, opts = {}) {
341
362
  const { retries = 100, baseDelayMs = 5, maxDelayMs = 100 } = opts
342
- const tmp = join(dirname(filePath), basename(filePath) + '.tmp-' + process.pid)
363
+ const tmp = join(dirname(filePath), basename(filePath) + '.tmp-' + process.pid + '-' + randomBytes(4).toString('hex')) // 随机段:Windows PID 重用激进,仅 pid 会撞名(对齐 fs-atomic.js 的同款修复)
343
364
  writeFileSync(tmp, content) // 同目录 = 同文件系统,rename 才原子(跨 fs 会退化 copy+delete)
344
365
  for (let attempt = 0; ; attempt++) {
345
366
  try {
@@ -417,24 +438,34 @@ function tasksPath(specBase, change) {
417
438
  // 关联变更目录不存在(笔误 / 未建 / 仅作标签关联)→ 不 fabricate stub 目录(历史坑 quick-change-phantom:
418
439
  // mkdirSync 硬造 changes/<名>/tasks.md,致 quick --done 边界审计自造自拦 BLOCK)。关联仍记入
419
440
  // QUICKLOG「关联变更」行(allocateQuicklogEntry 独立写入,不依赖本函数)。
420
- function appendTaskCheckbox(specBase, change, qlId, desc) {
441
+ // 体检 BUG-17:tasks.md 是跨用户共享文件——QUICKLOG 按 <user> 分锁只串行化同用户会话,
442
+ // 用户 A 勾选与用户 B 追加跨用户并发时须按目标文件加锁,否则读-改-写互相覆盖。
443
+ function tasksLockPath(p) {
444
+ return p + '.tasklock'
445
+ }
446
+
447
+ async function appendTaskCheckbox(specBase, change, qlId, desc) {
421
448
  const dir = join(specBase, 'changes', change)
422
449
  if (!existsSync(dir)) return
423
450
  const p = tasksPath(specBase, change)
424
- let content = existsSync(p) ? readFileSync(p, 'utf8') : ''
425
- if (content.includes(qlId)) return
426
- const prefix = content && !content.endsWith('\n') ? '\n' : ''
427
- appendFileSync(p, `${prefix}- [ ] ${qlId} ${desc}\n`)
451
+ await withFileLock(tasksLockPath(p), () => {
452
+ let content = existsSync(p) ? readFileSync(p, 'utf8') : ''
453
+ if (content.includes(qlId)) return
454
+ const prefix = content && !content.endsWith('\n') ? '\n' : ''
455
+ appendFileSync(p, `${prefix}- [ ] ${qlId} ${desc}\n`)
456
+ })
428
457
  }
429
458
 
430
- // 勾选该 qlId 对应 task:- [ ] → - [x]
459
+ // 勾选该 qlId 对应 task:- [ ] → - [x](同 BUG-17:按 tasks.md 文件锁串行化跨用户读-改-写)
431
460
  async function checkTaskCheckbox(specBase, change, qlId) {
432
461
  const p = tasksPath(specBase, change)
433
462
  if (!existsSync(p)) return
434
- let content = readFileSync(p, 'utf8')
435
- const re = new RegExp(`- \\[ \\] (${escapeRe(qlId)})`)
436
- content = content.replace(re, '- [x] $1')
437
- await writeAtomic(p, content)
463
+ await withFileLock(tasksLockPath(p), async () => {
464
+ let content = readFileSync(p, 'utf8')
465
+ const re = new RegExp(`- \\[ \\] (${escapeRe(qlId)})`)
466
+ content = content.replace(re, '- [x] $1')
467
+ await writeAtomic(p, content)
468
+ })
438
469
  }
439
470
 
440
471
  // 就地翻某 qlId 条目:状态进行中→已完成,追加结果行
@@ -606,7 +637,7 @@ export async function allocateQuicklogEntry(specBase, gitUser, { description, li
606
637
  ].join('\n')
607
638
  appendFileSync(userFile, entry)
608
639
 
609
- for (const c of linked) appendTaskCheckbox(specBase, c, qlId, desc)
640
+ for (const c of linked) await appendTaskCheckbox(specBase, c, qlId, desc)
610
641
 
611
642
  // 平台推送(task-06 / FR-02 / D-003):锁外语义等同(推送不回写本地),但放锁内
612
643
  // 保证「分配即推送」顺序一致;best-effort 失败不影响返回。
@@ -23,7 +23,7 @@ import { basename, join, resolve, dirname } from 'node:path'
23
23
  import { existsSync, readdirSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
24
24
  import { randomBytes, randomUUID } from 'node:crypto'
25
25
  import { writeAtomicSync } from '../fs-atomic.js'
26
- import { resolveSpecDir, countAncestorSpecDirs, resolveChangeDir, triggerSync, getStageSteps, formatWaitOptions, checkApproval, didYouMean, assertSafeChangeName, detectQuickSessionDrift, detectWorktreeSpecDrift, resolveRuntimeRoot, writePlatformPointer, checkPlatformManaged, PLATFORM_MANAGED_FILENAME } from './shared.js'
26
+ import { resolveSpecDir, countAncestorSpecDirs, resolveChangeDir, triggerSync, getStageSteps, formatWaitOptions, checkApproval, warnApprovalUnknown, didYouMean, assertSafeChangeName, detectQuickSessionDrift, detectWorktreeSpecDrift, resolveRuntimeRoot, writePlatformPointer, checkPlatformManaged, PLATFORM_MANAGED_FILENAME } from './shared.js'
27
27
  import { resolveQuickLinkedChanges } from './quick-audit.js'
28
28
  import { outputStep } from './prompt.js'
29
29
  import { completeStep, skipStep, waitStep, continueStep } from './complete.js'
@@ -108,6 +108,11 @@ export async function ensureStageSteps(progress, stageName, cwd, specDir = null)
108
108
  // 检查步骤数量是否匹配(execute 动态步骤可能变化)
109
109
  if (progress.stages[stageName].steps.length !== steps.length) {
110
110
  const oldSteps = progress.stages[stageName].steps
111
+ // 漂移显式化(坑 execute-step-table-drift,2026-08-20 实证):plan.md Wave 数在 execute
112
+ // 中途被改后 DB 步骤表与重算定义错位(17/12 交替),重播种本身按名保留完成态是对的,
113
+ // 但此前完全静默——agent 只见各处计数打架的报错,不知道发生了什么、该不该继续
114
+ console.warn(`⚠️ ${stageName} 步骤表与当前定义漂移(DB ${oldSteps.length} 步 → 定义 ${steps.length} 步,常见原因:execute 中途修改了 plan.md 的 Wave 结构)`)
115
+ console.warn(` 已按步骤名保留完成态重播种(新增步骤置 pending;Wave 重编号时同名步骤继承旧状态,勾选真相以 tasks.md/review.json 为准)`)
111
116
  progress.stages[stageName].steps = steps.map(s => {
112
117
  const old = oldSteps.find(step => step.name === s.name)
113
118
  if (old) return old // 精确名命中 → 保留旧状态
@@ -195,10 +200,13 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
195
200
  // ── cwd 纠正:向上查找真实项目根 ──
196
201
  // 防止多 project 工作区中 cwd 停在子目录(如 backend/)时
197
202
  // 状态写入子目录下误建的 .sillyspec,导致状态分裂
198
- if (!specDir && !existsSync(join(cwd, '.sillyspec-platform.json'))) {
199
- // 平台模式(cwd 有 .sillyspec-platform.json 指针)不做 cwd 纠正:指针已明确 specDir,
200
- // 向上找 .sillyspec 反而会撞到无关项目(如用户 home 的 .sillyspec),导致 --done 恢复
201
- // 读写 ~/.sillyspec-platform.json 出错的 specRoot。
203
+ // HUB-12c:守卫须同时查平台接管声明——指针被 cleanup、声明还在、祖先有别的 .sillyspec
204
+ //(monorepo 嵌套)时,cwd 被上移后 checkPlatformManaged 在新 cwd 扑空 → 静默落本地库,
205
+ // 双入口 fail-closed 被绕过(状态分裂正是它要防的)
206
+ if (!specDir && !existsSync(join(cwd, '.sillyspec-platform.json')) && !checkPlatformManaged(cwd)) {
207
+ // 平台模式(cwd 有 .sillyspec-platform.json 指针或接管声明)不做 cwd 纠正:指针已明确
208
+ // specDir,向上找 .sillyspec 反而会撞到无关项目(如用户 home 的 .sillyspec),导致
209
+ // --done 恢复读写 ~/.sillyspec-platform.json 出错的 specRoot。
202
210
  const resolvedRoot = resolveSpecDir(cwd)
203
211
  if (resolvedRoot && resolvedRoot !== join(cwd, '.sillyspec')) {
204
212
  const realRoot = dirname(resolvedRoot)
@@ -1271,8 +1279,9 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
1271
1279
  console.log(`⏳ 变更 ${changeName} 的执行审批待处理中...`)
1272
1280
  console.log(' 提示:使用 --skip-approval 跳过审批检查')
1273
1281
  }
1282
+ // HUB-07:unknown 醒目警告 + 留痕(原单行 warn 不显眼且无记录)
1274
1283
  if (approval.status === 'unknown') {
1275
- console.warn(`⚠️ 变更 ${changeName} 的审批状态未知(${approval.reason || '请求失败'}),按本地模式放行(非审批中,无需等待)`)
1284
+ warnApprovalUnknown(cwd, changeName, approval.reason)
1276
1285
  }
1277
1286
  }
1278
1287
  }
@@ -1288,6 +1297,12 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
1288
1297
  const result = await completeStep(pm, progress, currentStage, cwd, outputText, inputText, { printNext: false, changeName, platformOpts })
1289
1298
  if (!result) return
1290
1299
  progress = pm.read(cwd, changeName)
1300
+ // change 行被并发归档/删除时 read 返回 null(体检 BUG-05):引导排查而非 TypeError 崩溃
1301
+ if (!progress) {
1302
+ console.error(`❌ 完成步骤后进度数据消失(变更 ${changeName} 可能被并发归档/删除),请用 sillyspec progress show 核对当前状态`)
1303
+ process.exitCode = 1
1304
+ return
1305
+ }
1291
1306
 
1292
1307
  const nextPendingIdx = progress.stages[currentStage]?.steps?.findIndex(step => step.status === 'pending' || step.status === 'in-progress') ?? -1
1293
1308
  if (nextPendingIdx !== -1) {
@@ -1304,8 +1319,9 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
1304
1319
  console.log(`⏳ 变更 ${changeName} 的执行审批待处理中...`)
1305
1320
  console.log(' 提示:使用 --skip-approval 跳过审批检查')
1306
1321
  }
1322
+ // HUB-07:unknown 醒目警告 + 留痕(原单行 warn 不显眼且无记录)
1307
1323
  if (approval.status === 'unknown') {
1308
- console.warn(`⚠️ 变更 ${changeName} 的审批状态未知(${approval.reason || '请求失败'}),按本地模式放行(非审批中,无需等待)`)
1324
+ warnApprovalUnknown(cwd, changeName, approval.reason)
1309
1325
  }
1310
1326
  }
1311
1327
  }
@@ -1357,6 +1373,12 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
1357
1373
  pm._write(cwd, progress, changeName)
1358
1374
  triggerSync(cwd, changeName, platformOpts)
1359
1375
  progress = pm.read(cwd, changeName)
1376
+ // 同 BUG-05:写入后被并发删除的窗口,null 直接解引用会 TypeError
1377
+ if (!progress) {
1378
+ console.error(`❌ 阶段推进后进度数据消失(变更 ${changeName} 可能被并发归档/删除),请用 sillyspec progress show 核对当前状态`)
1379
+ process.exitCode = 1
1380
+ return
1381
+ }
1360
1382
 
1361
1383
  console.log(`\n${currentStage} complete. Auto advanced to ${next}.`)
1362
1384
  const nextSteps = await getAutoSteps(next)
@@ -1374,8 +1396,9 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
1374
1396
  console.log(`⏳ 变更 ${changeName} 的执行审批待处理中...`)
1375
1397
  console.log(' 提示:使用 --skip-approval 跳过审批检查')
1376
1398
  }
1399
+ // HUB-07:unknown 醒目警告 + 留痕(原单行 warn 不显眼且无记录)
1377
1400
  if (approval.status === 'unknown') {
1378
- console.warn(`⚠️ 变更 ${changeName} 的审批状态未知(${approval.reason || '请求失败'}),按本地模式放行(非审批中,无需等待)`)
1401
+ warnApprovalUnknown(cwd, changeName, approval.reason)
1379
1402
  }
1380
1403
  }
1381
1404
  }
@@ -98,10 +98,9 @@ export function validateParsedProjects(projects, sourceRoot) {
98
98
  /**
99
99
  * 在 changes/archive/ 下查找变更 <changeName> 是否已被归档(archive 脱钩自愈用)。
100
100
  *
101
- * 归档目录名 = archiveDestDirName(date, changeName) = `<归档日期>-<剥前导日期的描述>`;
102
- * 但手动 / 部分流程归档可能保留原 changeName(含前导日期)或用别的日期前缀。
103
- * 按「描述部分」(剥前导 YYYY-MM-DD-)匹配,并要求目录含 plan.md(归档必备产物,
104
- * 防止同名巧合误判)。issue: archive-stage-physical-tracking-desync。
101
+ * 归档目录名 = 原变更名(直接移入 archive/,不重命名)。
102
+ * 精确匹配 + 要求目录含 plan.md(归档必备产物,防止同名巧合误判)。
103
+ * issue: archive-stage-physical-tracking-desync。
105
104
  *
106
105
  * @param {string} archiveDir - changes/archive 绝对路径
107
106
  * @param {string} changeName - 变更名(currentChange)
@@ -115,15 +114,9 @@ export function findAlreadyArchivedDir(archiveDir, changeName) {
115
114
  } catch {
116
115
  return null
117
116
  }
118
- const descOf = (n) => String(n).replace(/^\d{4}-\d{2}-\d{2}-/, '')
119
- const targetDesc = descOf(changeName)
120
- if (!targetDesc) return null
121
- // 1. 精确原名命中(手动 mv 保留原 changeName)
117
+ // 精确原名命中
122
118
  const exact = entries.find((e) => e === changeName && existsSync(join(archiveDir, e, 'plan.md')))
123
119
  if (exact) return join(archiveDir, exact)
124
- // 2. 描述部分命中(archiveDestDirName 产出的 <date>-<desc>,或手动剥前导日期的目录)
125
- const descMatch = entries.find((e) => descOf(e) === targetDesc && existsSync(join(archiveDir, e, 'plan.md')))
126
- if (descMatch) return join(archiveDir, descMatch)
127
120
  return null
128
121
  }
129
122
 
@@ -506,9 +499,12 @@ export async function handleScanProjectListStep({ stageName, steps, currentIdx,
506
499
  const yamlContent = readFileSync(join(projectsDir, yf), 'utf8')
507
500
  const pathMatch = yamlContent.match(/^path:\s*(.+)/m)
508
501
  const pPath = pathMatch ? pathMatch[1].trim() : pName
509
- // 校验 path 是否存在且在 source_root 内
502
+ // 校验 path 是否存在且在 source_root 内。
503
+ // 用 relative 判越界(与 validateParsedProjects 同口径):startsWith 前缀比较
504
+ // 无分隔符,C:\repo 会放行兄弟目录 C:\repository2(体检 BUG-13)
510
505
  const absPath = resolve(cwd, pPath)
511
- if (pPath.includes('..') || (!absPath.startsWith(resolve(cwd)) && absPath !== resolve(cwd))) {
506
+ const rel = relative(resolve(cwd), absPath)
507
+ if (rel.startsWith('..') || isAbsolute(rel)) {
512
508
  fallbackSkipped.push(`${pName} (path 越界: ${pPath})`)
513
509
  continue
514
510
  }
@@ -1162,7 +1158,9 @@ export async function handleExecuteWaveArtifact({ stageName, steps, currentIdx,
1162
1158
  export async function collectExecuteChangedFiles({ runtimeRoot, changeName }) {
1163
1159
  if (!runtimeRoot || !changeName) return []
1164
1160
  const { resolveLatestExecuteRunIdWithTasks, readReview, normalizeRepoKey } = await import('../task-review.js')
1165
- const runId = resolveLatestExecuteRunIdWithTasks({ runtimeRoot })
1161
+ // changeName 透传:run 解析按 change 戳优先归属(坑 worktree-cleanup-marker-chain),
1162
+ // 避免 marker 断裂后聚合到其他变更 run 的 changedFiles 误报 missing
1163
+ const runId = resolveLatestExecuteRunIdWithTasks({ runtimeRoot, changeName })
1166
1164
  if (!runId) return []
1167
1165
  const tasksDir = join(runtimeRoot, 'execute-runs', runId, 'tasks')
1168
1166
  if (!existsSync(tasksDir)) return []
@@ -1300,6 +1298,15 @@ export async function handleExecuteWorktreeCleanup({ stageName, changeName, cwd
1300
1298
  export async function handleScanStageCompleted({ stageName, currentIdx, cwd, progress, pm, stageData, changeName, outputText, platformOpts }) {
1301
1299
  // 平台模式:scan 完成后生成 manifest.json + post-check
1302
1300
  if (stageName === 'scan' && (platformOpts.specRoot || platformOpts.runtimeRoot)) {
1301
+ if (!platformOpts.specRoot) {
1302
+ // 只传 runtimeRoot 无 specRoot(command.js 允许的组合):manifest 无处落盘。
1303
+ // 显式 fail-closed 报错——此前 mkdirSync(null) 抛 TypeError 被外层 catch 吞掉,
1304
+ // postcheck/指针升级/阻断全部静默跳过,scan 失败也"干净成功"(体检 BUG-12)
1305
+ console.error(`❌ 平台模式缺少 --spec-root,无法写 manifest.json(scan postcheck 中止)`)
1306
+ stageData.scanMeta = stageData.scanMeta || {}; stageData.scanMeta.manifestWritten = false
1307
+ stageData.scanMeta.manifestError = 'missing specRoot'
1308
+ return
1309
+ }
1303
1310
  try {
1304
1311
  stageData.scanMeta = stageData.scanMeta || {}; stageData.scanMeta.manifestWritten = false; // 默认失败
1305
1312
  const manifestDir = platformOpts.specRoot