sillyspec 3.25.5 → 3.25.7

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.
@@ -18,7 +18,7 @@ import { join } from 'node:path'
18
18
  import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, appendFileSync, statSync } from 'node:fs'
19
19
  import { triggerSync, WAIT_MARKER_RE, getStageSteps, formatWaitOptions } from './shared.js'
20
20
  import { outputStep } from './prompt.js'
21
- import { enforceDepsGate, runStageCompletionGates } from './gates.js'
21
+ import { enforceDepsGate, enforceReviewJsonGate, runStageCompletionGates } from './gates.js'
22
22
  import { handleArchiveConfirmStep, handlePlanGeneratePlanStep, handleScanProjectListStep, handleWorkflowPostCheck, handleQuickStageCompletion, handleExecuteWaveArtifact, handleExecuteWorktreeCleanup, handleScanStageCompleted } from './complete-handlers.js'
23
23
  import { stageRegistry } from '../stages/index.js'
24
24
  import { formatExecuteSummary } from '../worktree-apply.js'
@@ -54,6 +54,22 @@ function validateMetadata(cwd, stageName, specBase) {
54
54
  }
55
55
  }
56
56
 
57
+ /**
58
+ * 读取 design.md frontmatter 的 scale 字段(brainstorm 末步写入 'small'|'large')。
59
+ * completeStep 的下一步提示据此分叉:small→quick,large/读不到→plan(fail-safe 走重流程)。
60
+ * 只解析首个 YAML frontmatter 块,避免误读正文里的 "scale:"。
61
+ */
62
+ function readDesignScale(specBase, changeName) {
63
+ if (!changeName) return null
64
+ const designPath = join(specBase, 'changes', changeName, 'design.md')
65
+ if (!existsSync(designPath)) return null
66
+ const text = readFileSync(designPath, 'utf8')
67
+ const fm = text.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n/)
68
+ if (!fm) return null
69
+ const m = fm[1].match(/^scale:[ \t]*['"]?(\w+)/m)
70
+ return m ? m[1] : null
71
+ }
72
+
57
73
  /**
58
74
  * 验证关键文件是否存在于正确的变更目录下
59
75
  * 防止 AI 将文件写到错误的路径
@@ -100,6 +116,36 @@ function validateFileLocations(cwd, stageName, progress, changeName, specBase) {
100
116
  }
101
117
  }
102
118
 
119
+ /**
120
+ * 坑1:用 --done --answer 解掉「已 waiting 的步骤」。
121
+ *
122
+ * completeStep 的 currentIdx 选择(findIndex pending||in-progress)排除 waiting,导致
123
+ * --done --answer 落到已 --wait 暂停的 requiresWait 步骤时跳过它、--answer 静默丢失、步骤永久
124
+ * 卡 WAITING、末步报「Step N 等待用户输入」无法 finish。本函数把首个 waiting 步骤拉回 pending
125
+ * + 补 waitAnswer(对齐 continueStep 对 requiresWait 的 shouldReturnToCurrentStep=回 pending 语义),
126
+ * 让主流程 requiresWait 门控见 waitAnswer 已置→不阻断→正常 completed。
127
+ *
128
+ * @param {object[]} steps - stageData.steps(原地修改)
129
+ * @param {string|null|undefined} doneAnswer - --answer 值;空则不触发(返回 -1)
130
+ * @param {string} nowStr - 时间戳字符串(用于 waitAnswers[].answeredAt)
131
+ * @returns {number} 被解的 waiting 步骤 idx;无 waiting 或无 answer 返回 -1
132
+ */
133
+ export function resolveWaitingStepWithAnswer(steps, doneAnswer, nowStr) {
134
+ if (!doneAnswer) return -1
135
+ const waitIdx = steps.findIndex(s => s && s.status === 'waiting')
136
+ if (waitIdx === -1) return -1
137
+ const ws = steps[waitIdx]
138
+ ws.waitAnswer = doneAnswer
139
+ ws.waitAnswers = Array.isArray(ws.waitAnswers) ? ws.waitAnswers : []
140
+ ws.waitAnswers.push({ round: (ws.waitRound || 0) + 1, answer: doneAnswer, answeredAt: nowStr })
141
+ delete ws.waitReason
142
+ delete ws.waitOptions
143
+ delete ws.waitedAt
144
+ ws.status = 'pending'
145
+ ws.completedAt = null
146
+ return waitIdx
147
+ }
148
+
103
149
  export async function completeStep(pm, progress, stageName, cwd, outputText, inputText = null, options = {}) {
104
150
  const { printNext = true, confirm = false, changeName, platformOpts = {}, nonInteractive = false, confirmMode = null, isForceBaseline = false, isAllowNew = false } = options
105
151
  const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
@@ -129,7 +175,17 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
129
175
  }
130
176
 
131
177
  const steps = stageData.steps
132
- const currentIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
178
+ let currentIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
179
+ // ── 坑1:--done --answer 解「已 waiting 的步骤」──
180
+ // findIndex 仅查 pending/in-progress,排除 waiting;故 --done --answer 落到已 --wait 暂停的
181
+ // requiresWait 步骤时会跳过它、把 --answer 静默丢弃,步骤永久卡 WAITING、末步报「等待用户输入」。
182
+ // 修复:带 doneAnswer 且存在 waiting 步骤时,把首个 waiting 拉回 pending + 补 waitAnswer,主流程
183
+ // requiresWait 门控见 waitAnswer 已置→不阻断→正常 completed。仅 --answer 触发,普通 --done 零变化。
184
+ const _resolvedWaitIdx = resolveWaitingStepWithAnswer(steps, options && options.doneAnswer, new Date().toLocaleString('zh-CN', { hour12: false }))
185
+ if (_resolvedWaitIdx !== -1) {
186
+ currentIdx = _resolvedWaitIdx
187
+ console.log(`⚠️ Step "${steps[_resolvedWaitIdx].name}" 此前处于 waiting,--done --answer 已补回答并拉回待完成。`)
188
+ }
133
189
  if (currentIdx === -1) {
134
190
  console.error('没有待完成的步骤')
135
191
  process.exit(1)
@@ -174,6 +230,9 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
174
230
 
175
231
  // ── execute deps 验证硬门(change 2026-06-28-worktree-deps-provision)──
176
232
  await enforceDepsGate(stageName, cwd, changeName, steps[currentIdx], steps, currentIdx, specBase, platformOpts)
233
+ // review.json 字段硬门(坑 review-json-field-gap):已勾 [x] task 的 review.json 必须 schema 完整,
234
+ // 提前到每次 --done 校验(而非等 Task Review Gate 在整阶段收尾才暴露,迫使用户事后批量补)。
235
+ await enforceReviewJsonGate(stageName, cwd, changeName, steps[currentIdx], steps, currentIdx, specBase, platformOpts)
177
236
 
178
237
  steps[currentIdx].status = 'completed'
179
238
  steps[currentIdx].completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
@@ -226,6 +285,22 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
226
285
  if (_bf.batched && _bf.aligned > 0) {
227
286
  console.log(`\n🚀 execute 批量完成:plan 全勾 + 代码核验通过,一次性补完 ${_bf.aligned} 个剩余 step → 进入阶段完成分支`)
228
287
  }
288
+
289
+ // per-task review 草稿兜底(坑 worktree-execute-apply-friction 坑2):主 agent 直接实现模式
290
+ // 无子代理 review 落盘 → Task Review Gate 报缺 review.json 阻断。每次 execute --done 跑(幂等,
291
+ // 已存在跳过),据 git diff base..head 按 allowed_paths 归属生成 cannot_verify 草稿,进 gate 前就位。
292
+ try {
293
+ const { generateTaskReviewDrafts } = await import('../task-review.js')
294
+ const _drafts = await generateTaskReviewDrafts({ changeName, cwd, platformOpts })
295
+ if (_drafts.generated > 0) {
296
+ console.log(' 📄 自动补写 ' + _drafts.generated + ' 个 per-task review.json 草稿(cannot_verify,主 agent 实现模式兜底,需 agent 复核后升级为 pass/fail)')
297
+ if (_drafts.unattributed && _drafts.unattributed.length > 0) {
298
+ console.warn(' ⚠️ ' + _drafts.unattributed.length + ' 个变更文件未归属任何 task(顺带修复/非源码),草稿未覆盖:' + _drafts.unattributed.join(', '))
299
+ }
300
+ }
301
+ } catch (e) {
302
+ console.warn(' ⚠️ per-task review 草稿生成失败(不阻断 execute 完成):' + (e && e.message ? e.message : e))
303
+ }
229
304
  }
230
305
 
231
306
  const nextPendingIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
@@ -313,6 +388,22 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
313
388
  } else if (stageName === 'verify') {
314
389
  // verify 的"验证通过"提示延后到下方 validator 通过后才打印,
315
390
  // 避免校验失败(FAIL / 缺 verify-result.md)时仍声称"验证通过可以归档"。
391
+ } else if (stageName === 'brainstorm') {
392
+ // brainstorm 下一步按 design.md 的 scale 分叉(与末步 prompt 的 large→plan / small→quick 对齐),
393
+ // 不走下方 _getNextSuggestion —— 后者按全局状态机「第一个未完成且上游就绪的阶段」推荐,
394
+ // 当 scan 未完成时会误推 scan(回头路),与 brainstorm 已完成、应进入 plan/quick 矛盾。
395
+ // 历史教训:曾因此让 agent 在 brainstorm 完成后被误导去跑 scan。
396
+ const _bscale = readDesignScale(specBase, changeName)
397
+ if (_bscale === 'small') {
398
+ console.log(`\n👉 brainstorm 已完成(small)。下一步:sillyspec run quick --linked-changes ${changeName}`)
399
+ } else {
400
+ console.log(`\n👉 brainstorm 已完成。下一步:sillyspec run plan${changeName ? ` --change ${changeName}` : ''}(scale=large 或未标 small 走完整 plan)`)
401
+ }
402
+ } else if (stageName === 'quick') {
403
+ // quick 是收尾阶段(辅助流程,不走主链 scan→archive),完成后该提交,而非推 scan。
404
+ // 走下方 _getNextSuggestion 会因 scan 是 STAGE_ORDER 首位、永未完成而误推 scan(回头路),
405
+ // 与 brainstorm 同类问题(见上注释),故给 quick 专属分支。
406
+ console.log('\n👉 quick 已完成。下一步:提交本次改动(git commit / sillyspec-commit),或 sillyspec run <stage> 继续其他阶段。')
316
407
  } else {
317
408
  // D1 暗衔接修:阶段刚完成,CLI 本就知道状态机下一步是哪个阶段(_getNextSuggestion),
318
409
  // 却只丢一句「下一步由你决定」让 agent 自己猜命令。改为按 progress 实际状态给出精确命令。
@@ -719,7 +810,16 @@ export async function continueStep(pm, progress, stageName, cwd, answer, options
719
810
  // 阶段完成后明确下一步(agent 常卡:stageData completed 但不知要 run <下一阶段> 推进 currentStage)
720
811
  const nextStageHint = { brainstorm: 'plan', plan: 'execute', execute: 'verify', verify: 'archive' }[stageName]
721
812
  if (nextStageHint) {
722
- console.log(`\n👉 ${stageName} 已完成。下一步:sillyspec run ${nextStageHint}${changeName ? ` --change ${changeName}` : ''}`)
813
+ // brainstorm 按 design.md frontmatter 的 scale 分叉(与末步 prompt 对齐):
814
+ // small → quick --linked-changes(小变更免走完整 plan);large / 读不到 scale → plan(保守默认)。
815
+ // 修历史 bug:此处曾硬编码 brainstorm→plan 不读 scale,small 档与末步 prompt(quick)矛盾、误导小变更进 plan。
816
+ let hintStage = nextStageHint
817
+ let hintChangeFlag = changeName ? ` --change ${changeName}` : ''
818
+ if (stageName === 'brainstorm' && changeName && readDesignScale(specBase, changeName) === 'small') {
819
+ hintStage = 'quick'
820
+ hintChangeFlag = ` --linked-changes ${changeName}`
821
+ }
822
+ console.log(`\n👉 ${stageName} 已完成。下一步:sillyspec run ${hintStage}${hintChangeFlag}`)
723
823
  if (stageName === 'execute') {
724
824
  console.log(` ⚠️ 若 worktree 改动还没 apply 到主工作区,先:sillyspec worktree apply ${changeName}`)
725
825
  console.log(` (apply 不需要先 commit,支持 working tree 未提交改动)`)
package/src/run/gates.js CHANGED
@@ -96,6 +96,39 @@ export async function enforceDepsGate(stageName, cwd, changeName, step, steps, c
96
96
  }
97
97
  process.exit(1)
98
98
  }
99
+
100
+ /**
101
+ * execute --done 硬门:已勾 [x] task 的 review.json 必须 schema 完整(坑 review-json-field-gap)。
102
+ *
103
+ * Task Review Gate(validateTaskReviews)只在 execute 整阶段完成时跑(complete.js 阶段完成分支的
104
+ * actualCompleted===actualTotal 守卫),单 task --done 不校验 → 子代理勾 checkbox 却漏写/漏字段
105
+ * review.json,要到收尾才暴露,用户被迫事后批量补。本门提前到每次 --done:校验 plan 里所有已勾
106
+ * task 的 review.json,缺字段/不存在/JSON 坏 → 置 step=blocked + exit(1),与 enforceDepsGate 同范式。
107
+ * 未勾 task 不校验(还没做)。平台模式/无 marker/无 plan 时放行(下游 Task Review Gate 兜底)。
108
+ */
109
+ export async function enforceReviewJsonGate(stageName, cwd, changeName, step, steps, currentIdx, specBase, platformOpts) {
110
+ if (stageName !== 'execute' || !changeName) return true
111
+ const runtimeRoot = platformOpts?.runtimeRoot || join(specBase, '.runtime')
112
+ const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
113
+ const planPath = join(specBase, 'changes', changeName, 'plan.md')
114
+ if (!existsSync(runIdFile) || !existsSync(planPath)) return true
115
+ const executeRunId = readFileSync(runIdFile, 'utf8').trim()
116
+ const planContent = readFileSync(planPath, 'utf8')
117
+ const { validateCheckedTaskReviews } = await import('../task-review.js')
118
+ const result = validateCheckedTaskReviews({ planContent, runtimeRoot, executeRunId })
119
+ if (result.ok) return true
120
+ if (steps && steps[currentIdx]) steps[currentIdx].status = 'blocked'
121
+ console.error('❌ ── review.json 字段校验阻断(本次 --done 未完成,进度未推进)──')
122
+ console.error(' 已勾选 [x] 的 task review.json 不完整(铁律:勾 checkbox 前必须先写完整 review.json):')
123
+ for (const f of result.failures) {
124
+ const kindLabel = f.kind === 'missing' ? 'review.json 不存在' : (f.kind === 'parseError' ? 'JSON 解析失败' : '字段缺失')
125
+ console.error(` • ${f.taskId}(${kindLabel}): ${f.reviewPath}`)
126
+ for (const e of f.errors) console.error(` - ${e}`)
127
+ }
128
+ console.error(' 修复:补全上述 review.json 字段后重跑 execute --done。')
129
+ process.exit(1)
130
+ }
131
+
99
132
  /**
100
133
  * 阶段完成校验失败时回滚状态。
101
134
  *
@@ -183,6 +216,10 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
183
216
  const { runVerifyParityCheck, printVerifyParityCheck } = await import('../verify-postcheck.js')
184
217
  const parityCheck = runVerifyParityCheck({ cwd, specBase, changeName, runtimeRoot: platformOpts?.runtimeRoot })
185
218
  printVerifyParityCheck(parityCheck)
219
+ // ── 删除探针(advisory,不阻断):切斯特顿栅栏护栏,用 git 事实对账静默删除代码 ──
220
+ const { runVerifyDeletionCheck, printVerifyDeletionCheck } = await import('../verify-postcheck.js')
221
+ const deletionCheck = runVerifyDeletionCheck({ cwd, specBase, changeName })
222
+ printVerifyDeletionCheck(deletionCheck)
186
223
  console.log('\n✅ 验证通过,下一步:sillyspec run archive')
187
224
  }
188
225
 
@@ -34,7 +34,14 @@ export function printQuickAuditReview(review) {
34
34
  console.warn(` - ${r}`)
35
35
  }
36
36
  } else {
37
- console.log(`\n✅ quick 变更边界审计 — SAFE (变更 ${review.changedFiles.length} 个文件)`)
37
+ // 区分「本轮新增」(changedFiles,已扣前序 baseline)vs「累计暂存」(stagedTotal,全部未提交)。
38
+ // 仅当存在前序 baseline 残留(累计 > 本轮新增)时才追加括注,避免普通场景冗余。
39
+ const stagedTotal = typeof review.stagedTotal === 'number' ? review.stagedTotal : review.changedFiles.length
40
+ if (stagedTotal > review.changedFiles.length) {
41
+ console.log(`\n✅ quick 变更边界审计 — SAFE (本轮新增变更 ${review.changedFiles.length} 个文件;累计暂存 ${stagedTotal} 个,含前序 baseline ${stagedTotal - review.changedFiles.length} 个未计入本轮)`)
42
+ } else {
43
+ console.log(`\n✅ quick 变更边界审计 — SAFE (本轮新增变更 ${review.changedFiles.length} 个文件)`)
44
+ }
38
45
  }
39
46
  }
40
47
 
@@ -15,23 +15,36 @@ import { safeGit } from './shared.js'
15
15
  /**
16
16
  * 估算源码规模(文件数 + 字节数)—— 跨平台 Node 遍历,替代 Unix `find`(原生 Windows 无 find,
17
17
  * 原 find 失效会让所有 Windows 项目永远 fallback 到 standard profile)。
18
- * 扩展名与排除目录与原 find 命令逐字一致;maxFiles 封顶避免超大仓库遍历过久
19
- * (超限时 fileCount 已远超 quick/standard 阈值,不影响 profile 判定)。
18
+ * 扩展名限定源码后缀;skipDirs 排除依赖/构建产物/覆盖率等非源码目录;maxFiles 封顶避免超大仓库遍历过久
19
+ * (超限时 fileCount 已远超 quick/standard 阈值,不影响 profile 判定);maxDepth 兜底防极深嵌套产物。
20
20
  */
21
- export function estimateSourceSize(cwd, maxFiles = 5000) {
21
+ export function estimateSourceSize(cwd, maxFiles = 5000, maxDepth = 6) {
22
22
  const sourceExts = new Set(['.js','.ts','.tsx','.py','.java','.go','.rs','.rb','.php','.c','.cpp','.h','.jsx','.vue','.svelte'])
23
- const skipDirs = new Set(['node_modules','.git','dist','build','__pycache__','.sillyspec','.claude'])
23
+ // skipDirs:排除依赖、版本控制、构建产物、覆盖率、临时目录等非源码目录。
24
+ // 原清单只排了 node_modules/.git/dist/build 等,漏了 .next/.nuxt/coverage/.svelte-kit 等构建产物,
25
+ // 导致带构建产物的小项目被产物里的海量 .js 拉高 fileCount/sourceBytes → 误判 deep。
26
+ const skipDirs = new Set([
27
+ 'node_modules', '.git', 'dist', 'build', 'out', 'target',
28
+ '__pycache__', '.venv', 'venv',
29
+ '.next', '.nuxt', '.output', '.svelte-kit', '.astro', '.turbo', '.parcel-cache',
30
+ 'coverage', '.nyc_output', '.vitest', '.cache',
31
+ 'vendor', 'bower_components',
32
+ 'dist-types',
33
+ '.sillyspec', '.claude',
34
+ 'tmp', 'temp', '.tmp', 'logs',
35
+ ])
24
36
  let fileCount = 0
25
37
  let sourceBytes = 0
26
- const stack = [cwd]
38
+ // stack 存 [dir, depth]:maxDepth 兜底,防极深嵌套的产物/生成目录在 skipDirs 漏网时被全遍历。
39
+ const stack = [[cwd, 0]]
27
40
  while (stack.length) {
28
- const dir = stack.pop()
41
+ const [dir, depth] = stack.pop()
29
42
  let entries
30
43
  try { entries = readdirSync(dir, { withFileTypes: true }) }
31
44
  catch { continue }
32
45
  for (const e of entries) {
33
46
  if (e.isDirectory()) {
34
- if (!skipDirs.has(e.name)) stack.push(join(dir, e.name))
47
+ if (!skipDirs.has(e.name) && depth < maxDepth) stack.push([join(dir, e.name), depth + 1])
35
48
  } else if (e.isFile() && sourceExts.has(extname(e.name))) {
36
49
  fileCount++
37
50
  try { sourceBytes += statSync(join(dir, e.name)).size } catch {}
@@ -49,8 +62,14 @@ export function estimateSourceSize(cwd, maxFiles = 5000) {
49
62
  * deep: 大项目或 --deep → 完整流程
50
63
  */
51
64
  export function computeScanProfile(cwd, platformOpts) {
52
- // --deep 标志强制 deep
65
+ // 显式 profile flag 优先于自动判定(三档互斥由 command.js 检测;此处取首个命中即可)
53
66
  const flags = process.argv.slice(2)
67
+ if (flags.includes('--quick')) {
68
+ return { mode: 'quick', reason: '用户指定 --quick', maxAgentCalls: 0, maxDocs: 5 }
69
+ }
70
+ if (flags.includes('--standard')) {
71
+ return { mode: 'standard', reason: '用户指定 --standard', maxAgentCalls: 1, maxDocs: 8 }
72
+ }
54
73
  if (flags.includes('--deep')) {
55
74
  return { mode: 'deep', reason: '用户指定 --deep', maxAgentCalls: 4, maxDocs: 99 }
56
75
  }
@@ -115,7 +134,7 @@ export function applyScanProfileSteps(stageData, profile, cwd, platformOpts) {
115
134
  status: 'pending',
116
135
  prompt: `## Quick Scan — 核心文档生成
117
136
 
118
- 项目规模较小(quick profile),请一次性生成所有核心文档。
137
+ 当前为 quick profile(小项目自动判定 或 用户显式 --quick),一次性生成核心文档用于快速接入。
119
138
 
120
139
  ### 操作
121
140
  1. 读取项目结构和关键文件(package.json / pyproject.toml / README / 入口文件)
@@ -126,7 +145,7 @@ export function applyScanProfileSteps(stageData, profile, cwd, platformOpts) {
126
145
  - **STRUCTURE.md** — 目录树 + 模块说明
127
146
  3. 如发现子项目,注册到 \`{PROJECTS_ROOT}/\` 下
128
147
 
129
- 每份文档头必须包含 frontmatter:\`author\` 和 \`created_at\`。
148
+ 每份文档 frontmatter 必须包含:\`author\`、\`created_at\`、\`scan_depth: quick\`(标记快速接入的浅层版本;后续深度扫描 --deep 会识别此标记并覆盖升级为完整文档)。
130
149
 
131
150
  ### ⛔ 硬约束
132
151
  - **严禁使用子代理(Agent/Task 工具)。** 所有文档在一个 turn 内完成。
package/src/run/shared.js CHANGED
@@ -9,8 +9,8 @@
9
9
  * - triggerSync 的动态 import './sync.js' → '../sync.js'(src/sync.js)
10
10
  * - safeGit 原用顶层 require('child_process'),改静态 import execSync(更 ESM-native)
11
11
  */
12
- import { basename, join, resolve, dirname } from 'node:path'
13
- import { existsSync, readdirSync, readFileSync } from 'node:fs'
12
+ import { basename, join, resolve, dirname, sep } from 'node:path'
13
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
14
14
  import { execSync } from 'node:child_process'
15
15
  import { fileURLToPath } from 'node:url'
16
16
 
@@ -104,6 +104,156 @@ export function resolveSpecDir(cwd, opts = {}) {
104
104
  return join(resolve(cwd), '.sillyspec')
105
105
  }
106
106
 
107
+ /**
108
+ * 枚举 cwd 祖先链(含自身)上所有 .sillyspec 目录的绝对路径,上界 = git root。
109
+ *
110
+ * 返回顺序:从 cwd 最近开始向上到 git root(含两端命中项)。单实例项目任意子目录恒返回 [自身]。
111
+ *
112
+ * 上界 = git root(monorepo 天然边界)。必须有限上界:一路数到文件系统根会撞 home 等
113
+ * 无关祖先的孤立 .sillyspec(home 下任何项目都被误报多实例)。非 git 仓库:不向上数,
114
+ * 只看 cwd 自身(≤1,永不误报)。
115
+ *
116
+ * 被 countAncestorSpecDirs(漂移提醒 warn)与 locateQuickSessionGuard(quick 漂移 fail-fast
117
+ * 守卫,坑 quick-cwd-drift-splits-specdir)共用 —— 单一真相源,避免两处各自重写祖先枚举漂移。
118
+ */
119
+ export function ancestorSpecDirs(cwd) {
120
+ const resolved = resolve(cwd)
121
+ // 上界 = git root。但 linked worktree 内 --show-toplevel 返回 worktree 根(非主仓根),
122
+ // 祖先链到不了主仓 .sillyspec → 漂移提醒/quick 守卫全哑(坑 worktree-execute-spec-drift)。
123
+ // 复刻 worktree.js _resolveMainRepoRoot:--git-common-dir 的 dirname 才是主仓根,取更靠上者作 ceiling。
124
+ // (git 可能返回相对路径,须 resolve(cwd, commonDir) 绝对化,否则相对 process.cwd 误解析。)
125
+ const topLevel = safeGit(resolved, ['rev-parse', '--show-toplevel']).value
126
+ let ceiling = topLevel ? resolve(topLevel) : null
127
+ const commonDir = safeGit(resolved, ['rev-parse', '--git-common-dir']).value
128
+ if (commonDir) {
129
+ const absCommonDir = resolve(resolved, commonDir)
130
+ if (existsSync(absCommonDir) && statSync(absCommonDir).isDirectory()) {
131
+ const mainRoot = dirname(absCommonDir)
132
+ // 主仓根是 worktree 根的祖先时取它(更靠上);monorepo/单仓两者相等不替换,行为不变
133
+ if (!ceiling || resolve(ceiling).startsWith(resolve(mainRoot) + sep)) ceiling = mainRoot
134
+ }
135
+ }
136
+ const dirs = []
137
+ let dir = resolved
138
+ while (true) {
139
+ if (existsSync(join(dir, '.sillyspec'))) dirs.push(join(dir, '.sillyspec'))
140
+ if (!ceiling || resolve(dir) === resolve(ceiling)) break
141
+ const parent = dirname(dir)
142
+ if (parent === dir) break
143
+ dir = parent
144
+ }
145
+ return dirs
146
+ }
147
+
148
+ /**
149
+ * 统计 cwd 祖先链上有多少个 .sillyspec 目录(= ancestorSpecDirs(cwd).length)。
150
+ *
151
+ * ≥2 个 = monorepo 多实例(子项目独立 .sillyspec + 根 .sillyspec 并存),resolveSpecDir 命中的
152
+ * 「最近」实例未必是用户意图的项目(如 cd 进被独立 scan 的子项目跑测试后忘回根)→ 漂移风险。
153
+ * 单实例项目在任意子目录工作恒返回 1,不误报。
154
+ */
155
+ export function countAncestorSpecDirs(cwd) {
156
+ return ancestorSpecDirs(cwd).length
157
+ }
158
+
159
+ /**
160
+ * 在 cwd 祖先链的各 specBase 中查找 quick 会话 guard.json(坑 quick-cwd-drift-splits-specdir)。
161
+ *
162
+ * sessionId(quick-<uuid8>)全局唯一,但 guard.json 按 specBase 分散落盘
163
+ * (<specBase>/.runtime/quick-sessions/<sessionId>/guard.json)。cd 漂移到子项目后,当前 specBase
164
+ * 下读不到本 session 的 guard,但祖先链别处的 specBase(创建 session 的根)仍有它 → 据此定位
165
+ * session 真正归属,供 detectQuickSessionDrift 的 fail-fast 守卫判断。
166
+ *
167
+ * 遍历顺序 = ancestorSpecDirs(cwd 最近 → git root),返回第一个命中。
168
+ *
169
+ * @param {string} cwd
170
+ * @param {string} sessionId - quick-<uuid8>
171
+ * @returns {{ specBase: string, guard: object } | null}
172
+ */
173
+ export function locateQuickSessionGuard(cwd, sessionId) {
174
+ if (!sessionId) return null
175
+ for (const specBase of ancestorSpecDirs(cwd)) {
176
+ const guardFile = join(specBase, '.runtime', 'quick-sessions', sessionId, 'guard.json')
177
+ if (existsSync(guardFile)) {
178
+ try {
179
+ const guard = JSON.parse(readFileSync(guardFile, 'utf8'))
180
+ return { specBase, guard }
181
+ } catch {
182
+ // guard.json 损坏:当作不存在,继续找下一个(不阻断主流程)
183
+ }
184
+ }
185
+ }
186
+ return null
187
+ }
188
+
189
+ /**
190
+ * 检测 quick 会话是否跨 specDir 漂移(坑 quick-cwd-drift-splits-specdir)。
191
+ *
192
+ * quick 被 validateChangeExists 的 sessionId 豁免(quick-<8hex> 不在 changes/ 下),故 quick
193
+ * 漂移除 countAncestorSpecDirs 的 warn 外无硬守卫 → 无声分裂(progress/artifact/QUICKLOG 落
194
+ * 子项目、根 session 停滞)。本函数补这个口:
195
+ * - 当前 specBase 已有本 session guard → null(同一 specDir,无漂移)
196
+ * - 当前 specBase 没有,但祖先链别处有同 sessionId guard → 判漂移,返回引导信息供调用方 fail-fast
197
+ * - 别处也没有 → null(真·新会话首次启动,guard 尚未建,放行 —— 不误伤子项目主动启新 quick)
198
+ *
199
+ * 风格仿 validateChangeExists:null = 通过,对象 = 失败(含 message)。
200
+ *
201
+ * @param {string} cwd
202
+ * @param {string} currentSpecBase - 当前命中的 specBase
203
+ * @param {string} sessionId - quick-<uuid8>
204
+ * @returns {{ realSpecBase: string, message: string } | null}
205
+ */
206
+ export function detectQuickSessionDrift(cwd, currentSpecBase, sessionId) {
207
+ if (!sessionId || !currentSpecBase) return null
208
+ // 当前 specBase 已有本 session guard → 同一 specDir,无漂移
209
+ const currentGuard = join(currentSpecBase, '.runtime', 'quick-sessions', sessionId, 'guard.json')
210
+ if (existsSync(currentGuard)) return null
211
+ // 当前没有 → 看祖先链别处有没有同 sessionId 的 guard
212
+ const located = locateQuickSessionGuard(cwd, sessionId)
213
+ if (!located) return null // 别处也没有 = 真·新会话首次启动,放行
214
+ return {
215
+ realSpecBase: located.specBase,
216
+ message: `quick 会话 "${sessionId}" 创建于 ${located.specBase},当前 cwd 解析到 ${currentSpecBase}(跨 specDir 漂移)。` +
217
+ `继续会把 progress/artifact/QUICKLOG 写到错误的 spec,与原会话分裂。` +
218
+ `排查:① cd 回 ${dirname(located.specBase)}(原 spec 所属项目根)再跑;② 或显式 --spec-dir ${located.specBase} 指定原 spec。`,
219
+ }
220
+ }
221
+
222
+ /**
223
+ * 检测 specBase 是否命中 worktree 内 checkout 出来的 .sillyspec 副本(坑 worktree-execute-spec-drift)。
224
+ *
225
+ * worktree 根 = <mainRepo>/.sillyspec/.runtime/worktrees/<changeName>/(worktree.js WORKTREES_REL)。
226
+ * worktree 是主仓完整 checkout,若 .sillyspec/changes/ 被跟踪 → 副本里 change 目录真实存在,
227
+ * validateChangeExists 被骗放行 → 进度/产出写进副本,与主仓 .sillyspec 分裂(副本随工作树清理丢失)。
228
+ *
229
+ * 判据:specBase 路径含 `.sillyspec/.runtime/worktrees/<seg>/.sillyspec`。主仓 specBase=<mainRepo>/.sillyspec
230
+ * 无此段 → null,零误伤。返回 null=通过;对象=漂移(含 mainSpecBase/changeName/message),风格仿
231
+ * validateChangeExists。覆盖 plan/execute/verify/archive(同 Set)——都要求 change 已存在、都会被副本骗过。
232
+ * 平台模式/--spec-dir 已明确指定,由调用方跳过。
233
+ */
234
+ export function detectWorktreeSpecDrift(specBase) {
235
+ if (!specBase) return null
236
+ const seg = resolve(specBase).split(sep)
237
+ // specBase 恒以 .sillyspec 结尾(command.js specBase=join(cwd,'.sillyspec'))。worktree 副本形如
238
+ // <mainRepo>/.sillyspec/.runtime/worktrees/<change>/.sillyspec —— 尾段也须是 .sillyspec,
239
+ // 否则 worktree 根目录本身(<.../worktrees/<change>)会被误判(它不是 spec,不会作 specBase 传入)。
240
+ if (seg[seg.length - 1] !== '.sillyspec') return null
241
+ for (let i = 0; i + 3 < seg.length; i++) {
242
+ if (seg[i] === '.sillyspec' && seg[i + 1] === '.runtime' && seg[i + 2] === 'worktrees') {
243
+ const changeName = seg[i + 3]
244
+ const mainSpec = seg.slice(0, i + 1).join(sep) // <mainRepo>/.sillyspec
245
+ return {
246
+ changeName,
247
+ mainSpecBase: mainSpec,
248
+ message: `当前 spec 命中 worktree 副本(${specBase})——这是 ${changeName} 隔离工作树内 checkout 出来的 .sillyspec,不是主仓 spec。\n` +
249
+ ` 在此跑 plan/execute/verify/archive 会把进度与产出写到副本,与主仓 .sillyspec 分裂(副本随工作树清理而丢失)。\n` +
250
+ ` 排查:① cd 回 ${dirname(mainSpec)}(主仓根)再跑;② 或 --spec-dir ${mainSpec} 显式指定主仓 spec。`,
251
+ }
252
+ }
253
+ }
254
+ return null
255
+ }
256
+
107
257
  /**
108
258
  * 统一查找变更目录(与 progress.js 的变更检测逻辑一致)。
109
259
  */
@@ -128,6 +278,11 @@ export function resolveChangeDir(cwd, progress, specDir = null) {
128
278
  /**
129
279
  * 触发 sync(平台模式走自己的链路,跳过;否则 await import sync.js)。
130
280
  */
281
+ // sync 总超时熔断:sync.js 是 best-effort 后台回传(每请求已有 10s 超时),但 sync() 可能串行多次
282
+ // fetchJson 累积等待、阻塞 --done。给 8s 总超时,超时放弃(best-effort,失败不影响正确性,下次 --done 重试)。
283
+ // 历史痛点:--done 在 sync 慢时体感 hang,用户被迫用外部 timeout 兜底。
284
+ const SYNC_TOTAL_TIMEOUT_MS = 8_000
285
+
131
286
  export async function triggerSync(cwd, changeName, platformOpts = {}) {
132
287
  // 平台模式(SillyHub)走自己的回传链路,不走 CLI 内置 sync
133
288
  if (platformOpts?.specRoot || platformOpts?.runtimeRoot) return
@@ -135,7 +290,15 @@ export async function triggerSync(cwd, changeName, platformOpts = {}) {
135
290
  if (changeName && !existsSync(join(cwd, '.sillyspec', 'changes', changeName))) return
136
291
  // shared.js 在 src/run/,sync.js 在 src/ → 退一层
137
292
  const syncMod = await import('../sync.js')
138
- await syncMod.sync(changeName, cwd)
293
+ let timer
294
+ try {
295
+ await Promise.race([
296
+ syncMod.sync(changeName, cwd),
297
+ new Promise((resolve) => { timer = setTimeout(resolve, SYNC_TOTAL_TIMEOUT_MS) }),
298
+ ])
299
+ } finally {
300
+ clearTimeout(timer) // sync 先完成则清掉未触发的 timer,避免泄漏
301
+ }
139
302
  } catch (e) {
140
303
  // sync.js 不存在或同步失败,静默跳过
141
304
  console.warn('⚠️ 同步失败:', e.message)
@@ -222,12 +385,15 @@ export function isQuickMetadata(p, linkedChanges = []) {
222
385
 
223
386
  /**
224
387
  * quick 完成审计:对比 baseline 与实际变更。
225
- * @returns {{ status: 'safe'|'warning'|'blocked', reasons: string[], changedFiles: string[], newFiles: string[], deletedFiles: string[], baselineHit: string[] }}
388
+ * @returns {{ status: 'safe'|'warning'|'blocked', reasons: string[], changedFiles: string[], newFiles: string[], deletedFiles: string[], baselineHit: string[], stagedTotal: number }}
226
389
  */
227
390
  export async function auditQuickCompletion(cwd, guard, options = {}) {
228
391
  const { baselineFiles, allowedFiles = [], allowNew = false, forceBaseline = false } = guard
229
392
  const { isConfirm } = options
230
- const result = { status: 'safe', reasons: [], changedFiles: [], newFiles: [], deletedFiles: [], baselineHit: [] }
393
+ // stagedTotal:当前所有非 quick 元数据的未提交条目(含前序 baseline 残留)。
394
+ // 与 changedFiles(扣 baseline 后的本轮新增)区分,供审计文案同时展示「本轮新增 vs 累计暂存」,
395
+ // 避免叠加 quick 会话时把前序会话未提交文件误读为「本会话只动了 N 个」。
396
+ const result = { status: 'safe', reasons: [], changedFiles: [], newFiles: [], deletedFiles: [], baselineHit: [], stagedTotal: 0 }
231
397
 
232
398
  try {
233
399
  const gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 10000 })
@@ -268,6 +434,10 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
268
434
  const file = parsePorcelainPath(entry) // 已去引号/处理 rename/归一化,修正首行丢首字符
269
435
  if (!file) continue
270
436
 
437
+ // 累计暂存计数:所有非 quick 元数据的未提交条目(含下方将被 baseline 跳过的前序残留)。
438
+ // 放在 isBaselineFile 跳过之前,确保 baseline 内文件也计入「累计暂存」。
439
+ if (!isQuickMetadata(file, guard.linkedChanges)) result.stagedTotal++
440
+
271
441
  // 预存脏文件:step1 baseline 已记录,非本次 quick 产生,跳过审计(含折叠目录前缀匹配)
272
442
  if (isBaselineFile(file)) continue
273
443
 
package/src/run/stage.js CHANGED
@@ -22,7 +22,7 @@ import { writeAtomicSync } from '../fs-atomic.js'
22
22
  import { resolveSpecDir, resolveChangeDir, triggerSync, safeGit, parsePorcelainPath, formatWaitOptions, checkApproval, getStageSteps } from './shared.js'
23
23
  import { computeScanProfile, applyScanProfileSteps, executeScanPreflight, executeScanPostcheck } from './scan-profile.js'
24
24
  import { outputStep } from './prompt.js'
25
- import { allocateQuicklogEntry } from '../quicklog.js'
25
+ import { allocateQuicklogEntry, deriveTitleFromLinkedChange } from '../quicklog.js'
26
26
  import { checkTransition } from '../stage-contract.js'
27
27
 
28
28
  export async function runStage(pm, progress, stageName, cwd, changeName, skipApproval = false, platformOpts = {}, quickOpts = {}) {
@@ -239,13 +239,23 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
239
239
  const linkedChanges = Array.isArray(quickOpts?.linkedChanges) ? quickOpts.linkedChanges : []
240
240
  // CLI 接管:分配 ql-ID + 写 QUICKLOG「进行中」条目 + 关联 tasks.md(持锁、当天唯一)
241
241
  const gitUser = safeGit(cwd, ['config', 'user.name']).value || 'unknown'
242
+ // 标题回退:启动 quick 不带 --input 时,从关联变更的 proposal/design 提取语义标题,
243
+ // 避免 QUICKLOG/tasks.md 落 (quick 任务) 占位(deriveTitleFromLinkedChange 读不到则空→sanitizeDesc 回退占位)。
244
+ let quickDesc = quickOpts?.taskDescription || ''
245
+ if (!quickDesc && linkedChanges.length > 0) {
246
+ quickDesc = deriveTitleFromLinkedChange(specBase, linkedChanges[0])
247
+ }
242
248
  const { qlId } = await allocateQuicklogEntry(specBase, gitUser, {
243
- description: quickOpts?.taskDescription || '',
249
+ description: quickDesc,
244
250
  linkedChanges,
245
251
  allowedFiles,
246
252
  })
247
253
  progress.quickGuard = {
248
254
  sessionId: changeName,
255
+ // 锚定创建时的 specBase(坑 quick-cwd-drift-splits-specdir):sessionId 全局复用但
256
+ // guard 按 specDir 落盘,记下归属让漂移可自证(detectQuickSessionDrift 靠目录定位即可,
257
+ // 此字段供诊断/未来一致性校验)。
258
+ specDir: specBase,
249
259
  name_zh: '快速任务守卫',
250
260
  baselineCommit: safeGit(cwd, ['rev-parse', 'HEAD']).value,
251
261
  baselineFiles,