sillyspec 3.26.14 → 3.27.1

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 (70) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +2 -0
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +1 -1
  3. package/.claude/skills/sillyspec-commit/SKILL.md +18 -60
  4. package/.claude/skills/sillyspec-continue/SKILL.md +2 -0
  5. package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
  6. package/.claude/skills/sillyspec-explore/SKILL.md +10 -3
  7. package/.claude/skills/sillyspec-plan/SKILL.md +10 -2
  8. package/.claude/skills/sillyspec-quick/SKILL.md +2 -2
  9. package/.claude/skills/sillyspec-resume/SKILL.md +2 -0
  10. package/.claude/skills/sillyspec-verify/SKILL.md +4 -0
  11. package/.claude/skills/sillyspec-workspace/SKILL.md +7 -1
  12. package/README.md +1 -1
  13. package/package.json +1 -1
  14. package/packages/dashboard/server/index.js +22 -6
  15. package/src/change-risk-profile.js +21 -6
  16. package/src/commit-suggest.js +202 -0
  17. package/src/config-cat.js +88 -0
  18. package/src/db.js +14 -3
  19. package/src/docs-check.js +598 -534
  20. package/src/doctor-diagnostics.js +102 -1
  21. package/src/endpoint-extractor.js +21 -2
  22. package/src/fs-atomic.js +4 -2
  23. package/src/git-helper.js +73 -5
  24. package/src/hooks/claude-pre-tool-use.cjs +10 -5
  25. package/src/hooks/worktree-guard.js +42 -4
  26. package/src/index.js +557 -12
  27. package/src/init.js +1 -1
  28. package/src/local-register.js +98 -0
  29. package/src/module-impact.js +166 -0
  30. package/src/module-resolve.js +211 -0
  31. package/src/modules.js +82 -2
  32. package/src/progress/change-registry.js +43 -4
  33. package/src/progress/stage-machine.js +10 -5
  34. package/src/progress/step-store.js +12 -8
  35. package/src/progress.js +19 -3
  36. package/src/quicklog.js +106 -1
  37. package/src/run/command.js +95 -1
  38. package/src/run/complete-handlers.js +73 -5
  39. package/src/run/complete.js +47 -14
  40. package/src/run/gates.js +144 -17
  41. package/src/run/multi-repo-context.js +3 -1
  42. package/src/run/next.js +128 -0
  43. package/src/run/prompt.js +36 -4
  44. package/src/run/quick-audit.js +5 -2
  45. package/src/run/shared.js +48 -20
  46. package/src/run/stage.js +37 -3
  47. package/src/scan-postcheck.js +142 -2
  48. package/src/stage-contract-spec.js +1 -1
  49. package/src/stage-contract.js +14 -1
  50. package/src/stage-review.js +15 -6
  51. package/src/stages/archive.js +4 -2
  52. package/src/stages/brainstorm.js +1 -0
  53. package/src/stages/execute.js +275 -44
  54. package/src/stages/explore.js +11 -2
  55. package/src/stages/knowledge.js +3 -2
  56. package/src/stages/quick.js +18 -9
  57. package/src/stages/scan.js +1 -0
  58. package/src/stages/verify.js +16 -51
  59. package/src/sync.js +44 -3
  60. package/src/task-review.js +273 -11
  61. package/src/taskcard.js +52 -22
  62. package/src/verify-postcheck.js +223 -6
  63. package/src/verify-probes.js +264 -0
  64. package/src/workspace.js +125 -0
  65. package/src/worktree-apply.js +43 -11
  66. package/src/worktree-deps.js +59 -5
  67. package/src/worktree.js +128 -14
  68. package/templates/claude-instruction.md +1 -1
  69. package/templates/prompts/taskcard-rules.md +1 -1
  70. package/templates/prompts/verify-probes.md +13 -47
