sillyspec 3.26.8 → 3.26.9

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 (50) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +1 -2
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +1 -2
  3. package/.claude/skills/sillyspec-execute/SKILL.md +10 -2
  4. package/.claude/skills/sillyspec-explore/SKILL.md +0 -1
  5. package/.claude/skills/sillyspec-plan/SKILL.md +1 -2
  6. package/.claude/skills/sillyspec-quick/SKILL.md +1 -2
  7. package/.claude/skills/sillyspec-scan/SKILL.md +2 -2
  8. package/.claude/skills/sillyspec-status/SKILL.md +0 -1
  9. package/.claude/skills/sillyspec-verify/SKILL.md +1 -2
  10. package/README.md +5 -9
  11. package/package.json +2 -2
  12. package/packages/dashboard/server/index.js +14 -8
  13. package/src/change-risk-profile.js +5 -207
  14. package/src/config-schema.js +6 -1
  15. package/src/constants.js +97 -106
  16. package/src/db-engine.js +1 -1
  17. package/src/docs-check.js +34 -5
  18. package/src/git-helper.js +2 -2
  19. package/src/hooks/worktree-guard.js +1 -1
  20. package/src/index.js +96 -13
  21. package/src/init.js +14 -3
  22. package/src/machine-interface.js +7 -4
  23. package/src/progress/stage-machine.js +3 -0
  24. package/src/quicklog.js +182 -1
  25. package/src/run/command.js +101 -3
  26. package/src/run/complete.js +9 -1
  27. package/src/run/gates.js +67 -1
  28. package/src/run/prompt.js +721 -713
  29. package/src/run/quick-audit.js +146 -137
  30. package/src/run/shared.js +1197 -1109
  31. package/src/run/stage.js +17 -1
  32. package/src/scan-diff.js +388 -0
  33. package/src/scan-staleness.js +18 -13
  34. package/src/stage-contract-spec.js +1 -1
  35. package/src/stage-review.js +1 -1
  36. package/src/stages/brainstorm.js +2 -2
  37. package/src/stages/execute.js +991 -985
  38. package/src/stages/plan.js +22 -7
  39. package/src/stages/quick.js +1 -1
  40. package/src/stages/scan.js +2 -2
  41. package/src/stages/verify.js +10 -11
  42. package/src/sync.js +12 -0
  43. package/src/task-review.js +12 -4
  44. package/src/workflow.js +8 -185
  45. package/src/worktree-apply.js +122 -0
  46. package/src/worktree.js +2 -2
  47. package/templates/claude-instruction.md +1 -1
  48. package/templates/prompts/taskcard-rules.md +1 -0
  49. package/templates/prompts/verify-probes.md +2 -2
  50. package/templates/skills/sillyspec-onboard/SKILL.md +0 -92
package/src/quicklog.js CHANGED
@@ -81,9 +81,153 @@ export function sanitizeDesc(description) {
81
81
  return s.length > 120 ? s.slice(0, 120) + '…' : s
82
82
  }
83
83
 
