sillyspec 3.23.8 → 3.24.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.
package/src/run.js CHANGED
@@ -9,8 +9,28 @@ import { existsSync, readdirSync, mkdirSync, writeFileSync, appendFileSync, read
9
9
  import { randomBytes, randomUUID } from 'crypto'
10
10
  import { createRequire } from 'module'
11
11
  const require = createRequire(import.meta.url)
12
+ import { fileURLToPath } from 'url'
13
+ const __dirname = dirname(fileURLToPath(import.meta.url))
14
+
15
+ /**
16
+ * 解析 prompt 中的 {{include: <name>}} 占位符:读包内 templates/prompts/<name>.md 注入。
17
+ * 把 stage step prompt 里 self-contained 的大块(如 verify 探针)抽到外部模板,
18
+ * CLI 端组装时注入——agent 收到的仍是自包含 prompt 字符串,无需自己 Read。
19
+ * 单次替换(不递归);模板缺失则保留占位符并 warn,便于发现配置错误。
20
+ */
21
+ function resolvePromptIncludes(text) {
22
+ return text.replace(/\{\{include:\s*([\w.-]+)\s*\}\}/g, (match, name) => {
23
+ const tplPath = join(__dirname, '..', 'templates', 'prompts', `${name}.md`)
24
+ if (!existsSync(tplPath)) {
25
+ console.warn(`⚠️ prompt include 模板缺失: ${name} (期望: ${tplPath})`)
26
+ return match
27
+ }
28
+ return readFileSync(tplPath, 'utf8')
29
+ })
30
+ }
12
31
  import { ProgressManager } from './progress.js'
13
32
  import { SCAN_STATUS, POINTER_STATUS, isPointerCorrupted } from './constants.js'
33
+ import { allocateQuicklogEntry, completeQuicklogEntry, findQuicklogEntry, validateQuickResult } from './quicklog.js'
14
34
  import { checkbox } from '@inquirer/prompts'
15
35
 
16
36
  /**
@@ -158,7 +178,7 @@ function resolveSpecDir(cwd, opts = {}) {
158
178
  return join(resolve(cwd), '.sillyspec')
159
179
  }
160
180
  import { stageRegistry, auxiliaryStages } from './stages/index.js'
161
- import { checkTransition, runValidators } from './stage-contract.js'
181
+ import { checkTransition, runValidators, validateChangeExists } from './stage-contract.js'
162
182
  import { buildExecuteSteps } from './stages/execute.js'
163
183
  import { buildPlanSteps } from './stages/plan.js'
164
184
  import { formatExecuteSummary } from './worktree-apply.js'
@@ -676,7 +696,8 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
676
696
  console.log(`changeDir: ${changeDir}`)
677
697
  }
678
698
  console.log(`---\n`)
679
- if (personas[stageName]) {
699
+ // persona 只在 stage 首步注入(step0)——角色设定一次即可,后续 step 重复注入纯属 token 浪费
700
+ if (personas[stageName] && stepIndex === 0) {
680
701
  console.log(personas[stageName])
681
702
  console.log('')
682
703
  }
@@ -689,7 +710,9 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
689
710
  console.log(guardrails.trim())
690
711
  console.log('')
691
712
  }
692
- let promptText = step.prompt
713
+ // 先解析 {{include: name}}(把外部模板片段拉进 prompt),再做下方占位符替换,
714
+ // 保证模板内容里的 {SPEC_ROOT}/<change-name> 等也能被替换
715
+ let promptText = resolvePromptIncludes(step.prompt)
693
716
  // 替换 prompt 中的占位符
694
717
  if (projectName && promptText.includes('<project>')) {
695
718
  promptText = promptText.replace(/<project>/g, projectName)
@@ -721,6 +744,19 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
721
744
  if (changeName && promptText.includes('<quick-session-id>')) {
722
745
  promptText = promptText.replace(/<quick-session-id>/g, changeName)
723
746
  }
747
+ // 替换 <quicklog-id> 占位符(quick 阶段:从 session guard.json 读 CLI 分配的 ql-ID,
748
+ // 供 agent 在模块文档变更索引等处引用)
749
+ if (promptText.includes('<quicklog-id>')) {
750
+ const specBaseQl = platformOpts?.specRoot || join(cwd, '.sillyspec')
751
+ let qlIdVal = ''
752
+ try {
753
+ const sessionGuardFile = join(specBaseQl, '.runtime', 'quick-sessions', changeName, 'guard.json')
754
+ if (existsSync(sessionGuardFile)) {
755
+ qlIdVal = JSON.parse(readFileSync(sessionGuardFile, 'utf8')).quicklogId || ''
756
+ }
757
+ } catch {}
758
+ promptText = promptText.replace(/<quicklog-id>/g, qlIdVal || '(未分配)')
759
+ }
724
760
  // 替换 <linked-changes> 占位符(quick 阶段:从 .runtime/quick-sessions/<sessionId>/guard.json 读关联变更)
725
761
  if (promptText.includes('<linked-changes>')) {
726
762
  const specBaseLc = platformOpts?.specRoot || join(cwd, '.sillyspec')
@@ -1553,6 +1589,18 @@ export async function runCommand(args, cwd, specDir = null) {
1553
1589
  process.exit(1)
1554
1590
  }
1555
1591
 
1592
+ // --change 变更名存在性校验(治 cwd 漂移误匹配,缺陷 execute-in-place-windows-pitfalls 坑5):
1593
+ // 必须在 pm.read/initChange 之前——initChange 会为 --change 指定的新名字创建 changes/ 目录,
1594
+ // 校验若在其后则目录已存在、永远通过。用原始 changeName(--change 参数),覆盖 plan/execute/
1595
+ // verify/archive;quick-<8hex> sessionId 与 brainstorm 新建豁免(见 validateChangeExists)。
1596
+ const changeMissing = validateChangeExists(specBase, stageName, changeName)
1597
+ if (changeMissing) {
1598
+ console.error(`❌ ${changeMissing.message}`)
1599
+ console.error(` 可能 cwd 漂移——当前 cwd=${cwd},命中的 spec=${specBase}。`)
1600
+ console.error(` 若意图操作别的项目,请 cd 到对应项目根,或用 --spec-dir 指定正确的 spec 目录。`)
1601
+ process.exit(1)
1602
+ }
1603
+
1556
1604
  let progress = await pm.read(cwd, changeName)
1557
1605
 
1558
1606
  if (!progress) {
@@ -1740,7 +1788,7 @@ export async function runCommand(args, cwd, specDir = null) {
1740
1788
  }
1741
1789
 
1742
1790
  // 默认:输出当前步骤
1743
- return await runStage(pm, progress, stageName, cwd, effectiveChange, isSkipApproval, platformOpts, { quickFiles, isAllowNew, isForceBaseline, isForceRescan, linkedChanges })
1791
+ return await runStage(pm, progress, stageName, cwd, effectiveChange, isSkipApproval, platformOpts, { quickFiles, isAllowNew, isForceBaseline, isForceRescan, linkedChanges, taskDescription: inputText })
1744
1792
  }
1745
1793
 
1746
1794
  /**
@@ -2000,45 +2048,67 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
2000
2048
  console.log(`🔄 ${stageName} 阶段已自动重置,重新开始。\n`)
2001
2049
  }
2002
2050
 
2003
- // quick 阶段:记录 baselineFiles
2004
- if (stageName === 'quick' && !progress.quickGuard) {
2051
+ // quick 阶段:记录 baselineFiles + 分配 ql-ID(CLI 接管 QUICKLOG 写入)
2052
+ // 幂等判据是 session guard.json 文件(跨进程可靠),不是 progress.quickGuard
2053
+ // (D-003@v1:顶层 quickGuard 不跨进程持久化;agent 在 step 间用 `run quick` 取下一步
2054
+ // prompt 时每个新进程都进此块,按文件判幂等才不会重复分配 ql-ID / 重复写条目)。
2055
+ if (stageName === 'quick') {
2056
+ const sessionGuardDir = join(specBase, '.runtime', 'quick-sessions', changeName)
2057
+ const guardFile = join(sessionGuardDir, 'guard.json')
2058
+ let existingGuard = null
2005
2059
  try {
2006
- const { execSync } = await import('child_process')
2007
- const gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 10000 })
2008
- // 记录全部预存脏文件(含 untracked + .sillyspec/ 路径)。quick 会话期间自身写入的元数据
2009
- // (quicklog/.runtime/modules/_module-map 等)由 auditQuickCompletion 的 isQuickMetadata 精确豁免,
2010
- // 不需要这里粗放过滤 .sillyspec/——旧过滤致预存 untracked .sillyspec/changes/ 不进 baseline,
2011
- // 却在 audit 被当「危险(.sillyspec/)+新增」误判永久 blocked(ql-20260713-002-7628 修复)。
2012
- const baselineFiles = gitStatus
2013
- .trim().split('\n').filter(Boolean)
2014
- .map(line => line.slice(3).trim())
2015
- const allowedFiles = quickOpts?.quickFiles || []
2016
- const allowNew = quickOpts?.isAllowNew || false
2017
- const forceBaseline = quickOpts?.isForceBaseline || false
2018
- progress.quickGuard = {
2019
- sessionId: changeName,
2020
- name_zh: '快速任务守卫',
2021
- baselineCommit: safeGit(cwd, ['rev-parse', 'HEAD']).value,
2022
- baselineFiles,
2023
- allowedFiles,
2024
- allowNew,
2025
- forceBaseline,
2026
- linkedChanges: Array.isArray(quickOpts?.linkedChanges) ? quickOpts.linkedChanges : [],
2027
- startedAt: new Date().toISOString(),
2060
+ if (existsSync(guardFile)) existingGuard = JSON.parse(readFileSync(guardFile, 'utf8'))
2061
+ } catch {}
2062
+ if (existingGuard) {
2063
+ // 跨进程重入:复用已分配的 ql-ID,跳过 baseline 重捕与分配(幂等)
2064
+ progress.quickGuard = existingGuard
2065
+ } else {
2066
+ try {
2067
+ const { execSync } = await import('child_process')
2068
+ const gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 10000 })
2069
+ // 记录全部预存脏文件(含 untracked + .sillyspec/ 路径)。quick 会话期间自身写入的元数据
2070
+ // (quicklog/.runtime/modules/_module-map 等)由 auditQuickCompletion 的 isQuickMetadata 精确豁免,
2071
+ // 不需要这里粗放过滤 .sillyspec/——旧过滤致预存 untracked .sillyspec/changes/ 不进 baseline,
2072
+ // 却在 audit 被当「危险(.sillyspec/)+新增」误判永久 blocked(ql-20260713-002-7628 修复)。
2073
+ const baselineFiles = gitStatus
2074
+ .trim().split('\n').filter(Boolean)
2075
+ .map(line => line.slice(3).trim())
2076
+ const allowedFiles = quickOpts?.quickFiles || []
2077
+ const allowNew = quickOpts?.isAllowNew || false
2078
+ const forceBaseline = quickOpts?.isForceBaseline || false
2079
+ const linkedChanges = Array.isArray(quickOpts?.linkedChanges) ? quickOpts.linkedChanges : []
2080
+ // CLI 接管:分配 ql-ID + 写 QUICKLOG「进行中」条目 + 关联 tasks.md(持锁、当天唯一)
2081
+ const gitUser = safeGit(cwd, ['config', 'user.name']).value || 'unknown'
2082
+ const { qlId } = await allocateQuicklogEntry(specBase, gitUser, {
2083
+ description: quickOpts?.taskDescription || '',
2084
+ linkedChanges,
2085
+ allowedFiles,
2086
+ })
2087
+ progress.quickGuard = {
2088
+ sessionId: changeName,
2089
+ name_zh: '快速任务守卫',
2090
+ baselineCommit: safeGit(cwd, ['rev-parse', 'HEAD']).value,
2091
+ baselineFiles,
2092
+ allowedFiles,
2093
+ allowNew,
2094
+ forceBaseline,
2095
+ linkedChanges,
2096
+ quicklogId: qlId,
2097
+ startedAt: new Date().toISOString(),
2098
+ }
2099
+ // 写入 .runtime/quick-sessions/<sessionId>/guard.json 供 worktree-guard hook 读取
2100
+ // (D-002:按 session 存,多会话各自 guard 不互覆盖。runStage 作用域内 sessionId == changeName == quick-<uuid8>,见 §4.4/4.5)
2101
+ mkdirSync(sessionGuardDir, { recursive: true })
2102
+ writeFileSync(guardFile, JSON.stringify(progress.quickGuard, null, 2))
2103
+ const parts = [`${baselineFiles.length} 个已有脏文件`]
2104
+ if (allowedFiles.length > 0) parts.push(`${allowedFiles.length} 个 allowedFiles`)
2105
+ if (allowNew) parts.push('允许新增文件')
2106
+ console.log(`🛡️ quick 变更边界已记录: ${parts.join(', ')}`)
2107
+ console.log(`📝 QUICKLOG 条目已创建: ${qlId}`)
2108
+ await pm._write(cwd, progress, changeName)
2109
+ } catch (e) {
2110
+ console.warn(`⚠️ baseline 记录失败: ${e.message}`)
2028
2111
  }
2029
- // 写入 .runtime/quick-sessions/<sessionId>/guard.json 供 worktree-guard hook 读取
2030
- // (D-002:按 session 存,多会话各自 guard 不互覆盖。runStage 作用域内 sessionId == changeName == quick-<uuid8>,见 §4.4/4.5)
2031
- const sessionGuardDir = join(specBase, '.runtime', 'quick-sessions', changeName)
2032
- mkdirSync(sessionGuardDir, { recursive: true })
2033
- const guardFile = join(sessionGuardDir, 'guard.json')
2034
- writeFileSync(guardFile, JSON.stringify(progress.quickGuard, null, 2))
2035
- const parts = [`${baselineFiles.length} 个已有脏文件`]
2036
- if (allowedFiles.length > 0) parts.push(`${allowedFiles.length} 个 allowedFiles`)
2037
- if (allowNew) parts.push('允许新增文件')
2038
- console.log(`🛡️ quick 变更边界已记录: ${parts.join(', ')}`)
2039
- await pm._write(cwd, progress, changeName)
2040
- } catch (e) {
2041
- console.warn(`⚠️ baseline 记录失败: ${e.message}`)
2042
2112
  }
2043
2113
  }
2044
2114
 
@@ -3011,61 +3081,118 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
3011
3081
  if (steps[wsIdx].waitReason) console.log(` 原因:${steps[wsIdx].waitReason}`)
3012
3082
  return { stageCompleted: false, currentIdx, nextPendingIdx: -1 }
3013
3083
  }
3014
- // quick 阶段完成前强制检查 quicklog 是否创建
3084
+ // quick 收尾:强校验 QUICKLOG 条目 + 翻状态 + 勾 tasks.md(CLI 接管)
3015
3085
  if (stageName === 'quick') {
3016
- const quicklogDir = join(specBase, 'quicklog')
3017
- const hasQuicklog = existsSync(quicklogDir) && readdirSync(quicklogDir).some(f => f.endsWith('.md') && f.startsWith('QUICKLOG'))
3018
- if (!hasQuicklog) {
3019
- console.error(`\n❌ quick 阶段完成校验失败:未检测到 QUICKLOG 记录文件。`)
3020
- console.error(` step 2 要求创建 quicklog 记录,但文件不存在。`)
3021
- console.error(` 请先创建 quicklog 记录再 --done。`)
3022
- return { stageCompleted: false, currentIdx, nextPendingIdx: -1 }
3023
- }
3024
- // §4.6 quick 收尾:从 session guard.json 读 guard(不依赖 progress.quickGuard)。
3086
+ // §4.6 从 session guard.json 读 guard(不依赖 progress.quickGuard)。
3025
3087
  // D-003@v1:progress._write 不持久化顶层 quickGuard,跨进程 --done 时读出的 progress 无 quickGuard,
3026
3088
  // 若仍用 if (progress.quickGuard) 驱动收尾会整体跳过,导致 .runtime/quick-sessions/<sessionId>/ 残留僵尸。
3027
3089
  // 改为从文件读 guard:优先 session 目录 guard.json,回退旧单文件 quick-guard.json(task-03 前兼容)。
3028
3090
  // sessionId == changeName == quick-<uuid8>(completeStep 作用域内 changeName 已解构自 options)。
3029
- {
3030
- const runtimeBase = platformOpts.runtimeRoot || join(specBase, '.runtime')
3031
- const sessionGuardFile = join(runtimeBase, 'quick-sessions', changeName, 'guard.json')
3032
- const legacyGuardFile = join(specBase, '.runtime', 'quick-guard.json')
3033
- let guard = null
3034
- try {
3035
- guard = existsSync(sessionGuardFile)
3036
- ? JSON.parse(readFileSync(sessionGuardFile, 'utf8'))
3037
- : (existsSync(legacyGuardFile) ? JSON.parse(readFileSync(legacyGuardFile, 'utf8')) : null)
3038
- } catch {}
3039
- if (guard) {
3040
- // --done 的 --force-baseline/--allow-new 并入 guard(与 step1 持久化值取或)。
3041
- // 修复 ql-20260713-002-7628:旧代码解析了这两个 flag 但只传 {isConfirm} 给审计,
3042
- // 致 --done --force-baseline 静默无效、用户被误导「重跑 --confirm」也无法解锁。
3043
- const mergedGuard = {
3044
- ...guard,
3045
- forceBaseline: guard.forceBaseline || isForceBaseline,
3046
- allowNew: guard.allowNew || isAllowNew,
3047
- }
3048
- const review = await auditQuickCompletion(cwd, mergedGuard, { isConfirm: confirm })
3049
- printQuickAuditReview(review)
3050
- if (review.status === 'blocked') {
3051
- steps[currentIdx].status = 'pending'
3052
- steps[currentIdx].completedAt = null
3053
- if (outputText) steps[currentIdx].output = null
3054
- process.exit(1)
3055
- }
3056
- progress.lastQuickReview = review
3091
+ const runtimeBase = platformOpts.runtimeRoot || join(specBase, '.runtime')
3092
+ const sessionGuardFile = join(runtimeBase, 'quick-sessions', changeName, 'guard.json')
3093
+ const legacyGuardFile = join(specBase, '.runtime', 'quick-guard.json')
3094
+ let guard = null
3095
+ try {
3096
+ guard = existsSync(sessionGuardFile)
3097
+ ? JSON.parse(readFileSync(sessionGuardFile, 'utf8'))
3098
+ : (existsSync(legacyGuardFile) ? JSON.parse(readFileSync(legacyGuardFile, 'utf8')) : null)
3099
+ } catch {}
3100
+
3101
+ // 强校验 / 收尾:本会话必须有一条真实 QUICKLOG 条目(治「报 SAFE 但漏写」bug)。
3102
+ // guard 缺失(brownfield:新代码前启动的会话)不阻断——兜底补写一条记录,保住「完成必有记录」不变量。
3103
+ const gitUser = safeGit(cwd, ['config', 'user.name']).value || 'unknown'
3104
+ let qlId = guard?.quicklogId || null
3105
+ const linkedChanges = Array.isArray(guard?.linkedChanges) ? guard.linkedChanges : []
3106
+
3107
+ // 审计:仅在有 guard 时跑(brownfield 无 guard 跳过,兼容 D-003 brownfield 行为)。
3108
+ if (guard) {
3109
+ // --done 的 --force-baseline/--allow-new 并入 guard(与 step1 持久化值取或)。
3110
+ // 修复 ql-20260713-002-7628:旧代码解析了这两个 flag 但只传 {isConfirm} 给审计,
3111
+ // 致 --done --force-baseline 静默无效、用户被误导「重跑 --confirm」也无法解锁。
3112
+ const mergedGuard = {
3113
+ ...guard,
3114
+ forceBaseline: guard.forceBaseline || isForceBaseline,
3115
+ allowNew: guard.allowNew || isAllowNew,
3116
+ }
3117
+ const review = await auditQuickCompletion(cwd, mergedGuard, { isConfirm: confirm })
3118
+ printQuickAuditReview(review)
3119
+ if (review.status === 'blocked') {
3120
+ steps[currentIdx].status = 'pending'
3121
+ steps[currentIdx].completedAt = null
3122
+ if (outputText) steps[currentIdx].output = null
3123
+ process.exit(1)
3057
3124
  }
3058
- // 清理:guard 命中与缺失两种情况都执行(guard 缺失 → 跳过审计,仅清理不抛错)。
3059
- // rmSync {recursive,force} / unlinkSync 都容忍文件不存在(brownfield)。
3125
+ progress.lastQuickReview = review
3126
+ }
3127
+
3128
+ // 结果摘要结构校验(最后一步、isDone 且带了 --output 时):--output 是 QUICKLOG「结果:」
3129
+ // 归档的唯一来源,要求按 需求/根因/方案/结果 模板给全(见 stages/quick.js step3 prompt)。
3130
+ // 确定性校验:只查必填字段是否齐全,不判内容质量。缺字段 → 本次不完成(回滚 step 状态 +
3131
+ // exit 1),保留「进行中」条目,agent 补全 --output 后重跑 --done 即可,不丢进度。
3132
+ // 仅 completeQuicklogEntry 会实际持久化时才校验;前两个 step 的 --done output 不入 QUICKLOG,不校验。
3133
+ if (outputText) {
3134
+ const resultCheck = validateQuickResult(outputText)
3135
+ if (!resultCheck.ok) {
3136
+ console.error(`\n❌ quick 结果摘要结构不完整:缺少字段 ${resultCheck.missing.join('、')}`)
3137
+ console.error(` --output 会写入 QUICKLOG「结果:」,请按模板补全再重跑 --done:`)
3138
+ console.error(` 需求:用户/任务要什么`)
3139
+ console.error(` 根因:为什么这样改(纯新增/样式则写「无,纯新增/纯样式」)`)
3140
+ console.error(` 方案:怎么改的`)
3141
+ console.error(` 结果:验证情况(测试数 / lint / typecheck / 部署状态)`)
3142
+ steps[currentIdx].status = 'pending'
3143
+ steps[currentIdx].completedAt = null
3144
+ if (outputText) steps[currentIdx].output = null
3145
+ process.exit(1)
3146
+ }
3147
+ }
3148
+
3149
+ if (!qlId) {
3150
+ // 无 ql-ID(guard 缺失或 brownfield 无 quicklogId):补分配后立即完成,不阻断。
3060
3151
  try {
3061
- const { unlinkSync } = await import('fs')
3062
- if (changeName) {
3063
- const sessionDir = join(runtimeBase, 'quick-sessions', changeName)
3064
- rmSync(sessionDir, { recursive: true, force: true })
3065
- }
3066
- if (existsSync(legacyGuardFile)) unlinkSync(legacyGuardFile)
3067
- } catch {}
3152
+ const alloc = await allocateQuicklogEntry(specBase, gitUser, {
3153
+ description: guard?.taskDescription || '(补分配)',
3154
+ linkedChanges,
3155
+ allowedFiles: Array.isArray(guard?.allowedFiles) ? guard.allowedFiles : [],
3156
+ })
3157
+ qlId = alloc.qlId
3158
+ console.log(`📝 QUICKLOG 兜底补写: ${qlId}(guard 缺失/brownfield 会话)`)
3159
+ } catch (e) {
3160
+ console.error(`\n❌ QUICKLOG 补分配失败: ${e.message}`)
3161
+ steps[currentIdx].status = 'pending'
3162
+ steps[currentIdx].completedAt = null
3163
+ if (outputText) steps[currentIdx].output = null
3164
+ process.exit(1)
3165
+ }
3166
+ }
3167
+ if (!findQuicklogEntry(specBase, gitUser, qlId)) {
3168
+ console.error(`\n❌ quick 阶段完成校验失败:QUICKLOG 条目 ${qlId} 不存在。`)
3169
+ console.error(` 会话期间记录被删除或从未写入。请检查 .sillyspec/quicklog/ 后重跑 --done。`)
3170
+ steps[currentIdx].status = 'pending'
3171
+ steps[currentIdx].completedAt = null
3172
+ if (outputText) steps[currentIdx].output = null
3173
+ process.exit(1)
3174
+ }
3175
+ // 翻状态进行中→已完成 + 追加结果 + 勾选关联 tasks.md
3176
+ // resultText 不再截断:结构化结果块(需求/根因/方案/结果)完整落盘,多行写成字段化块。
3177
+ try {
3178
+ await completeQuicklogEntry(specBase, gitUser, qlId, {
3179
+ resultText: outputText || '',
3180
+ linkedChanges,
3181
+ })
3182
+ console.log(`📝 QUICKLOG 条目 ${qlId} 已标记完成`)
3183
+ } catch (e) {
3184
+ console.warn(`⚠️ QUICKLOG 完成态写入失败: ${e.message}`)
3068
3185
  }
3186
+
3187
+ // 清理 session 目录(rmSync/unlinkSync 容忍不存在)。
3188
+ try {
3189
+ const { unlinkSync } = await import('fs')
3190
+ if (changeName) {
3191
+ const sessionDir = join(runtimeBase, 'quick-sessions', changeName)
3192
+ rmSync(sessionDir, { recursive: true, force: true })
3193
+ }
3194
+ if (existsSync(legacyGuardFile)) unlinkSync(legacyGuardFile)
3195
+ } catch {}
3069
3196
  }
3070
3197
 
3071
3198
  stageData.status = 'completed'
@@ -33,6 +33,36 @@ function resolveChangeDir(cwd, changeName, specRoot = null) {
33
33
  return join(changesRoot, changeName)
34
34
  }
35
35
 
36
+ /**
37
+ * --change 变更名存在性校验(治 cwd 漂移误匹配,缺陷 execute-in-place-windows-pitfalls 坑5)。
38
+ *
39
+ * 多项目 monorepo 下,cwd 在子项目目录会让 resolveSpecDir 命中子项目 spec,
40
+ * 此时 --change 传根项目变更名,旧逻辑(resolveChangeDir 纯拼路径)不报错而静默
41
+ * fallback 误启动子项目流程。本校验对「操作已有变更」的阶段强制 changes/<changeName>
42
+ * 存在,不存在则 fail-fast,把 cwd 漂移暴露给用户。
43
+ *
44
+ * 豁免:
45
+ * - 非 plan/execute/verify/archive 阶段(scan/brainstorm/quick/explore 不校验:
46
+ * brainstorm 可新建变更、quick 用 sessionId、scan/explore 无 change 语义)
47
+ * - quick-<8hex> sessionId(quick 会话 changeName 是 sessionId,不在 changes/)
48
+ *
49
+ * @param {string} specBase - 规范根目录(.sillyspec 或平台 specRoot)
50
+ * @param {string} stageName - 阶段名
51
+ * @param {string} changeName - 变更名(--change 或 auto)
52
+ * @returns {{ changeName: string, specBase: string, message: string } | null} null=通过,对象=失败
53
+ */
54
+ export function validateChangeExists(specBase, stageName, changeName) {
55
+ const STAGES_REQUIRE_EXISTING_CHANGE = new Set(['plan', 'execute', 'verify', 'archive'])
56
+ if (!changeName || !STAGES_REQUIRE_EXISTING_CHANGE.has(stageName)) return null
57
+ if (/^quick-[0-9a-f]{8}$/.test(changeName)) return null
58
+ if (existsSync(join(specBase, 'changes', changeName))) return null
59
+ return {
60
+ changeName,
61
+ specBase,
62
+ message: `变更 "${changeName}" 在当前 spec 下不存在:${join(specBase, 'changes', changeName)}`,
63
+ }
64
+ }
65
+
36
66
  function collectIdsFromLine(line, re, ids) {
37
67
  for (const match of line.matchAll(re)) {
38
68
  ids.add(match[0].toUpperCase())