@@ -282,13 +282,18 @@ export class StageMachine {
282
282
  * @returns {{ text: string, command?: string }|null}
283
283
  */
284
284
  _getNextSuggestion(data) {
285
+ // 建议命令携带 --change(坑 suggestion-command-missing-change,2026-08-21 实证):
286
+ // 多活跃变更仓 pm.read(cwd, null) 无法自动定位变更 → 照抄建议命令(不带 --change)执行
287
+ // 报「未找到进度数据」,阶段完结后的 --wait 确认门也登记不上。progress 带 currentChange
288
+ // 时附加;无(测试 fixture / 旧数据)保持裸命令(存量断言零回归)。
289
+ const cc = data.currentChange ? ` --change ${data.currentChange}` : '';
285
290
  // 找到第一个 revising 阶段
286
291
  const revisingStage = STAGE_ORDER.find(s => data.stages[s]?.status === 'revising');
287
292
  if (revisingStage) {
288
293
  const sd = data.stages[revisingStage];
289
294
  return {
290
295
  text: `${STAGE_LABELS[revisingStage] || revisingStage} 正在修订中(revision ${sd.revision || 1}),请继续完成修订。`,
291
- command: `sillyspec run ${revisingStage}`,
296
+ command: `sillyspec run ${revisingStage}${cc}`,
292
297
  };
293
298
  }
294
299
 
@@ -298,7 +303,7 @@ export class StageMachine {
298
303
  const sd = data.stages[staleStage];
299
304
  return {
300
305
  text: `${STAGE_LABELS[staleStage] || staleStage} 已失效(${sd.staleReason || '上游修订'}),需要从第一步重建。`,
301
- command: `sillyspec run ${staleStage} --reopen --from-step 1`,
306
+ command: `sillyspec run ${staleStage} --reopen --from-step 1${cc}`,
302
307
  };
303
308
  }
304
309
 
@@ -318,14 +323,14 @@ export class StageMachine {
318
323
  const ws = sd.steps[waitIdx];
319
324
  return {
320
325
  text: `${STAGE_LABELS[s] || s} 进行中,Step ${waitIdx + 1}「${ws.name}」等待用户输入${ws.waitReason ? `(${ws.waitReason})` : ''},用 --answer 恢复。`,
321
- command: `sillyspec run ${s} --continue --answer "用户回答"`,
326
+ command: `sillyspec run ${s} --continue --answer "用户回答"${cc}`,
322
327
  };
323
328
  }
324
329
  const hasPending = sd.steps.some(st => ['pending', 'waiting', 'failed'].includes(st.status));
325
330
  if (hasPending) {
326
331
  return {
327
332
  text: `${STAGE_LABELS[s] || s} 进行中,继续执行下一步。`,
328
- command: `sillyspec run ${s}`,
333
+ command: `sillyspec run ${s}${cc}`,
329
334
  };
330
335
  }
331
336
  }
@@ -349,7 +354,7 @@ export class StageMachine {
349
354
  if (upstreamOk) {
350
355
  return {
351
356
  text: `可以开始 ${STAGE_LABELS[s] || s}。`,
352
- command: `sillyspec run ${s}`,
357
+ command: `sillyspec run ${s}${cc}`,
353
358
  };
354
359
  }
355
360
  }
@@ -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,12 +13,14 @@
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';
19
20
  import { ChangeRegistry } from './progress/change-registry.js';
20
21
  import { StepStore } from './progress/step-store.js';
21
22
  import { StageMachine } from './progress/stage-machine.js';
23
+ import { stageRegistry } from './stages/index.js';
22
24
  import { STAGE_ORDER, MAIN_FLOW_ORDER, VALID_STAGES, STAGE_LABELS, SPEC_DIR_NAME, CURRENT_VERSION, emptyStage } from './progress/shared.js';
23
25
  // resolveSpecDir 单一真相源在 src/run/shared.js(含 home 拒绝守卫,坑 cwd-correction-home-collision
24
26
  // 根治:home 下 .sillyspec 恒不命中,防 smoke/临时目录污染自我延续)。此处 re-export 保持
@@ -812,7 +814,18 @@ export class ProgressManager {
812
814
 
813
815
  renameChange(cwd, oldName, newName) { return this._changeRegistry.renameChange(cwd, oldName, newName); }
814
816
 
815
- unregisterChange(cwd, changeName) { return this._changeRegistry.unregisterChange(cwd, changeName); }
817
+ // opts.archiveStepNames 给定时做归档终态一致化收尾(见 change-registry.unregisterChange 注释,
818
+ // 坑 manual-archive-desync-status-only);archiveStepNamesForArchive() 取 stageRegistry 单一真相
819
+ //(stages/* 不反向依赖 progress.js,静态 import 无环)。
820
+ unregisterChange(cwd, changeName, opts) { return this._changeRegistry.unregisterChange(cwd, changeName, opts); }
821
+
822
+ archiveStepNamesForArchive() {
823
+ if (!this._archiveStepNamesCache) {
824
+ const defs = stageRegistry?.archive?.steps
825
+ this._archiveStepNamesCache = Array.isArray(defs) ? defs.map(s => s.name) : []
826
+ }
827
+ return this._archiveStepNamesCache
828
+ }
816
829
 
817
830
  updateChangeMeta(cwd, changeName, meta) { return this._changeRegistry.updateChangeMeta(cwd, changeName, meta); }
818
831
 
@@ -1003,9 +1016,12 @@ export class ProgressManager {
1003
1016
  if (existsSync(gitignorePath)) {
1004
1017
  const content = readFileSync(gitignorePath, 'utf8');
1005
1018
  if (content.includes(rule)) return;
1006
- 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);
1007
1023
  } else {
1008
- writeFileSync(gitignorePath, rule + '\n');
1024
+ writeAtomicSync(gitignorePath, rule + '\n');
1009
1025
  }
1010
1026
  }
1011
1027
 
package/src/quicklog.js CHANGED
@@ -14,7 +14,7 @@ import { join, dirname, basename } from 'path'
14
14
  import { getRule } from './stage-contract-spec.js'
15
15
  import {
16
16
  openSync, closeSync, unlinkSync, statSync, mkdirSync, existsSync,
17
- readFileSync, writeFileSync, writeSync, readdirSync, renameSync, appendFileSync,
17
+ readFileSync, writeFileSync, writeSync, readdirSync, renameSync, appendFileSync, rmSync,
18
18
  } from 'fs'
19
19
  import { randomBytes } from 'crypto'
20
20
 
@@ -722,3 +722,108 @@ export function findQuicklogEntry(specBase, gitUser, qlId) {
722
722
  }
723
723
  return false
724
724
  }
