sillyspec 3.29.4 → 3.31.0

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 (148) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +1 -1
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +2 -2
  3. package/.claude/skills/sillyspec-commit/SKILL.md +1 -1
  4. package/.claude/skills/sillyspec-flow/SKILL.md +57 -0
  5. package/.claude/skills/sillyspec-quick/SKILL.md +7 -105
  6. package/AGENTS.md +38 -20
  7. package/SKILL.md +9 -3
  8. package/assets/command-cards/flow.md +32 -0
  9. package/assets/command-cards/run-archive.md +26 -0
  10. package/assets/command-cards/run-brainstorm.md +33 -0
  11. package/assets/command-cards/run-execute.md +29 -0
  12. package/assets/command-cards/run-plan.md +27 -0
  13. package/assets/command-cards/run-quick.md +17 -0
  14. package/assets/command-cards/run-verify.md +29 -0
  15. package/assets/command-cards/status.md +27 -0
  16. package/package.json +2 -1
  17. package/packages/dashboard/server/executor.js +2 -1
  18. package/packages/dashboard/server/parser.js +7 -7
  19. package/r7-test-env/README-R7-TEST.md +93 -0
  20. package/r7-test-env/demo-project/lib/calc.js +6 -0
  21. package/r7-test-env/demo-project/package.json +1 -0
  22. package/r7-test-env/demo-project/test.js +10 -0
  23. package/r7-test-env/prompt-R7-SMOKE.md +41 -0
  24. package/r7-test-env/sillyspec-r7.cmd +3 -0
  25. package/round4/account.mjs +98 -0
  26. package/round4/b1-analyze-task08.mjs +40 -0
  27. package/round4/b1-b1-target-validation.md +32 -0
  28. package/round4/b3a-prompt-audit-result.md +66 -0
  29. package/round4/b3a-prompt-audit.mjs +89 -0
  30. package/round4/brief-autocompact.md +34 -0
  31. package/round4/brief-session-replay.md +50 -0
  32. package/round4/deep-read-synthesis.md +38 -0
  33. package/round4/extract-full.mjs +38 -0
  34. package/round4/extract-timeline.mjs +43 -0
  35. package/round4/find-sessions.mjs +13 -0
  36. package/round4/full-r4-l.md +4707 -0
  37. package/round4/full-r4-o-l.md +4300 -0
  38. package/round4/full-r4-os.md +1681 -0
  39. package/round4/full-r4-sf.md +4255 -0
  40. package/round4/full-r4-sq.md +1734 -0
  41. package/round4/gate-audit-relay-receipt.md +30 -0
  42. package/round4/gate-value-audit.md +52 -0
  43. package/round4/optimization-plan.md +165 -0
  44. package/round4/prereg.md +67 -0
  45. package/round4/prompt-R4-L.md +63 -0
  46. package/round4/prompt-R4-O-L.md +59 -0
  47. package/round4/prompt-R4-O-S.md +56 -0
  48. package/round4/prompt-R4-S-F.md +60 -0
  49. package/round4/prompt-R4-S-Q.md +66 -0
  50. package/round4/r4-final-report.html +347 -0
  51. package/round4/r4-os-timeline.txt +297 -0
  52. package/round4/r4-sf-timeline.txt +738 -0
  53. package/round4/r4-sq-timeline.txt +322 -0
  54. package/round4/r4l-timeline.txt +793 -0
  55. package/round4/r4ol-timeline.txt +729 -0
  56. package/round4/r4sf-timeline.txt +852 -0
  57. package/round4/r5-scenario-matrix.md +46 -0
  58. package/round4/result-S.md +171 -0
  59. package/round4/result-final.md +46 -0
  60. package/round4/result-quality.md +63 -0
  61. package/src/change-list.js +9 -0
  62. package/src/classify-change.js +6 -0
  63. package/src/command-cards.js +74 -0
  64. package/src/commit-guard.js +2 -0
  65. package/src/config-schema.js +64 -12
  66. package/src/contract-matrix.js +26 -5
  67. package/src/db.js +35 -4
  68. package/src/decision-distill.js +9 -3
  69. package/src/diagnostic-codes.js +2 -1
  70. package/src/docs-check.js +1 -1
  71. package/src/doctor-diagnostics.js +44 -2
  72. package/src/flow-draft.js +748 -0
  73. package/src/flow-parity.js +236 -0
  74. package/src/flow-review.js +184 -0
  75. package/src/flow.js +1492 -0
  76. package/src/foreign-declared.js +17 -1
  77. package/src/fr-index.js +396 -22
  78. package/src/fs-atomic.js +5 -1
  79. package/src/git-helper.js +2 -2
  80. package/src/greenfield-bootstrap.js +98 -0
  81. package/src/handoff.js +120 -7
  82. package/src/hunk-attribution.js +191 -0
  83. package/src/index.js +602 -21
  84. package/src/init.js +25 -8
  85. package/src/knowledge-classify.js +47 -1
  86. package/src/knowledge-digest.js +162 -0
  87. package/src/knowledge-quicklog.js +246 -0
  88. package/src/machine-draft.js +128 -0
  89. package/src/machine-interface.js +194 -7
  90. package/src/mcp-server.js +1 -0
  91. package/src/module-impact.js +51 -22
  92. package/src/module-resolve.js +3 -1
  93. package/src/preview-progress.js +160 -0
  94. package/src/progress/change-registry.js +11 -3
  95. package/src/progress/shared.js +3 -2
  96. package/src/progress.js +56 -13
  97. package/src/quicklog.js +44 -6
  98. package/src/redomain.js +147 -0
  99. package/src/review-material-pack.js +204 -1
  100. package/src/run/archive-distill.js +4 -2
  101. package/src/run/bg-sync.js +67 -0
  102. package/src/run/command.js +127 -10
  103. package/src/run/complete-handlers.js +363 -73
  104. package/src/run/complete.js +356 -74
  105. package/src/run/gate-snapshot-ledger.js +213 -0
  106. package/src/run/gate-snapshot.js +260 -38
  107. package/src/run/gates.js +482 -268
  108. package/src/run/green-cache.js +123 -0
  109. package/src/run/junction-rm.js +70 -0
  110. package/src/run/next.js +14 -0
  111. package/src/run/prompt.js +185 -55
  112. package/src/run/quick-audit.js +213 -14
  113. package/src/run/shared.js +123 -4
  114. package/src/run/stage.js +182 -216
  115. package/src/run/test-ledger.js +264 -0
  116. package/src/run/verify-quality-scan.js +84 -7
  117. package/src/scope-audit.js +6 -1
  118. package/src/sentinel-assertions.js +114 -0
  119. package/src/setup.js +3 -3
  120. package/src/spec-sync.js +41 -1
  121. package/src/stage-contract.js +26 -8
  122. package/src/stage-review-checklist.js +2 -0
  123. package/src/stage-review.js +7 -2
  124. package/src/stage-templates.js +157 -0
  125. package/src/stages/archive.js +2 -0
  126. package/src/stages/brainstorm.js +24 -106
  127. package/src/stages/execute.js +222 -85
  128. package/src/stages/knowledge.js +74 -1
  129. package/src/stages/plan-postcheck.js +385 -20
  130. package/src/stages/plan.js +23 -1
  131. package/src/stages/quick.js +10 -2
  132. package/src/stages/verify.js +4 -2
  133. package/src/sync.js +81 -23
  134. package/src/task-done.js +181 -0
  135. package/src/task-review.js +8 -55
  136. package/src/taskcard-frontmatter.js +57 -0
  137. package/src/test-bindings.js +549 -0
  138. package/src/timeline.js +218 -0
  139. package/src/ui-visual.js +175 -0
  140. package/src/verify-draft.js +214 -0
  141. package/src/verify-postcheck.js +919 -159
  142. package/src/verify-probes.js +103 -25
  143. package/src/watcher.js +1031 -0
  144. package/src/worktree-apply.js +44 -4
  145. package/src/worktree-deps.js +22 -8
  146. package/src/worktree-policy.js +42 -0
  147. package/src/worktree.js +102 -15
  148. package/templates/agents-instruction.md +37 -18