84
+
85
+ // ── 平台推送(best-effort,2026-08-16-change-center-quick-tab task-06 / D-003)──
86
+
87
+ // 读 local.yaml platform 段(url+token)。quicklog 不 import sync.js(会拖 ProgressManager
88
+ // 整链),自带同款轻量解析:只取 platform.url / platform.token 两键,与 parseSimpleYaml
89
+ // 同风格(丢注释可接受——只读)。未连接返回 null(合法本地状态,静默跳过)。
90
+ function _readPlatformConfig(specBase) {
91
+ try {
92
+ // specBase 即 <cwd>/.sillyspec(或平台 specRoot);local.yaml 就在 spec 根下
93
+ const yamlPath = join(specBase, 'local.yaml')
94
+ if (!existsSync(yamlPath)) return null
95
+ const lines = readFileSync(yamlPath, 'utf8').split(/\r?\n/)
96
+ let inPlatform = false
97
+ const cfg = {}
98
+ for (const line of lines) {
99
+ if (!line.trim() || line.trim().startsWith('#')) continue
100
+ if (!/^\s/.test(line)) inPlatform = line.startsWith('platform:')
101
+ else if (inPlatform) {
102
+ const m = line.match(/^\s+(url|token)\s*:\s*(.*)$/)
103
+ if (m) {
104
+ let v = m[2].trim()
105
+ if (v.length >= 2 && ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))) v = v.slice(1, -1)
106
+ if (v) cfg[m[1]] = v
107
+ }
108
+ }
109
+ }
110
+ if (!cfg.url || !cfg.token) return null
111
+ return cfg
112
+ } catch {
113
+ return null
114
+ }
115
+ }
116
+
117
+ const PUSH_TIMEOUT_MS = 5_000
118
+
119
+ // 从文件全文提取指定 qlId 的条目块(## 头到下一个 ## 头/文件尾)。null=未命中。
120
+ // 与平台 quicklog_parser.py 同款切分口径(剥 \r),保证 CLI 推送的 raw_block 与平台解析一致。
121
+ function extractRawBlock(content, qlId) {
122
+ if (!content) return null
123
+ const lines = content.split('\n')
124
+ const start = lines.findIndex(l => l.replace(/\r$/, '').startsWith(`## ${qlId} |`))
125
+ if (start === -1) return null
126
+ let end = lines.length
127
+ for (let i = start + 1; i < lines.length; i++) {
128
+ if (lines[i].replace(/\r$/, '').startsWith('## ')) { end = i; break }
129
+ }
130
+ return lines.slice(start, end).map(l => l.replace(/\r$/, '')).join('\n').trimEnd()
131
+ }
132
+
133
+ // 落盘条目块 → 推送 payload(design §5.3:以落盘终态为准,不从入参拼)。
134
+ // 宽松标签解析对齐平台:全半角冒号、多条状态行取最后、文件行单行/多行 bullet。
135
+ function buildPushPayloadFromRaw(rawBlock, { ql_id, author_raw, status, linked_changes, fallback_title }) {
136
+ const payload = {
137
+ ql_id,
138
+ timestamp: null,
139
+ title: fallback_title || '(quick 任务)',
140
+ status,
141
+ status_note: null,
142
+ author_raw,
143
+ linked_changes: Array.isArray(linked_changes) ? linked_changes : [],
144
+ files: [],
145
+ body_sections: {},
146
+ raw_block: rawBlock,
147
+ }
148
+ if (!rawBlock) return payload
149
+ const lines = rawBlock.split('\n')
150
+ const header = lines[0] || ''
151
+ const parts = header.split('|').map(s => s.trim())
152
+ if (parts.length >= 3) {
153
+ payload.timestamp = parts[1] || null
154
+ payload.title = parts.slice(2).join('|').trim() || payload.title
155
+ }
156
+ const labelRe = /^(状态|关联变更|文件|需求|根因|方案|结果)\s*[::]\s*(.*)$/
157
+ const bulletRe = /^-\s+(.*)$/
158
+ let inFiles = false
159
+ let inLinked = false
160
+ let lastLabel = null
161
+ let lastStatus = null
162
+ for (const line of lines.slice(1)) {
163
+ const stripped = line.trim()
164
+ if (!stripped) { inFiles = false; inLinked = false; continue }
165
+ const m = stripped.match(labelRe)
166
+ if (m) {
167
+ const [, label, value] = m
168
+ if (label === '状态') lastStatus = value
169
+ else if (label === '关联变更') { payload.linked_changes = value.split(/[,,、+;;]/).map(s => s.trim()).filter(Boolean); inLinked = true; inFiles = false }
170
+ else if (label === '文件') { if (value) payload.files.push(...value.split(/[,,、+;;]/).map(s => s.trim()).filter(Boolean).map(p => ({ path: p, note: null }))); inFiles = true; inLinked = false }
171
+ else { payload.body_sections[label] = value; lastLabel = label }
172
+ continue
173
+ }
174
+ if (bulletRe.test(stripped) && inFiles) {
175
+ payload.files.push({ path: stripped.replace(/^-\s+/, ''), note: null })
176
+ continue
177
+ }
178
+ if (bulletRe.test(stripped) && inLinked) {
179
+ payload.linked_changes.push(...stripped.replace(/^-\s+/, '').split(/[,,、+;;]/).map(s => s.trim()).filter(Boolean))
180
+ continue
181
+ }
182
+ if (lastLabel) payload.body_sections[lastLabel] += '\n' + stripped
183
+ }
184
+ // 状态以落盘为准(flip 后是「已完成」或「已完成(括注)」)——多条取最后
185
+ if (lastStatus) {
186
+ payload.status = lastStatus.startsWith('已完成') ? 'completed'
187
+ : lastStatus.startsWith('已暂存') ? 'partial_done'
188
+ : lastStatus.startsWith('进行中') ? 'in_progress'
189
+ : status
190
+ const note = lastStatus.match(/((.+))$/)
191
+ payload.status_note = note ? note[1] : null
192
+ }
193
+ // 白名单正则对齐平台(^\d{4}-\d{2}-\d{2}- 才进列表,防自由文本进反向区块)
194
+ payload.linked_changes = payload.linked_changes.filter(c => /^\d{4}-\d{2}-\d{2}-/.test(c))
195
+ return payload
196
+ }
197
+
198
+ // 单条推送:payload 字段对齐平台 QuicklogEntryPushRequest(snake_case)。测试经 allocate/complete 间接触发。
199
+ // 任何失败(无配置/网络/非 2xx/超时)静默 warn 一行不抛(FR-02 best-effort,quick 主流程零阻断)。
200
+ async function pushQuicklogEntryToPlatform(specBase, entry) {
201
+ const cfg = _readPlatformConfig(specBase)
202
+ if (!cfg) {
203
+ if (process.env.SILLYSPEC_DEBUG_SYNC) console.warn('[quicklog-push] 未配置 platform 段,跳过推送')
204
+ return false
205
+ }
206
+ const controller = new AbortController()
207
+ const timer = setTimeout(() => controller.abort(), PUSH_TIMEOUT_MS)
208
+ try {
209
+ const res = await fetch(`${cfg.url.replace(/\/$/, '')}/api/quicklog-entries`, {
210
+ method: 'POST',
211
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.token}` },
212
+ body: JSON.stringify(entry),
213
+ signal: controller.signal,
214
+ })
215
+ if (!res.ok) {
216
+ console.warn(`[quicklog-push] ${entry.ql_id} → ${res.status}(文件链路兜底,不影响本地记录)`)
217
+ return false
218
+ }
219
+ return true
220
+ } catch (err) {
221
+ console.warn(`[quicklog-push] ${entry.ql_id} 推送失败: ${err && err.name === 'AbortError' ? '超时' : (err.message || err)}(文件链路兜底)`)
222
+ return false
223
+ } finally {
224
+ clearTimeout(timer)
225
+ }
226
+ }
227
+
84
228
  // git user.name → 文件名安全形式:白名单保留字母数字._- 与中文等 Unicode 字,
85
229
  // 剥路径元字符(/ \ ..)与控制字符。防 QUICKLOG-<user>.md / 锁文件 / 轮转归档穿越写。
86
- export function sanitizeQuicklogUser(user) {
230
+ function sanitizeQuicklogUser(user) {
87
231
  const s = String(user || '').replace(/[\\/\r\n\0]/g, '').replace(/\.{2,}/g, '.').trim()
88
232
  if (!s) return ''
89
233
  return s.length > 64 ? s.slice(0, 64) : s
@@ -216,7 +360,13 @@ function scanExisting(quicklogDir, today) {
216
360
  let maxSeq = 0
217
361
  const usedSuffix = new Set()
218
362
  const re = /^## ql-(\d{8})-(\d{3})-([0-9a-fA-F]{4})\b/gm
363
+ // E22c:轮转归档按文件名日期过滤——归档名 = QUICKLOG-<user>-<YYYY-MM-DD>.md(最后条目日期),
364
+ // 归档内全部条目 ≤ 名内日期;名内日期早于今天的归档不可能含当日条目,跳过读取
365
+ // (O(全历史归档) → O(当日文件),consumer 已 10 归档文件 756KB 时免全量扫描)。
366
+ const todayDashed = today.length === 8 ? `${today.slice(0, 4)}-${today.slice(4, 6)}-${today.slice(6, 8)}` : today
219
367
  for (const f of listQuicklogFiles(quicklogDir)) {
368
+ const dm = f.match(/-(\d{4}-\d{2}-\d{2})\.md$/)
369
+ if (dm && dm[1] < todayDashed) continue // 早于今天的归档,必无当日条目
220
370
  let content = ''
221
371
  try { content = readFileSync(join(quicklogDir, f), 'utf8') } catch { continue }
222
372
  let m
@@ -441,6 +591,22 @@ export async function allocateQuicklogEntry(specBase, gitUser, { description, li
441
591
 
442
592
  for (const c of linked) appendTaskCheckbox(specBase, c, qlId, desc)
443
593
 
594
+ // 平台推送(task-06 / FR-02 / D-003):锁外语义等同(推送不回写本地),但放锁内
595
+ // 保证「分配即推送」顺序一致;best-effort 失败不影响返回。
596
+ await pushQuicklogEntryToPlatform(specBase, {
597
+ ql_id: qlId,
598
+ timestamp: nowDatetime(),
599
+ title: desc,
600
+ status: 'in_progress',
601
+ status_note: null,
602
+ author_raw: user,
603
+ // 白名单正则对齐平台(^\d{4}-\d{2}-\d{2}- 才进列表;原文在 raw_block 不丢)
604
+ linked_changes: linked.filter(c => /^\d{4}-\d{2}-\d{2}-/.test(c)),
605
+ files: files.map(f => ({ path: f, note: null })),
606
+ body_sections: {},
607
+ raw_block: entry,
608
+ }).catch(() => {}) // pushQuicklogEntryToPlatform 自身已吞错;双保险防未来改动破坏 best-effort 契约
609
+
444
610
  return { qlId }
445
611
  })
446
612
  }
@@ -464,6 +630,8 @@ export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultTex
464
630
 
465
631
  await withFileLock(lockPath, async () => {
466
632
  // 条目可能在主文件或轮转归档中
633
+ let updatedContent = null
634
+ let updatedFile = null
467
635
  for (const f of listQuicklogFiles(quicklogDir)) {
468
636
  const filePath = join(quicklogDir, f)
469
637
  let content = ''
@@ -471,10 +639,23 @@ export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultTex
471
639
  const updated = flipEntryInContent(content, qlId, result, realFiles, fileNotes)
472
640
  if (updated !== null) {
473
641
  await writeAtomic(filePath, updated) // 命中处原子落盘(只改含目标条目的那一个文件)
642
+ updatedContent = updated
643
+ updatedFile = filePath
474
644
  break
475
645
  }
476
646
  }
477
647
  for (const c of linked) await checkTaskCheckbox(specBase, c, qlId)
648
+
649
+ // 平台推送(task-06 / FR-02 / D-003):**以落盘终态为准**读回条目组装 payload
650
+ // (design §5.3:翻完成时标题行被 extractTitleFromResult 刷新,推送须与落盘一致,
651
+ // 不能用入参拼——标题/结果块/文件行都会在 flipEntryInContent 中重写)。
652
+ const rawBlock = extractRawBlock(updatedContent, qlId)
653
+ await pushQuicklogEntryToPlatform(specBase, buildPushPayloadFromRaw(rawBlock, {
654
+ ql_id: qlId,
655
+ author_raw: user,
656
+ status: 'completed',
657
+ linked_changes: linked,
658
+ })).catch(() => {})
478
659
  })
479
660
  }
480
661
 
@@ -19,7 +19,7 @@
19
19
  * './run/shared.js' / './run/quick-audit.js' / './run/prompt.js' / './run/complete.js' /
20
20
  * './run/stage.js' → './shared.js' 等同级
21
21
  */
22
- import { join, resolve, dirname } from 'node:path'
22
+ import { basename, join, resolve, dirname } from 'node:path'
23
23
  import { existsSync, readdirSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
24
24
  import { randomBytes, randomUUID } from 'node:crypto'
25
25
  import { writeAtomicSync } from '../fs-atomic.js'
@@ -30,7 +30,8 @@ import { completeStep, skipStep, waitStep, continueStep } from './complete.js'
30
30
  import { runStage } from './stage.js'
31
31
  import { sanitizeDesc } from '../quicklog.js'
32
32
  import { ProgressManager } from '../progress.js'
33
- import { validateChangeExists } from '../stage-contract.js'
33
+ import { validateChangeExists, checkTransition } from '../stage-contract.js'
34
+ import { READONLY_AUXILIARY_STAGES } from '../constants.js'
34
35
  import { stageRegistry, auxiliaryStages } from '../stages/index.js'
35
36
  import { definition as brainstormAutoDef } from '../stages/brainstorm-auto.js'
36
37
  import { setQuickFileNotes } from '../quicklog.js'
@@ -43,6 +44,7 @@ const VALUE_FLAGS = new Set([
43
44
  '--output', '--input', '--change', '--linked-changes',
44
45
  '--spec-dir', '--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
45
46
  '--files', '--file-notes', '--from-step', '--mode', '--dir', '--confirm-mode',
47
+ '--base', // scan diff 基线 commit(吃值;只在 run scan --diff 转发路径消费)
46
48
  ])
47
49
 
48
50
  /**
@@ -145,7 +147,7 @@ sillyspec run ${stageName}${stage && stage.title ? ` — ${stage.title}` : ''}${
145
147
  --interactive --skip-approval --json(不支持)
146
148
  quick 专属: --linked-changes none|a,b --files a.js,b.js --allow-new
147
149
  --allow-delete --force-baseline --confirm --file-notes
148
- scan 专属: --quick --standard --deep --force-rescan
150
+ scan 专属: --quick --standard --deep --force-rescan --diff [--base <commit>] [--full] [--report]
149
151
  archive专属: --confirm
150
152
  `)
151
153
  }
@@ -353,6 +355,24 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
353
355
  }