725
+
726
+ /**
727
+ * 取消一个 quick 会话(2026-08-21 agent-手工产出审计第四批 C-2,`run quick --cancel` 数据源)。
728
+ *
729
+ * 误启动/放弃的 quick 会话此前无取消出口:QUICKLOG「进行中」条目 + 关联变更 tasks.md 挂载行
730
+ * + .runtime/quick-sessions/<id>/ 永久残留,doctor --cleanup-ghosts 只清 db 幽灵行不清这三处。
731
+ * 本函数做 allocateQuicklogEntry 的反操作(三步全是已有函数的反面):
732
+ * 1. QUICKLOG 条目状态翻「已取消」(非删除——保留痕迹可审计,与 doctor 宁漏勿杀口径一致)
733
+ * 2. tasks.md 里该 qlId 的挂载行整行移除(未勾的空壳行,勾了说明实际做了工作 → 拒绝取消)
734
+ * 3. 会话 guard 目录删除(quick-sessions/<sessionId>/,含 guard.json)
735
+ * 4. current-quick-run-id 若指向本会话则删除(防后续 --done fallback 命中死会话)
736
+ * db 侧 change 行由调用方 unregisterChange(quick 会话行,非真实变更)。
737
+ *
738
+ * fail-closed:已翻「已完成」的条目拒绝取消(那是真实工作);tasks.md 挂载行已勾选同样拒绝。
739
+ *
740
+ * @param {{ specBase: string, gitUser: string, qlId: string, sessionId: string|null }} opts
741
+ * @returns {Promise<{ ok: boolean, reason?: string, quicklogFile?: string, removedTaskRows: string[] }>}
742
+ */
743
+ export async function cancelQuickSession({ specBase, gitUser, qlId, sessionId = null }) {
744
+ if (!qlId) return { ok: false, reason: '缺 qlId(--change <quick-会话ID> 或 --ql <ql-xxx>)' }
745
+ const quicklogDir = join(specBase, 'quicklog')
746
+ const user = sanitizeQuicklogUser(gitUser) || 'unknown'
747
+ const lockPath = join(quicklogDir, `.QUICKLOG-${user}.md.lock`)
748
+
749
+ // 1. QUICKLOG 翻「已取消」:找到条目,校验未完成,就地改状态行
750
+ let quicklogFile = null
751
+ let flipped = false
752
+ await withFileLock(lockPath, async () => {
753
+ // 用户文件缺失时扫全部 QUICKLOG 文件(条目可能在别的 user 文件里,如 CI 场景 user 漂移)
754
+ const files = existsSync(join(quicklogDir, `QUICKLOG-${user}.md`))
755
+ ? [`QUICKLOG-${user}.md`]
756
+ : listQuicklogFiles(quicklogDir)
757
+ for (const f of files) {
758
+ const p = join(quicklogDir, f)
759
+ const content = existsSync(p) ? readFileSync(p, 'utf8') : ''
760
+ if (!content.includes(`## ${qlId} |`)) continue
761
+ quicklogFile = p
762
+ const lines = content.split('\n')
763
+ const startIdx = lines.findIndex(l => l.startsWith(`## ${qlId} |`))
764
+ let endIdx = lines.length
765
+ for (let i = startIdx + 1; i < lines.length; i++) {
766
+ if (lines[i].startsWith('## ')) { endIdx = i; break }
767
+ }
768
+ for (let i = startIdx + 1; i < endIdx; i++) {
769
+ if (/^状态:已完成/.test(lines[i])) {
770
+ throw new Error(`${qlId} 已完成(真实工作记录),拒绝取消;如需修正请手改 QUICKLOG`)
771
+ }
772
+ }
773
+ for (let i = startIdx + 1; i < endIdx; i++) {
774
+ if (/^状态:进行中/.test(lines[i])) {
775
+ lines[i] = '状态:已取消'
776
+ flipped = true
777
+ break
778
+ }
779
+ }
780
+ if (flipped) {
781
+ const eol = content.includes('\r\n') ? '\r\n' : '\n'
782
+ writeFileSync(p, lines.join(eol === '\r\n' ? '\r\n' : '\n'))
783
+ }
784
+ break
785
+ }
786
+ })
787
+ if (!quicklogFile) return { ok: false, reason: `未在任何 QUICKLOG 文件找到条目 ${qlId}` }
788
+ if (!flipped) return { ok: false, reason: `${qlId} 状态行异常(非进行中也非已完成),请手查 QUICKLOG` }
789
+
790
+ // 2. tasks.md 挂载行移除(所有活跃变更统一扫——allocate 时可能挂多个关联变更)
791
+ const removedTaskRows = []
792
+ const changesRoot = join(specBase, 'changes')
793
+ if (existsSync(changesRoot)) {
794
+ for (const c of readdirSync(changesRoot, { withFileTypes: true })) {
795
+ if (!c.isDirectory() || c.name === 'archive') continue
796
+ const tp = tasksPath(specBase, c.name)
797
+ if (!existsSync(tp)) continue
798
+ await withFileLock(tasksLockPath(tp), () => {
799
+ const content = readFileSync(tp, 'utf8')
800
+ if (!content.includes(qlId)) return
801
+ const lines = content.split('\n')
802
+ const kept = lines.filter(l => {
803
+ const m = l.match(/^[-*]\s*\[([ xX])\]\s*(\S+)/)
804
+ if (m && m[2] === qlId) {
805
+ if (m[1] !== ' ') throw new Error(`${c.name}/tasks.md 的 ${qlId} 行已勾选(实际做了工作),拒绝取消`)
806
+ removedTaskRows.push(`${c.name}/tasks.md`)
807
+ return false
808
+ }
809
+ return true
810
+ })
811
+ if (kept.length !== lines.length) {
812
+ const eol = content.includes('\r\n') ? '\r\n' : '\n'
813
+ writeFileSync(tp, kept.join(eol === '\r\n' ? '\r\n' : '\n'))
814
+ }
815
+ })
816
+ }
817
+ }
818
+
819
+ // 3+4. 会话目录与 current marker
820
+ if (sessionId) {
821
+ try { rmSync(join(specBase, '.runtime', 'quick-sessions', sessionId), { recursive: true, force: true }) } catch {}
822
+ const marker = join(specBase, '.runtime', 'current-quick-run-id')
823
+ try {
824
+ if (existsSync(marker) && readFileSync(marker, 'utf8').trim() === sessionId) unlinkSync(marker)
825
+ } catch {}
826
+ }
827
+
828
+ return { ok: true, quicklogFile, removedTaskRows }
829
+ }
@@ -44,6 +44,8 @@ const VALUE_FLAGS = new Set([
44
44
  '--output', '--input', '--change', '--linked-changes',
45
45
  '--spec-dir', '--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
46
46
  '--files', '--file-notes', '--from-step', '--mode', '--dir', '--confirm-mode',
47
+ '--req', '--cause', '--solution', '--result', // quick 末步四字段参数(合成 --output,见 outputText 解析段)
48
+ '--ql', // quick --cancel 显式指定 qlId(缺省读会话 guard.json)
47
49
  '--base', // scan diff 基线 commit(吃值;只在 run scan --diff 转发路径消费)
48
50
  ])
