sillyspec 3.26.11 → 3.26.13

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 (58) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +1 -1
  2. package/.claude/skills/sillyspec-execute/SKILL.md +1 -1
  3. package/.claude/skills/sillyspec-knowledge/SKILL.md +1 -1
  4. package/.claude/skills/sillyspec-plan/SKILL.md +5 -5
  5. package/.claude/skills/sillyspec-quick/SKILL.md +5 -1
  6. package/README.md +7 -5
  7. package/SKILL.md +5 -4
  8. package/package.json +1 -1
  9. package/packages/dashboard/server/executor.js +10 -1
  10. package/packages/dashboard/server/index.js +108 -46
  11. package/src/config-schema.js +3 -0
  12. package/src/contract-matrix.js +22 -13
  13. package/src/db.js +24 -4
  14. package/src/dispatch/probe.js +99 -63
  15. package/src/docs-check.js +58 -13
  16. package/src/docs-debt.js +6 -2
  17. package/src/docs-gate.js +1 -0
  18. package/src/doctor-diagnostics.js +208 -21
  19. package/src/fs-atomic.js +3 -1
  20. package/src/git-helper.js +7 -4
  21. package/src/hooks/worktree-guard.js +8 -2
  22. package/src/index.js +161 -12
  23. package/src/init.js +3 -2
  24. package/src/local-detect.js +9 -1
  25. package/src/modules.js +7 -5
  26. package/src/progress/stage-machine.js +589 -578
  27. package/src/progress/step-store.js +1 -1
  28. package/src/progress.js +184 -30
  29. package/src/quicklog.js +47 -16
  30. package/src/run/command.js +31 -8
  31. package/src/run/complete-handlers.js +21 -14
  32. package/src/run/complete.js +1181 -1121
  33. package/src/run/gates.js +148 -25
  34. package/src/run/prompt.js +9 -5
  35. package/src/run/shared.js +138 -58
  36. package/src/run/stage.js +41 -1
  37. package/src/setup.js +8 -10
  38. package/src/sillyhub-mcp/client.js +74 -6
  39. package/src/spec-dir-typo.js +3 -24
  40. package/src/spec-sync.js +43 -8
  41. package/src/stage-contract.js +12 -38
  42. package/src/stages/archive.js +4 -3
  43. package/src/stages/brainstorm-auto.js +1 -1
  44. package/src/stages/brainstorm.js +2 -1
  45. package/src/stages/doctor.js +28 -13
  46. package/src/stages/execute.js +164 -153
  47. package/src/stages/plan-postcheck.js +49 -28
  48. package/src/stages/plan.js +57 -40
  49. package/src/stages/quick.js +3 -1
  50. package/src/stages/verify.js +1 -1
  51. package/src/sync.js +323 -221
  52. package/src/task-review.js +246 -61
  53. package/src/taskcard.js +178 -0
  54. package/src/verify-postcheck.js +48 -35
  55. package/src/worktree-apply.js +58 -34
  56. package/src/worktree-deps.js +120 -20
  57. package/src/worktree.js +55 -9
  58. package/.npmrc-publish +0 -2
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync, statSync } from 'fs'
12
12
  import { join, resolve } from 'path'
13
- import { execFileSync } from 'child_process'
13
+ import { git } from './git-helper.js'
14
14
  import { pathMatches } from './change-list.js'
15
15
  import { parseAllowedPaths, parseRepo, parseBaseCommit, parseHeadCommit } from './stages/plan-postcheck.js'
16
16
  import { resolveVerifyChangedFiles } from './verify-postcheck.js'
@@ -105,64 +105,63 @@ export function summarizeTaskCompletion({ changeDir, runtimeRoot, changeName })
105
105
  }
106
106
  if (!changeDir) return noPlan
107
107
 
108
+ // 2026-08-20-task-truth-unify:任务清单唯一真相在 tasks.md,优先读注册表;plan.md 回退
109
+ // (旧归档变更兼容读侧——新契约 plan.md 为纯 ID 引用行,无 checkbox 可解析)
108
110
  const planPath = join(changeDir, 'plan.md')
109
111
  const tasksPath = join(changeDir, 'tasks.md')
110
112
  let planContent = null
111
- if (existsSync(planPath)) planContent = readFileSync(planPath, 'utf8')
112
- else if (existsSync(tasksPath)) planContent = readFileSync(tasksPath, 'utf8')
113
+ if (existsSync(tasksPath)) planContent = readFileSync(tasksPath, 'utf8')
114
+ else if (existsSync(planPath)) planContent = readFileSync(planPath, 'utf8')
113
115
  else return noPlan
114
116
 
115
117
  const taskIds = parseTaskIdsFromPlan(planContent)
116
118
  if (taskIds.length === 0) {
117
119
  return { source: 'no-tasks', total: 0, completed: 0, pending: [],
118
- report: '⚠️ plan.md 未解析出任何 task-NN 条目(plan 可能未按规范写 checkbox 列表)。' }
120
+ report: '⚠️ tasks.md 未解析出任何 task-NN 条目(任务注册表可能未按规范写 checkbox 列表)。' }
119
121
  }
120
122
 