354
356
  }
355
357
 
358
+ // scan diff 转发(D-001@v1:command.js 只认 --diff flag,等价 `sillyspec scan diff` 子命令;
359
+ // 裸 token `run scan diff` 不在此解析——那是 index.js 子命令拦截的职责,防 command.js 静默吞)。
360
+ // 纯只读比较:零会话/QUICKLOG 副作用,仅解析 --base/--full/--report 后转发 scan-diff 退出码。
361
+ if (stageName === 'scan' && flags.includes('--diff')) {
362
+ const { runScanDiff } = await import('../scan-diff.js')
363
+ const diffBaseIdx = flags.indexOf('--base')
364
+ const diffBase = diffBaseIdx >= 0 && flags[diffBaseIdx + 1] ? flags[diffBaseIdx + 1] : null
365
+ process.exitCode = runScanDiff({
366
+ projectRoot: cwd,
367
+ specBase,
368
+ projectName: basename(cwd),
369
+ base: diffBase,
370
+ full: flags.includes('--full'),
371
+ report: flags.includes('--report'),
372
+ })
373
+ return
374
+ }
375
+
356
376
  // 平台模式:首次接入时清理旧版本残留的 cwd/.sillyspec/(防止源码污染)。
357
377
  // ⚠️ 同 init.js:必须保护真实资产(changes/、projects/、sillyspec.db)。