49
51
 
@@ -232,6 +234,18 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
232
234
  const isStatus = flags.includes('--status')
233
235
  const isReset = flags.includes('--reset')
234
236
  const isReopen = flags.includes('--reopen')
237
+ // --cancel(2026-08-21 审计第四批 C-2):quick 误启动会话的官方取消口。取消 = QUICKLOG 条目
238
+ // 翻「已取消」+ tasks.md 挂载行移除 + 会话 guard 目录清理 + db 行注销(取消 quick 会话行,
239
+ // 非真实变更)。fail-closed:已完成/已勾选的拒绝(真实工作记录)。
240
+ const isCancel = flags.includes('--cancel')
241
+ if (isCancel && stageName !== 'quick') {
242
+ console.error('❌ --cancel 仅支持 quick 阶段(sillyspec run quick --cancel --change <quick-会话ID>)')
243
+ process.exit(2)
244
+ }
245
+ if (isCancel && (isDone || isSkip || isReset || isReopen || isStatus)) {
246
+ console.error('❌ --cancel 与 --done/--skip/--reset/--reopen/--status 互斥')
247
+ process.exit(2)
248
+ }
235
249
  const fromStepValue = getFlagValue('--from-step')
236
250
  const isConfirm = flags.includes('--confirm')
237
251
  const isSkipApproval = flags.includes('--skip-approval')
@@ -425,6 +439,28 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
425
439
  const outputValue = getFlagValue('--output')
426
440
  if (outputValue !== null) outputText = outputValue
427
441
 
442
+ // 解析 quick 末步四字段参数(2026-08-21 agent-手工产出审计第二批 F6):--req/--cause/
443
+ // --solution/--result 各传一项,CLI 合成单行四字段 outputText(quicklog 落盘侧
444
+ // splitSingleLineFields 会归一为多行字段块)。消灭 agent 手拼「需求:… 根因:…」模板的
445
+ // 格式事故面(嵌套全角冒号拆分误判 / 缺字段被 --done 拒 / 标题截断规则踩坑)。
446
+ // 任一四参数出现即要求全四;与 --output 互斥(两路同时给说明意图不明,fail-fast)。
447
+ {
448
+ const FOUR = [['--req', '需求:'], ['--cause', '根因:'], ['--solution', '方案:'], ['--result', '结果:']]
449
+ const fourVals = FOUR.map(([f]) => [f, getFlagValue(f)])
450
+ if (fourVals.some(([, v]) => v !== null)) {
451
+ if (outputText !== null) {
452
+ console.error('❌ --req/--cause/--solution/--result 与 --output 互斥——四参数由 CLI 合成结构化 output,勿混用');
453
+ process.exit(2)
454
+ }
455
+ const missing = fourVals.filter(([, v]) => v === null).map(([f]) => f)
456
+ if (missing.length > 0) {
457
+ console.error(`❌ quick 末步四字段参数缺项:${missing.join(' ')}(--req/--cause/--solution/--result 必须全给,或缺字段 --done 会被拒)`);
458
+ process.exit(2)
459
+ }
460
+ outputText = FOUR.map(([f, label]) => `${label}${getFlagValue(f)}`).join(' ')
461
+ }
462
+ }
463
+
428
464
  // 解析 --input
429
465
  let inputText = null
430
466
  const inputValue = getFlagValue('--input')
@@ -588,10 +624,11 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
588
624
 
589
625
  // 未知参数 fail-fast
590
626
  const knownFlags = new Set([
591
- '--done', '--skip', '--status', '--reset', '--confirm', '--skip-approval',
627
+ '--done', '--skip', '--status', '--reset', '--confirm', '--skip-approval', '--cancel', '--ql',
592
628
  '--wait', '--continue', '--non-interactive', '--interactive',
593
629
  '--reason', '--options', '--answer', '--confirm-mode',
594
630
  '--output', '--input', '--change', '--linked-changes',
631
+ '--req', '--cause', '--solution', '--result',
595
632
  '--spec-dir', '--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
596
633
  '--files', '--file-notes', '--allow-new', '--allow-delete', '--force-baseline', '--force-rescan',
597
634
  '--json', '--dir', '--help',
@@ -830,6 +867,15 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
830
867
  changeName = autoName
831
868
  } else {
832
869
  console.error('❌ 未找到进度数据,请先运行 sillyspec init 或指定 --change <变更名>')
870
+ // 自愈引导(坑 suggestion-command-missing-change 同族):多活跃变更仓不带 --change 时
871
+ // pm.read 无法自动定位(单活跃可自动),列出候选让 agent 一步补对,而非盲猜变更名
872
+ try {
873
+ const activeChanges = pm.listChanges(cwd)
874
+ if (activeChanges.length > 1) {
875
+ console.error(` 当前有 ${activeChanges.length} 个活跃变更(无法自动定位),请显式指定:`)
876
+ for (const c of activeChanges) console.error(` - --change ${c}`)
877
+ }
878
+ } catch { /* 列举失败不掩盖原报错 */ }
833
879
  process.exit(2) // 环境错(未 init / 未传 --change)→ exit 2
834
880
  }
835
881
  }