@@ -0,0 +1,160 @@
1
+ /**
2
+ * preview-progress.js — watcher 预览进度账本写路径(2026-09-23-watcher-preview-progress task-02)。
3
+ *
4
+ * 职责:把 watcher 快照的阶段级推断态投影为 progress 库 stages 预览行(authority='watcher')。
5
+ * 设计契约(decisions D-004/D-005,design §3):
6
+ * - 粒度=阶段级(工件签名→progress 阶段映射;steps 级不投影,D-004);
7
+ * - 写纪律=短连接(open→写→close)+ 自带 PRAGMA busy_timeout(连接级,openDatabase 不自动——
8
+ * 影子审查 gap①)+ 存在性条件进 SQL + maxRows 写放大护栏 + 全程 fail-open(任何异常返回 false,
9
+ * 绝不影响 watcher 事件流主循环)。WHERE 认领语义(D-005@v2 执行期修正:initChange 预建
10
+ * 全套 cli/pending 行,纯 authority='watcher' 条件永远空转):watcher 行可自刷;**CLI 从未
11
+ * 触碰的 pending 行(status='pending' 且 started_at IS NULL)可被预览认领**(flip 为 watcher,
12
+ * 语义无损——pending 空行不含 CLI 任何 authored 信息);CLI 已触碰行(started/completed/
13
+ * reopened)SQL 层不可中招;CLI --done 归章(task-01 fail① 修复)把认领行收回 cli。
14
+ * - archived 拍不投影(终态只认 CLI)。
15
+ * 读侧出口在 progress.js readPreviewProgress(task-03);读侧保险丝在 progress.js 读方法(task-01)。
16
+ */
17
+ import { join } from 'node:path'
18
+ import { openDatabase } from './db-engine.js'
19
+
20
+ /** 工件签名 → progress 阶段映射(纯静态;watcher 快照 files 的键为工件基名)。 */
21
+ export const PREVIEW_STAGE_SOURCES = {
22
+ brainstorm: ['proposal.md', 'requirements.md', 'decisions.md', 'design.md'],
23
+ plan: ['plan.md'],
24
+ execute: ['tasks.md'],
25
+ verify: ['verify-result.md', 'verify-facts.json'],
26
+ }
27
+
28
+ /**
29
+ * 纯函数:快照差分 → 阶段级预览行集合。
30
+ * @param {{ ts:number, archived:boolean, files?:Object }} snapshot 当前拍
31
+ * @param {{ files?:Object }=} prevSnapshot 上一拍(缺省全量视角)
32
+ * @returns {{ stage:string, status:'in-progress', startedAt:string, evidence:Object }[]}
33
+ * evidence = { ts, files: 命中的工件清单, changed: 本拍有无内容变化(差分信号) }
34
+ */
35
+ export function projectPreviewStages({ snapshot, prevSnapshot = null } = {}) {
36
+ if (!snapshot || snapshot.archived) return []
37
+ const files = snapshot.files || {}
38
+ const prevFiles = (prevSnapshot && prevSnapshot.files) || {}
39
+ const rows = []
40
+ for (const [stage, sources] of Object.entries(PREVIEW_STAGE_SOURCES)) {
41
+ const hit = sources.filter((k) => Object.prototype.hasOwnProperty.call(files, k))
42
+ if (hit.length === 0) continue
43
+ const changed = hit.some((k) => {
44
+ const a = files[k]; const b = prevFiles[k]
45
+ if (b === undefined) return true // 出现
46
+ return JSON.stringify(a) !== JSON.stringify(b) // 内容变化(checked 增/键值漂移)
47
+ })
48
+ rows.push({
49
+ stage,
50
+ status: 'in-progress',
51
+ startedAt: new Date(snapshot.ts || Date.now()).toISOString(),
52
+ evidence: { ts: snapshot.ts || null, files: hit, changed },
53
+ })
54
+ }
55
+ return rows
56
+ }
57
+
58
+ /**
59
+ * 短连接写入预览行(best-effort,全程 fail-open)。
60
+ * @returns {boolean} true=至少尝试写入(库在/变更注册);false=跳过(库缺失/变更未注册/异常)
61
+ */
62
+ export function writePreviewStages({ specDir, changeName, rows, maxRows = 8 } = {}) {
63
+ if (!specDir || !changeName || !Array.isArray(rows) || rows.length === 0) return false
64
+ let db = null
65
+ try {
66
+ db = openDatabase(join(specDir, '.runtime', 'sillyspec.db'), {})
67
+ // 连接级 PRAGMA(影子审查 gap①):openDatabase 不自动带,缺省 0 = 撞锁立即 BUSY
68
+ db.exec('PRAGMA busy_timeout = 5000')
69
+ const changeRow = db.prepare('SELECT id FROM changes WHERE name = ?').get(changeName)
70
+ if (!changeRow) return false // 变更未注册(未跑过任何阶段)——无从挂预览,安静跳过
71
+ const cid = changeRow.id
72
+ const stmt = db.prepare(
73
+ `INSERT INTO stages (change_id, stage, status, started_at, authority, preview_evidence)
74
+ VALUES (?, ?, ?, ?, 'watcher', ?)
75
+ ON CONFLICT(change_id, stage) DO UPDATE SET
76
+ status = excluded.status,
77
+ started_at = COALESCE(stages.started_at, excluded.started_at),
78
+ preview_evidence = excluded.preview_evidence,
79
+ authority = 'watcher'
80
+ WHERE stages.authority = 'watcher'
81
+ OR (stages.authority = 'cli' AND stages.status = 'pending' AND stages.started_at IS NULL)`
82
+ )
83
+ for (const r of rows.slice(0, maxRows)) {
84
+ stmt.run(cid, r.stage, r.status, r.startedAt, JSON.stringify(r.evidence || {}))
85
+ }
86
+ return true
87
+ } catch {
88
+ return false // fail-open:锁竞争/库损坏/迁移未达——事件流不受影响,下轮重投影
89
+ } finally {
90
+ if (db) { try { db.close() } catch { /* 关闭失败不放大 */ } }
91
+ }
92
+ }
93
+
94
+ /** 步骤级工件映射:变更工件基名 → (阶段, 步骤名) 的对应——watcher 步骤节拍的数据源。 */
95
+ export const PREVIEW_STEP_SOURCES = {
96
+ 'proposal.md': { stage: 'brainstorm', step: '写设计文档并自审' },
97
+ 'requirements.md': { stage: 'brainstorm', step: '写设计文档并自审' },
98
+ 'design.md': { stage: 'brainstorm', step: '写设计文档并自审' },
99
+ 'decisions.md': { stage: 'brainstorm', step: '写设计文档并自审' },
100
+ 'plan.md': { stage: 'plan', step: '生成分级计划' },
101
+ 'verify-result.md': { stage: 'verify', step: '输出验证报告' },
102
+ 'verify-facts.json': { stage: 'verify', step: '输出验证报告' },
103
+ 'module-impact.md': { stage: 'archive', step: 'extract-module-impact 与归档语义收尾' },
104
+ }
105
+
106
+ /**
107
+ * 纯函数:快照差分 → 步骤级预览行集合(预览账本 v2,D-004 兑现)。
108
+ * 粒度=工件信号可映射的步骤(tasks.md 勾选→execute 逐任务步不可靠映射,不做——纯思考
109
+ * 型步骤由 --done 批量自述,burst 已支持)。返回行带 stage+step 双键。
110
+ */
111
+ export function projectPreviewSteps({ snapshot, prevSnapshot = null } = {}) {
112
+ if (!snapshot || snapshot.archived) return []
113
+ const files = snapshot.files || {}
114
+ const prevFiles = (prevSnapshot && prevSnapshot.files) || {}
115
+ const rows = []
116
+ for (const [fname, target] of Object.entries(PREVIEW_STEP_SOURCES)) {
117
+ if (!Object.prototype.hasOwnProperty.call(files, fname)) continue
118
+ const changed = prevFiles[fname] === undefined || JSON.stringify(files[fname]) !== JSON.stringify(prevFiles[fname])
119
+ rows.push({
120
+ stage: target.stage,
121
+ step: target.step,
122
+ status: 'in-progress',
123
+ startedAt: new Date(snapshot.ts || Date.now()).toISOString(),
124
+ evidence: { ts: snapshot.ts || null, file: fname, changed },
125
+ })
126
+ }
127
+ return rows
128
+ }
129
+
130
+ /**
131
+ * 写步骤级预览行(短连接,fail-open):steps 表 authority='watcher',仅当该步骤
132
+ * 不存在或也是 watcher 行时写入(CLI 步骤行不可中招)。
133
+ */
134
+ export function writePreviewSteps({ specDir, changeName, rows, maxRows = 12 } = {}) {
135
+ if (!specDir || !changeName || !Array.isArray(rows) || rows.length === 0) return false
136
+ let db = null
137
+ try {
138
+ db = openDatabase(join(specDir, '.runtime', 'sillyspec.db'), {})
139
+ db.exec('PRAGMA busy_timeout = 5000')
140
+ const changeRow = db.prepare('SELECT id FROM changes WHERE name = ?').get(changeName)
141
+ if (!changeRow) return false
142
+ const cid = changeRow.id
143
+ const stageStmt = db.prepare("SELECT id FROM stages WHERE change_id = ? AND stage = ? AND (authority = 'watcher' OR authority = 'cli' OR authority IS NULL)")
144
+ const insStmt = db.prepare(
145
+ `INSERT INTO steps (stage_id, name, status, completed_at, ordering, authority, preview_evidence)
146
+ VALUES (?, ?, 'in-progress', NULL, 0, 'watcher', ?)
147
+ ON CONFLICT DO NOTHING`
148
+ )
149
+ for (const r of rows.slice(0, maxRows)) {
150
+ const stageRow = stageStmt.get(cid, r.stage)
151
+ if (!stageRow) continue // 阶段行不在场——CLI 尚未进入该阶段,不预建
152
+ insStmt.run(stageRow.id, r.step, JSON.stringify(r.evidence || {}))
153
+ }
154
+ return true
155
+ } catch {
156
+ return false
157
+ } finally {
158
+ if (db) { try { db.close() } catch {} }
159
+ }
160
+ }
@@ -613,9 +613,9 @@ export class ChangeRegistry {
613
613
  const row = sqlDb.prepare('SELECT id FROM changes WHERE name = ?').get(changeName);
614
614
  if (row) {
615
615
  sqlDb.prepare(
616
- `INSERT INTO stages (change_id, stage, status, started_at, completed_at)
617
- VALUES (?, 'archive', 'completed', ?, ?)
618
- ON CONFLICT(change_id, stage) DO UPDATE SET status = 'completed', completed_at = excluded.completed_at`
616
+ `INSERT INTO stages (change_id, stage, status, started_at, completed_at, authority)
617
+ VALUES (?, 'archive', 'completed', ?, ?, 'cli')
618
+ ON CONFLICT(change_id, stage) DO UPDATE SET status = 'completed', completed_at = excluded.completed_at, authority = 'cli'`
619
619
  ).run(row.id, now, now);
620
620
  const stageRow = sqlDb.prepare(
621
621
  'SELECT id FROM stages WHERE change_id = ? AND stage = ?'
@@ -638,6 +638,14 @@ export class ChangeRegistry {
638
638
  }
639
639
  }
640
640
  }
641
+ // 预览账本 GC(2026-09-23-watcher-preview-progress task-04 / FR-06):归档收尾清 watcher
642
+ // 预览行(steps 随 stages 的 ON DELETE CASCADE 级联自动清)。fail-open:失败 warn 不阻断归档。
643
+ try {
644
+ const prow = sqlDb.prepare('SELECT id FROM changes WHERE name = ?').get(changeName);
645
+ if (prow) sqlDb.prepare("DELETE FROM stages WHERE change_id = ? AND authority = 'watcher'").run(prow.id);
646
+ } catch (e) {
647
+ console.warn(`⚠️ 预览行 GC 失败(fail-open 不阻断归档):${(e && e.message) || e}`);
648
+ }
641
649
  // 本地脏度(D-013 / task-04):归档也是本地状态推进
642
650
  this.pm._touchLocalModified(cwd, changeName, now);
643
651
  });
