sillyspec 3.26.11 → 3.26.14

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 (62) 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 +200 -27
  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 +187 -30
  29. package/src/quicklog.js +47 -16
  30. package/src/run/command.js +88 -46
  31. package/src/run/complete-handlers.js +27 -15
  32. package/src/run/complete.js +1186 -1121
  33. package/src/run/concurrent-detect.js +3 -1
  34. package/src/run/gates.js +172 -25
  35. package/src/run/prompt.js +17 -9
  36. package/src/run/scan-profile.js +35 -23
  37. package/src/run/shared.js +164 -63
  38. package/src/run/stage.js +41 -1
  39. package/src/setup.js +8 -10
  40. package/src/sillyhub-mcp/client.js +74 -6
  41. package/src/spec-dir-typo.js +3 -24
  42. package/src/spec-sync.js +43 -8
  43. package/src/stage-contract.js +12 -38
  44. package/src/stage-review.js +40 -1
  45. package/src/stages/archive.js +4 -3
  46. package/src/stages/brainstorm-auto.js +1 -1
  47. package/src/stages/brainstorm.js +27 -13
  48. package/src/stages/doctor.js +29 -16
  49. package/src/stages/execute.js +170 -154
  50. package/src/stages/knowledge.js +18 -3
  51. package/src/stages/plan-postcheck.js +72 -37
  52. package/src/stages/plan.js +70 -48
  53. package/src/stages/quick.js +3 -1
  54. package/src/stages/verify.js +1 -1
  55. package/src/sync.js +339 -225
  56. package/src/task-review.js +281 -64
  57. package/src/taskcard.js +178 -0
  58. package/src/verify-postcheck.js +48 -35
  59. package/src/worktree-apply.js +58 -34
  60. package/src/worktree-deps.js +120 -20
  61. package/src/worktree.js +55 -9
  62. 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,148 @@ 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. 活跃变更(取 last_active 最新的一个)
1192
+ // ql-20260821-003:多活跃仓(变更隔离是常态,单仓可同时存在 N 个 active)下
1193
+ // 原 ORDER BY name 恒取字典序最前(通常是最老的变更),「当前变更/最后活动」
1194
+ // 停留在历史数据。dump 语义是「当前工作流状态」→ 取最近活跃的那个。
1195
+ const changeRows = sqlDb.prepare("SELECT id, name, current_stage, last_active FROM changes WHERE status = 'active' ORDER BY last_active DESC").all();
1196
+ if (changeRows.length === 0) {
1197
+ // 无活跃变更 → 返回仅含 project 的骨架
1198
+ return {
1199
+ project: projectName,
1200
+ current_stage: null,
1201
+ current_change: null,
1202
+ last_active: null,
1203
+ stages: {},
1204
+ user_inputs: this._readUserInputs(cwd),
1205
+ artifacts: this._listArtifacts(cwd),
1206
+ };
1207
+ }
1208
+
1209
+ const change = changeRows[0];
1210
+ const { id: changeId, name: changeName, current_stage: currentStage, last_active: lastActive } = change;
1211
+
1212
+ // 3. stages
1213
+ const stageRows = sqlDb.prepare(
1214
+ 'SELECT id, stage, status, started_at, completed_at, revision FROM stages WHERE change_id = ? ORDER BY id'
1215
+ ).all(changeId);
1216
+
1217
+ const stages = {};
1218
+ for (const s of VALID_STAGES) {
1219
+ stages[s] = { status: 'pending', steps: [] };
1220
+ }
1221
+ // PERF-06:一次 IN (...) 批量取全部 steps 再按 stage_id 分组(原逐 stage 查询是 N+1,
1222
+ // dump 是 daemon 轮询接口;与 read() :283-287 的批量读法对齐)
1223
+ const stepsByStage = new Map();
1224
+ if (stageRows.length > 0) {
1225
+ const placeholders = stageRows.map(() => '?').join(', ');
1226
+ const allStepRows = sqlDb.prepare(
1227
+ `SELECT stage_id, name, status, output, completed_at FROM steps WHERE stage_id IN (${placeholders}) ORDER BY stage_id, ordering`
1228
+ ).all(...stageRows.map(r => r.id));
1229
+ for (const sr of allStepRows) {
1230
+ if (!stepsByStage.has(sr.stage_id)) stepsByStage.set(sr.stage_id, []);
1231
+ stepsByStage.get(sr.stage_id).push(sr);
1232
+ }
1233
+ }
1234
+ for (const row of stageRows) {
1235
+ const stepRows = stepsByStage.get(row.id) || [];
1236
+ stages[row.stage] = {
1237
+ status: row.status,
1238
+ started_at: this._dumpIso(row.started_at) || undefined,
1239
+ completed_at: this._dumpIso(row.completed_at) || undefined,
1240
+ ...(row.revision ? { revision: row.revision } : {}),
1241
+ steps: stepRows.map(sr => ({
1242
+ name: sr.name,
1243
+ status: sr.status,
1244
+ ...(sr.output ? { output: sr.output } : {}),
1245
+ ...(sr.completed_at ? { completed_at: this._dumpIso(sr.completed_at) } : {}),
1246
+ })),
1247
+ };
1248
+ }
1249
+
1250
+ return {
1251
+ project: projectName,
1252
+ current_stage: currentStage || null,
1253
+ current_change: changeName,
1254
+ last_active: this._dumpIso(lastActive) || null,
1255
+ stages,
1256
+ user_inputs: this._readUserInputs(cwd),
1257
+ artifacts: this._listArtifacts(cwd),
1258
+ };
1259
+ }
1260
+
1261
+ /** 只读 user-inputs.md 内容(不存在返回 null) */
1262
+ _readUserInputs(cwd) {
1263
+ const p = this._runtimePath(cwd, 'user-inputs.md');
1264
+ if (!existsSync(p)) return null;
1265
+ return readFileSync(p, 'utf8');
1266
+ }
1267
+
1268
+ /** 列 artifacts 目录(不存在返回空数组) */
1269
+ _listArtifacts(cwd) {
1270
+ const dir = this._runtimePath(cwd, 'artifacts');
1271
+ if (!existsSync(dir)) return [];
1272
+ try {
1273
+ return readdirSync(dir, { withFileTypes: true })
1274
+ .filter(e => e.isFile())
1275
+ .map(e => {
1276
+ const fullPath = join(dir, e.name);
1277
+ const st = statSync(fullPath);
1278
+ return {
1279
+ filename: e.name,
1280
+ size_bytes: st.size,
1281
+ last_modified: st.mtime.toISOString(),
1282
+ };
1283
+ });
1284
+ } catch {
1285
+ return [];
1286
+ }
1287
+ }
1131
1288
  }
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 失败不影响返回。