@@ -950,6 +996,54 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
950
996
  }
951
997
  }
952
998
 
999
+ // ── quick --cancel:误启动会话的取消口(数据源 quicklog.js cancelQuickSession)──
1000
+ // --change 取 quick-<uuid8> 会话 ID(quick 启动时 CLI echo 的 sessionId);qlId 从会话 guard
1001
+ // 或 --ql 显式参数解析。取消后 db 行注销(quick 会话行非真实变更,直接 unregister 安全)。
1002
+ if (stageName === 'quick' && isCancel) {
1003
+ const cancelSessionRaw = quickSessionId || changeName
1004
+ const qlFlag = getFlagValue('--ql')
1005
+ if (!cancelSessionRaw || cancelSessionRaw === 'quick-unknown') {
1006
+ console.error('用法: sillyspec run quick --cancel --change <quick-会话ID> [--ql <ql-xxx>]\n 取消误启动的 quick 会话:QUICKLOG 条目翻「已取消」+ tasks.md 挂载行移除 + 会话目录清理 + db 行注销。\n 已完成/已勾选的会话拒绝取消(真实工作记录)')
1007
+ process.exit(2)
1008
+ }
1009
+ const specBase = resolveSpecDir(cwd, { specDir })
1010
+ // qlId 优先 --ql 显式;否则读会话 guard.json(allocate 时 CLI 写入);再退 current marker 旁的会话
1011
+ let qlId = qlFlag
1012
+ if (!qlId) {
1013
+ const guardPath = join(specBase, '.runtime', 'quick-sessions', cancelSessionRaw, 'guard.json')
1014
+ try {
1015
+ if (existsSync(guardPath)) qlId = JSON.parse(readFileSync(guardPath, 'utf8')).qlId
1016
+ } catch { /* guard 损坏 → 下面按找不到报错 */ }
1017
+ }
1018
+ if (!qlId) {
1019
+ console.error(`❌ 无法定位会话 ${cancelSessionRaw} 的 qlId(guard.json 缺失/损坏)——显式传 --ql <ql-xxx>(QUICKLOG 条目头)`)
1020
+ process.exit(1)
1021
+ }
1022
+ let gitUser = 'unknown'
1023
+ try { gitUser = (await import('../git-helper.js')).git(cwd, ['config', 'user.name']) || 'unknown' } catch {}
1024
+ const { cancelQuickSession } = await import('../quicklog.js')
1025
+ let result
1026
+ try {
1027
+ result = await cancelQuickSession({ specBase, gitUser, qlId, sessionId: cancelSessionRaw })
1028
+ } catch (e) {
1029
+ console.error(`❌ 取消失败: ${e.message}`)
1030
+ process.exit(1)
1031
+ }
1032
+ if (!result.ok) {
1033
+ console.error(`❌ 取消失败: ${result.reason}`)
1034
+ process.exit(1)
1035
+ }
1036
+ // db 侧:quick 会话行注销(quick-<uuid8> 是进度库里的会话行,非真实变更目录)
1037
+ try {
1038
+ pm.unregisterChange(cwd, cancelSessionRaw)
1039
+ } catch { /* 行不存在/平台模式 → 会话目录清理已完成,db 无行可注销不算失败 */ }
1040
+ console.log(`🗑️ 已取消 quick 会话 ${cancelSessionRaw}(qlId ${qlId})`)
1041
+ console.log(` QUICKLOG 条目翻「已取消」: ${result.quicklogFile}`)
1042
+ if (result.removedTaskRows.length > 0) console.log(` tasks.md 挂载行已移除: ${result.removedTaskRows.join('、')}`)
1043
+ console.log(' 会话 guard 目录与 current marker 已清理(已完成条目不会被取消)')
1044
+ return
1045
+ }
1046
+
953
1047
  // quick 启动(非 --done):reset steps + 写 current-quick-run-id(本会话 sessionId,作 --done fallback)。
954
1048
  // sessionId 已在参数解析阶段生成(quickSessionId == changeName == quick-<uuid8>),
955
1049
  // 这里复用同一值写 current-quick-run-id,保证两处一致。
