sillyspec 3.26.15 → 3.27.2

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 (47) hide show
  1. package/.claude/skills/sillyspec-explore/SKILL.md +10 -3
  2. package/.claude/skills/sillyspec-verify/SKILL.md +2 -0
  3. package/README.md +2 -2
  4. package/package.json +1 -1
  5. package/packages/dashboard/server/index.js +22 -6
  6. package/src/agent-session-log.js +763 -0
  7. package/src/change-list.js +22 -2
  8. package/src/change-risk-profile.js +21 -6
  9. package/src/contract-matrix.js +91 -7
  10. package/src/db.js +14 -3
  11. package/src/docs-check.js +598 -570
  12. package/src/doctor-diagnostics.js +53 -1
  13. package/src/endpoint-extractor.js +21 -2
  14. package/src/fs-atomic.js +4 -2
  15. package/src/git-helper.js +73 -5
  16. package/src/hooks/claude-pre-tool-use.cjs +10 -5
  17. package/src/hooks/worktree-guard.js +22 -3
  18. package/src/index.js +59 -7
  19. package/src/module-impact.js +21 -1
  20. package/src/module-resolve.js +211 -0
  21. package/src/modules.js +82 -2
  22. package/src/progress/step-store.js +12 -8
  23. package/src/progress.js +6 -2
  24. package/src/quicklog.js +11 -3
  25. package/src/run/command.js +40 -0
  26. package/src/run/complete-handlers.js +17 -2
  27. package/src/run/complete.js +81 -8
  28. package/src/run/gates.js +83 -14
  29. package/src/run/prompt.js +16 -2
  30. package/src/run/quick-audit.js +22 -2
  31. package/src/run/shared.js +70 -19
  32. package/src/run/stage.js +60 -3
  33. package/src/stage-contract.js +14 -1
  34. package/src/stages/archive.js +3 -1
  35. package/src/stages/execute.js +171 -18
  36. package/src/stages/explore.js +11 -2
  37. package/src/stages/plan-postcheck.js +3 -1
  38. package/src/stages/plan.js +3 -0
  39. package/src/stages/quick.js +4 -3
  40. package/src/stages/verify.js +9 -2
  41. package/src/sync.js +98 -3
  42. package/src/task-review.js +55 -7
  43. package/src/taskcard.js +52 -22
  44. package/src/verify-postcheck.js +223 -6
  45. package/src/worktree-apply.js +28 -10
  46. package/src/worktree-deps.js +71 -5
  47. package/src/worktree.js +70 -6