358
378
  // 只在「首次」执行一次——用 cwd 下的 .sillyspec-platform-cleaned 标记文件记录已处理,
@@ -491,6 +511,24 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
491
511
  }
492
512
  }
493
513
 
514
+ // quick 关联变更存在性守卫(坑 quick-change-phantom-linked):quick 的 --change/--linked-changes
515
+ // 被解析为关联变更后静默接受不存在的名字(quick 不建变更,链接幻影名必是笔误/语义误用——想给
516
+ // 会话起名却传了 --change),后果是 QUICKLOG 挂悬空关联污染 change 关联图谱 + --done 时 sessionId
517
+ // 走 fallback 可能命中他者会话。f70c9c3 只修了「建幻影目录」后果(appendTaskCheckbox 加
518
+ // existsSync 守卫),本守卫把误用拦在 flag 装载层。只校验 CLI 显式装载的 linkedChanges;
519
+ // 持久化 guard.json 复用(下方 run↔--done 之间变更可能被归档)与交互式选择(列表只列存在项)不检。
520
+ if (stageName === 'quick' && linkedChanges.length > 0) {
521
+ const missing = linkedChanges.filter(lc => lc !== 'none' && !existsSync(join(specRoot, 'changes', lc)))
522
+ if (missing.length > 0) {
523
+ console.error(`❌ quick 的 --change/--linked-changes 被当作「关联变更」处理,但以下变更不存在:${missing.join('、')}`)
524
+ console.error(' (quick 不创建变更,关联的变更必须预先存在)')
525
+ console.error(' - 想给会话起名:不要传 --change,sessionId 由 CLI 自动生成(后续 --done 用它)')
526
+ console.error(' - 想关联变更:修正拼写(.sillyspec/changes/ 下已存在的目录名),显式写法 --linked-changes <名>')
527
+ console.error(' - 想新建变更:quick 不建变更,走 brainstorm 完整流程')
528
+ process.exit(2)
529
+ }
530
+ }
531
+
494
532
  // 解析 --files a.js,b.js(quick 专用:显式声明 allowedFiles)