@@ -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`)
@@ -283,7 +293,10 @@ export async function archiveChangeDirectory(pm, cwd, progress, specBase, platfo
283
293
  const alreadyArchivedDir = findAlreadyArchivedDir(archiveDir, archiveChangeName)
284
294
  if (alreadyArchivedDir) {
285
295
  console.log(`ℹ️ 源目录不存在但变更已在 archive/(${basename(alreadyArchivedDir)}),判定已归档,自愈进度 DB`)
286
- pm.unregisterChange(cwd, archiveChangeName)
296
+ // 终态一致化(坑 manual-archive-desync-status-only):手动搬目录绕过标准归档后,本自愈路径
297
+ // 不能只翻 status——同时收尾 archive 阶段(steps/stages/current_stage),否则推送平台的终态
298
+ // 是「已归档 + 归档 0/5 + 停在 execute」的矛盾体,详情页渲染成「进度丢失」
299
+ pm.unregisterChange(cwd, archiveChangeName, { archiveStepNames: typeof pm.archiveStepNamesForArchive === 'function' ? pm.archiveStepNamesForArchive() : null })
287
300
  await archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platformOpts)
288
301
  console.log(`📦 已自愈归档:${archiveChangeName} → archive/${basename(alreadyArchivedDir)}/`)
289
302
  return alreadyArchivedDir
@@ -332,7 +345,9 @@ export async function archiveChangeDirectory(pm, cwd, progress, specBase, platfo
332
345
  process.exit(1)
333
346
  }
334
347
 
335
- pm.unregisterChange(cwd, archiveChangeName)
348
+ // 正常路径同样走终态一致化(坑 manual-archive-desync-status-only):标准 --done --confirm 流程
349
+ // 中 completeStep 随后也会逐项完成,此处先收尾是幂等写同值——保证任何走出本函数的归档终态一致
350
+ pm.unregisterChange(cwd, archiveChangeName, { archiveStepNames: typeof pm.archiveStepNamesForArchive === 'function' ? pm.archiveStepNamesForArchive() : null })
336
351
 
337
352
  // CLI 下沉 git add(坑4,FR-04):确定性暂存归档目录 + 模块文档,不靠 step5 prompt 驱动。
338
353
  // step5 prompt 的 git add 保留作幂等兜底;POSIX 路径跨平台(git 接受正斜杠)。
@@ -343,6 +358,53 @@ export async function archiveChangeDirectory(pm, cwd, progress, specBase, platfo
343
358
  safeGit(cwd, ['add', '--', '.sillyspec/docs/'])
344
359
  } catch {}
345
360
 
361
+ // ── 他者半归档残留探测(坑 archive-other-residual-rename,2026-08-21 实证)──
362
+ // 并行变更的手动归档把 R 残留(源目录 rename 目标行)留在暂存区;本变更归档提交时
363
+ // git status 显示它们,agent 会误判「还没提交完 / 要做第二次提交」。区分:
364
+ // 本变更:未暂存的源侧移动(` D changes/<me>/...` + 未跟踪 archive/<me>/...)→ 补暂存
365
+ // 让归档成单次原子提交;
366
+ // 他者:已暂存的 rename(`R changes/<他人>/... -> changes/archive/<他人>/...`)→ 只 warn
367
+ // 提示归属(不 stage 不动——别人的归别人的)。
368
+ try {
369
+ const raw = gitQuiet(cwd, ['status', '--porcelain'], { trim: false, timeout: 30000 })
370
+ if (raw && raw.trim()) {
371
+ const changesPrefix = '.sillyspec/changes/'
372
+ const minePaths = []
373
+ const othersResidual = []
374
+ for (const line of raw.split('\n')) {
375
+ if (!line || line.length < 4) continue
376
+ const x = line[0], y = line[1]
377
+ const body = line.slice(3).trim()
378
+ const arrow = body.indexOf(' -> ')
379
+ const src = arrow !== -1 ? body.slice(0, arrow).replace(/^"|"$/g, '') : null
380
+ const dst = (arrow !== -1 ? body.slice(arrow + 4) : body).replace(/^"|"$/g, '')
381
+ if (x === 'R' && y === ' ' && arrow !== -1 && dst.startsWith(changesPrefix + 'archive/')) {
382
+ // 已暂存 rename → 半归档残留;按目标目录名归属他者变更
383
+ const m = /^\.sillyspec\/changes\/archive\/([^/]+)\//.exec(dst)
384
+ const owner = m ? m[1] : '?'
385
+ if (owner !== archiveChangeName) othersResidual.push(owner)
386
+ } else if (x === ' ' && (y === 'D' || y === 'A' || y === 'M')) {
387
+ // 未暂存的源侧移动:源目录 D / 新位置 A——指向本变更的补暂存
388
+ const p = dst || src
389
+ if (p && (p.startsWith(changesPrefix + archiveChangeName + '/')
390
+ || p.startsWith(changesPrefix + 'archive/' + archiveChangeName + '/'))) {
391
+ minePaths.push(p)
392
+ }
393
+ }
394
+ }
395
+ if (minePaths.length > 0) {
396
+ try { safeGit(cwd, ['add', '-A', '--', changesPrefix]) } catch {}
397
+ console.log(`🧾 已补暂存本变更归档的源侧移动(${minePaths.length} 项,归档成单次原子提交)`)
398
+ }
399
+ if (othersResidual.length > 0) {
400
+ const owners = [...new Set(othersResidual)].filter(Boolean)
401
+ console.warn(`⚠️ 检测到「他者半归档」残留(暂存区存在其他变更的 rename 记录):${owners.join('、')}`)
402
+ console.warn(` 这些是别的变更此前手动归档留下的暂存项,不属于本次归档——git status 里看到它们是正常的,`)
403
+ console.warn(` 本变更归档已完成,无需为其做第二次提交;如需清理走它们自己的收尾(或 git restore --staged 后核对)。`)
404
+ }
405
+ }
406
+ } catch { /* 探测失败不阻断归档(advisory) */ }
407
+
346
408
  // 归档时清理可能残留的 worktree(自愈路径也复用,见上方 srcDir 缺失分支)。
347
409
  await archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platformOpts)
348
410
 
@@ -371,6 +433,11 @@ export async function handleArchiveConfirmStep({ stageName, steps, currentIdx, c
371
433
  }
372
434
  const archivedDir = await archiveChangeDirectory(pm, cwd, progress, specBase, platformOpts)
373
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'
374
441
  const recommendedDocs = ['design.md', 'module-impact.md']
375
442
  const missingRecommended = recommendedDocs.filter(d => !existsSync(join(archivedDir, d)))
376
443
  if (missingRecommended.length > 0) {
@@ -1037,7 +1104,8 @@ async function closeSingleQuickLinkedChange({ pm, cwd, specBase, changeName, pla
1037
1104
  return { closed: false, reason: `移动目录失败:${e.message}` }
1038
1105
  }
1039
1106
 
1040
- pm.unregisterChange(cwd, changeName)
1107
+ // 终态一致化同标准归档(坑 manual-archive-desync-status-only):轻量归档路径不能只翻 status
1108
+ pm.unregisterChange(cwd, changeName, { archiveStepNames: typeof pm.archiveStepNamesForArchive === 'function' ? pm.archiveStepNamesForArchive() : null })
1041
1109
  await archiveWorktreeCleanup(cwd, changeName, specBase, platformOpts)
1042
1110
  safeGit(cwd, ['add', '--', `.sillyspec/changes/archive/${destName}/`])
1043
1111
 
@@ -222,10 +222,13 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
222
222
  }
223
223
 
224
224
  // ── execute deps 验证硬门(change 2026-06-28-worktree-deps-provision)──
225
- await enforceDepsGate(stageName, cwd, changeName, steps[currentIdx], steps, currentIdx, specBase, platformOpts)
225
+ // persist 回调:gate 置 step=blocked 后 exit(1) 前落库(否则 blocked 只在内存,
226
+ // progress show/doctor 看到的仍是 pending,误导诊断)
227
+ const persistBlocked = () => pm._write(cwd, progress, changeName)
228
+ await enforceDepsGate(stageName, cwd, changeName, steps[currentIdx], steps, currentIdx, specBase, platformOpts, persistBlocked)
226
229
  // review.json 字段硬门(坑 review-json-field-gap):已勾 [x] task 的 review.json 必须 schema 完整,
227
230
  // 提前到每次 --done 校验(而非等 Task Review Gate 在整阶段收尾才暴露,迫使用户事后批量补)。
228
- await enforceReviewJsonGate(stageName, cwd, changeName, steps[currentIdx], steps, currentIdx, specBase, platformOpts)
231
+ await enforceReviewJsonGate(stageName, cwd, changeName, steps[currentIdx], steps, currentIdx, specBase, platformOpts, persistBlocked)
229
232
  // 符号影响面报告硬门(ql-20260816-005-3d7f):execute「加载上下文」步产出落盘核验——
230
233
  // symbol-impact.md 存在 + plan 每 task 有结论行;防前缀步被一句「上下文在会话内」盖章跳过。
231
234
  await enforceSymbolImpactGate(stageName, changeName, steps[currentIdx]?.name, specBase)
@@ -456,7 +459,7 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
456
459
  console.log(`\n${summary}`)
457
460
  } catch (e) {
458
461
  // summary 失败不影响主流程
459
- console.log('\n👉 下一步:sillyspec run verify(验证通过后才能归档)')
462
+ console.log(`\n👉 下一步:sillyspec run verify${changeName ? ` --change ${changeName}` : ''}(验证通过后才能归档)`)
460
463
  }
461
464
  } else if (stageName === 'archive') {
462
465
  console.log('\n👉 归档完成!现在可以提交了:git commit -m "..."')
@@ -534,12 +537,9 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
534
537
  // 易被 tail 截断),agent 常到 --done 被拦才第一次见到模板。推进到末步的此刻用底部短块预告
535
538
  // 模板 + 可照抄命令,省一轮拦截往返(拦截路径保留兜底)。
536
539
  if (stageName === 'quick' && nextPendingIdx === steps.length - 1) {
537
- console.log(`\n📌 本步是 quick 末步:--done 的 --output 必须含四字段(CLI 硬校验,缺任一项会被拒、补全重跑不丢进度)。预告模板:`)
538
- console.log(` sillyspec run quick --done${changeName ? ` --change ${changeName}` : ''} --output "需求:一句话语义化短标题(即 QUICKLOG 条目标题)`)
539
- console.log(` 根因:为什么这样改(纯新增/样式则写「无,纯新增/纯样式」)`)
540
- console.log(` 方案:怎么改的`)
541
- console.log(` 结果:验证情况(测试数 / lint / typecheck / 部署状态)"`)
542
- console.log(` 四字段逐项一句话、不可「见前述」;可选 --file-notes "path::括注 || path2" 落多行文件括注`)
540
+ console.log(`\n📌 本步是 quick 末步:--done 必须给全四字段(CLI 硬校验,缺任一项会被拒、补全重跑不丢进度)。推荐四参数形式(CLI 自动合成,无格式事故面):`)
541
+ console.log(` sillyspec run quick --done${changeName ? ` --change ${changeName}` : ''} --req "一句话语义化短标题(即 QUICKLOG 条目标题)" --cause "为什么这样改(纯新增/样式则写「无,纯新增/纯样式」)" --solution "怎么改的" --result "验证情况(测试数 / lint / typecheck / 部署状态)"`)
542
+ console.log(` 兼容旧形式:--output "需求:… 根因:… 方案:… 结果:…"(四字段逐项一句话、不可「见前述」,正文内禁嵌套全角冒号);可选 --file-notes "path::括注 || path2" 落多行文件括注`)
543
543
  }