@@ -0,0 +1,211 @@
1
+ /**
2
+ * module-resolve.js — 模块卡两级级联解析(token 成本优化 P0a,2026-08-22-token-cost-optimization)
3
+ *
4
+ * 背景(multi-agent-platform 2026-08-22 实测):monorepo 根层 _module-map.yaml 把整个子项目
5
+ * (backend/**)映射到单张大卡(backend.md 58KB),而子项目细粒度卡(docs/backend/modules/
6
+ * agent.md,~6KB)由 scan 生成却无人消费——execute 每个 task 子代理都被指引去读整张大卡,
7
+ * 单卡一项全流程烧 20 万+ tokens 输入,且大卡「变更索引」段每变更追加、单调膨胀。
8
+ *
9
+ * 本模块 = 纯机械分类(与 module-impact.js 同性质,CLI 算事实):
10
+ * - collectModuleMaps:收齐 <spec>/docs/<project>/modules/_module-map.yaml 全部层级;
11
+ * project 名恰为仓库顶层目录(monorepo 子项目,如 backend/)→ 其 paths 视为相对该
12
+ * 子目录(effective = `<project>/` + path);否则视为仓库根相对(根层粗粒度 map)
13
+ * - matchModuleCard:跨全部 map 最长有效前缀匹配(细卡 agent/** 胜过粗卡 backend/**)
14
+ * - resolveChangeModuleCards:tasks.md 注册表 × tasks/task-NN.md allowed_paths → per-task
15
+ * 最优卡表;卡的体量(字节数)一并给出,读取建议按体量分级(>12KB 按节读)
16
+ *
17
+ * 铁律(对齐仓内先例):
18
+ * - 纯读:不写任何文件,不跑 git
19
+ * - 无 map / 无 tasks.md / 无 task 卡 → 空结果(调用方注入占位说明,不抛)
20
+ * - 判定语义(哪个模块才是「对的」)仍归 agent——本表只是机械最优匹配
21
+ */
22
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
23
+ import { join, relative } from 'path'
24
+ import { parseModuleMapSimple } from './modules.js'
25
+ import { parseTaskRegistry } from './stages/execute.js'
26
+ import { parseAllowedPaths } from './stages/plan-postcheck.js'
27
+
28
+ /** 模块卡「整卡可读」软上限(字节)。超过则建议按节读(契约摘要/注意事项/定位),跳过历史累积段。 */
29
+ export const MODULE_CARD_SOFT_LIMIT_BYTES = 12 * 1024
30
+
31
+ /**
32
+ * 收齐 spec 下全部模块映射层。
33
+ * @param {{ cwd: string, specBase: string }} opts
34
+ * @returns {Array<{ project: string, mapPath: string, modulesDir: string, prefix: string,
35
+ * entries: Map<string, { paths: string[], doc: string|null }> }>}
36
+ * prefix:project 名为仓库顶层目录时 `<project>/`(子项目细 map),否则 ''(根层粗 map)
37
+ */
38
+ export function collectModuleMaps({ cwd, specBase }) {
39
+ const maps = []
40
+ const docsDir = join(specBase, 'docs')
41
+ if (!existsSync(docsDir)) return maps
42
+ let projects = []
43
+ try {
44
+ projects = readdirSync(docsDir, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name)
45
+ } catch { return maps }
46
+ for (const project of projects) {
47
+ const modulesDir = join(docsDir, project, 'modules')
48
+ const mapPath = join(modulesDir, '_module-map.yaml')
49
+ if (!existsSync(mapPath)) continue
50
+ let parsed = {}
51
+ try {
52
+ parsed = parseModuleMapSimple(readFileSync(mapPath, 'utf8'))
53
+ } catch { continue }
54
+ const entries = new Map()
55
+ for (const [id, data] of Object.entries(parsed)) {
56
+ const paths = Array.isArray(data?.paths) ? data.paths : []
57
+ if (paths.length === 0) continue
58
+ entries.set(id, { paths, doc: typeof data?.doc === 'string' ? data.doc : null })
59
+ }
60
+ if (entries.size === 0) continue
61
+ // 子项目判定:project 名恰为仓库顶层目录 → map 的 paths 相对该子目录(monorepo 细 map)。
62
+ // 根层 map(project = 仓名)paths 本就仓库根相对,prefix=''。
63
+ let prefix = ''
64
+ try {
65
+ const topDir = join(cwd, project)
66
+ if (existsSync(topDir) && statSync(topDir).isDirectory()) prefix = `${project}/`
67
+ } catch { /* stat 失败按根层处理 */ }
68
+ maps.push({ project, mapPath, modulesDir, prefix, entries })
69
+ }
70
+ return maps
71
+ }
72
+
73
+ /**
74
+ * 路径归一:反斜杠→正斜杠,剥尾部 glob(/** 与 /*)与目录斜杠。
75
+ * `backend/**` / `backend/` / `backend` → `backend`。
76
+ */
77
+ function normalizeMapPath(raw) {
78
+ return String(raw).replace(/\\/g, '/').replace(/\/\*\*$/, '').replace(/\/\*$/, '').replace(/\/$/, '')
79
+ }
80
+
81
+ /**
82
+ * 跨全部 map 最长有效前缀匹配一个仓库根相对路径。
83
+ * @param {string} posixPath 仓库根相对 posix 路径
84
+ * @param {Array} maps collectModuleMaps 产物
85
+ * @returns {{ moduleId: string, map: object, effectivePrefix: string }|null}
86
+ */
87
+ export function matchModuleCard(posixPath, maps) {
88
+ const p = String(posixPath).replace(/\\/g, '/')
89
+ let best = null
90
+ for (const map of maps) {
91
+ for (const [moduleId, entry] of map.entries) {
92
+ for (const raw of entry.paths) {
93
+ const effective = (map.prefix + normalizeMapPath(raw)).replace(/\/+$/, '')
94
+ if (!effective) continue
95
+ if (p === effective || p.startsWith(effective + '/')) {
96
+ if (!best || effective.length > best.effectivePrefix.length) {
97
+ best = { moduleId, map, effectivePrefix: effective }
98
+ }
99
+ }
100
+ }
101
+ }
102
+ }
103
+ return best
104
+ }
105
+
106
+ /**
107
+ * 解析变更全部 task 的最优模块卡。
108
+ * @param {{ cwd: string, specBase: string, changeName: string }} opts
109
+ * @returns {{ hasMaps: boolean, rows: Array<{ taskId: string, taskName: string,
110
+ * moduleId: string|null, granularity: 'fine'|'coarse'|null,
111
+ * cardPath: string|null, cardBytes: number, advice: string,
112
+ * matchedCount: number, pathCount: number }> }}
113
+ * granularity:按胜者有效前缀段数判——1 段(整子项目)=coarse 大卡,≥2 段=fine 细卡
114
+ */
115
+ export function resolveChangeModuleCards({ cwd, specBase, changeName }) {
116
+ const maps = collectModuleMaps({ cwd, specBase })
117
+ const changeDir = join(specBase, 'changes', changeName || '')
118
+ let registry = []
119
+ const tasksMd = join(changeDir, 'tasks.md')
120
+ if (existsSync(tasksMd)) {
121
+ registry = parseTaskRegistry(readFileSync(tasksMd, 'utf8'))
122
+ }
123
+ const rows = []
124
+ for (const task of registry) {
125
+ // 任务路径源:task 卡 allowed_paths(主)> 注册表行内 (文件路径) 尾注(兜底)
126
+ let paths = []
127
+ const cardFile = join(changeDir, 'tasks', `${task.id}.md`)
128
+ if (existsSync(cardFile)) {
129
+ paths = parseAllowedPaths(readFileSync(cardFile, 'utf8'))
130
+ }
131
+ if (paths.length === 0 && task.file) paths = [task.file]
132
+ paths = paths.map(p => p.replace(/\\/g, '/')).filter(Boolean)
133
+
134
+ // 全路径逐个匹配,取「最长有效前缀」的模块为该 task 的最优卡。粒度按有效前缀段数判:
135
+ // 1 段(backend/frontend 整子项目)=粗层大卡;≥2 段=细层卡(段数与卡体量正相关,
136
+ // SillyHub 这类根相对细 map 同样正确——不能按 map 的 prefix 有无判,会误标)
137
+ let winner = null
138
+ let matchedCount = 0
139
+ for (const p of paths) {
140
+ const m = matchModuleCard(p, maps)
141
+ if (!m) continue
142
+ matchedCount++
143
+ if (!winner || m.effectivePrefix.length > winner.effectivePrefix.length) winner = m
144
+ }
145
+ let granularity = null
146
+ if (winner) {
147
+ const segs = winner.effectivePrefix.split('/').filter(Boolean).length
148
+ granularity = segs >= 2 ? 'fine' : 'coarse'
149
+ }
150
+
151
+ let cardPath = null
152
+ let cardBytes = 0
153
+ let advice = '—'
154
+ if (winner) {
155
+ const docRel = winner.map.entries.get(winner.moduleId)?.doc || `modules/${winner.moduleId}.md`
156
+ const abs = join(winner.map.modulesDir, '..', docRel)
157
+ if (existsSync(abs)) {
158
+ const rel = relative(cwd, abs).replace(/\\/g, '/')
159
+ cardPath = rel.startsWith('..') ? abs.replace(/\\/g, '/') : rel
160
+ try { cardBytes = statSync(abs).size } catch { cardBytes = 0 }
161
+ advice = cardBytes > MODULE_CARD_SOFT_LIMIT_BYTES
162
+ ? `按节读(契约摘要/注意事项/定位;跳过变更索引/人工备注)`
163
+ : '整卡可读'
164
+ } else {
165
+ cardPath = relative(cwd, abs).replace(/\\/g, '/')
166
+ advice = '卡文件缺失(modules rebuild?)'
167
+ }
168
+ }
169
+ rows.push({
170
+ taskId: task.id,
171
+ taskName: task.name,
172
+ moduleId: winner ? winner.moduleId : null,
173
+ granularity,
174
+ cardPath,
175
+ cardBytes,
176
+ advice,
177
+ matchedCount,
178
+ pathCount: paths.length,
179
+ })
180
+ }
181
+ return { hasMaps: maps.length > 0, rows }
182
+ }
183
+
184
+ /**
185
+ * 渲染 per-task 模块卡表(注入 execute「加载上下文」步 prompt 用)。
186
+ * 无 map → 提示跳过;有 map 无命中行 → 单行说明。恒返回非空字符串(占位符必被替换)。
187
+ */
188
+ export function renderModuleResolveTable({ cwd, specBase, changeName }) {
189
+ const { hasMaps, rows } = resolveChangeModuleCards({ cwd, specBase, changeName })
190
+ if (!hasMaps) return '(未找到 _module-map.yaml——本项目无模块卡,跳过模块文档加载)'
191
+ const withMatch = rows.filter(r => r.moduleId)
192
+ if (withMatch.length === 0) {
193
+ return '(已找到 _module-map.yaml,但当前变更任务未命中任何模块 paths——跳过模块文档加载;游离文件可在 Wave 收尾后跑 `sillyspec modules rebuild`)'
194
+ }
195
+ const kb = (n) => n > 0 ? `${(n / 1024).toFixed(1)}KB` : '?'
196
+ const L = [
197
+ '| task | 模块 | 粒度 | 模块卡(只读命中的卡,勿按目录整读) | 体量 | 读取建议 |',
198
+ '|---|---|---|---|---|---|',
199
+ ]
200
+ for (const r of rows) {
201
+ if (r.moduleId) {
202
+ L.push(`| ${r.taskId} | ${r.moduleId} | ${r.granularity === 'fine' ? '细' : '粗'} | \`${r.cardPath}\` | ${kb(r.cardBytes)} | ${r.advice} |`)
203
+ } else {
204
+ L.push(`| ${r.taskId} | — | — | (无命中,跳过模块文档) | — | — |`)
205
+ }
206
+ }
207
+ L.push('')
208
+ L.push('> 匹配规则:全部 _module-map.yaml 跨层最长前缀(子项目细卡优先于根层大卡),源=tasks 卡 allowed_paths。')
209
+ L.push('> 细卡整卡可读;仅粗卡命中且体量大时**按节读**——只读「契约摘要/注意事项/定位」,跳过「变更索引/人工备注」历史累积段。')
210
+ return L.join('\n')
211
+ }
package/src/modules.js CHANGED
@@ -181,11 +181,91 @@ export async function rebuildModuleMap(cwd, { force = false } = {}) {
181
181
  return { dryRun: false, path: mapPath, modules: allModuleIds.size }
182
182
  }