495
533
  // --files 是单值 flag,空格分隔的多文件只取首个致其余静默丢失(坑 quick-files-space-separated-silently-drops)。
496
534
  // detectSpaceSeparatedFiles 检测误用 → fail-loud 报错指明逗号用法(同 run --json :109 风格)。
@@ -534,6 +572,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
534
572
  '--json', '--dir', '--help',
535
573
  '--reopen', '--from-step', '--mode',
536
574
  '--deep', '--quick', '--standard', // scan profile 三档显式选择(scan-profile.js 从 argv 读;互斥见下方 PROFILE_FLAGS 检测)
575
+ '--diff', '--base', '--full', '--report', // scan diff(D-001:command.js 只补 flag,裸 token 解析归 index.js 子命令拦截)
537
576
  '-h',
538
577
  ])
539
578
  for (let i = 0; i < flags.length; i++) {
@@ -658,6 +697,39 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
658
697
 
659
698
  let progress = pm.read(cwd, changeName)
660
699
 
700
+ // task-03 (D-005@v2 / FR-04): READONLY_AUXILIARY_STAGES(status/doctor)查询只读短路。
701
+ // 无显式写 flag 时在 registerChange / ensureStageSteps 之前拦截,零副作用:
702
+ // - 目标 progress 不存在 → 提示「只读查询不建变更」exit 0(不 initChange、不建 default 行,治 8b)
703
+ // - 目标 progress 存在 → 只读展示,不 seed steps、不刷新 lastActive、不 registerChange
704
+ // (治 B7 多 agent 并发 lastActive 互相覆盖)
705
+ // 显式写意图不短路:doctor 写操作 flag(--cleanup-remnant / --align-execute-progress)与
706
+ // 步骤动作 flag(--done/--skip/--reset/--reopen/--wait/--continue)走原写路径;--status 本身
707
+ // 是只读查询,纳入短路。--fix 是 worktree doctor 的 flag 不在此列(design.md Phase 4)。
708
+ const READONLY_WRITE_ACTIONS = ['--cleanup-remnant', '--align-execute-progress', '--done', '--skip', '--reset', '--reopen', '--wait', '--continue']
709
+ if (READONLY_AUXILIARY_STAGES.includes(stageName) && !flags.some(f => READONLY_WRITE_ACTIONS.includes(f))) {
710
+ if (!progress) {
711
+ console.log('ℹ️ 未找到进度数据(只读查询不建变更)')
712
+ process.exit(0)
713
+ }
714
+ // 只读展示路径:复用阶段定义渲染当前步骤 prompt(outputStep 对 status/doctor 纯只读),
715
+ // 不落盘任何 progress 改动。--status 走现有 showStatus 展示。
716
+ const readOnlyChange = changeName || progress.currentChange || resolveChangeName(cwd, progress, specRoot)
717
+ if (isStatus) {
718
+ let nextSuggestion = null
719
+ try { nextSuggestion = pm._getNextSuggestion(progress) } catch { /* 读建议失败不阻断展示 */ }
720
+ return showStatus(progress, stageName, nextSuggestion)
721
+ }
722
+ const readonlySteps = await getStageSteps(stageName, cwd, progress, specRoot)
723
+ if (readonlySteps && readonlySteps.length > 0) {
724
+ // 已有进度(历史 seed 过)则渲染当前待办步;无进度从首步开始
725
+ const existingSteps = progress.stages?.[stageName]?.steps
726
+ const pendingIdx = existingSteps ? existingSteps.findIndex(s => s.status === 'pending' || s.status === 'in-progress') : -1
727
+ const stepIdx = pendingIdx !== -1 ? pendingIdx : 0
728
+ await outputStep(stageName, stepIdx, readonlySteps, cwd, readOnlyChange, progress.project || null, platformOpts)
729
+ }
730
+ return
731
+ }
732
+
661
733
  if (!progress) {
662
734
  // 如果指定了变更名或有变更目录,自动初始化变更的 progress
663
735
  const autoChange = changeName || resolveChangeNameAuto(cwd, specRoot)
@@ -703,6 +775,17 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
703
775
  console.error(' 或先运行 sillyspec run brainstorm --change <变更名> 初始化。')
704
776
  process.exit(2) // 用法错(--done 找不到变更,未传 --change)→ exit 2
705
777
  }
778
+ // task-03 (D-006@v1 / FR-05): 无 --change 仅当无已存在活跃变更时 auto-create;
779
+ // 多活跃变更仓强制 --change(exit 2 引导),防 B8 幽灵变更。活跃变更数用 pm.listChanges
780
+ // (与 progress show 同源,DB changes 表 status='active')。
781
+ const activeChanges = pm.listChanges(cwd)
782
+ if (activeChanges.length > 0) {
783
+ console.error(`❌ 已存在 ${activeChanges.length} 个活跃变更,run brainstorm 无 --change 不会自动创建新变更。`)
784
+ console.error(` 活跃变更:${activeChanges.join('、')}`)
785
+ console.error(' 请用 --change <变更名> 指定要操作的变更,')
786
+ console.error(' 或先归档/收尾现有活跃变更后重试。')
787
+ process.exit(2) // 用法错(多活跃变更未指定 --change)→ exit 2
788
+ }
706
789
  const date = new Date().toISOString().slice(0, 10)
707
790
  const autoName = `${date}-new-change-${randomBytes(4).toString('hex')}`
708
791
  console.log(`🔄 自动创建变更:${autoName}`)
@@ -883,6 +966,21 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
883
966
 
884
967
  // --done
885
968
  if (isDone) {
969
+ // task-03 (D-004@v1 / FR-02): --done 与 runStage(stage.js:35-45)同源状态转换守卫,
970
+ // 含 fromStageData 透传(让 scan failed_post_check 门控对 --done 同样生效)。
971
+ // auxiliary 的 --done 不受影响(checkTransition 对 auxiliary toStage 放行,见 stage-contract.js)。
972
+ // --skip-approval 绕过语义对齐 runStage(打错但放行)。
973
+ const prevStage = progress.currentStage || ''
974
+ const fromStageData = (progress.stages && prevStage && progress.stages[prevStage]) || undefined
975
+ const transition = checkTransition(prevStage, stageName, fromStageData ? { fromStageData } : {})
976
+ if (!transition.allowed) {
977
+ console.error(`❌ 阶段转换不允许: ${prevStage || '(起始)'} → ${stageName}`)
978
+ console.error(` 原因: ${transition.reason}`)
979
+ console.error(` 提示: 使用 --skip-approval 绕过(需明确意图)`)
980
+ if (!isSkipApproval) {
981
+ process.exit(1)
982
+ }
983
+ }
886
984
  const doneAnswer = getFlagValue('--answer')
887
985
  return await completeStep(pm, progress, stageName, cwd, outputText, inputText, { confirm: isConfirm, changeName: effectiveChange, nonInteractive: isNonInteractive && !isInteractive, platformOpts, doneAnswer, isForceBaseline, isAllowNew, isAllowDelete })
888
986
  }
@@ -24,7 +24,7 @@ import { triggerSync, WAIT_MARKER_RE, getStageSteps, formatWaitOptions, resolveR
24
24
  import { executeScanPreflight, executeScanPostcheck, computeScanProfile } from './scan-profile.js'
25
25
  import { executePlanPostcheck as runPlanPostcheckLib } from '../stages/plan-postcheck.js'
26
26
  import { outputStep } from './prompt.js'
27
- import { enforceDepsGate, enforceReviewJsonGate, completeStageGates, readDesignScale } from './gates.js'
27
+ import { enforceDepsGate, enforceReviewJsonGate, enforceSymbolImpactGate, completeStageGates, readDesignScale } from './gates.js'
28
28
  import { handleArchiveConfirmStep, handlePlanGeneratePlanStep, handleScanProjectListStep, handleWorkflowPostCheck, handleQuickStageCompletion, handleExecuteWaveArtifact } from './complete-handlers.js'
29
29
  import { formatExecuteSummary } from '../worktree-apply.js'
30
30
  import { isEndToEndTaskText } from '../change-risk-profile.js'
@@ -160,6 +160,9 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
160
160
  // review.json 字段硬门(坑 review-json-field-gap):已勾 [x] task 的 review.json 必须 schema 完整,
161
161
  // 提前到每次 --done 校验(而非等 Task Review Gate 在整阶段收尾才暴露,迫使用户事后批量补)。
162
162
  await enforceReviewJsonGate(stageName, cwd, changeName, steps[currentIdx], steps, currentIdx, specBase, platformOpts)
163
+ // 符号影响面报告硬门(ql-20260816-005-3d7f):execute「加载上下文」步产出落盘核验——
164
+ // symbol-impact.md 存在 + plan 每 task 有结论行;防前缀步被一句「上下文在会话内」盖章跳过。
165
+ await enforceSymbolImpactGate(stageName, changeName, steps[currentIdx]?.name, specBase)
163
166
 
164
167
  // ── noAI 步骤硬门(坑 noai-done-bypass):noAI 步骤的确定性校验不可被 --done 绕过 ──
165
168
  // 正常路径 agent 跑 `run <stage>` 推进到 noAI step 时,runStage 自动执行 _cliAction
@@ -323,6 +326,9 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
323
326
  console.warn('⚠️ 阶段完成 ctx 构造失败,降级单仓 gate(' + (e && e.message ? e.message : e) + ')')
324
327
  }
325
328
  const _stageGatesResult = await completeStageGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx, outputText, ctx: _completeCtx })
