sillyspec 3.28.0 → 3.28.3

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 (60) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +54 -93
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
  3. package/.claude/skills/sillyspec-continue/SKILL.md +17 -47
  4. package/.claude/skills/sillyspec-execute/SKILL.md +222 -214
  5. package/.claude/skills/sillyspec-knowledge/SKILL.md +40 -270
  6. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  7. package/.claude/skills/sillyspec-propose/SKILL.md +16 -87
  8. package/.claude/skills/sillyspec-resume/SKILL.md +21 -70
  9. package/.claude/skills/sillyspec-state/SKILL.md +25 -64
  10. package/.claude/skills/sillyspec-workspace/SKILL.md +33 -175
  11. package/package.json +1 -1
  12. package/src/archive-delta.js +462 -0
  13. package/src/constants.js +6 -0
  14. package/src/design-facts.js +418 -0
  15. package/src/dispatch/backends/sillyhub-mcp.js +17 -5
  16. package/src/docs-check.js +197 -34
  17. package/src/docs-migrate.js +124 -0
  18. package/src/doctor-diagnostics.js +11 -2
  19. package/src/endpoint-baseline.js +109 -0
  20. package/src/git-helper.js +31 -0
  21. package/src/index.js +381 -22
  22. package/src/modules.js +41 -12
  23. package/src/progress/change-registry.js +39 -0
  24. package/src/progress/consistency-doctor.js +124 -5
  25. package/src/progress/stage-machine.js +13 -2
  26. package/src/progress.js +5 -0
  27. package/src/quicklog.js +51 -0
  28. package/src/run/command.js +199 -5
  29. package/src/run/complete-handlers.js +60 -4
  30. package/src/run/complete.js +105 -6
  31. package/src/run/gates.js +353 -2
  32. package/src/run/progress-confirm.js +66 -0
  33. package/src/run/prompt.js +193 -15
  34. package/src/run/quick-audit.js +12 -5
  35. package/src/run/scan-profile.js +117 -7
  36. package/src/run/shared.js +22 -6
  37. package/src/run/stage.js +14 -1
  38. package/src/scan-diff.js +54 -1
  39. package/src/scan-facts.js +173 -0
  40. package/src/scan-postcheck.js +114 -3
  41. package/src/spec-sync.js +19 -2
  42. package/src/stage-review.js +62 -3
  43. package/src/stages/archive.js +5 -5
  44. package/src/stages/brainstorm-auto.js +2 -1
  45. package/src/stages/brainstorm.js +12 -20
  46. package/src/stages/execute.js +19 -21
  47. package/src/stages/plan-postcheck.js +194 -1
  48. package/src/stages/plan.js +8 -3
  49. package/src/stages/quick.js +4 -2
  50. package/src/stages/scan.js +44 -102
  51. package/src/stages/verify.js +18 -18
  52. package/src/task-review.js +200 -0
  53. package/src/taskcard.js +8 -1
  54. package/src/verify-postcheck.js +771 -29
  55. package/src/verify-probes.js +89 -10
  56. package/src/workflow.js +30 -0
  57. package/templates/agents-instruction.md +1 -1
  58. package/templates/prompts/taskcard-rules.md +5 -0
  59. package/templates/workflows/scan-docs.yaml +6 -0
  60. package/.claude/skills/verify-per-user/SKILL.md +0 -6