121
- // runId 解析:marker → 扫描最新目录(marker 是 agent 可写内容,格式校验防注入/穿越)
122
- let runId = null
123
- const marker = join(runtimeRoot, `current-execute-run-id-${changeName}`)
124
- try {
125
- if (existsSync(marker)) {
126
- const c = readFileSync(marker, 'utf8').trim()
127
- if (c && !isValidExecuteRunId(c)) console.warn(`[sillyspec] execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),忽略并回退目录扫描`)
128
- if (isValidExecuteRunId(c)) runId = c
129
- }
130
- } catch {}
123
+ // runId 解析(坑 worktree-cleanup-marker-chain 根治):归属化解析 resolveExecuteRunForChange——
124
+ // marker 优先但校验覆盖度;marker 断裂(worktree cleanup / 归档清理 / 并行误删)时按 change 戳
125
+ // 精确归属,旧 run 退覆盖度启发。不再盲目 mtime 最新(曾把其他变更的 run 错配给本变更 →
126
+ // 全部 task 误报「review.json 缺失」→ 手工回填 7 份实际存在于正确 run 里的 review)。
127
+ const resolved = resolveExecuteRunForChange({ runtimeRoot, changeName, taskIds })
128
+ const runId = resolved ? resolved.runId : null
129
+ if (resolved && resolved.relocated) {
130
+ console.warn(`[sillyspec] execute run 归属修正:marker 指向的 run 对变更 ${changeName} 零覆盖,改用 ${resolved.origin === 'stamp' ? 'change 戳' : '覆盖度'}命中的 run ${runId}(真实 review 所在)`)
131
+ }
132
+
133
+ // 无归属 run → 降级 plan.md checkbox 统计;报告区分「无法归属」与「没跑过 execute」,
134
+ // 避免把定位失败误导成「review 全缺、需手工回填」
131
135
  if (!runId) {
136
+ const { total, checked } = countPlanCheckboxes(planContent)
137
+ let runsNote = '无 execute-runs 目录(本变更未跑过 execute?)'
132
138
  try {
133
139
  const runsDir = join(runtimeRoot, 'execute-runs')
134
140
  if (existsSync(runsDir)) {
135
- const entries = readdirSync(runsDir)
136
- .map(e => ({ e, p: join(runsDir, e) }))
137
- .filter(x => { try { return statSync(x.p).isDirectory() } catch { return false } })
138
- .map(x => ({ e: x.e, mtime: statSync(x.p).mtimeMs }))
139
- .sort((a, b) => b.mtime - a.mtime)
140
- if (entries.length > 0) runId = entries[0].e
141
+ const n = readdirSync(runsDir).filter(e => { try { return statSync(join(runsDir, e)).isDirectory() } catch { return false } }).length
142
+ if (n > 0) runsNote = `execute-runs/ 有 ${n} 个 run 但无一能归属到本变更(无 change 戳且零覆盖——marker 链断裂且 review 可能从未生成)`
141
143
  }
142
144
  } catch {}
143
- }
144
-
145
- // 无 runId → 降级 plan.md checkbox 统计
146
- if (!runId) {
147
- const { total, checked } = countPlanCheckboxes(planContent)
148
145
  return {
149
146
  source: 'plan-checkbox-fallback', total, completed: checked,
150
147
  pending: taskIds.slice(0, Math.max(0, total - checked)).map(id => ({ id, reason: 'checkbox 未勾(降级口径)' })),
151
148
  report:
152
- '⚠️ 客观源不可用(无 execute runId marker,review.json 无法定位)→ 降级用 plan.md checkbox。\n' +
149
+ '⚠️ 客观源不可用(execute run 无法归属到本变更:' + runsNote + ')→ 降级用 plan.md checkbox。\n' +
153
150
  `- 总任务:${total}\n- plan.md 已勾选:${checked}\n` +
154
151
  '⚠️ checkbox 可能因 autoCheckPlanFromReviews 回填断裂而失真,务必交叉核对 .runtime/execute-runs/*/tasks/task-NN/review.json 的 verdict 再下结论。'
155
152
  }
156
153
  }
157
154
 
158
- // 有 runId → 读 review.json verdict
155
+ // 有 runId → 读 review.json verdict;cannot_verify 草稿单列(不是真正复核,归档前应可见)
159
156
  const completed = []
160
157
  const pending = []