329
+ // task-04 / A5:gate 失败(stageCompleted===false)设进程退出码 1,对齐 quick 审计 blocked→exit 1 惯例。
330
+ // 用 process.exitCode 而非 process.exit(1):让回滚 pm._write + triggerSync 落盘完成后自然退出,保留失败现场。
331
+ if (_stageGatesResult?.stageCompleted === false) process.exitCode = 1
326
332
  if (_stageGatesResult) return _stageGatesResult
327
333
 
328
334
  // 完整流程 change title 刷新:proposal/design 落盘后(brainstorm/plan 完成),从 proposal 首个 #
@@ -805,6 +811,8 @@ export async function continueStep(pm, progress, stageName, cwd, answer, options
805
811
  console.warn('⚠️ 阶段完成 ctx 构造失败,降级单仓 gate(' + (e && e.message ? e.message : e) + ')')
806
812
  }
807
813
  const _stageGatesResult = await completeStageGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps: stageData.steps, currentIdx, outputText: null, ctx: _contCtx })
814
+ // task-04 / A5:gate 失败(stageCompleted===false)设进程退出码 1(与 completeStep 完成分支同语义)。
815
+ if (_stageGatesResult?.stageCompleted === false) process.exitCode = 1
808
816
  if (_stageGatesResult) return _stageGatesResult