183
183
 
184
+ /**
185
+ * split-changelog:把模块卡的「变更索引」历史段迁出到同目录 <module>.changelog.md sidecar
186
+ * (token 成本优化 P0b,2026-08-22-token-cost-optimization)。
187
+ *
188
+ * 背景:变更索引段每变更追加、单调膨胀(multi-agent-platform backend.md 实测 19.9KB/占整卡
189
+ * 36%,18 天 3.6 倍),而子代理实现新 task 只需要卡的「定位/契约摘要/注意事项」当前态——
190
+ * 历史段是纯读取税。迁出后卡只留指针行,新条目由 prompt 约定直接追加 sidecar。
191
+ *
192
+ * 安全(对齐 rebuild 的破坏性保护):默认 dry-run 只预览不写,--force 才落盘。
193
+ * 只动「## 变更索引」一节,卡内其他节原样保留;sidecar 已存在则追加(幂等可重跑)。
194
+ *
195
+ * @param {string} cwd 项目根
196
+ * @param {{ force?: boolean }} opts
197
+ * @returns {{ dryRun: boolean, files: Array<{ card: string, sidecar: string, bytesMoved: number }>, skipped: number }}
198
+ */
199
+ export function splitChangelog(cwd, { force = false } = {}) {
200
+ const docsDir = join(cwd, '.sillyspec', 'docs');
201
+ const results = [];
202
+ let skipped = 0;
203
+ const projects = existsSync(docsDir)
204
+ ? readdirSync(docsDir, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name)
205
+ : [];
206
+
207
+ for (const project of projects) {
208
+ const modulesDir = join(docsDir, project, 'modules');
209
+ if (!existsSync(modulesDir)) continue;
210
+ const cards = readdirSync(modulesDir).filter(f => f.endsWith('.md') && f !== '_module-map.yaml');
211
+ for (const f of cards) {
212
+ const cardPath = join(modulesDir, f);
213
+ const content = readFileSync(cardPath, 'utf8');
214
+ // 提取「## 变更索引」节(CRLF 归一后行级匹配,与 modules.js 其余解析同防线)
215
+ const lines = content.replace(/\r\n/g, '\n').split('\n');
216
+ const startIdx = lines.findIndex(l => /^##\s*变更索引\s*$/.test(l));
217
+ if (startIdx < 0) { skipped++; continue; }
218
+ let endIdx = lines.length;
219
+ for (let i = startIdx + 1; i < lines.length; i++) {
220
+ if (/^##\s/.test(lines[i])) { endIdx = i; break; }
221
+ }
222
+ const body = lines.slice(startIdx + 1, endIdx).join('\n').trim();
223
+ // 已迁出(只剩指针行)或本就为空 → 跳过
224
+ if (!body || /^见\s/.test(body) || body.includes('.changelog.md')) { skipped++; continue; }
225
+ const bytesMoved = Buffer.byteLength(body, 'utf8');
226
+ const stem = f.replace(/\.md$/, '');
227
+ const sidecarPath = join(modulesDir, `${stem}.changelog.md`);
228
+ results.push({ card: cardPath, sidecar: sidecarPath, bytesMoved, _lines: lines, _startIdx: startIdx, _endIdx: endIdx, _body: body });
229
+ }
230
+ }
231
+
232
+ if (!force) {
233
+ if (results.length === 0) {
234
+ console.log('📭 没有需要迁出的「变更索引」段(无模块卡 / 已迁出 / 段为空)');
235
+ } else {
236
+ console.log('⚠️ split-changelog 默认不写入(破坏性保护):会把模块卡「变更索引」节迁出到同目录 <module>.changelog.md');
237
+ for (const r of results) {
238
+ console.log(` ${r.card} → ${r.sidecar}(${(r.bytesMoved / 1024).toFixed(1)}KB)`);
239
+ }
240
+ console.log(' 本次为预览,未写入磁盘。确认迁出请运行:sillyspec modules split-changelog --force');
241
+ }
242
+ return { dryRun: true, files: results.map(({ card, sidecar, bytesMoved }) => ({ card, sidecar, bytesMoved })), skipped };
243
+ }
244
+
245
+ for (const r of results) {
246
+ // sidecar:已存在追加(分隔线 + 迁出来源),否则新建带头部
247
+ const header = `# ${r.card.split(/[\\/]/).pop().replace(/\.md$/, '')} 变更索引\n\n> 由 sillyspec modules split-changelog 自模块卡迁出;新条目继续追加到本文件,勿写回模块卡。\n`;
248
+ let sidecarContent = header;
249
+ if (existsSync(r.sidecar)) {
250
+ sidecarContent = readFileSync(r.sidecar, 'utf8').replace(/\s*$/, '') + '\n\n---\n\n' + r._body + '\n';
251
+ } else {
252
+ sidecarContent = header + '\n' + r._body + '\n';
253
+ }
254
+ writeFileSync(r.sidecar, sidecarContent, 'utf8');
255
+ // 卡内节体替换为指针行(只动该节,其余逐字保留;_lines 已是 LF 归一)
256
+ const stem = r.card.split(/[\\/]/).pop().replace(/\.md$/, '');
257
+ const newLines = [...r._lines.slice(0, r._startIdx + 1), '', `见 \`${stem}.changelog.md\`——历史条目已迁出;新条目直接追加 sidecar,勿写回本卡。`, '', ...r._lines.slice(r._endIdx)];
258
+ writeFileSync(r.card, newLines.join('\n'), 'utf8');
259
+ console.log(`✅ ${r.card} → ${r.sidecar}(迁出 ${(r.bytesMoved / 1024).toFixed(1)}KB,卡内留指针)`);
260
+ }
261
+ if (results.length === 0) console.log('📭 没有需要迁出的「变更索引」段');
262
+ return { dryRun: false, files: results.map(({ card, sidecar, bytesMoved }) => ({ card, sidecar, bytesMoved })), skipped };
263
+ }
264
+
184
265
  /**
185
266
  * status: 显示模块索引状态
186
267
  */
187
- export async function showModuleStatus(cwd) {
188
- const mapPath = findModuleMapPath(cwd);
268
+ export async function showModuleStatus(cwd) { const mapPath = findModuleMapPath(cwd);
189
269
  if (!mapPath || !existsSync(mapPath)) {
190
270
  console.log('📭 未找到 _module-map.yaml');
191
271
  console.log(' 提示:先运行 sillyspec run scan 生成模块文档');
@@ -88,16 +88,16 @@ export class StepStore {
88
88
  }
89
89
  const stageId = stageRow.id;
90
90
 
91
- // 重复步骤名检查
92
- const dupRow = sqlDb.prepare('SELECT id FROM steps WHERE stage_id = ? AND name = ?').get(stageId, stepName);
93
- if (dupRow !== undefined) {
94
- console.log(`ℹ️ 步骤 "${stepName}" 已存在于 ${stage}`);
95
- return;
96
- }
97
-
98
- // INSERT INTO steps(ordering 递增)
91
+ // 重复步骤名检查 + INSERT 同事务(2026-08-21 审查 BUG-8):查重在事务外时,
92
+ // 并发 add-step 同名可双双通过检查双插(steps 无 UNIQUE 约束),另一份 status 后续被 _write 覆盖丢失
93
+ let dupFound = false;
99
94
  db.transaction(() => {
100
95
  const tDb = db.getDb();
96
+ const dupRow = tDb.prepare('SELECT id FROM steps WHERE stage_id = ? AND name = ?').get(stageId, stepName);
97
+ if (dupRow !== undefined) {
98
+ dupFound = true;
99
+ return;
100
+ }
101
101
  tDb.prepare(
102
102
  `INSERT INTO steps (stage_id, name, ordering, status)
103
103
  VALUES (?, ?, (SELECT COALESCE(MAX(ordering), 0) + 1 FROM steps WHERE stage_id = ?), 'pending')`
@@ -106,6 +106,10 @@ export class StepStore {
106
106
  // 本地脏度(D-013 / task-04):添加步骤是本地推进
107
107
  this.pm._touchLocalModified(cwd, cn);
108
108
  });
109
+ if (dupFound) {
110
+ console.log(`ℹ️ 步骤 "${stepName}" 已存在于 ${stage}`);
111
+ return;
112
+ }
109
113
 
110
114
  console.log(`✅ 已添加步骤: ${stage}/${stepName}`);
111
115
  }
package/src/progress.js CHANGED
@@ -13,6 +13,7 @@
13
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
+ import { writeAtomicSync } from './fs-atomic.js';
16
17
  import { DB } from './db.js';
17
18
  import { checkExecuteCodeEvidence } from './stage-contract.js';
18
19
  import { ConsistencyDoctor } from './progress/consistency-doctor.js';
@@ -1015,9 +1016,12 @@ export class ProgressManager {
1015
1016
  if (existsSync(gitignorePath)) {
1016
1017
  const content = readFileSync(gitignorePath, 'utf8');
1017
1018
  if (content.includes(rule)) return;
1018
- writeFileSync(gitignorePath, content.trimEnd() + '\n' + rule + '\n');
1019
+ // 按既有 EOL 追加(2026-08-21 审查 BUG-9):CRLF 检出的文件混入 LF 行产生全文件
1020
+ // diff 噪声/autocrlf 折腾;写走原子 rename 防并发 init 双跑交错
1021
+ const eol = content.includes('\r\n') ? '\r\n' : '\n';
1022
+ writeAtomicSync(gitignorePath, content.trimEnd() + eol + rule + eol);
1019
1023
  } else {
1020
- writeFileSync(gitignorePath, rule + '\n');
1024
+ writeAtomicSync(gitignorePath, rule + '\n');
1021
1025
  }
1022
1026
  }
1023
1027
 
package/src/quicklog.js CHANGED
@@ -265,10 +265,18 @@ export function deriveTitleFromLinkedChange(specBase, change) {
265
265
  try { content = readFileSync(join(specBase, 'changes', change, f), 'utf8') } catch { continue }
266
266
  const m = content.match(/^#\s+(.+?)\s*$/m)
267
267
  if (!m) continue
268
- const raw = m[1].trim()
269
- // 标题惯例「# 提案书(Proposal)— <desc>」「# 设计文档(Design)— <desc>」→ 取破折号后的 desc
268
+ let raw = m[1].trim()
269
+ // 标题惯例「# 提案书(Proposal)— <desc>」「# 设计文档(Design)— <desc>」→ 取破折号后的 desc。
270
+ // 固定前缀显式剥(坑 linked-task-placeholder-title,2026-08-22 实证:标题「# 提案书(Proposal):
271
+ // xxx」或「# 提案书(Proposal)」无破折号时 dash 匹配失败,整串前缀落进 tasks.md 追加行)——
272
+ // 先剥固定模板词(含全/半角冒号分隔形态),再剥破折号;剥完为空(纯模板标题)继续找下一文档
273
+ for (const prefix of [/^提案书(Proposal)\s*[::—\-]?\s*/, /^设计文档(Design)\s*[::—\-]?\s*/, /^Proposal\s*[::—\-]?\s*/i, /^Design\s*[::—\-]?\s*/i]) {
274
+ raw = raw.replace(prefix, '').trim()
275
+ }
276
+ if (!raw) continue
270
277
  const dash = raw.match(/[—-]\s*(.+)$/)
271
- return dash ? dash[1].trim() : raw
278
+ const title = dash ? dash[1].trim() : raw
279
+ if (title) return title
272
280
  }
273
281
  return ''
274
282
  }
@@ -163,6 +163,9 @@ sillyspec run ${stageName}${stage && stage.title ? ` — ${stage.title}` : ''}${
163
163
  * sillyspec run <stage> 主命令
164
164
  */
165
165
  export async function runCommand(args, cwd, specDir = null, opts = {}) {
166
+ // 原始 cwd 快照(下方 cwd 纠正前):agent 在子目录启动时,其会话 transcript 挂在子目录
167
+ // 对应的 harness 项目目录下,agent-log 探测须用原始 cwd(纠正后的是项目根,探测会扑空)
168
+ const invokedCwd = cwd
166
169
  // W1-B: run <stage> 暂不支持 --json。之前 --json 进 knownFlags 白名单却从不读取(静默吞),
167
170
  // agent 按 gate/derive 契约期望 envelope 实则拿到混合人类文本。显式 fail-fast 杜绝歧义;
168
171
  // 完整 run envelope 待 outputStep 重构(W6)后支持(prompt + nextAction 结构化)。
@@ -366,6 +369,29 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
366
369
  // let:下方 worktree 副本漂移守卫命中时锚回 wt.mainSpecBase(task-05/D-03)
367
370
  let specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
368
371
 
372
+ // ── 本地 agent 会话日志登记 + 上报(平台会话展示用,best-effort)──
373
+ // 平台/daemon 模式下 SillyHub 只能看到 CLI 阶段信息,看不到本地 agent 的实际执行日志。
374
+ // 在此探测当前 agent 环境(Claude Code / Codex / ZCode transcript / SILLYSPEC_AGENT_LOG
375
+ // 覆盖),把日志本地路径登记进 <runtimeRoot>/agent-session-log.json(留底)并立即
376
+ // REST 上报平台(POST /api/agent-logs,与进度上报同风格;协议见
377
+ // docs/platform-agent-log-protocol.md)。探测不到不发不写;任何失败只 warn 一行,
378
+ // 绝不阻断 run 主流程。
379
+ try {
380
+ const { recordAgentLogInvocation } = await import('../agent-session-log.js')
381
+ const r = await recordAgentLogInvocation({
382
+ cwd,
383
+ invokedCwd,
384
+ platformOpts,
385
+ specBase,
386
+ // 只记 flag 名不记值(--output 等 flag 值是 agent 工作文本,不进产物)
387
+ command: [stageName, ...flags.filter(t => typeof t === 'string' && t.startsWith('--'))].join(' '),
388
+ })
389
+ if (r && r.isNew && r.latestLogPath) {
390
+ const pushNote = r.pushed === true ? '已上报平台' : r.pushed === false ? '上报失败(本地已留底)' : '本地留底'
391
+ console.log(`📄 本地 agent 日志已登记(${pushNote}): ${r.latestLogPath}`)
392
+ }
393
+ } catch { /* best-effort:登记失败不影响 run 主流程 */ }
394
+
369
395
  // 漂移提醒:cwd 祖先链 ≥2 个 .sillyspec = monorepo 多实例,当前命中的「最近」实例
370
396
  // 可能不是用户意图的项目(如 cd 进被独立 scan 的子项目跑测试后忘回根)。
371
397
  // 平台模式 / 显式 --spec-dir 跳过(已明确指定,无歧义)。仅提醒不阻断。
@@ -1076,6 +1102,20 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
1076
1102
  progress = pm.read(cwd, effectiveChange) || progress
1077
1103
  }
1078
1104
 
1105
+ // --file-notes 非末步静默忽略前置 warn(坑 quick-file-notes-nonfinal-ignored,2026-08-22 实证:
1106
+ // step2 --done 传 --file-notes 被静默丢——CLI 短进程,注入随进程结束即丢,白传一轮)。
1107
+ // 放此处:ensureStageSteps 之后 progress 已就绪可判步骤状态;仅 quick + 带了 --file-notes +
1108
+ // 非「唯一 pending 的末步 --done」时提示。末步 --done 是消费点,不提示。
1109
+ if (quickFileNotes !== '' && stageName === 'quick' && !isStatus) {
1110
+ try {
1111
+ const qSteps = progress?.stages?.quick?.steps
1112
+ const pendingCount = Array.isArray(qSteps) ? qSteps.filter(st => ['pending', 'in-progress'].includes(st?.status)).length : 0
1113
+ if (!isDone || pendingCount > 1) {
1114
+ console.warn(`⚠️ --file-notes 本次不会生效:它只在 quick 末步(暂存和更新记录)--done 时随收尾消费——CLI 是短进程,本次注入随进程结束即丢。请在末步 --done 时连同 --output 一起传。`)
1115
+ }
1116
+ } catch { /* 判定失败不提示(fail-open) */ }
1117
+ }
1118
+
1079
1119
  // --status
1080
1120
  if (isStatus) {
1081
1121
  // D9:状态展示末尾附「下一步该跑什么」。pm 持有状态机,按 progress 实际进度算出精确命令;
@@ -127,7 +127,7 @@ export function findAlreadyArchivedDir(archiveDir, changeName) {
127
127
  * 见 stage.js execute 启动固定 executeRunId / stage-review.js stageReviewMarkerPath)。
128
128
  * 从 archiveChangeDirectory 抽出,供正常归档 + 自愈归档复用。
129
129
  */
130
- async function archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platformOpts = {}) {
130
+ export async function archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platformOpts = {}) {
131
131
  // ── 清理 runId marker:execute(current-execute-run-id-<change>)与 stage-review
132
132
  // (current-stage-review-run-id-<stage>-<change>)。marker 只服务 execute→verify→archive
133
133
  // 期间,归档后无读者;不删则 .runtime 随变更数无限累积。runtimeRoot 解析同写入侧
@@ -158,7 +158,17 @@ async function archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platform
158
158
  const { WorktreeManager } = await import('../worktree.js')
159
159
  const wm = new WorktreeManager({ cwd })
160
160
  const meta = wm.getMeta(archiveChangeName)
161
- if (!meta) return
161
+ if (!meta) {
162
+ // 无 meta 兜底(坑 archive-cleanup-orphan-physical-dir,2026-08-21 实证):meta 已被先行
163
+ // 流程注销(apply 自动 cleanup / doctor 幽灵清理)时此前直接 return——若物理目录因中途
164
+ // 失败残留下就是孤儿(无 meta=无锚定基准,残留即孤儿)。force 清理(幂等:什么都不存在
165
+ // 时 cleanup 返回 skipped 零副作用)。
166
+ const orphan = wm.cleanup(archiveChangeName, { force: true })
167
+ if (orphan.result === 'cleaned' || orphan.result === 'force-cleaned' || orphan.result === 'partial') {
168
+ console.log(`🧹 归档清理孤儿 worktree 残留(meta 已注销): ${archiveChangeName}${orphan.residual?.length ? '(残留: ' + orphan.residual.join('; ') + ')' : ''}`)
169
+ }
170
+ return
171
+ }
162
172
  const check = meta.mode !== 'in-place-fallback' ? wm.hasUnappliedChanges(archiveChangeName) : { hasChanges: false }
163
173
  if (check.hasChanges) {
164
174
  console.warn(`⚠️ 归档时 worktree 仍有 ${check.changedFiles.length} 个未 apply 变更,保留 worktree`)
@@ -423,6 +433,11 @@ export async function handleArchiveConfirmStep({ stageName, steps, currentIdx, c
423
433
  }
424
434
  const archivedDir = await archiveChangeDirectory(pm, cwd, progress, specBase, platformOpts)
425
435
  if (archivedDir && existsSync(archivedDir)) {
436
+ // 内存快照同步(坑 archive-progress-show-stale,2026-08-21 实证):archiveChangeDirectory 内
437
+ // unregisterChange 已在 DB 写 current_stage='archive'(终态一致化),但本进程 progress 是命令
438
+ // 开始时读的旧快照——不同步的话,completeStep 后续 _write 会用旧值(如停在 verify)把
439
+ // DB 覆盖回去,progress show 就一直显示归档前的阶段
440
+ progress.currentStage = 'archive'
426
441
  const recommendedDocs = ['design.md', 'module-impact.md']
427
442
  const missingRecommended = recommendedDocs.filter(d => !existsSync(join(archivedDir, d)))
428
443
  if (missingRecommended.length > 0) {
@@ -167,6 +167,38 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
167
167
  steps[_staleIdx].status = 'pending'
168
168
  currentIdx = _staleIdx
169
169
  console.log(`⚠️ --confirm 收尾:把 stale 步骤 "${steps[_staleIdx].name}" 拉回当前步骤走完成管线,其余 stale 由回填门控一并确认。`)
170
+ } else if (stageData?.status !== 'completed' && steps.length > 0) {
171
+ // ── 矛盾态自愈(坑 execute-status-machine-contradiction,2026-08-23 实证:--done 报
172
+ // 「没有待完成的步骤」而 status 显示 15 步未完成——并发进程写入竞争后 DB 步骤行停在
173
+ // completed 但阶段 status 未推进的矛盾态,用户只能靠 verify 启动碰运气自愈)。
174
+ // 自愈 = 重播种:ensureStageSteps 按 def 步骤名重建步骤行(保留同名完成态),矛盾行
175
+ // (DB 有但 def 无的幽灵行/半写行)被清掉;重读后再走正常管线。自愈失败再报错给精确指引。
176
+ console.warn(`⚠️ 检测到状态机矛盾:阶段 ${stageName} 无 pending/in-progress 步骤但 status=${stageData.status}(并发写入竞争的半写态)。自动重播种修复…`)
177
+ try {
178
+ const { ensureStageSteps } = await import('./command.js')
179
+ const reseeded = await ensureStageSteps(progress, stageName, cwd, platformOpts?.specRoot || null)
180
+ if (reseeded && effectiveChange) {
181
+ pm._write(cwd, progress, effectiveChange)
182
+ progress = pm.read(cwd, effectiveChange) || progress
183
+ }
184
+ const freshSteps = progress?.stages?.[stageName]?.steps || []
185
+ const freshPending = freshSteps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
186
+ if (freshPending !== -1) {
187
+ console.log(`✅ 重播种成功:${freshSteps.filter(s => s.status === 'pending').length} 个待完成步骤恢复,继续完成管线`)
188
+ currentIdx = freshPending
189
+ steps.length = 0
190
+ steps.push(...freshSteps)
191
+ stageData.steps = freshSteps
192
+ } else {
193
+ console.error(`重播种后仍无待完成步骤(阶段 status=${progress?.stages?.[stageName]?.status ?? '未知'})。`)
194
+ console.error(` 手动修复:sillyspec run ${stageName} --reset${changeName ? ` --change ${changeName}` : ''} 从头重跑,或 sillyspec doctor 诊断。`)
195
+ process.exit(1)
196
+ }
197
+ } catch (e) {
198
+ console.error(`❌ 矛盾态自愈失败: ${e.message}`)
199
+ console.error(` 手动修复:sillyspec run ${stageName} --reset${changeName ? ` --change ${changeName}` : ''},或 sillyspec doctor`)
200
+ process.exit(1)
201
+ }
170
202
  } else {
171
203
  console.error(`没有待完成的步骤(阶段 ${stageName} 已无 pending/in-progress 步骤)。当前阶段状态:${stageData?.status ?? '未知'}。用 \`sillyspec run ${stageName} --status\` 查看进度,或 \`sillyspec progress show\` 看全局下一步。`)
172
204
  process.exit(1)
@@ -222,10 +254,13 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
222
254
  }
223
255
 
224
256
  // ── execute deps 验证硬门(change 2026-06-28-worktree-deps-provision)──
225
- await enforceDepsGate(stageName, cwd, changeName, steps[currentIdx], steps, currentIdx, specBase, platformOpts)
257
+ // persist 回调:gate 置 step=blocked 后 exit(1) 前落库(否则 blocked 只在内存,
258
+ // progress show/doctor 看到的仍是 pending,误导诊断)
259
+ const persistBlocked = () => pm._write(cwd, progress, changeName)
260
+ await enforceDepsGate(stageName, cwd, changeName, steps[currentIdx], steps, currentIdx, specBase, platformOpts, persistBlocked)
226
261
  // review.json 字段硬门(坑 review-json-field-gap):已勾 [x] task 的 review.json 必须 schema 完整,
227
262
  // 提前到每次 --done 校验(而非等 Task Review Gate 在整阶段收尾才暴露,迫使用户事后批量补)。
228
- await enforceReviewJsonGate(stageName, cwd, changeName, steps[currentIdx], steps, currentIdx, specBase, platformOpts)
263
+ await enforceReviewJsonGate(stageName, cwd, changeName, steps[currentIdx], steps, currentIdx, specBase, platformOpts, persistBlocked)
229
264
  // 符号影响面报告硬门(ql-20260816-005-3d7f):execute「加载上下文」步产出落盘核验——
230
265
  // symbol-impact.md 存在 + plan 每 task 有结论行;防前缀步被一句「上下文在会话内」盖章跳过。
231
266
  await enforceSymbolImpactGate(stageName, changeName, steps[currentIdx]?.name, specBase)
@@ -594,6 +629,22 @@ async function buildDraftContext(cwd, changeName) {
594
629
  }
595
630
  }
596
631
 
632
+ /**
633
+ * 预取 base..head 全量 diff 文件集(一次 git spawn),供草稿零 diff 守卫按 task 内存归属判定。
634
+ * 此前每草稿 task 一次 `git diff --name-only base..head -- <files>`(同一对 base..head 查 N 次,
635
+ * 8 task ≈ 8-16 次串行 spawn)。失败返回 null → 调用方回退逐 task 实测路径。
636
+ */
637
+ function prefetchDiffFileSet(ctx) {
638
+ if (!ctx?.gitDir || !ctx.base || !ctx.head) return ctx
639
+ try {
640
+ const out = gitQuiet(ctx.gitDir, ['diff', '--name-only', `${ctx.base}..${ctx.head}`], { trim: true })
641
+ if (!out) return ctx
642
+ return { ...ctx, diffFileSet: new Set(out.split('\n').filter(Boolean)) }
643
+ } catch {
644
+ return ctx
645
+ }
646
+ }
647
+
597
648
  /**
598
649
  * 判定 task 是否该被 autoCheckPlanFromReviews 自动勾选。
599
650
  * 端到端/deployment-critical task 要求 review spec+quality 双 pass(cannot_verify 不算,防批量完成
@@ -628,6 +679,16 @@ export function shouldAutoCheckTask(r, endToEnd, ctx = null) {
628
679
  console.warn(`⚠️ ctx 信息不完整(gitDir=${ctx.gitDir}, base=${ctx.base}, head=${ctx.head}),跳过草稿 diff 校验,保守不勾`)
629
680
  return false
630
681
  }
682
+ // 预取路径:一次全量 diff 文件集内存归属(autoCheckPlanFromReviews/批量路径已 prefetch)
683
+ if (ctx.diffFileSet) {
684
+ const hit = changedFiles.filter(f => ctx.diffFileSet.has(f))
685
+ if (hit.length === 0) {
686
+ console.warn(`⚠️ 草稿 review 实测 diff 为空(base=${ctx.base.slice(0, 8)}, head=${ctx.head.slice(0, 8)}, files=${changedFiles.length}),跳过自动勾选`)
687
+ return false
688
+ }
689
+ console.log(` ✓ 草稿 diff 校验通过(实测 ${hit.length}/${changedFiles.length} 个声明文件有改动)`)
690
+ return true
691
+ }
631
692
  try {
632
693
  // 实测 diff:git diff --name-only <base>..<head> -- <changedFiles>
633
694
  // 用数组参数避免 shell 拆词,复用 git-helper 安全模式
@@ -650,7 +711,12 @@ async function autoCheckPlanFromReviews({ stageName, changeName, cwd, platformOp
650
711
  return { autoChecked: false, checkedCount: 0, skippedCount: 0 }
651
712
  }
652
713
  try {
653
- const specBaseLc = platformOpts?.specRoot || join(cwd, '.sillyspec')
714
+ // 勾选写入目标漂移锚定(坑 autocheck-worktree-tasks-lost,2026-08-23 实证:agent 在
715
+ // worktree 内跑 execute --done 且 drift 守卫未拦(非平台模式+无 specDriftAnchor 透传的
716
+ // 直调路径)时,tasks.md 勾选写进 worktree 的 .sillyspec/changes/ 副本——worktree 被
717
+ // cleanup 整目录删除后勾选丢失,主仓 tasks.md 停在未勾态需手动补。runtimeRoot 已走
718
+ // resolveRuntimeRoot(drift-aware),changeDir 同步对齐:specDriftAnchor > specRoot > cwd。
719
+ const specBaseLc = platformOpts?.specDriftAnchor || platformOpts?.specRoot || join(cwd, '.sillyspec')
654
720
  const changeDir = join(specBaseLc, 'changes', changeName)
655
721
  // 2026-08-20-task-truth-unify D-001@v1:勾选写入目标从 plan.md 迁 tasks.md(任务唯一真相;
656
722
  // plan.md Wave 引用行无 checkbox,无处可勾)。tasks.md 存在性作前置。
@@ -670,7 +736,7 @@ async function autoCheckPlanFromReviews({ stageName, changeName, cwd, platformOp
670
736
  const executeRunId = c
671
737
 
672
738
  // W2 task-04 FR-03:构造 ctx 供草稿零 diff 守卫用(与 task-review.js generateTaskReviewDrafts 同源)
673
- const ctx = await buildDraftContext(cwd, changeName)
739
+ const ctx = prefetchDiffFileSet(await buildDraftContext(cwd, changeName))
674
740
 
675
741
  // tasks.md 是 agent 与 CLI 都会写的共享文件(agent 勾 checkbox、此处 autoCheck 也勾选)。
676
742
  // 读-改-写必须整体持锁(withFileLock 串行化多进程),否则并发 execute --done / 手动勾选互相覆盖
@@ -748,7 +814,7 @@ async function detectExecuteBatchFinish({ pm, stageName, changeName, cwd, specBa
748
814
  }
749
815
 
750
816
  // 构造 ctx 供草稿零 diff 校验用(与 task-04 同源)
751
- const ctx = await buildDraftContext(cwd, changeName)
817
+ const ctx = prefetchDiffFileSet(await buildDraftContext(cwd, changeName))
752
818
  const runtimeRoot = resolveRuntimeRoot({ specRoot: specBase }, specBase)
753
819
  const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
754
820
  const blockedTasks = []
@@ -783,11 +849,18 @@ async function detectExecuteBatchFinish({ pm, stageName, changeName, cwd, specBa
783
849
  blockedTasks.push(taskId)
784
850
  continue
785
851
  }
786
- // 草稿且有 changedFiles → 实测 diff 校验(ctx 与 task-04 同源)
852
+ // 草稿且有 changedFiles → 实测 diff 校验(ctx 与 task-04 同源;优先用预取全量集,
853
+ // 免去每 task 一次 spawn——与 shouldAutoCheckTask 同口径)
787
854
  if (ctx && ctx.gitDir && ctx.base && ctx.head) {
788
855
  try {
789
- const diffResult = gitQuiet(ctx.gitDir, ['diff', '--name-only', `${ctx.base}..${ctx.head}`, '--', ...changedFiles], { trim: true })
790
- if (!diffResult || diffResult.trim() === '') {
856
+ let hit = null
857
+ if (ctx.diffFileSet) {
858
+ hit = changedFiles.filter(f => ctx.diffFileSet.has(f))
859
+ } else {
860
+ const diffResult = gitQuiet(ctx.gitDir, ['diff', '--name-only', `${ctx.base}..${ctx.head}`, '--', ...changedFiles], { trim: true })
861
+ hit = diffResult && diffResult.trim() !== '' ? changedFiles : []
862
+ }
863
+ if (hit.length === 0) {
791
864
  blockedTasks.push(taskId)
792
865
  }
793
866
  } catch (e) {