@@ -27,8 +27,9 @@ export const STAGE_ORDER = ['scan', 'brainstorm', 'plan', 'execute', 'verify', '
27
27
  export const MAIN_FLOW_ORDER = ['brainstorm', 'plan', 'execute', 'verify', 'archive'];
28
28
 
29
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;
30
+ // v6 = changes 加 owner_session 所有权列,2026-09-14-change-ownership-guards D-005@v1;
31
+ // v8 = 出生阶段 'scan'→'brainstorm' + 存量出生默认行迁移,2026-09-27-change-birth-stage-brainstorm)
32
+ export const CURRENT_VERSION = 8;
32
33
 
33
34
  // 滞留提示阈值(天,2026-08-30 用户反馈②:7 个「代码全落地但流程没收口」的变更挂 38 天
34
35
  // 无人发现):活跃变更 last_active 距今超该天数且流程未收口(archive 未完成)时,progress
package/src/progress.js CHANGED
@@ -15,6 +15,7 @@ import { join, basename, dirname, resolve, sep } from 'path';
15
15
  import { tmpdir, hostname } from 'os';
16
16
  import { writeAtomicSync } from './fs-atomic.js';
17
17
  import { DB } from './db.js';
18
+ import { openDatabase } from './db-engine.js';
18
19
  import { checkExecuteCodeEvidence } from './stage-contract.js';
19
20
  import { ConsistencyDoctor } from './progress/consistency-doctor.js';
20
21
  import { ChangeRegistry } from './progress/change-registry.js';
@@ -270,6 +271,36 @@ const CHANGES_SUBDIR = 'changes';
270
271
 
271
272
  // ── ProgressManager ──
272
273
 
274
+ /**
275
+ * 预览进度读出口(2026-09-23-watcher-preview-progress task-03 / FR-04):
276
+ * 读 watcher 预览行(authority='watcher',读侧保险丝之外的显式出口)+ evidence 解析。
277
+ * 纯只读短连接;库缺失/变更未注册/异常 → { exists:false, stages:[] }(fail-open)。
278
+ */
279
+ export function readPreviewProgress(specDir, changeName) {
280
+ if (!specDir || !changeName) return { exists: false, stages: [] }
281
+ let db = null
282
+ try {
283
+ db = openDatabase(join(specDir, '.runtime', 'sillyspec.db'), { readOnly: true })
284
+ const changeRow = db.prepare('SELECT id FROM changes WHERE name = ?').get(changeName)
285
+ if (!changeRow) return { exists: false, stages: [] }
286
+ const rows = db.prepare(
287
+ "SELECT stage, status, started_at, preview_evidence FROM stages WHERE change_id = ? AND authority = 'watcher' ORDER BY stage"
288
+ ).all(changeRow.id)
289
+ return {
290
+ exists: true,
291
+ stages: rows.map((r) => {
292
+ let evidence = null
293
+ try { evidence = r.preview_evidence ? JSON.parse(r.preview_evidence) : null } catch { evidence = null }
294
+ return { stage: r.stage, status: r.status, startedAt: r.started_at, evidence }
295
+ }),
296
+ }
297
+ } catch {
298
+ return { exists: false, stages: [] }
299
+ } finally {
300
+ if (db) { try { db.close() } catch { /* 只读关闭失败不放大 */ } }
301
+ }
302
+ }
303
+
273
304
  export class ProgressManager {
274
305
  /** 模块级 DB 连接池:Map<dbPath, DB>(SEC-09/PERF-10,见 _ensureDB 注释) */
275
306
  static _dbPool = new Map();
@@ -401,7 +432,7 @@ export class ProgressManager {
401
432
  const { id: changeId, name: cName, current_stage: currentStage, no_worktree: noWorktree, last_active: lastActive } = changeRow;
402
433
 
403
434
  // 2. 从 stages 表获取所有阶段(含 revision 列)
404
- const stageRows = sqlDb.prepare('SELECT id, stage, status, started_at, completed_at, revision, reopened_from_step, reopened_at, stale_reason FROM stages WHERE change_id = ? ORDER BY id').all(changeId);
435
+ const stageRows = sqlDb.prepare(`SELECT id, stage, status, started_at, completed_at, revision, reopened_from_step, reopened_at, stale_reason FROM stages WHERE change_id = ? AND (authority = 'cli' OR authority IS NULL) ORDER BY id`).all(changeId);
405
436
  const stageMap = {};
406
437
  const stageIds = [];
407
438
  for (const row of stageRows) {
@@ -420,7 +451,7 @@ export class ProgressManager {
420
451
  if (stageIds.length > 0) {
421
452
  const placeholders = stageIds.map(() => '?').join(',');
422
453
  const stepRows = sqlDb.prepare(
423
- `SELECT stage_id, name, status, output, completed_at, ordering, wait_reason, wait_options, wait_answer, waited_at, wait_answers, wait_round, max_wait_rounds FROM steps WHERE stage_id IN (${placeholders}) ORDER BY stage_id, ordering`
454
+ `SELECT stage_id, name, status, output, completed_at, ordering, wait_reason, wait_options, wait_answer, waited_at, wait_answers, wait_round, max_wait_rounds FROM steps WHERE stage_id IN (${placeholders}) AND (authority = 'cli' OR authority IS NULL) ORDER BY stage_id, ordering`
424
455
  ).all(...stageIds);
425
456
  // 按阶段分组步骤
426
457
  for (const row of stepRows) {
@@ -490,7 +521,9 @@ export class ProgressManager {
490
521
  }
491
522
 
492
523
  const result = {
493
- _version: 6,
524
+ // 版本单一源(2026-09-27-change-birth-stage-brainstorm 收敛):此前是字面量 6,
525
+ // v7 bump(watcher-preview-progress)漏改此处成「四处一致」之外的第五处漂移。
526
+ _version: CURRENT_VERSION,
494
527
  project: projectName,
495
528
  currentChange: cName,
496
529
  currentStage: currentStage || '',
@@ -572,7 +605,14 @@ export class ProgressManager {
572
605
 
573
606
  // 3. stages(该 change 下,用 change_name+stage 表达外键)
574
607
  const stageRows = sqlDb.prepare(
575
- `SELECT id, stage, status, started_at, completed_at, revision, reopened_from_step, reopened_at, stale_reason
608
+ // 权威视图渲染(D-007 钉,2026-09-23-watcher-preview-progress task-05):被 watcher 认领的行
609
+ // 在平台载荷里按其 CLI 已知态(pending/未起止)投影——载荷与无预览的干净态逐字节一致,
610
+ // 不因预览认领缺行(隔离钉 T1 实证:纯过滤会整行剔除该阶段)。
611
+ `SELECT id, stage,
612
+ CASE WHEN authority = 'watcher' THEN 'pending' ELSE status END AS status,
613
+ CASE WHEN authority = 'watcher' THEN NULL ELSE started_at END AS started_at,
614
+ CASE WHEN authority = 'watcher' THEN NULL ELSE completed_at END AS completed_at,
615
+ revision, reopened_from_step, reopened_at, stale_reason
576
616
  FROM stages WHERE change_id = ? ORDER BY id`
577
617
  ).all(changeId);
578
618
  const stageJson = stageRows.map(r => ({
@@ -596,7 +636,7 @@ export class ProgressManager {
596
636
  const stepRows = sqlDb.prepare(
597
637
  `SELECT stage_id, name, status, output, completed_at, ordering, wait_reason, wait_options,
598
638
  wait_answer, waited_at, wait_answers, wait_round, max_wait_rounds
599
- FROM steps WHERE stage_id IN (${placeholders}) ORDER BY stage_id, ordering`
639
+ FROM steps WHERE stage_id IN (${placeholders}) AND (authority = 'cli' OR authority IS NULL) ORDER BY stage_id, ordering`
600
640
  ).all(...stageIds);
601
641
  stepJson = stepRows.map(r => ({
602
642
  change_name: changeRow.name,
@@ -921,8 +961,8 @@ export class ProgressManager {
921
961
  for (const [stageName, stageData] of Object.entries(data.stages)) {
922
962
  // UPSERT stages 行(含 revision 列)
923
963
  sqlDb.prepare(
924
- `INSERT INTO stages (change_id, stage, status, started_at, completed_at, revision, reopened_from_step, reopened_at, stale_reason)
925
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
964
+ `INSERT INTO stages (change_id, stage, status, started_at, completed_at, revision, reopened_from_step, reopened_at, stale_reason, authority)
965
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'cli')
926
966
  ON CONFLICT(change_id, stage) DO UPDATE SET
927
967
  status = excluded.status,
928
968
  started_at = excluded.started_at,
@@ -930,7 +970,8 @@ export class ProgressManager {
930
970
  revision = COALESCE(excluded.revision, stages.revision),
931
971
  reopened_from_step = excluded.reopened_from_step,
932
972
  reopened_at = excluded.reopened_at,
933
- stale_reason = excluded.stale_reason`
973
+ stale_reason = excluded.stale_reason,
974
+ authority = 'cli'`
934
975
  ).run(changeId, stageName, stageData.status || 'pending', stageData.startedAt || null, stageData.completedAt || null,
935
976
  stageData.revision || 0, stageData.reopenedFromStep || null, stageData.reopenedAt || null, stageData.staleReason || null);
936
977
 
@@ -1125,10 +1166,12 @@ export class ProgressManager {
1125
1166
  // 检查变更是否已存在
1126
1167
  const existing = sqlDb.prepare('SELECT id FROM changes WHERE name = ?').get(changeName);
1127
1168
  if (existing === undefined) {
1128
- // 插入 changes 行
1169
+ // 插入 changes 行。出生阶段=brainstorm(2026-09-27 用户裁定「起步就是头脑风暴」):
1170
+ // scan 是 auxiliary(shared.js AUXILIARY_STAGES 语义),从未是变更主流程起点;
1171
+ // 旧默认 'scan' 让 thin/quick 等不进主流程的变更全生命周期显示「🔍 代码扫描」误导。
1129
1172
  sqlDb.prepare(
1130
1173
  `INSERT INTO changes (name, current_stage, status, created_at, last_active, title, quicklog_id)
1131
- VALUES (?, 'scan', 'active', ?, ?, ?, ?)`
1174
+ VALUES (?, 'brainstorm', 'active', ?, ?, ?, ?)`
1132
1175
  ).run(changeName, now, now, meta.title || null, meta.quicklogId || null);
1133
1176
  }
1134
1177
 
@@ -1203,7 +1246,7 @@ export class ProgressManager {
1203
1246
  const sqlDb = db.getDb();
1204
1247
  const now = new Date().toISOString();
1205
1248
  const ins = sqlDb.prepare(
1206
- `INSERT OR IGNORE INTO changes (name, current_stage, status, created_at, last_active) VALUES (?, 'scan', 'active', ?, ?)`
1249
+ `INSERT OR IGNORE INTO changes (name, current_stage, status, created_at, last_active) VALUES (?, 'brainstorm', 'active', ?, ?)`
1207
1250
  ).run(changeName, now, now);
1208
1251
  const changeRow = sqlDb.prepare('SELECT id FROM changes WHERE name = ?').get(changeName);
1209
1252
  if (changeRow !== undefined) {
@@ -1468,7 +1511,7 @@ export class ProgressManager {
1468
1511
 
1469
1512
  // 3. stages
1470
1513
  const stageRows = sqlDb.prepare(
1471
- 'SELECT id, stage, status, started_at, completed_at, revision FROM stages WHERE change_id = ? ORDER BY id'
1514
+ `SELECT id, stage, status, started_at, completed_at, revision FROM stages WHERE change_id = ? AND (authority = 'cli' OR authority IS NULL) ORDER BY id`
1472
1515
  ).all(changeId);
1473
1516
 
1474
1517
  const stages = {};
@@ -1481,7 +1524,7 @@ export class ProgressManager {
1481
1524
  if (stageRows.length > 0) {
1482
1525
  const placeholders = stageRows.map(() => '?').join(', ');
1483
1526
  const allStepRows = sqlDb.prepare(
1484
- `SELECT stage_id, name, status, output, completed_at FROM steps WHERE stage_id IN (${placeholders}) ORDER BY stage_id, ordering`
1527
+ `SELECT stage_id, name, status, output, completed_at FROM steps WHERE stage_id IN (${placeholders}) AND (authority = 'cli' OR authority IS NULL) ORDER BY stage_id, ordering`
1485
1528
  ).all(...stageRows.map(r => r.id));
1486
1529
  for (const sr of allStepRows) {
1487
1530
  if (!stepsByStage.has(sr.stage_id)) stepsByStage.set(sr.stage_id, []);
package/src/quicklog.js CHANGED
@@ -752,10 +752,12 @@ const GUARD_CLAIM_STALE_MS = 7 * 24 * 60 * 60 * 1000
752
752
  * 遍历 quick-sessions 目录,按同一僵尸窗口口径读各会话 guard.json(collectGuardReservedQuicklogIds
753
753
  * 与 collectActiveQuickGuardFiles 共用,活跃判定勿分叉):guard.json 损坏/缺失 → guard=null 保留
754
754
  * 条目(消费方各自决定跳过或视为空声明);startedAt 可解析且超 GUARD_CLAIM_STALE_MS → 僵尸剔除。
755
+ * 附 guardMtimeMs(guard.json 最后写时间——会话结构性更新【启动/--files 追加边界】都会重写该文件,
756
+ * 是比 startedAt 更新的活性信号;非周期心跳,长思考会话会漂老,消费方 TTL 须以此为前提选窗)。
755
757
  *
756
758
  * @param {string} dir quick-sessions 目录
757
759
  * @param {number} [nowMs] 当前时间戳(可注入,测试用)
758
- * @returns {Array<{sessionId: string, guard: object|null}>}
760
+ * @returns {Array<{sessionId: string, guard: object|null, guardMtimeMs: number|null}>}
759
761
  */
760
762
  function listQuickSessionGuards(dir, nowMs = Date.now()) {
761
763
  const out = []
@@ -765,12 +767,17 @@ function listQuickSessionGuards(dir, nowMs = Date.now()) {
765
767
  } catch { return out } // 目录不存在/不可读 → 无会话
766
768
  for (const sessionName of sessionDirs) {
767
769
  let guard = null
768
- try { guard = JSON.parse(readFileSync(join(dir, sessionName, 'guard.json'), 'utf8')) } catch { guard = null } // 损坏/缺失 → null
770
+ let guardMtimeMs = null
771
+ const guardPath = join(dir, sessionName, 'guard.json')
772
+ try {
773
+ guard = JSON.parse(readFileSync(guardPath, 'utf8'))
774
+ guardMtimeMs = statSync(guardPath).mtimeMs
775
+ } catch { guard = null } // 损坏/缺失 → null(mtime 一并放弃)
769
776
  if (guard && guard.startedAt) {
770
777
  const startedAtMs = Date.parse(guard.startedAt)
771
778
  if (Number.isFinite(startedAtMs) && nowMs - startedAtMs > GUARD_CLAIM_STALE_MS) continue // 僵尸不活跃
772
779
  }
773
- out.push({ sessionId: sessionName, guard })
780
+ out.push({ sessionId: sessionName, guard, guardMtimeMs })
774
781
  }
775
782
  return out
776
783
  }
@@ -795,30 +802,61 @@ export function collectGuardReservedQuicklogIds(specBase, sessionsDirHint = null
795
802
  * (quick --done 完成时清理 guard 目录,完成态天然退出),超 7 天僵尸窗口(GUARD_CLAIM_STALE_MS,
796
803
  * 异常残留不钉死)剔除。勿用 changes.last_active 判活跃(非周期心跳,D-002)。
797
804
  *
805
+ * **拦截面 TTL(ql-20260921-009 ①,2026-09-21 batch2 归档实证)**:在僵尸窗口之上,本函数(apply
806
+ * 拦截面专用消费口)再按 guard.json **mtime** 收紧——超过 staleAfterMs(缺省 4h)无结构性更新的
807
+ * 会话视为放弃,声明不再参与相交拦截。实证形态:quick 会话中断未 --done、guard 目录残留 36h,
808
+ * apply 被幽灵守卫拦住,人工查证 owner.json 才敢解锁。安全性:守卫只是预警层,apply 的内容级
809
+ * 脏文件三方合并检查(EXCLUDE-DIRTY)仍兜底真实冲突;4h 窗口对 quick(短命流程)足够宽,
810
+ * 超窗未动 CLI 的会话即使活着,其声明文件若真有未提交改动也走内容级检查。quicklog ID 预留
811
+ * (collectGuardReservedQuicklogIds)**不套用**此 TTL——ID 复用避开近期死会话仍取 7 天窗。
812
+ * 过期跳过时打一行 ℹ️(可观测,不静默);staleAfterMs=null 关闭(测试/回退口径)。
813
+ *
798
814
  * @param {string} specBase .sillyspec 根目录
799
- * @param {{ excludeChange?: string|null, sessionsDir?: string|null, nowMs?: number }} [opts]
815
+ * @param {{ excludeChange?: string|null, sessionsDir?: string|null, nowMs?: number, staleAfterMs?: number|null }} [opts]
800
816
  * - excludeChange:排除自身 change 的 quick 会话——sessionId == changeName(quick 会话 id 即
801
817
  * change 名)或 guard.linkedChanges 显式关联该 change 的协作会话(自己人,非拦截面)
802
818
  * - sessionsDir:quick-sessions 目录(缺省 <specBase>/.runtime/quick-sessions)
803
819
  * - nowMs:当前时间戳(可注入,测试用)
820
+ * - staleAfterMs:拦截面 TTL 毫秒数(缺省 GUARD_INTERCEPT_STALE_MS=4h;null 关闭)
804
821
  * @returns {Map<string, string[]>} sessionId → guard.allowedFiles(无 guard/无声明 → 空数组;fail-open)
805
822
  */
806
- export function collectActiveQuickGuardFiles(specBase, { excludeChange = null, sessionsDir = null, nowMs = Date.now() } = {}) {
823
+ const GUARD_INTERCEPT_STALE_MS = 4 * 60 * 60 * 1000
824
+ export function collectActiveQuickGuardFiles(specBase, { excludeChange = null, sessionsDir = null, nowMs = Date.now(), staleAfterMs = GUARD_INTERCEPT_STALE_MS } = {}) {
807
825
  const out = new Map()
826
+ let staleSkipped = 0
808
827
  try {
809
828
  const dir = sessionsDir || join(specBase, '.runtime', 'quick-sessions')
810
- for (const { sessionId, guard } of listQuickSessionGuards(dir, nowMs)) {
829
+ for (const { sessionId, guard, guardMtimeMs } of listQuickSessionGuards(dir, nowMs)) {
811
830
  if (excludeChange && (sessionId === excludeChange
812
831
  || (guard && Array.isArray(guard.linkedChanges) && guard.linkedChanges.includes(excludeChange)))) continue
832
+ if (staleAfterMs != null && guardMtimeMs != null && Number.isFinite(guardMtimeMs)
833
+ && nowMs - guardMtimeMs > staleAfterMs) { staleSkipped++; continue } // TTL 过期:拦截面视为放弃
813
834
  const allowed = (guard && Array.isArray(guard.allowedFiles))
814
835
  ? guard.allowedFiles.filter(f => typeof f === 'string' && f.length > 0)
815
836
  : []
816
837
  out.set(sessionId, allowed)
817
838
  }
818
839
  } catch { /* fail-open:采集失败等同无活跃 guard(不放大拦截面) */ }
840
+ if (staleSkipped > 0) {
841
+ console.log(`ℹ️ 已忽略 ${staleSkipped} 个过期 quick 守卫(>${Math.round(staleAfterMs / 3600000)}h 无活动,TTL 自动失效)——内容级脏文件合并检查仍兜底真实冲突`)
842
+ }
819
843
  return out
820
844
  }
821
845
 
846
+ /**
847
+ * quick 启动并发错峰建议探测(ql-20260921-009 ③,2026-09-21 batch2 归档实证)。纯读:
848
+ * 他者活跃 quick 守卫(TTL 过滤后)≥1 且主仓已有 src/test 脏文件 → suggest=true——两者
849
+ * 同时在场时工作区互写/apply 相交拦截摩擦集中爆发。单会话常态 suggest=false 零输出。
850
+ * @returns {{ suggest: boolean, others: string[] }}
851
+ */
852
+ export function detectQuickConcurrencyAdvice({ specBase, changeName, baselineFiles, sessionsDir = null, nowMs = Date.now() } = {}) {
853
+ const others = collectActiveQuickGuardFiles(specBase, { excludeChange: changeName, sessionsDir, nowMs })
854
+ const srcTestDirty = Array.isArray(baselineFiles) && baselineFiles.some(
855
+ (p) => { const n = String(p).split('\\').join('/'); return n.startsWith('src/') || n.startsWith('test/') }
856
+ )
857
+ return { suggest: others.size > 0 && !!srcTestDirty, others: [...others.keys()] }
858
+ }
859
+
822
860
  /**
823
861
  * 数某 qlId 在所有 QUICKLOG-*.md 里的条目头出现次数(--done 占用校验用)。
824
862
  * ≥2 = 分配竞态残留已损坏记录(同 ID 双条目);容错空白形态与 scanExisting 的 reLoose 同口径。
@@ -0,0 +1,147 @@
1
+ /**
2
+ * redomain.js — FR 条目跨域迁移(2026-09-27-redomain,三层治理 v2 ③)
3
+ *
4
+ * 伪域信号卡指出迁移建议(suggestDomainFromFiles),本模块给机器通道:
5
+ * 把 fr/<from>.md 的条目段切出、拼进 fr/<to>.md(缺席则按 loadDomainSections
6
+ * 同款头新建),目标域无 INDEX 路由行则补(syncIndexRoutingLines 单源)。
7
+ *
8
+ * 身份铁律(D-001 单一身份同源):迁域**不换号**——条目 ID(FR-<旧域>-NNN)
9
+ * 保持原样,绑定行/supersede 链/最近确认锚全靠 ID 寻址(upsertFrBindings 按
10
+ * `## ${frId}` 全目录扫描定位,与所在文件无关);前缀与域不符属历史痕迹。
11
+ *
12
+ * 空壳防线:全域迁移后源文件剩 0 条目则删除(防留空壳域文件污染域列表)。
13
+ * 纯文件手术——不动条目内容、不动绑定块(随条目段整体搬)。
14
+ */
15
+ import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs'
16
+ import { join } from 'path'
17
+ import { splitKnowledgeSections, joinKnowledgeFile, syncIndexRoutingLines } from './decision-distill.js'
18
+ import { FR_SECTION_RE } from './fr-index.js'
19
+ import { writeAtomicSync } from './fs-atomic.js'
20
+
21
+ /** 条目段头(## FR-xxx-NNN / ## FR-NNN)判定与 ID 提取 */
22
+ const FR_HEADER_RE = /^## (FR-[\w.-]+-\d+|FR-\d+)\s*(.*)$/
23
+
24
+ function frEntryId(headerLine) {
25
+ const m = FR_HEADER_RE.exec(headerLine)
26
+ return m ? m[1] : null
27
+ }
28
+
29
+ /** 目标域文件头(与 fr-index loadDomainSections 新建头逐字同款) */
30
+ function newDomainPreamble(domain) {
31
+ // 与 fr-index loadDomainSections 新建头逐字同款(评审 P2-2 清偿:含 superseded 回溯行
32
+ // 与 伪域/模块卡 条件行,勿再分叉)
33
+ return [
34
+ '---',
35
+ `author: sillyspec-fr-index`,
36
+ `created_at: ${new Date().toISOString()}`,
37
+ '---',
38
+ '',
39
+ `# FR 索引 — ${domain}`,
40
+ '',
41
+ '> fr-index 从归档变更 requirements.md 幂等提炼(「最近确认」= 归档时 HEAD)。条目字段行为机械解析契约,勿手改。',
42
+ '> superseded 条目保留供取代链回溯;brainstorm 注入默认只给 active。',
43
+ String(domain).startsWith('auto-')
44
+ ? '> 伪域(auto- 前缀):由文件路径段投票派生,无模块卡——为该域补模块卡后,新变更将自动落回真域'
45
+ : `> 模块卡:modules/${domain}.md(域=模块 id 同构;行为条目↔模块契约互跳)`,
46
+ '',
47
+ ]
48
+ }
49
+
50
+ /**
51
+ * 执行迁移(纯函数式落盘)。
52
+ * @param {{knowledgeRoot: string, from: string, to: string, anchors?: string[]}} opts
53
+ * @returns {{moved: Array<{id,title}>, sourceDeleted: boolean, targetCreated: boolean, indexLineAdded: boolean}}
54
+ * @throws 源域缺席 / 无匹配条目 / anchor 未命中
55
+ */
56
+ export function redomainFrEntries({ knowledgeRoot, from, to, anchors = null }) {
57
+ const frDir = join(knowledgeRoot, 'fr')
58
+ const fromPath = join(frDir, `${from}.md`)
59
+ const toPath = join(frDir, `${to}.md`)
60
+ if (!existsSync(fromPath)) throw new Error(`源域文件不存在:fr/${from}.md`)
61
+ if (from === to) throw new Error('源域与目标域相同')
62
+
63
+ const fromParsed = splitKnowledgeSections(readFileSync(fromPath, 'utf8'), { sectionRegex: FR_SECTION_RE, buildId: (n) => n })
64
+ const anchorSet = Array.isArray(anchors) && anchors.length > 0 ? new Set(anchors) : null
65
+
66
+ // 目标域既有内容(缺席=新建头)
67
+ const targetCreated = !existsSync(toPath)
68
+ const toParsed = targetCreated
69
+ ? { preamble: newDomainPreamble(to), sections: [] }
70
+ : splitKnowledgeSections(readFileSync(toPath, 'utf8'), { sectionRegex: FR_SECTION_RE, buildId: (n) => n })
71
+
72
+ const moving = []
73
+ const staying = []
74
+ for (const s of fromParsed.sections) {
75
+ const id = frEntryId(s.lines[0] || '')
76
+ if (!id) { staying.push(s); continue } // 非条目段(说明/杂项)留守
77
+ if (anchorSet && !anchorSet.has(id)) { staying.push(s); continue }
78
+ moving.push({ id, title: (FR_HEADER_RE.exec(s.lines[0])?.[2] || '').trim(), section: s })
79
+ }
80
+ if (moving.length === 0) {
81
+ throw new Error(anchorSet ? `anchor 未命中(${[...anchorSet].join('、')} 不在 fr/${from}.md)` : `fr/${from}.md 无 FR 条目段`)
82
+ }
83
+ if (anchorSet && moving.length !== anchorSet.size) {
84
+ const got = new Set(moving.map((m) => m.id))
85
+ const miss = [...anchorSet].filter((a) => !got.has(a))
86
+ throw new Error(`anchor 未命中:${miss.join('、')}(不在 fr/${from}.md)`)
87
+ }
88
+ // 目标域 ID 冲突防线(同 ID 已在目标域=迁移语义不明)
89
+ const toIds = new Set(toParsed.sections.map((s) => frEntryId(s.lines[0] || '')).filter(Boolean))
90
+ const clash = moving.filter((m) => toIds.has(m.id))
91
+ if (clash.length > 0) throw new Error(`目标域已有同 ID 条目(${clash.map((c) => c.id).join('、')})——先解冲突再迁移`)
92
+
93
+ // 落盘:目标域追加;源域留余或删除空壳
94
+ toParsed.sections.push(...moving.map((m) => m.section))
95
+ writeAtomicSync(toPath, joinKnowledgeFile(toParsed.preamble, toParsed.sections))
96
+ const sourceDeleted = staying.length === 0
97
+ if (sourceDeleted) unlinkSync(fromPath)
98
+ else writeAtomicSync(fromPath, joinKnowledgeFile(fromParsed.preamble, staying))
99
+
100
+ // INDEX 路由行:syncIndexRoutingLines 扫全目录幂等同步(既有行 no-op、目标域缺席行补上)
101
+ const indexPath = join(knowledgeRoot, 'INDEX.md')
102
+ const before = existsSync(indexPath) ? readFileSync(indexPath, 'utf8') : ''
103
+ try {
104
+ syncIndexRoutingLines(knowledgeRoot, {
105
+ section: 'FR 需求索引',
106
+ subdir: 'fr',
107
+ makeLine: (d) => d.startsWith('auto-')
108
+ ? `- ${d}|${d.slice(5)}|FR|需求|承接 → [fr/${d}.md](fr/${d}.md)`
109
+ : `- ${d}|FR|需求|承接 → [fr/${d}.md](fr/${d}.md)`,
110
+ })
111
+ } catch { /* INDEX 缺席/同步失败不拦迁移(主手术已成) */ }
112
+ const after = existsSync(indexPath) ? readFileSync(indexPath, 'utf8') : ''
113
+
114
+ return {
115
+ moved: moving.map((m) => ({ id: m.id, title: m.title })),
116
+ sourceDeleted,
117
+ targetCreated,
118
+ indexLineAdded: after !== before,
119
+ }
120
+ }
121
+
122
+ /** 干跑预览:列出将迁移条目,不落盘 */
123
+ export function planRedomain({ knowledgeRoot, from, to, anchors = null }) {
124
+ const frDir = join(knowledgeRoot, 'fr')
125
+ const fromPath = join(frDir, `${from}.md`)
126
+ if (!existsSync(fromPath)) throw new Error(`源域文件不存在:fr/${from}.md`)
127
+ if (from === to) throw new Error('源域与目标域相同')
128
+ const fromParsed = splitKnowledgeSections(readFileSync(fromPath, 'utf8'), { sectionRegex: FR_SECTION_RE, buildId: (n) => n })
129
+ const anchorSet = Array.isArray(anchors) && anchors.length > 0 ? new Set(anchors) : null
130
+ const candidates = []
131
+ for (const s of fromParsed.sections) {
132
+ const id = frEntryId(s.lines[0] || '')
133
+ if (!id) continue
134
+ if (anchorSet && !anchorSet.has(id)) continue
135
+ candidates.push({ id, title: (FR_HEADER_RE.exec(s.lines[0])?.[2] || '').trim() })
136
+ }
137
+ if (candidates.length === 0) {
138
+ throw new Error(anchorSet ? `anchor 未命中(${[...anchorSet].join('、')})` : `fr/${from}.md 无 FR 条目段`)
139
+ }
140
+ return {
141
+ from,
142
+ to,
143
+ targetExists: existsSync(join(frDir, `${to}.md`)),
144
+ entries: candidates,
145
+ sourceWillDelete: candidates.length === fromParsed.sections.filter((s) => frEntryId(s.lines[0] || '')).length,
146
+ }
147
+ }