809
817
  // 完整流程 change title 刷新(同 completeStep 完成分支,wait 解除后阶段完成也刷新)。
810
818
  if (changeName && !/^quick-[0-9a-f]{8}$/.test(changeName)) {
package/src/run/gates.js CHANGED
@@ -27,6 +27,68 @@ import { handleScanStageCompleted, handleExecuteWorktreeCleanup } from './comple
27
27
  import { detectConcurrentChanges, formatConcurrentWarning } from './concurrent-detect.js'
28
28
  import { stageRegistry } from '../stages/index.js'
29
29
 
30
+ /**
31
+ * 从 plan.md 提取全部 task id(task-XX)——符号影响面覆盖度校验用。
32
+ * 只认 checkbox 行 `- [ ] task-XX:` / `- [x] task-XX:`(与 execute.js parseWavesFromPlan 的
33
+ * 收容口径一致:### 标题式 task / 非 task 区 checkbox 不算),light plan 的 `## Tasks` 段同样收。
34
+ */
35
+ function extractTaskIdsFromPlan(planContent) {
36
+ if (!planContent) return []
37
+ return [...String(planContent).matchAll(/^[-*]\s*\[[ x]\]\s*(?:.*\b)?task-(\d+)\b/gim)]
38
+ .map(m => `task-${m[1].padStart(2, '0')}`)
39
+ }
40
+
41
+ /**
42
+ * 符号影响面报告结构校验(纯函数):execute「加载上下文」步的产出落盘核验。
43
+ *
44
+ * 治 persuasion-only 失效现场(ql-20260816-005-3d7f):execute 前缀 4 步无任何 gate,agent 可
45
+ * <1s 连发 4 次 --done 盖章跳过,「符号影响面扫描」(execute.js 操作 11)被一句「上下文在会话内」
46
+ * 带过(输出契约只写「上下文摘要」,操作与输出脱节)。修法 = 报告落盘
47
+ * {SPEC_ROOT}/changes/<change>/symbol-impact.md + 本函数校验:plan.md 每个 task-XX 在报告中出现
48
+ * (逐 task 结论,「无签名级变更」也要显式写,治整体一句话带过)。语义质量(调用点找没找全)
49
+ * 仍归 agent,CLI 只核结构覆盖度——符合债单「persuasion-only → 补最小硬门」原则。
50
+ *
51
+ * @param {{reportContent: string|null, planContent: string|null}} params
52
+ * @returns {{ok: boolean, errors: string[]}}
53
+ */
54
+ export function validateSymbolImpactReport({ reportContent, planContent }) {
55
+ const errors = []
56
+ if (!reportContent || !String(reportContent).trim()) {
57
+ return { ok: false, errors: ['symbol-impact.md 不存在或内容为空——「加载上下文」步的符号影响面报告必须落盘(逐 task 一行结论,「无签名级变更」也要显式写)'] }
58
+ }
59
+ const taskIds = extractTaskIdsFromPlan(planContent)
60
+ if (taskIds.length === 0) return { ok: true, errors } // plan 无 task 可列(默认兜底形态)→ 无可校验对象
61
+ const missing = taskIds.filter(id => !String(reportContent).includes(id))
62
+ for (const id of missing) {
63
+ errors.push(`${id} 未在 symbol-impact.md 中出现——每个 task 都要有一行结论(含「无签名级变更」的显式声明)`)
64
+ }
65
+ return { ok: errors.length === 0, errors }
66
+ }
67
+
68
+ /**
69
+ * 符号影响面报告硬门:execute「加载上下文」步 --done 时校验 symbol-impact.md 落盘 + task 覆盖度。
70
+ * fail-closed:不通过 → 当前步标 blocked + exit 1(进度不推进)。非目标步骤/阶段直接放行。
71
+ */
72
+ export async function enforceSymbolImpactGate(stageName, changeName, currentStepName, specBase) {
73
+ if (stageName !== 'execute') return
74
+ if (!currentStepName || !currentStepName.includes('加载上下文')) return
75
+ const planPath = join(specBase, 'changes', changeName || '', 'plan.md')
76
+ const reportPath = join(specBase, 'changes', changeName || '', 'symbol-impact.md')
77
+ if (!existsSync(planPath)) return // plan.md 缺失场景已有 plan 阶段 gate 把关,此处不重复拦
78
+ let planContent = ''
79
+ try { planContent = readFileSync(planPath, 'utf8') } catch { return }
80
+ let reportContent = null
81
+ try { reportContent = readFileSync(reportPath, 'utf8') } catch { /* 缺失由 validate 报 */ }
82
+ const result = validateSymbolImpactReport({ reportContent, planContent })
83
+ if (result.ok) return
84
+ console.error('❌ ── 符号影响面报告校验阻断(本次 --done 未完成,进度未推进)──')
85
+ console.error(` 「加载上下文」步的符号影响面检查报告未落盘或不完整:`)
86
+ console.error(` 期望路径:${reportPath}`)
87
+ for (const e of result.errors) console.error(` • ${e}`)
88
+ console.error(' 修复:按「加载上下文」步操作 11 完成符号影响面扫描,逐 task 写结论(无签名级变更也要显式写「无」),落盘后重跑 execute --done。')
89
+ process.exit(1)
90
+ }
91
+
30
92
  /**
31
93
  * 判断当前 execute step 所在 wave 是否全部 task 都声明 no_deps_verify: true(D-006@v2)。
32
94
  * 仅 wave 执行步骤(名如 "Wave N 执行")可 opt-out;非 wave 步骤恒返回 false(保守过门)。
@@ -379,7 +441,11 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
379
441
  if (!executeRunId) {
380
442
  executeRunId = generateExecuteRunId()
381
443
  // 落盘(marker 缺失时 fallback 生成后写盘,保证后续 checkbox/gate 读到同一 ID)
382
- try { mkdirSync(runtimeRoot, { recursive: true }); writeFileSync(runIdFile, executeRunId + '\n') } catch {}
444
+ // D-001#1 fallback 写入点:mkdir execute-runs/<runId>/tasks 先于 marker(不变量:
445
+ // marker 在则目录在)。不 try/catch——异常直穿外层 catch 走 fail-closed 阻断
446
+ //(gate 自身写 run 目录失败不能静默放行完成)。
447
+ mkdirSync(join(runtimeRoot, 'execute-runs', executeRunId, 'tasks'), { recursive: true })
448
+ writeFileSync(runIdFile, executeRunId + '\n')
383
449
  }
384
450
  }
385
451