544
544
  }
545
545
  return { stageCompleted: false, currentIdx, nextPendingIdx }
@@ -597,6 +597,22 @@ async function buildDraftContext(cwd, changeName) {
597
597
  }
598
598
  }
599
599
 
600
+ /**
601
+ * 预取 base..head 全量 diff 文件集(一次 git spawn),供草稿零 diff 守卫按 task 内存归属判定。
602
+ * 此前每草稿 task 一次 `git diff --name-only base..head -- <files>`(同一对 base..head 查 N 次,
603
+ * 8 task ≈ 8-16 次串行 spawn)。失败返回 null → 调用方回退逐 task 实测路径。
604
+ */
605
+ function prefetchDiffFileSet(ctx) {
606
+ if (!ctx?.gitDir || !ctx.base || !ctx.head) return ctx
607
+ try {
608
+ const out = gitQuiet(ctx.gitDir, ['diff', '--name-only', `${ctx.base}..${ctx.head}`], { trim: true })
609
+ if (!out) return ctx
610
+ return { ...ctx, diffFileSet: new Set(out.split('\n').filter(Boolean)) }
611
+ } catch {
612
+ return ctx
613
+ }
614
+ }
615
+
600
616
  /**
601
617
  * 判定 task 是否该被 autoCheckPlanFromReviews 自动勾选。
602
618
  * 端到端/deployment-critical task 要求 review spec+quality 双 pass(cannot_verify 不算,防批量完成
@@ -631,6 +647,16 @@ export function shouldAutoCheckTask(r, endToEnd, ctx = null) {
631
647
  console.warn(`⚠️ ctx 信息不完整(gitDir=${ctx.gitDir}, base=${ctx.base}, head=${ctx.head}),跳过草稿 diff 校验,保守不勾`)
632
648
  return false
633
649
  }
650
+ // 预取路径:一次全量 diff 文件集内存归属(autoCheckPlanFromReviews/批量路径已 prefetch)
651
+ if (ctx.diffFileSet) {
652
+ const hit = changedFiles.filter(f => ctx.diffFileSet.has(f))
653
+ if (hit.length === 0) {
654
+ console.warn(`⚠️ 草稿 review 实测 diff 为空(base=${ctx.base.slice(0, 8)}, head=${ctx.head.slice(0, 8)}, files=${changedFiles.length}),跳过自动勾选`)
655
+ return false
656
+ }
657
+ console.log(` ✓ 草稿 diff 校验通过(实测 ${hit.length}/${changedFiles.length} 个声明文件有改动)`)
658
+ return true
659
+ }
634
660
  try {
635
661
  // 实测 diff:git diff --name-only <base>..<head> -- <changedFiles>
636
662
  // 用数组参数避免 shell 拆词,复用 git-helper 安全模式
@@ -673,7 +699,7 @@ async function autoCheckPlanFromReviews({ stageName, changeName, cwd, platformOp
673
699
  const executeRunId = c
674
700
 
675
701
  // W2 task-04 FR-03:构造 ctx 供草稿零 diff 守卫用(与 task-review.js generateTaskReviewDrafts 同源)
676
- const ctx = await buildDraftContext(cwd, changeName)
702
+ const ctx = prefetchDiffFileSet(await buildDraftContext(cwd, changeName))
677
703
 
678
704
  // tasks.md 是 agent 与 CLI 都会写的共享文件(agent 勾 checkbox、此处 autoCheck 也勾选)。
679
705
  // 读-改-写必须整体持锁(withFileLock 串行化多进程),否则并发 execute --done / 手动勾选互相覆盖
@@ -751,7 +777,7 @@ async function detectExecuteBatchFinish({ pm, stageName, changeName, cwd, specBa
751
777
  }
752
778
 
753
779
  // 构造 ctx 供草稿零 diff 校验用(与 task-04 同源)
754
- const ctx = await buildDraftContext(cwd, changeName)
780
+ const ctx = prefetchDiffFileSet(await buildDraftContext(cwd, changeName))
755
781
  const runtimeRoot = resolveRuntimeRoot({ specRoot: specBase }, specBase)
756
782
  const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
757
783
  const blockedTasks = []
@@ -786,11 +812,18 @@ async function detectExecuteBatchFinish({ pm, stageName, changeName, cwd, specBa
786
812
  blockedTasks.push(taskId)
787
813
  continue
788
814
  }
789
- // 草稿且有 changedFiles → 实测 diff 校验(ctx 与 task-04 同源)
815
+ // 草稿且有 changedFiles → 实测 diff 校验(ctx 与 task-04 同源;优先用预取全量集,
816
+ // 免去每 task 一次 spawn——与 shouldAutoCheckTask 同口径)
790
817
  if (ctx && ctx.gitDir && ctx.base && ctx.head) {
791
818
  try {
792
- const diffResult = gitQuiet(ctx.gitDir, ['diff', '--name-only', `${ctx.base}..${ctx.head}`, '--', ...changedFiles], { trim: true })
793
- if (!diffResult || diffResult.trim() === '') {
819
+ let hit = null
820
+ if (ctx.diffFileSet) {
821
+ hit = changedFiles.filter(f => ctx.diffFileSet.has(f))
822
+ } else {
823
+ const diffResult = gitQuiet(ctx.gitDir, ['diff', '--name-only', `${ctx.base}..${ctx.head}`, '--', ...changedFiles], { trim: true })
824
+ hit = diffResult && diffResult.trim() !== '' ? changedFiles : []
825
+ }
826
+ if (hit.length === 0) {
794
827
  blockedTasks.push(taskId)
795
828
  }
796
829
  } catch (e) {