package/src/modules.js CHANGED
@@ -212,21 +212,42 @@ export function splitChangelog(cwd, { force = false } = {}) {
212
212
  for (const f of cards) {
213
213
  const cardPath = join(modulesDir, f);
214
214
  const content = readFileSync(cardPath, 'utf8');
215
- // 提取「## 变更索引」节(CRLF 归一后行级匹配,与 modules.js 其余解析同防线)
215
+ // 提取「## 变更索引」节(CRLF 归一后行级匹配,与 modules.js 其余解析同防线)。
216
+ // 标题正则允许后缀(如「(表格,初始为空)」——2026-09-07 实证 core-engine 形态漏迁);
217
+ // miss 时再试 MANUAL_NOTES 区(stages/runtime 形态:<!-- MANUAL_NOTES_END --> 前的尾部
218
+ // 行块——全或无:块内行须全部为「- <变更名> | 摘要」形态才整体迁出,防误迁散文)
216
219
  const lines = content.replace(/\r\n/g, '\n').split('\n');
217
- const startIdx = lines.findIndex(l => /^##\s*变更索引\s*$/.test(l));
218
- if (startIdx < 0) { skipped++; continue; }
219
- let endIdx = lines.length;
220
- for (let i = startIdx + 1; i < lines.length; i++) {
221
- if (/^##\s/.test(lines[i])) { endIdx = i; break; }
220
+ const startIdx = lines.findIndex(l => /^##\s*变更索引/.test(l));
221
+ let kind = 'index';
222
+ let segStart = -1, segEnd = -1, body = '';
223
+ if (startIdx >= 0) {
224
+ let endIdx = lines.length;
225
+ for (let i = startIdx + 1; i < lines.length; i++) {
226
+ if (/^##\s/.test(lines[i])) { endIdx = i; break; }
227
+ }
228
+ const b = lines.slice(startIdx + 1, endIdx).join('\n').trim();
229
+ // 已迁出(只剩指针行)或本就为空 → 跳过
230
+ if (!b || /^见\s/.test(b) || b.includes('.changelog.md')) { skipped++; continue; }
231
+ kind = 'index'; segStart = startIdx + 1; segEnd = endIdx; body = b;
232
+ } else {
233
+ const endMark = lines.findIndex(l => l.includes('<!-- MANUAL_NOTES_END -->'));
234
+ if (endMark < 0) { skipped++; continue; }
235
+ // 块首 = END 标记向上、以空行为界的连续内容块起点
236
+ let bs = endMark - 1;
237
+ while (bs >= 0 && lines[bs].trim() !== '') bs--;
238
+ // 块内剔除 HTML 注释行(区开标记 <!-- MANUAL_NOTES ... --> 等)后须全为变更行形态
239
+ const block = lines.slice(bs + 1, endMark).filter(l => {
240
+ const t = l.trim()
241
+ return t !== '' && !/^<!--.*-->$/.test(t)
242
+ });
243
+ const CHG_LINE = /^- [A-Za-z0-9][\w\-./]* \| /;
244
+ if (block.length === 0 || !block.every(l => CHG_LINE.test(l))) { skipped++; continue; }
245
+ kind = 'manual-notes'; segStart = bs + 1; segEnd = endMark; body = block.join('\n');
222
246
  }
223
- const body = lines.slice(startIdx + 1, endIdx).join('\n').trim();
224
- // 已迁出(只剩指针行)或本就为空 → 跳过
225
- if (!body || /^见\s/.test(body) || body.includes('.changelog.md')) { skipped++; continue; }
226
247
  const bytesMoved = Buffer.byteLength(body, 'utf8');
227
248
  const stem = f.replace(/\.md$/, '');
228
249
  const sidecarPath = join(modulesDir, `${stem}.changelog.md`);
229
- results.push({ card: cardPath, sidecar: sidecarPath, bytesMoved, _lines: lines, _startIdx: startIdx, _endIdx: endIdx, _body: body });
250
+ results.push({ card: cardPath, sidecar: sidecarPath, bytesMoved, kind, _lines: lines, _segStart: segStart, _segEnd: segEnd, _body: body });
230
251
  }
231
252
  }
232
253
 
@@ -253,9 +274,17 @@ export function splitChangelog(cwd, { force = false } = {}) {
253
274
  sidecarContent = header + '\n' + r._body + '\n';
254
275
  }
255
276
  writeFileSync(r.sidecar, sidecarContent, 'utf8');
256
- // 卡内节体替换为指针行(只动该节,其余逐字保留;_lines 已是 LF 归一)
277
+ // 卡内替换(只动该段,其余逐字保留;_lines 已是 LF 归一):
278
+ // index → 节体换指针行;manual-notes → 尾部块清空 + END 标记前留迁移指针注释
257
279
  const stem = r.card.split(/[\\/]/).pop().replace(/\.md$/, '');
258
- const newLines = [...r._lines.slice(0, r._startIdx + 1), '', `见 \`${stem}.changelog.md\`——历史条目已迁出;新条目直接追加 sidecar,勿写回本卡。`, '', ...r._lines.slice(r._endIdx)];
280
+ let newLines;
281
+ if (r.kind === 'manual-notes') {
282
+ newLines = [...r._lines.slice(0, r._segStart),
283
+ `<!-- MANUAL_NOTES 区变更条目已迁出至 ${stem}.changelog.md(split-changelog);新条目追加 sidecar,勿写回本卡 -->`,
284
+ ...r._lines.slice(r._segEnd)];
285
+ } else {
286
+ newLines = [...r._lines.slice(0, r._segStart), '', `见 \`${stem}.changelog.md\`——历史条目已迁出;新条目直接追加 sidecar,勿写回本卡。`, '', ...r._lines.slice(r._segEnd)];
287
+ }
259
288
  writeFileSync(r.card, newLines.join('\n'), 'utf8');
260
289
  console.log(`✅ ${r.card} → ${r.sidecar}(迁出 ${(r.bytesMoved / 1024).toFixed(1)}KB,卡内留指针)`);
261
290
  }
@@ -19,6 +19,25 @@ export class ChangeRegistry {
19
19
  return rows.map(r => r.name);
20
20
  }
21
21
 
22
+ /**
23
+ * 读变更创建时间(IR 严格模式闸门判别源,change: 2026-09-07-ir-hardening D-001@v1)。
24
+ * 只读不抛(与 listChanges 同款 DB 访问):变更不存在 / db 异常 → null——调用方
25
+ * (isStrictChange)按 fail-open 落存量豁免,不误伤。
26
+ * @param {string} cwd
27
+ * @param {string} changeName
28
+ * @returns {string|null} ISO 时间字符串或 null
29
+ */
30
+ getChangeCreatedAt(cwd, changeName) {
31
+ if (!changeName || typeof changeName !== 'string') return null;
32
+ try {
33
+ const db = this.pm._ensureDB(cwd);
34
+ const row = db.getDb().prepare('SELECT created_at FROM changes WHERE name = ?').get(changeName);
35
+ return row && typeof row.created_at === 'string' ? row.created_at : null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
22
41
  /**
23
42
  * 查询单个变更的流程阶段与状态(quick 轻量归档阶段闸用)。
24
43
  * 与 readChangeIsolation 不同(展示读,catch → null 宽容):本方法服务权限判定,读失败
@@ -126,6 +145,26 @@ export class ChangeRegistry {
126
145
  }
127
146
  }
128
147
 
148
+ /**
149
+ * 读取变更回填的 quicklog_id(quick 启动时 stage.js 写入的分配 ql-ID)。
150
+ * quick --done 兜底路径复用启动 ql-ID 用(坑 platform-takeover-phantom-progress-db
151
+ * 同日变体:guard 缺失时补分配新号制造引用劈叉——落码注释/模块文档的 ql-ID 是启动
152
+ * 时就给出的,兜底换号必然劈叉;启动 ID 进度库里可查)。
153
+ * @param {string} cwd
154
+ * @param {string} changeName
155
+ * @returns {string|null} 行缺失/未回填/读取失败返回 null
156
+ */
157
+ getQuicklogId(cwd, changeName) {
158
+ if (!changeName) return null;
159
+ try {
160
+ const db = this.pm._ensureDB(cwd);
161
+ const row = db.getDb().prepare('SELECT quicklog_id FROM changes WHERE name = ?').get(changeName);
162
+ return (row && typeof row.quicklog_id === 'string' && row.quicklog_id) || null;
163
+ } catch {
164
+ return null;
165
+ }
166
+ }
167
+
129
168
  /**
130
169
  * 读取变更的隔离状态
131
170
  * @param {string} cwd - 项目根目录
@@ -1,8 +1,9 @@
1
1
  // W6 Step9a: Revision v1 状态一致性检查 + 修复 + --force 审计日志。
2
2
  // 从 src/progress.js 单体 ProgressManager 抽出。持有 pm 引用(构造注入),调 pm.read / pm._write /
3
3
  // pm._ensureRuntimeDir / pm._runtimePath(persistence-core 留 facade 本体)。
4
- import { appendFileSync, existsSync, readdirSync } from 'fs';
5
- import { join } from 'path';
4
+ import { appendFileSync, existsSync, readFileSync, readdirSync } from 'fs';
5
+ import { join, resolve } from 'path';
6
+ import { DB } from '../db.js';
6
7
  import { summarizeTaskCompletion } from '../task-review.js';
7
8
  import { STAGE_ORDER, MAIN_FLOW_ORDER } from './shared.js';
8
9
 
@@ -11,6 +12,104 @@ export class ConsistencyDoctor {
11
12
  this.pm = pm;
12
13
  }
13
14
 
15
+ /**
16
+ * 双进度库分裂探测(坑 progress-repair-dual-library-blind,2026-09-04 用户实证②:
17
+ * 接管指针切换库后 progress repair 只看当前库,旧库残留的活跃变更进度完全不可见,
18
+ * 报告「未发现问题,无需修复」的假阴性)。
19
+ *
20
+ * 判据:cwd 存在平台接管指针(.sillyspec-platform.json 指向库 A = 当前修复目标),
21
+ * 同时 cwd/.sillyspec/.runtime/sillyspec.db(库 B = 切库前旧库)也存在且 ≠ 库 A
22
+ * → 双库并存。自指指针(specRoot 解析回本地 .sillyspec)不算。
23
+ *
24
+ * 只读探测:另一库仅在 sillyspec.db 已存在时直读(DB 类 + 存在性前置检查,绝不新建库文件);
25
+ * 读失败 fail-open 返回库存在但活跃清单不可读,不阻断 check/repair 主流程。
26
+ *
27
+ * @param {string} cwd - 进度库锚定 cwd(index.js 传入 progDir)
28
+ * @param {string|null} [changeName] - check/repair 的 --change 目标(判「操作错了库」用)
29
+ * @returns {{ split: boolean, dualDb: boolean, bothActive: boolean, targetInOther: boolean, lines: string[] }}
30
+ */
31
+ detectLibrarySplit(cwd, changeName = null) {
32
+ const out = { split: false, dualDb: false, bothActive: false, targetInOther: false, lines: [] };
33
+ const pointerPath = join(cwd, '.sillyspec-platform.json');
34
+ if (!existsSync(pointerPath)) return out;
35
+ let ptr = null;
36
+ try {
37
+ ptr = JSON.parse(readFileSync(pointerPath, 'utf8'));
38
+ } catch {
39
+ return out; // 指针损坏由 resolvePlatformSpecDir fail-closed 负责,此处不重复报
40
+ }
41
+ if (!ptr.specRoot) return out;
42
+
43
+ const localRoot = join(cwd, '.sillyspec');
44
+ const localDbPath = join(localRoot, '.runtime', 'sillyspec.db');
45
+ if (resolve(ptr.specRoot) === resolve(localRoot)) return out; // 自指/同库
46
+ if (!existsSync(localDbPath)) return out; // 旧库无 DB → 无分裂
47
+
48
+ out.dualDb = true;
49
+ const currentSpecDir = this.pm._getSpecDir(cwd);
50
+ // 当前库:DB 已存在才读(readGlobal 走 pm 连接池,不新开连接;不存在时 readGlobal 会
51
+ // 隐式建库,须前置 existsSync 挡住——探测绝不产生写副作用)
52
+ let current = null;
53
+ if (existsSync(join(currentSpecDir, '.runtime', 'sillyspec.db'))) {
54
+ try {
55
+ const g = this.pm.readGlobal(cwd);
56
+ current = { activeChanges: (g && g.activeChanges) || [] };
57
+ } catch (e) {
58
+ current = { activeChanges: [], unreadable: true, error: e.message };
59
+ }
60
+ }
61
+ const other = this._readActiveQuiet(localRoot, cwd);
62
+ const currentActive = current?.activeChanges || [];
63
+ const otherActive = other?.activeChanges || [];
64
+
65
+ out.lines.push(
66
+ `双进度库并存(接管指针 ${pointerPath} → ${currentSpecDir};切库前旧库残留 ${localRoot})` +
67
+ `——本次 check/repair 只作用于当前库,旧库状态不可见`
68
+ );
69
+ out.lines.push(
70
+ ` 当前库活跃变更: ${currentActive.length > 0 ? currentActive.join('、') : '(无)'}${current?.unreadable ? '(读取失败:' + current.error + ')' : ''}`
71
+ );
72
+ out.lines.push(
73
+ ` 旧库活跃变更: ${otherActive.length > 0 ? otherActive.join('、') : '(无)'}${other?.unreadable ? '(读取失败:' + other.error + ')' : ''}`
74
+ );
75
+ out.lines.push(
76
+ ` 处理:确认权威库后收尾/归档弃用库的活跃变更;或 sillyspec platform pointer --cleanup 删指针后重跑平台 scan 指向正确库;临时读旧库用 --spec-dir ${localRoot}`
77
+ );
78
+
79
+ if (currentActive.length > 0 && otherActive.length > 0) {
80
+ out.bothActive = true;
81
+ out.split = true;
82
+ }
83
+ // 目标变更只在旧库活跃(当前库查无)→ 本次操作几乎必然找错了库,最强信号
84
+ if (changeName && otherActive.includes(changeName) && !currentActive.includes(changeName)) {
85
+ out.targetInOther = true;
86
+ out.split = true;
87
+ out.lines.push(
88
+ ` ⚠️ 目标变更 "${changeName}" 不在当前库,但旧库有同名活跃变更——本次操作错了库,修复应带上 --spec-dir ${localRoot}(或修正指针)`
89
+ );
90
+ }
91
+ return out;
92
+ }
93
+
94
+ /**
95
+ * 只读读一个库的活跃变更清单(detectLibrarySplit 专用)。
96
+ * sillyspec.db 不存在返回 null(绝不新建库);打开/读取失败返回 { unreadable, error }
97
+ * 不上抛——分裂探测 fail-open,不阻断主流程。
98
+ */
99
+ _readActiveQuiet(specDir, cwd) {
100
+ const dbPath = join(specDir, '.runtime', 'sillyspec.db');
101
+ if (!existsSync(dbPath)) return null;
102
+ let db = null;
103
+ 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();
107
+ return { activeChanges: rows.map(r => r.name) };
108
+ } catch (e) {
109
+ return { activeChanges: [], unreadable: true, error: e.message };
110
+ }
111
+ }
112
+
14
113
  /**
15
114
  * 强制状态变更的审计记录(--force 逃生口专用)。
16
115
  * 追加到 .runtime/audit.log,供人工/doctor 追溯"谁在什么时候绕过了校验"。
@@ -36,12 +135,26 @@ export class ConsistencyDoctor {
36
135
  checkConsistency(cwd, changeName = null) {
37
136
  const data = this.pm.read(cwd, changeName);
38
137
  if (!data) {
39
- return { ok: false, issues: ['无法读取进度数据'], warnings: [] };
138
+ // 双库分裂探测不依赖单变更 progress 可读(read 失败时更要看见另一库,坑
139
+ // progress-repair-dual-library-blind):无 progress 也要报分裂,不能只报「无法读取」
140
+ const splitNoData = this.detectLibrarySplit(cwd, changeName);
141
+ const issuesNoData = ['无法读取进度数据'];
142
+ if (splitNoData.targetInOther || splitNoData.bothActive) issuesNoData.push(...splitNoData.lines);
143
+ return { ok: false, issues: issuesNoData, warnings: splitNoData.dualDb && !splitNoData.split ? splitNoData.lines : [] };
40
144
  }
41
145
 
42
146
  const issues = [];
43
147
  const warnings = [];
44
148
 
149
+ // 双库分裂(2026-09-04 ②):目标变更只在旧库 / 两库皆有活跃 → issue;
150
+ // 仅双库并存(单侧活跃)→ warning(平台模式本地库保留真实资产是容忍态,避免常态误报)
151
+ const libSplit = this.detectLibrarySplit(cwd, changeName);
152
+ if (libSplit.targetInOther || libSplit.bothActive) {
153
+ issues.push(...libSplit.lines);
154
+ } else if (libSplit.dualDb) {
155
+ warnings.push(...libSplit.lines);
156
+ }
157
+
45
158
  for (const stageName of STAGE_ORDER) {
46
159
  const sd = data.stages[stageName];
47
160
  if (!sd) continue;
@@ -186,14 +299,20 @@ export class ConsistencyDoctor {
186
299
  repairConsistency(cwd, opts = {}) {
187
300
  const { apply = false, changeName = null } = opts;
188
301
 
302
+ // 双库分裂(2026-09-04 ②):repair 修不了「哪边是权威库」,只能把另一库的存在与活跃
303
+ // 变更亮出来进 manual 清单。放 read 之前——多活跃/读不了库时 read 早退,探测不能跟着瞎。
304
+ const libSplit = this.detectLibrarySplit(cwd, changeName);
305
+ const splitManual = libSplit.dualDb ? [...libSplit.lines] : [];
306
+
189
307
  const data = this.pm.read(cwd, changeName);
190
308
  if (!data) {
191
309
  console.log('❌ 无法读取进度数据');
192
- return { fixable: [], manual: ['无法读取进度数据'], applied: [] };
310
+ for (const m of splitManual) console.log(` - ${m}`);
311
+ return { fixable: [], manual: ['无法读取进度数据', ...splitManual], applied: [] };
193
312
  }
194
313
 
195
314
  const fixable = []; // { stage, action, description, apply: (data) => void }
196
- const manual = []; // string
315
+ const manual = [...splitManual]; // string
197
316
 
198
317
  const now = new Date().toLocaleString('zh-CN', { hour12: false });
199
318
 
@@ -7,8 +7,19 @@ import { mkdirSync, existsSync, readdirSync, readFileSync } from 'fs';
7
7
  import { join, resolve, basename } from 'path';
8
8
  import { writeAtomicSync } from '../fs-atomic.js';
9
9
  import { runValidators } from '../stage-contract.js';
10
+ import { QUICK_SID_RE } from '../run/shared.js';
10
11
  import { VALID_STAGES, STAGE_LABELS, STAGE_ORDER, MAIN_FLOW_ORDER, SPEC_DIR_NAME, CURRENT_VERSION, STALL_WARN_DAYS, emptyStage } from './shared.js';
11
12
 
13
+ // quick 会话行(quick-<8hex>)按设计无实体 changes/ 目录(progress.js initChange 跳过建目录),
14
+ // 「DB active + changes/ 无同名目录」的 ghost 判定对它是类别错误——进行中的 quick 从写库那
15
+ // 一刻起即命中误报(2026-09-07 quick-inflight-ghost-misjudge,面板「一键清理」清了又长:
16
+ // 每次清理都成功,新 ghost 是清理后才启动的 quick)。show 与 overview 统一经此判定排除。
17
+ // 注意:排除的只是「判定」,doctor --cleanup-ghosts 仍保留 quick 行归档能力(QUICKLOG 已
18
+ // 完成但 DB 行未注销的收尾中断形态,那是兜底出口,不在此排除)。
19
+ function _isGhostChange(changesRoot, cn) {
20
+ return !QUICK_SID_RE.test(cn) && !existsSync(join(changesRoot, cn));
21
+ }
22
+
12
23
  /**
13
24
  * 归一化步骤的已记录等待回答(show 渲染用,跨会话恢复数据源)。
14
25
  * waitAnswers 走 pm.read 时已 JSON.parse,但容错字符串/损坏;单值列 wait_answer(旧路径)并入第 1 轮。
@@ -209,7 +220,7 @@ export class StageMachine {
209
220
  const pendingConflicts = this._listPendingConflicts(this.pm._getSpecDir(cwd));
210
221
  for (const cn of changes) {
211
222
  const data = this.pm.read(cwd, cn);
212
- const dirMissing = !existsSync(join(changesRoot, cn));
223
+ const dirMissing = _isGhostChange(changesRoot, cn);
213
224
  if (!data) {
214
225
  console.log(` 📂 ${cn} — (无法读取)${dirMissing ? ' ⚠️ 目录缺失(残留记录,sillyspec doctor --cleanup-ghosts --confirm 可归档清理)' : ''}`);
215
226
  continue;
@@ -272,7 +283,7 @@ export class StageMachine {
272
283
  const list = [];
273
284
  for (const cn of changes) {
274
285
  const data = this.pm.read(cwd, cn);
275
- const dirMissing = !existsSync(join(changesRoot, cn));
286
+ const dirMissing = _isGhostChange(changesRoot, cn);
276
287
  const entry = {
277
288
  name: cn,
278
289
  readable: !!data,
package/src/progress.js CHANGED
@@ -833,6 +833,8 @@ export class ProgressManager {
833
833
  // ── 变更管理 ──
834
834
 
835
835
  listChanges(cwd) { return this._changeRegistry.listChanges(cwd); }
836
+ /** 读变更创建时间(IR 严格模式闸门判别源,2026-09-07-ir-hardening D-001)——只读不抛,缺行/异常 → null */
837
+ getChangeCreatedAt(cwd, changeName) { return this._changeRegistry.getChangeCreatedAt(cwd, changeName); }
836
838
 
837
839
  // 单变更阶段查询(quick 轻量归档阶段闸):无行 null,读失败抛(调用方 fail-closed)
838
840
  getChangeStage(cwd, changeName) { return this._changeRegistry.getChangeStage(cwd, changeName); }
@@ -868,6 +870,9 @@ export class ProgressManager {
868
870
 
869
871
  updateChangeMeta(cwd, changeName, meta) { return this._changeRegistry.updateChangeMeta(cwd, changeName, meta); }
870
872
 
873
+ // quick --done 兜底复用启动 ql-ID(坑 platform-takeover-phantom-progress-db 同日变体)
874
+ getQuicklogId(cwd, changeName) { return this._changeRegistry.getQuicklogId(cwd, changeName); }
875
+
871
876
  // ── CLI 命令 ──
872
877
 
873
878
  init(cwd) {
package/src/quicklog.js CHANGED
@@ -713,6 +713,57 @@ export async function allocateQuicklogEntry(specBase, gitUser, { description, li
713
713
  })
714
714
  }
715
715
 
716
+ /**
717
+ * 以**给定 ql-ID** 追加一条「进行中」骨架条目(不分配新 ID)。
718
+ *
719
+ * 坑 platform-takeover-phantom-progress-db 同日变体:quick --done 兜底复用启动分配的
720
+ * ql-ID(进度库 changes.quicklog_id 可查)时,该 ID 的条目可能不在当前 specBase 的
721
+ * QUICKLOG 文件里(启动与 --done 落到不同库/文件的分裂形态)——用原 ID 补建骨架再走
722
+ * 完成翻态,保证「同一次会话只有一个 ql-ID」。条目已存在则幂等跳过(不重复追加)。
723
+ * 与 allocateQuicklogEntry 同锁同格式同平台推送,仅跳过 ID 分配段。
724
+ */
725
+ export async function appendQuicklogEntryWithId(specBase, gitUser, qlId, { description = '(guard 缺失补建)', linkedChanges = [], allowedFiles = [] } = {}) {
726
+ if (!qlId || typeof qlId !== 'string') throw new Error('appendQuicklogEntryWithId: qlId 必填')
727
+ if (findQuicklogEntry(specBase, gitUser, qlId)) return { qlId, existed: true }
728
+ const quicklogDir = join(specBase, 'quicklog')
729
+ mkdirSync(quicklogDir, { recursive: true })
730
+ const user = sanitizeQuicklogUser(gitUser) || 'unknown'
731
+ const userFile = join(quicklogDir, `QUICKLOG-${user}.md`)
732
+ const lockPath = join(quicklogDir, `.QUICKLOG-${user}.md.lock`)
733
+ const desc = sanitizeDesc(description)
734
+ const linked = Array.isArray(linkedChanges) ? linkedChanges : []
735
+ const files = Array.isArray(allowedFiles) ? allowedFiles : []
736
+
737
+ return await withFileLock(lockPath, async () => {
738
+ // 双检(锁内):并发兜底同 ID 只落一条
739
+ if (findQuicklogEntry(specBase, gitUser, qlId)) return { qlId, existed: true }
740
+ await rotateIfNeeded(userFile, user)
741
+ const entry = [
742
+ '',
743
+ `## ${qlId} | ${nowDatetime()} | ${desc}`,
744
+ '状态:进行中',
745
+ `关联变更:${linked.length > 0 ? linked.join(', ') : '(无)'}`,
746
+ `文件:${files.length > 0 ? files.join(', ') : '(见实际改动)'}`,
747
+ '',
748
+ ].join('\n')
749
+ appendFileSync(userFile, entry)
750
+ for (const c of linked) await appendTaskCheckbox(specBase, c, qlId, desc)
751
+ await pushQuicklogEntryToPlatform(specBase, {
752
+ ql_id: qlId,
753
+ timestamp: nowDatetime(),
754
+ title: desc,
755
+ status: 'in_progress',
756
+ status_note: null,
757
+ author_raw: user,
758
+ linked_changes: linked.filter(c => /^\d{4}-\d{2}-\d{2}-/.test(c)),
759
+ files: files.map(f => ({ path: f, note: null })),
760
+ body_sections: {},
761
+ raw_block: entry,
762
+ }).catch(() => {})
763
+ return { qlId, existed: false }
764
+ })
765
+ }
766
+
716
767
  /**
717
768
  * 翻某 qlId 条目为「已完成」+ 追加结果 + 勾选关联 tasks.md。持锁。
718
769
  */