158
+ let cannotVerifyCount = 0
161
159
  for (const id of taskIds) {
162
160
  const reviewPath = join(runtimeRoot, 'execute-runs', runId, 'tasks', id, 'review.json')
163
161
  const r = readReview(reviewPath)
164
162
  if (r.ok && r.review && r.review.specVerdict !== 'fail' && r.review.qualityVerdict !== 'fail') {
165
163
  completed.push(id)
164
+ if (r.review.specVerdict === 'cannot_verify' || r.review.qualityVerdict === 'cannot_verify') cannotVerifyCount++
166
165
  } else if (r.ok && r.review) {
167
166
  pending.push({ id, reason: `verdict 未通过(spec=${r.review.specVerdict}, quality=${r.review.qualityVerdict})` })
168
167
  } else {
@@ -173,12 +172,17 @@ export function summarizeTaskCompletion({ changeDir, runtimeRoot, changeName })
173
172
  const pendingLines = pending.length > 0
174
173
  ? pending.map(p => ` - ${p.id}: ${p.reason}`).join('\n')
175
174
  : ' (无)'
175
+ const draftLines = cannotVerifyCount > 0
176
+ ? `- cannot_verify 草稿(未真正复核): ${cannotVerifyCount}\n` +
177
+ ' ⚠️ 这些 task 的 review 是 cannot_verify(含自动草稿兜底)而非 pass——归档前应确认 verify 阶段已兑现其 requiredEvidence,否则派独立子代理对照 task brief + git diff 补真实复核,勿静默放行\n'
178
+ : ''
176
179
  return {
177
- source: 'review.json', total: taskIds.length, completed: completed.length, pending,
180
+ source: 'review.json', total: taskIds.length, completed: completed.length, pending, cannotVerify: cannotVerifyCount, runId,
178
181
  report:
179
- `客观任务完成度(真相源 = review.json verdict,runId=${runId}):\n` +
182
+ `客观任务完成度(真相源 = review.json verdict,runId=${runId}${resolved.origin !== 'marker' ? `,归属=${resolved.origin}` : ''}):\n` +
180
183
  `- 总任务:${taskIds.length}\n` +
181
184
  `- 已通过(spec + quality verdict 均非 fail):${completed.length}\n` +
185
+ draftLines +
182
186
  `- 未通过 / 缺失:${pending.length}\n` +
183
187
  `- 未完成列表:\n${pendingLines}\n` +
184
188
  '注:以 review.json verdict 为准;plan.md checkbox 仅作显示态(回填断裂时会与客观 verdict 不一致,以下方客观点为准)。'
@@ -387,6 +391,9 @@ export function validateTaskReviews(opts) {
387
391
  const errors = []
388
392
  const warnings = []
389
393
  const requiredEvidence = []
394
+ // PERF-01:整个循环共享一份 git 结果缓存(同 gitDir 的探测/status、同 (base,head) 的
395
+ // diff 在多 task 间高度重复;N task 从 5N 个 git 子进程降到 ~2+去重后数量)
396
+ const evidenceCache = {}
390
397
 
391
398
  for (const taskId of taskIds) {
392
399
  const reviewDir = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks', taskId)
@@ -447,7 +454,7 @@ export function validateTaskReviews(opts) {
447
454
  }
448
455
  }
449
456
  if (reviewGitDir) {
450
- const evidence = verifyReviewGitEvidence(review, reviewGitDir)
457
+ const evidence = verifyReviewGitEvidence(review, reviewGitDir, evidenceCache)
451
458
  for (const w of evidence.warnings) warnings.push(`${taskId}: ${w}`)
452
459
  if (!evidence.ok) {
453
460
  for (const err of evidence.errors) errors.push(`${taskId}: ${err}`)
@@ -516,13 +523,10 @@ export function validateTaskReviews(opts) {
516
523
 
517
524
  // ── Git 真实性交叉校验 ──
518
525
 
526
+ // QUAL-01 收口:原为本地 execFileSync('git')(无 safe.directory、与 git-helper 口径分裂),
527
+ // 现走统一入口;抛错/trim 语义不变,timeout 15000 保留(rev-parse/diff 大仓余量)
519
528
  function runGit(gitDir, args) {
520
- return execFileSync('git', args, {
521
- cwd: gitDir,
522
- encoding: 'utf8',
523
- timeout: 15000,
524
- stdio: ['ignore', 'pipe', 'pipe'],
525
- }).trim()
529
+ return git(gitDir, args, { timeout: 15000 })
526
530
  }
527
531
 
528
532
  // porcelain 状态行 → 变更文件路径(对齐 shared.js:343 parsePorcelainPath:`XY path`、引号包裹、
@@ -552,31 +556,57 @@ function parsePorcelainFiles(statusOut) {
552
556
  * git 环境不可用(非 git 仓库 / git 缺失)时返回 unavailable,由调用方降级为
553
557
  * warning——不因环境问题误杀,但记录在输出中。
554
558
  *
559
+ /**
555
560
  * @param {object} review - 已通过 schema 校验的 review 对象
556
561
  * @param {string} gitDir - 执行 git 命令的目录(worktree 优先,回退主仓库)
562
+ * @param {object} [cache] - 进程内单次校验批的共享缓存(体检 PERF-01:N task × 5 个 git
563
+ * 子进程去重——gitDir 探测与 status --porcelain 结果按 gitDir 恒同,commit 校验按
564
+ * (gitDir,hash)、diff 按 (gitDir,base,head) 在多 task 间高度重复。validateTaskReviews
565
+ * 整个循环传同一份;不传则退化为逐次执行(单测/独立调用零回归)。缓存生命周期 =
566
+ * 一次校验批:working-tree status 是快照语义,跨批复用会读到陈旧状态。
557
567
  * @returns {{ ok: boolean, emptyDiff: boolean, errors: string[], warnings: string[], unavailable: boolean }}
558
568
  */
559
- export function verifyReviewGitEvidence(review, gitDir) {
569
+ export function verifyReviewGitEvidence(review, gitDir, cache = null) {
560
570
  const errors = []
561
571
  const warnings = []
562
-
563
- // git 环境探测:失败即 unavailable,交由调用方降级
564
- try {
565
- runGit(gitDir, ['rev-parse', '--git-dir'])
566
- } catch (e) {
572
+ const c = cache || (cache = {})
573
+ c.gitOk = c.gitOk || new Map()
574
+ c.verified = c.verified || new Set()
575
+ c.diffs = c.diffs || new Map()
576
+ c.wtStatus = c.wtStatus || new Map()
577
+
578
+ // git 环境探测:失败即 unavailable,交由调用方降级(同 gitDir 结果恒同,批内缓存)
579
+ if (!c.gitOk.has(gitDir)) {
580
+ try {
581
+ runGit(gitDir, ['rev-parse', '--git-dir'])
582
+ c.gitOk.set(gitDir, true)
583
+ } catch (e) {
584
+ c.gitOk.set(gitDir, false)
585
+ return {
586
+ ok: true,
587
+ emptyDiff: false,
588
+ errors: [],
589
+ warnings: [`git 环境不可用(${gitDir}),跳过 review 真实性交叉校验: ${e.message?.split('\n')[0] || e.message}`],
590
+ unavailable: true,
591
+ }
592
+ }
593
+ } else if (c.gitOk.get(gitDir) === false) {
567
594
  return {
568
595
  ok: true,
569
596
  emptyDiff: false,
570
597
  errors: [],
571
- warnings: [`git 环境不可用(${gitDir}),跳过 review 真实性交叉校验: ${e.message?.split('\n')[0] || e.message}`],
598
+ warnings: [`git 环境不可用(${gitDir}),跳过 review 真实性交叉校验(批内缓存判定)`],
572
599
  unavailable: true,
573
600
  }
574
601
  }
575
602
 
576
603
  for (const field of ['base', 'head']) {
577
604
  const hash = review[field]
605
+ const vkey = `${gitDir}\u0000${hash}`
606
+ if (c.verified.has(vkey)) continue
578
607
  try {
579
608
  runGit(gitDir, ['rev-parse', '--verify', '--quiet', `${hash}^{commit}`])
609
+ c.verified.add(vkey)
580
610
  } catch {
581
611
  errors.push(`${field} "${hash}" 不是仓库中的真实 commit — review.json 疑似伪造`)
582
612
  }
@@ -586,21 +616,32 @@ export function verifyReviewGitEvidence(review, gitDir) {
586
616
  }
587
617
 
588
618
  let diffFiles = []
589
- try {
590
- const out = runGit(gitDir, ['diff', '--name-only', `${review.base}..${review.head}`])
591
- diffFiles = out ? out.split('\n').filter(Boolean) : []
592
- } catch (e) {
593
- warnings.push(`git diff ${review.base}..${review.head} 执行失败,跳过 diff 校验: ${e.message?.split('\n')[0] || e.message}`)
594
- return { ok: true, emptyDiff: false, errors, warnings, unavailable: false }
619
+ const dkey = `${gitDir}\u0000${review.base}\u0000${review.head}`
620
+ if (c.diffs.has(dkey)) {
621
+ diffFiles = c.diffs.get(dkey)
622
+ } else {
623
+ try {
624
+ const out = runGit(gitDir, ['diff', '--name-only', `${review.base}..${review.head}`])
625
+ diffFiles = out ? out.split('\n').filter(Boolean) : []
626
+ c.diffs.set(dkey, diffFiles)
627
+ } catch (e) {
628
+ warnings.push(`git diff ${review.base}..${review.head} 执行失败,跳过 diff 校验: ${e.message?.split('\n')[0] || e.message}`)
629
+ return { ok: true, emptyDiff: false, errors, warnings, unavailable: false }
630
+ }
595
631
  }
596
632
 
597
633
  // 并入 working-tree 未提交改动(execute 复盘 a):子代理默认不 commit,base..head 无 commit diff 时
598
634
  // diffFiles 留空 → 下方交叉比对拿空集对非空 changedFiles 必判「完全不相交」伪造,逼 agent 强制 commit
599
635
  // + 改 7 个 review head。对齐 checkExecuteCodeEvidence(stage-contract.js 同时查 working-tree)语义,
600
636
  // 未提交改动也算有效对账源。working-tree 有改动时无条件并入(覆盖「部分 commit + 未提交」)。
637
+ // PERF-01:同 gitDir 的 status 快照按批缓存(单次校验批内恒同,跨批复用会读到陈旧状态)
601
638
  const commitDiffFiles = diffFiles.slice()
602
639
  try {
603
- const wtStatus = runGit(gitDir, ['status', '--porcelain'])
640
+ let wtStatus = c.wtStatus.get(gitDir)
641
+ if (wtStatus === undefined) {
642
+ wtStatus = runGit(gitDir, ['status', '--porcelain'])
643
+ c.wtStatus.set(gitDir, wtStatus)
644
+ }
604
645
  if (wtStatus && wtStatus.trim().length > 0) {
605
646
  const wtFiles = parsePorcelainFiles(wtStatus)
606
647
  if (wtFiles.length > 0) diffFiles = diffFiles.concat(wtFiles)
@@ -700,7 +741,7 @@ export function isValidExecuteRunId(runId) {
700
741
  * @param {{ runtimeRoot: string }} opts
701
742
  * @returns {string|null} 含 tasks/ 的最新 runId,无则 null
702
743
  */
703
- export function resolveLatestExecuteRunIdWithTasks({ runtimeRoot }) {
744
+ export function resolveLatestExecuteRunIdWithTasks({ runtimeRoot, changeName = null }) {
704
745
  try {
705
746
  const runsDir = join(runtimeRoot, 'execute-runs')
706
747
  if (!existsSync(runsDir)) return null
@@ -710,7 +751,19 @@ export function resolveLatestExecuteRunIdWithTasks({ runtimeRoot }) {
710
751
  .filter(x => { try { return statSync(join(x.p, 'tasks')).isDirectory() } catch { return false } })
711
752
  .map(x => ({ e: x.e, mtime: statSync(x.p).mtimeMs }))
712
753
  .sort((a, b) => b.mtime - a.mtime)
713
- return entries.length > 0 ? entries[0].e : null
754
+ if (entries.length === 0) return null
755
+ // changeName 给定时优先 change 戳等值的 run(坑 worktree-cleanup-marker-chain:mtime 最新
756
+ // 会错拿其他变更的 run);无戳命中退回 mtime 最新(向后兼容,调用方多为 marker 漂移兜底,
757
+ // 宁可拿最新 run 也不空手而归——覆盖度由调用方 validateTaskReviews 复校验)
758
+ if (changeName) {
759
+ for (const x of entries) {
760
+ try {
761
+ const c = readFileSync(join(runsDir, x.e, 'change'), 'utf8').trim()
762
+ if (c === changeName) return x.e
763
+ } catch {}
764
+ }
765
+ }
766
+ return entries[0].e
714
767
  } catch {
715
768
  return null
716
769
  }
@@ -725,20 +778,145 @@ export function resolveLatestExecuteRunId({ runtimeRoot, changeName }) {
725
778
  if (isValidExecuteRunId(c)) return c
726
779
  }
727
780
  } catch {}
781
+ // marker 缺失(worktree cleanup / 归档清理 / 并行会话误删——坑 worktree-cleanup-marker-chain)
782
+ // 时不再盲目取 mtime 最新(会拿到其他变更的 run):先按 change 归属戳过滤,命中才返回;
783
+ // 无戳(旧 run)退回 mtime 最新保持向后兼容。
728
784
  try {
729
- const runsDir = join(runtimeRoot, 'execute-runs')
730
- if (existsSync(runsDir)) {
731
- const entries = readdirSync(runsDir)
732
- .map(e => ({ e, p: join(runsDir, e) }))
733
- .filter(x => { try { return statSync(x.p).isDirectory() } catch { return false } })
734
- .map(x => ({ e: x.e, mtime: statSync(x.p).mtimeMs }))
735
- .sort((a, b) => b.mtime - a.mtime)
736
- if (entries.length > 0) return entries[0].e
737
- }
785
+ const candidates = listExecuteRunCandidates(runtimeRoot, changeName)
786
+ if (candidates.stamped.length > 0) return candidates.stamped[0]
787
+ if (candidates.all.length > 0) return candidates.all[0]
738
788
  } catch {}
739
789
  return null
740
790
  }
741
791
 
792
+ // ── execute run 的 change 归属戳(坑 worktree-cleanup-marker-chain 根治)──
793
+ //
794
+ // 背景:run 目录 execute-runs/<runId>/ 本身不带变更身份,change→run 的唯一链接是
795
+ // current-execute-run-id-<change> marker;worktree cleanup / 归档 marker 清理 / 并行会话
796
+ // 误删后链即断,各 fallback(mtime 最新)会错拿其他变更的 run → archive 完成度报告把
797
+ // 已实现 task 全报「review.json 缺失」,只能手工回填。
798
+ // 修法:marker 写入点同步在 run 目录落 `change` 戳;marker 断裂后按戳精确归属,
799
+ // 旧 run(无戳)退回覆盖度启发式(run 的 tasks/ 含本变更 task-NN review 才算命中)。
800
+
801
+ /**
802
+ * 在 run 目录写入 change 归属戳(execute-runs/<runId>/change,内容为 changeName)。
803
+ * 与 marker 写入点同步调用(stage.js 主点 + gates.js/task-review.js 补写点)。
804
+ * best-effort:失败仅 warn 不抛(戳是归属优化的载体,缺了退回覆盖度启发式,不阻断主流程)。
805
+ * @param {string} runtimeRoot
806
+ * @param {string} runId
807
+ * @param {string} changeName
808
+ */
809
+ export function stampExecuteRunChange(runtimeRoot, runId, changeName) {
810
+ if (!runtimeRoot || !runId || !changeName) return
811
+ try {
812
+ // run 目录可能尚未创建(本 helper 先于 review 落盘调用的路径)——mkdir 保证自洽
813
+ mkdirSync(join(runtimeRoot, 'execute-runs', runId), { recursive: true })
814
+ writeFileSync(join(runtimeRoot, 'execute-runs', runId, 'change'), changeName + '\n')
815
+ } catch (e) {
816
+ console.warn(`[sillyspec] execute run change 戳写入失败(归属 fallback 退回覆盖度启发式,不阻断): ${e.message}`)
817
+ }
818
+ }
819
+
820
+ /**
821
+ * 读 run 目录的 change 归属戳。
822
+ * @returns {string|null} 戳内容(trim 后非空),无戳/读失败返回 null
823
+ */
824
+ function readExecuteRunChangeStamp(runtimeRoot, runId) {
825
+ try {
826
+ const c = readFileSync(join(runtimeRoot, 'execute-runs', runId, 'change'), 'utf8').trim()
827
+ return c || null
828
+ } catch { return null }
829
+ }
830
+
831
+ /**
832
+ * 扫描 execute-runs/ 候选 run(mtime 最新优先),按 change 归属戳分组。
833
+ * @returns {{ stamped: string[], all: string[] }} stamped=戳等 changeName 的 runId(新→旧);
834
+ * all=全部 runId(新→旧,含无戳旧 run,供覆盖度启发式与向后兼容 fallback)
835
+ */
836
+ function listExecuteRunCandidates(runtimeRoot, changeName) {
837
+ const runsDir = join(runtimeRoot, 'execute-runs')
838
+ const out = { stamped: [], all: [] }
839
+ if (!existsSync(runsDir)) return out
840
+ const entries = readdirSync(runsDir)
841
+ .map(e => ({ e, p: join(runsDir, e) }))
842
+ .filter(x => { try { return statSync(x.p).isDirectory() } catch { return false } })
843
+ .map(x => ({ e: x.e, mtime: statSync(x.p).mtimeMs }))
844
+ .sort((a, b) => b.mtime - a.mtime)
845
+ for (const x of entries) {
846
+ out.all.push(x.e)
847
+ if (changeName && readExecuteRunChangeStamp(runtimeRoot, x.e) === changeName) out.stamped.push(x.e)
848
+ }
849
+ return out
850
+ }
851
+
852
+ /**
853
+ * 判定 run 对本变更 taskIds 的覆盖度:tasks/ 下存在多少个 task-NN/review.json ∈ taskIds。
854
+ * taskIds 为空时退化为「tasks/ 下存在任意 task-NN/review.json」(保守启发)。
855
+ * @returns {number} 命中的 review 数
856
+ */
857
+ function executeRunCoverage(runtimeRoot, runId, taskIds) {
858
+ try {
859
+ const tasksDir = join(runtimeRoot, 'execute-runs', runId, 'tasks')
860
+ if (!existsSync(tasksDir)) return 0
861
+ const entries = readdirSync(tasksDir).filter(e => /^task-\d+$/.test(e))
862
+ if (taskIds.length > 0) return entries.filter(e => taskIds.includes(e) && existsSync(join(tasksDir, e, 'review.json'))).length
863
+ return entries.filter(e => existsSync(join(tasksDir, e, 'review.json'))).length
864
+ } catch { return 0 }
865
+ }
866
+
867
+ /**
868
+ * 归属化解析本变更的 execute run(marker 链断裂的根治入口)。
869
+ *
870
+ * 解析顺序:
871
+ * 1. marker(格式校验)指向的 run,若其对本变更有覆盖(tasks/ 有本变更 review)或戳匹配 → 用它;
872
+ * 2. marker 缺失/非法/零覆盖(坑10 型漂移:marker 指向空 run)→ 扫描候选:
873
+ * a. change 戳等值的最新的 run;
874
+ * b.(旧 run 无戳)覆盖度 ≥1 的最新 run;
875
+ * 3. 都不命中 → null(调用方降级,报告明确「run 无法归属」而非「review 全缺」)。
876
+ *
877
+ * fail-safe:任何异常 → null,绝不抛。
878
+ * @param {{ runtimeRoot: string, changeName: string, taskIds?: string[] }} opts
879
+ * @returns {{ runId: string, origin: 'marker'|'stamp'|'coverage', relocated?: boolean }|null}
880
+ */
881
+ export function resolveExecuteRunForChange({ runtimeRoot, changeName, taskIds = [] }) {
882
+ try {
883
+ // 1. marker
884
+ let markerRunId = null
885
+ try {
886
+ const marker = join(runtimeRoot, `current-execute-run-id-${changeName}`)
887
+ if (existsSync(marker)) {
888
+ const c = readFileSync(marker, 'utf8').trim()
889
+ if (c && !isValidExecuteRunId(c)) console.warn(`[sillyspec] execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),忽略并回退归属扫描`)
890
+ if (isValidExecuteRunId(c)) markerRunId = c
891
+ }
892
+ } catch {}
893
+ if (markerRunId) {
894
+ const stamped = readExecuteRunChangeStamp(runtimeRoot, markerRunId) === changeName
895
+ const covered = executeRunCoverage(runtimeRoot, markerRunId, taskIds)
896
+ if (stamped || covered > 0) return { runId: markerRunId, origin: 'marker' }
897
+ // marker 零覆盖且无戳:疑似漂移(坑10:marker 在、真实 review 在别的 run),下探扫描
898
+ }
899
+
900
+ // 2. 扫描:戳优先 → 覆盖度启发(旧 run)。
901
+ // 覆盖度启发跳过「戳属他变更」的 run:task-NN 命名跨变更同构(都从 task-01 起),只看覆盖度
902
+ // 会把他变更 run 的 task-01 review 误配给本变更;戳存在且不等值 = 该 run 有明确主人,排除。
903
+ const candidates = listExecuteRunCandidates(runtimeRoot, changeName)
904
+ if (candidates.stamped.length > 0) {
905
+ return { runId: candidates.stamped[0], origin: 'stamp', relocated: markerRunId ? markerRunId !== candidates.stamped[0] : true }
906
+ }
907
+ for (const runId of candidates.all) {
908
+ const stamp = readExecuteRunChangeStamp(runtimeRoot, runId)
909
+ if (stamp && stamp !== changeName) continue
910
+ if (executeRunCoverage(runtimeRoot, runId, taskIds) > 0) {
911
+ return { runId, origin: 'coverage', relocated: markerRunId ? markerRunId !== runId : true }
912
+ }
913
+ }
914
+ return null
915
+ } catch {
916
+ return null
917
+ }
918
+ }
919
+
742
920
  /**
743
921
  * worktree execute「主 agent 直接实现」模式收尾兜底:per-task review.json 缺失时,
744
922
  * 据 git diff base..head 按 task allowed_paths 归属,自动落盘 cannot_verify 草稿。
@@ -798,6 +976,7 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
798
976
  try {
799
977
  mkdirSync(join(runtimeRoot, 'execute-runs', executeRunId, 'tasks'), { recursive: true })
800
978
  writeFileSync(runIdFile, executeRunId + '\n')
979
+ stampExecuteRunChange(runtimeRoot, executeRunId, changeName)
801
980
  } catch (e) {
802
981
  console.error(`[sillyspec] execute run marker/目录写入失败(降级继续,草稿落盘仍尝试): ${e.message}`)
803
982
  }
@@ -907,6 +1086,12 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
907
1086
  skipped++
908
1087
  continue
909
1088
  }
1089
+ // 主仓 task 缺 base/head(meta 缺失 / HEAD 解析失败,见上方 :968-981 注释承诺的"后续跳过"):
1090
+ // 跳过留给 agent 手补。此前 null.slice(0,8) 直接 TypeError 崩掉整个草稿生成(体检 BUG-06)
1091
+ if (!crossEntry && (!taskBase || !taskHead)) {
1092
+ skipped++
1093
+ continue
1094
+ }
910
1095
  // unattributed 只统计主仓 diff(跨仓 diff 路径相对跨仓仓根,与主仓 unattributed 语义无关)
911
1096
  if (!crossEntry) {
912
1097
  taskChangedFiles.forEach(f => attributed.add(f))
@@ -0,0 +1,178 @@
1
+ /**
2
+ * sillyspec taskcard — 生成 Windows 安全的 TaskCard 骨架
3
+ *
4
+ * 背景:plan postcheck 任务卡被拒的根因集中在「LLM 子代理手写 frontmatter 不可靠」——
5
+ * CRLF 行尾、漏闭合 ---、缺 constraints 等硬校验字段(校验侧已对 CRLF 容错,但闭合/字段
6
+ * 缺失仍拦)。本命令从源头消灭这类格式错误:骨架由 CLI 用 writeFileSync 直写(字符串
7
+ * 只含 \n,天然 LF),frontmatter 闭合与硬校验 9 字段由代码保证,子代理只需 Edit 填充
8
+ * 占位符,不再手写整卡。
9
+ *
10
+ * 任务编号/标题从 plan.md checkbox 行自动带出(与 plan.js parseTaskNames 同源,
11
+ * 保编号不跳号、名称与 plan.md 一致)。
12
+ */
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
14
+ import { join } from 'path'
15
+ import { git } from './git-helper.js'
16
+ import { assertSafeChangeName, resolveSpecDir } from './run/shared.js'
17
+ import { parseTaskNames } from './stages/plan.js'
18
+
19
+ /**
20
+ * 任务号归一:接受 task-01 / task-1 / 01 / 1,统一为 task-NN(两位补零,与任务卡文件名一致)。
21
+ * @param {string} raw
22
+ * @returns {string} 形如 task-01
23
+ */
24
+ export function normalizeTaskId(raw) {
25
+ const s = String(raw || '').trim()
26
+ const m = s.match(/^(?:task-)?(\d+)$/i)
27
+ if (!m) {
28
+ throw new Error(`任务号格式非法: "${s}"(应为 task-NN 或 NN,可逗号分隔多个)`)
29
+ }
30
+ return `task-${String(m[1]).padStart(2, '0')}`
31
+ }
32
+
33
+ /**
34
+ * YAML 标量安全序列化(坑 taskcard-title-backtick-yaml,2026-08-20 实证):plan checkbox 行名
35
+ * 是自由文本,带反引号(如 "`-p` 参数支持")时裸插值进 frontmatter——`` ` `` 与 @ 是 YAML 保留
36
+ * 指示符,js-yaml 直接抛错,契约字段(allowed_paths/constraints 等)随整个 frontmatter 静默丢失,
37
+ * 靠 plan postcheck 才兜住。统一单引号包裹 + 内部 ' 双写(YAML 标准转义),无边界字符要判。
38
+ * @param {string} v
39
+ * @returns {string}
40
+ */
41
+ function yamlScalar(v) {
42
+ const s = String(v ?? '')
43
+ return `'${s.replace(/'/g, "''")}'`
44
+ }
45
+
46
+ /**
47
+ * 生成 TaskCard 骨架字符串(纯函数)。
48
+ *
49
+ * 硬校验 9 字段(plan-postcheck feasibility)+ 规范字段全部就位,占位符由子代理 Edit 填充;
50
+ * 可选字段(provides/expects_from/related_tests)默认不生成——缩小 YAML 出错面,何时加的
51
+ * 判据写在文件尾注释(规则详情见 templates/prompts/taskcard-rules.md)。
52
+ * 字符串只含 \n:writeFileSync 直写不经任何行尾转换,Windows 下天然 LF 安全。
53
+ * title/title_zh/author 经 yamlScalar 转义(自由文本防 YAML 指示符炸解析)。
54
+ *
55
+ * @param {{ taskId: string, title: string, titleZh: string, author: string, now: string }} p
56
+ * @returns {string}
57
+ */
58
+ export function buildTaskcardSkeleton({ taskId, title, titleZh, author, now }) {
59
+ return `---
60
+ id: ${taskId}
61
+ title: ${yamlScalar(title)}
62
+ title_zh: ${yamlScalar(titleZh)}
63
+ author: ${yamlScalar(author)}
64
+ created_at: ${now}
65
+ priority: P0
66
+ depends_on: []
67
+ blocks: []
68
+ requirement_ids: [FR-XX]
69
+ decision_ids: [D-XXX@vN]
70
+ allowed_paths:
71
+ - src/example/file.ts
72
+ goal: >
73
+ 一句话说明这个 task 要做什么、为什么。
74
+ implementation:
75
+ - 具体步骤 1
76
+ - 具体步骤 2
77
+ acceptance:
78
+ - 可验证的验收条件 1
79
+ - 可验证的验收条件 2
80
+ verify:
81
+ - cd frontend && pnpm exec tsc --noEmit
82
+ constraints:
83
+ - 边界约束 1(如:不加测试)
84
+ - 边界约束 2(如:不修改传入参数)
85
+ ---
86
+
87
+ <!-- 骨架由 sillyspec taskcard 生成(LF 行尾 + frontmatter 已闭合 + 硬校验 9 字段齐全)。
88
+ 用 Edit tool 填充上方占位符(allowed_paths/goal/implementation/acceptance/verify/constraints 等),
89
+ 勿用 Write 整文件重写——会引入 CRLF 行尾/漏闭合 ---/漏字段回归。
90
+ 可选字段按需插进上方 frontmatter(规则见 taskcard-rules):
91
+ provides: 仅当本 task 给其他 task 提供接口/DTO/响应时填
92
+ expects_from: 仅当本 task 消费其他 task 的契约时填
93
+ related_tests: 仅当本 task 改动导致既有测试断言失效时填(测试路径须同时进 allowed_paths) -->
94
+ `
95
+ }
96
+
97
+ /**
98
+ * taskcard 命令主逻辑:定位变更目录 → 解析任务清单(--task 显式列表 / 'all' 从 plan.md 取)→
99
+ * 逐个写骨架。
100
+ *
101
+ * @param {string} changeName 变更名(.sillyspec/changes/ 下目录名)
102
+ * @param {{ cwd: string, specDir?: string|null, taskIds: string[]|'all', title?: string|null,
103
+ * titleZh?: string|null, force?: boolean }} opts
104
+ * taskIds:normalizeTaskId 归一后的数组;或字面 'all' 表示从 plan.md 取全部 checkbox 任务
105
+ * @returns {{ created: string[], skipped: string[], tasksDir: string }}
106
+ */
107
+ export function cmdTaskcard(changeName, opts = {}) {
108
+ const { cwd, specDir = null, taskIds, title = null, titleZh = null, force = false } = opts
109
+ assertSafeChangeName(changeName, '变更名')
110
+
111
+ const changeDir = join(resolveSpecDir(cwd, { specDir }), 'changes', changeName)
112
+ if (!existsSync(changeDir)) {
113
+ throw new Error(`变更目录不存在: ${changeDir}(先完成 plan 前置步骤,或检查 --change 名)`)
114
+ }
115
+
116
+ // 任务注册表 tasks.md(2026-08-20-task-truth-unify 唯一真相;CRLF 归一后解析,
117
+ // --all 时强依赖)。tasks.md 缺失回退 plan.md(旧归档变更兼容读侧)
118
+ let planTasks = []
119
+ const tasksMdPath = join(changeDir, 'tasks.md')
120
+ const planPath = join(changeDir, 'plan.md')
121
+ const registryPath = existsSync(tasksMdPath) ? tasksMdPath : planPath
122
+ if (existsSync(registryPath)) {
123
+ const registryContent = readFileSync(registryPath, 'utf8').replace(/\r\n/g, '\n')
124
+ planTasks = parseTaskNames(registryContent)
125
+ }
126
+
127
+ let ids
128
+ if (taskIds === 'all') {
129
+ if (planTasks.length === 0) {
130
+ throw new Error(
131
+ `--all 需要 tasks.md 中存在 checkbox 任务行(格式 "- [ ] task-XX: 任务名")` +
132
+ (existsSync(registryPath) ? ',当前任务清单未解析到任何任务行' : ',且变更目录下未找到 tasks.md/plan.md')
133
+ )
134
+ }
135
+ ids = planTasks.map(t => `task-${t.num}`)
136
+ } else if (Array.isArray(taskIds) && taskIds.length > 0) {
137
+ ids = taskIds
138
+ } else {
139
+ throw new Error('缺少任务清单:需 --task task-01[,task-02...] 或 --all')
140
+ }
141
+
142
+ // 标题优先级:--title/--title-zh 参数 > plan.md checkbox 行名称 > 占位符。
143
+ // 参数仅单任务时可用(多任务各卡标题不同,统一参数必错)。
144
+ if ((title || titleZh) && ids.length > 1) {
145
+ throw new Error('--title/--title-zh 仅在 --task 指定单个任务时可用(多任务标题自动取自 plan.md checkbox 行)')
146
+ }
147
+
148
+ const author = (() => {
149
+ try {
150
+ // QUAL-01 收口:原 execSync 字符串拼接(经 shell,违反自家注入规约)→ git-helper 数组形式
151
+ return git(cwd, ['config', 'user.name']) || 'unknown'
152
+ } catch {
153
+ return 'unknown'
154
+ }
155
+ })()
156
+ const now = new Date().toISOString().slice(0, 19).replace('T', ' ')
157
+
158
+ const tasksDir = join(changeDir, 'tasks')
159
+ mkdirSync(tasksDir, { recursive: true })
160
+
161
+ const created = []
162
+ const skipped = []
163
+ for (const id of ids) {
164
+ const planMatch = planTasks.find(t => `task-${t.num}` === id)
165
+ const finalTitle = title || (planMatch ? planMatch.name : null) || `${id} fill-english-title`
166
+ const finalTitleZh = titleZh || (planMatch ? planMatch.name : null) || finalTitle
167
+ const filePath = join(tasksDir, `${id}.md`)
168
+ if (existsSync(filePath) && !force) {
169
+ skipped.push(filePath)
170
+ continue
171
+ }
172
+ writeFileSync(filePath, buildTaskcardSkeleton({
173
+ taskId: id, title: finalTitle, titleZh: finalTitleZh, author, now,
174
+ }), 'utf8')
175
+ created.push(filePath)
176
+ }
177
+ return { created, skipped, tasksDir }
178
+ }