sillyspec 3.28.6 → 3.28.8

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 (62) hide show
  1. package/.claude/skills/sillyspec-execute/SKILL.md +224 -222
  2. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  3. package/.claude/skills/sillyspec-quick/SKILL.md +117 -116
  4. package/.husky/pre-push +3 -1
  5. package/.patch16-tmp.mjs +169 -0
  6. package/attachments/2ab2fd87cfa568ca6acadbb934a651d293a0ad8f3227c66a70b56dcf53a0ea3d.pptx +0 -0
  7. package/attachments/dc4c2b314ee0c295e1fe5243073eb5c7df7e429074ad7c14424523fbe86e6193.pptx +0 -0
  8. package/meta.json +11 -0
  9. package/package.json +1 -1
  10. package/src/agent-session-log.js +446 -35
  11. package/src/config-cat.js +6 -3
  12. package/src/config-schema.js +13 -0
  13. package/src/constants.js +3 -0
  14. package/src/db.js +12 -3
  15. package/src/decision-distill.js +6 -2
  16. package/src/dispatch/probe.js +8 -1
  17. package/src/docs-check.js +137 -12
  18. package/src/docs-gate.js +3 -0
  19. package/src/doctor-diagnostics.js +19 -7
  20. package/src/friction-tally.js +7 -2
  21. package/src/git-helper.js +6 -4
  22. package/src/hooks/worktree-guard.js +59 -29
  23. package/src/index.js +168 -64
  24. package/src/knowledge-match.js +90 -6
  25. package/src/local-detect.js +30 -15
  26. package/src/local-register.js +13 -4
  27. package/src/mcp-server.js +38 -8
  28. package/src/progress/change-registry.js +58 -26
  29. package/src/progress/consistency-doctor.js +11 -4
  30. package/src/progress/stage-machine.js +3 -1
  31. package/src/progress/step-store.js +30 -17
  32. package/src/progress.js +26 -0
  33. package/src/quicklog.js +44 -18
  34. package/src/review-dispatch.js +7 -1
  35. package/src/run/command.js +17 -3
  36. package/src/run/complete-handlers.js +40 -10
  37. package/src/run/complete.js +48 -17
  38. package/src/run/concurrent-detect.js +1 -1
  39. package/src/run/gate-snapshot.js +115 -5
  40. package/src/run/gates.js +35 -5
  41. package/src/run/prompt.js +61 -1
  42. package/src/run/quick-audit.js +80 -4
  43. package/src/run/shared.js +98 -10
  44. package/src/run/verify-quality-scan.js +14 -3
  45. package/src/scope-audit.js +89 -14
  46. package/src/semantic-guard.js +207 -0
  47. package/src/sillyhub-mcp/client.js +14 -0
  48. package/src/spec-sync.js +19 -14
  49. package/src/stage-contract-spec.js +4 -3
  50. package/src/stage-review.js +6 -5
  51. package/src/stages/brainstorm.js +1 -1
  52. package/src/stages/execute.js +6 -1
  53. package/src/stages/plan-postcheck.js +57 -12
  54. package/src/sync.js +66 -4
  55. package/src/task-review.js +41 -6
  56. package/src/verify-facts-schema.js +10 -3
  57. package/src/verify-postcheck.js +21 -6
  58. package/src/verify-probes.js +8 -3
  59. package/src/worktree-apply.js +56 -18
  60. package/src/worktree-cross.js +12 -18
  61. package/src/worktree.js +138 -25
  62. package/src/wt-commit.js +128 -0
@@ -19,6 +19,7 @@
19
19
  * 纯读:不落盘、不改 guard/进度库。Windows 路径 \\→/ 归一,ESM-only,零新依赖。
20
20
  */
21
21
  import { existsSync, readFileSync, readdirSync } from 'node:fs'
22
+ import { createHash } from 'node:crypto'
22
23
  import { join, dirname } from 'node:path'
23
24
  import { locateQuickSessionGuard, auditQuickCompletion, resolveRuntimeRoot, collectOtherQuickSessionDeclarations, ancestorSpecDirs } from './run/shared.js'
24
25
  import { safeGit } from './git-helper.js'
@@ -41,7 +42,9 @@ function toPosix(p) {
41
42
  function extractRenameTarget(raw) {
42
43
  const brace = raw.match(/^(.*)\{([^{}]*)=>([^{}]*)\}(.*)$/)
43
44
  if (brace) {
44
- const merged = brace[1] + brace[3] + brace[4]
45
+ // git 花括号形态 `src/{old => new}.js`:组3 带前导对齐空格须剥(审查 C-F03——
46
+ // 不剥则拼出 `src/ new.js` 键错,改名文件落 untracked wc-l 档记成整文件新增)
47
+ const merged = brace[1] + brace[3].replace(/^ /, '') + brace[4]
45
48
  return merged.replace(/\/{2,}/g, '/')
46
49
  }
47
50
  const arrow = raw.indexOf(' => ')
@@ -262,6 +265,21 @@ function filterPatchForFiles(patchText, filesSet) {
262
265
  return out.join('\n')
263
266
  }
264
267
 
268
+ /**
269
+ /** patch 内容 sha256(A-F01 防篡改锚——写入方存 json.patchSha256,读取方校验) */
270
+ function sha256Text(text) {
271
+ return createHash('sha256').update(text, 'utf8').digest('hex')
272
+ }
273
+
274
+ /**
275
+ * 冻结 patch 完整性校验(A-F01):json 记录的 patchSha256 与 patch 实文比对。
276
+ * @returns {boolean|null} true=一致;false=不匹配(可能被篡改);null=未记录 hash(旧记录)
277
+ */
278
+ function verifyPatchIntegrity(recordedSha, patchText) {
279
+ if (!recordedSha || typeof recordedSha !== 'string') return null
280
+ return sha256Text(patchText) === recordedSha
281
+ }
282
+
265
283
  /**
266
284
  * 冻结 patch 生成(quick-359a48f1 审计级 / quick-90015473 归属口径修正):tracked 改动 =
267
285
  * `git diff --no-color <baseRef>` 后**按 files 过滤段**(与 rows 同归属口径,不含并行会话
@@ -438,8 +456,12 @@ async function computeQuickAudit({ cwd, platformOpts, sessionId, located, collec
438
456
  }
439
457
  }
440
458
 
459
+ // 审查 C-F05:patch 文件集对齐落盘 rows(declared+soft)——undeclared 可能是并行会话的
460
+ // 改动,冻结进本会话审计 patch 违背 filterPatchForFiles 的归属契约(json rows 落盘时
461
+ // complete-handlers 滤 undeclared,patch 必须同口径)
462
+ const patchFiles = [...new Set([...attributed, ...softSet])]
441
463
  const frozenPatch = collectPatch
442
- ? buildFrozenPatch(sessionRoot, rowFiles, { baseRef: 'HEAD' })
464
+ ? buildFrozenPatch(sessionRoot, patchFiles, { baseRef: 'HEAD' })
443
465
  : undefined
444
466
 
445
467
  return {
@@ -462,7 +484,7 @@ async function computeQuickAudit({ cwd, platformOpts, sessionId, located, collec
462
484
  * only;baseAnchor 缺失 → 行数按 HEAD 未提交窗口兜底(quick 模式同口径,不再恒 —);
463
485
  * 归档且实时窗口空 → execute 时点快照记录态(快照也缺 → 空表诚实说明)。
464
486
  */
465
- async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, collectPatch }) {
487
+ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, collectPatch, freshActual }) {
466
488
  const sb = specBase || join(cwd, '.sillyspec')
467
489
  const runtimeRoot = resolveRuntimeRoot(platformOpts, sb)
468
490
  const changeDirInfo = resolveChangeDir(sb, changeName)
@@ -474,7 +496,30 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
474
496
  const hasWorktreeMeta = existsSync(join(sb, '.runtime', 'worktrees', changeName, 'meta.json'))
475
497
  const hasBranch = !!safeGit(cwd, ['rev-parse', '--verify', '--quiet', `sillyspec/${changeName}^{commit}`], { timeout: 15 * 1000 }).value
476
498
  const hasAuditTag = !!safeGit(cwd, ['rev-parse', '--verify', '--quiet', `sillyspec-audit/sillyspec/${changeName}^{commit}`], { timeout: 15 * 1000 }).value
477
- if (!hasWorktreeMeta && !hasBranch && !hasAuditTag) {
499
+ // 审查 C-F02:预执行判定必须排除归档(归档=流程走完,58/77 归档变更三信号全缺被误判
500
+ // 「未执行」实证——tag 只在分支被 review 引用时才打)+ 补四类证据:快照文件本身 /
501
+ // execute-runs change 戳(task-review readExecuteRunChangeStamp 同口径)/ apply-pathspec /
502
+ // execute-cleanup 回执。任一命中 = 进过 execute。
503
+ const hasSnapshotFile = changeDirInfo
504
+ ? existsSync(join(changeDirInfo.dir, 'scope-audit.json')) || existsSync(join(runtimeRoot, `scope-audit-${changeName}.json`))
505
+ : existsSync(join(runtimeRoot, `scope-audit-${changeName}.json`))
506
+ const hasApplyPathspec = existsSync(join(runtimeRoot, `apply-pathspec-${changeName}.txt`))
507
+ const hasCleanupReceipt = existsSync(join(runtimeRoot, `execute-cleanup-${changeName}.json`))
508
+ let hasExecuteRunStamp = false
509
+ if (!hasWorktreeMeta && !hasBranch && !hasAuditTag && !hasSnapshotFile && !hasApplyPathspec && !hasCleanupReceipt) {
510
+ try {
511
+ const runsDir = join(runtimeRoot, 'execute-runs')
512
+ if (existsSync(runsDir)) {
513
+ for (const e of readdirSync(runsDir)) {
514
+ try {
515
+ if (readFileSync(join(runsDir, e, 'change'), 'utf8').trim() === changeName) { hasExecuteRunStamp = true; break }
516
+ } catch { /* 无戳 run 跳过 */ }
517
+ }
518
+ }
519
+ } catch { /* 扫描失败视为无证据(fail-open 到预执行视图,note 有 hedge) */ }
520
+ }
521
+ const isArchived = !!(changeDirInfo && changeDirInfo.archived)
522
+ if (!isArchived && !hasWorktreeMeta && !hasBranch && !hasAuditTag && !hasSnapshotFile && !hasApplyPathspec && !hasCleanupReceipt && !hasExecuteRunStamp) {
478
523
  const emptyBase = {
479
524
  mode: 'full-flow', ok: true, degradedReason: null, baseAnchor: null,
480
525
  totals: { files: 0, additions: 0, deletions: 0 }, rows: [],
@@ -495,8 +540,8 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
495
540
  totals: { files: preRows.length, additions: 0, deletions: 0 },
496
541
  rows: preRows,
497
542
  note: preRows.length > 0
498
- ? '变更尚未进入 execute(无 worktree/分支/审计 tag)——下表为 design 清单(全部待实现);工作区其他未提交改动归属各自会话/变更,不在本表(若确已执行过但三信号均被手工清理,此处会误判——git log 核实)'
499
- : '变更尚未进入 execute(无 worktree/分支/审计 tag)且 design 无可解析清单——暂无可对账内容',
543
+ ? '变更尚未进入 execute(无 worktree/分支/审计 tag/快照/execute-run 戳/apply 记录)——下表为 design 清单(全部待实现);工作区其他未提交改动归属各自会话/变更,不在本表(若确已执行过但全部执行证据被清理,此处会误判——git log 核实)'
544
+ : '变更尚未进入 execute(无 worktree/分支/审计 tag/快照/execute-run 戳/apply 记录)且 design 无可解析清单——暂无可对账内容',
500
545
  }
501
546
  }
502
547
 
@@ -558,7 +603,9 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
558
603
  // 快照即冻结记录(含行数)——已收尾一律先出快照;实时窗口(开放区间)只作快照缺失的兜底,
559
604
  // note 明示会漂。tag 封闭区间不可用:分支上仅 baseline checkpoint(并行 WIP 快照),本变更
560
605
  // 改动经工作树 apply 从未 commit 到分支。 ——
561
- const settled = changeDirInfo && (changeDirInfo.archived || actual.form === 'post-apply')
606
+ // freshActual(审查 C-F01):verify 漂移 current 侧走快照捷径会变成快照比快照恒「一致」
607
+ // (假阴性);execute --done 采集重跑也会陈旧回写——两处传 true 绕过,快照捷径只留给查询面
608
+ const settled = !freshActual && changeDirInfo && (changeDirInfo.archived || actual.form === 'post-apply')
562
609
  if (settled) {
563
610
  const snap = readScopeSnapshot(changeDirInfo.dir, runtimeRoot, changeName)
564
611
  if (snap && snap.rows.length > 0) {
@@ -571,7 +618,9 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
571
618
  const needsStats = rows.some(r => r && !Number.isFinite(r.additions) && r.kind !== 'binary')
572
619
  if (needsStats && actual && actual.ok && actual.baseAnchor) {
573
620
  const paths = rows.map(r => (r && r.path ? toPosix(r.path) : '')).filter(Boolean)
574
- const stats = collectNumstatByPath(cwd, paths, { baseRef: actual.baseAnchor })
621
+ // 审查 C-F04:补采执行根走共享 resolveDiffRoot(form=worktree 时必须对 worktree 跑,
622
+ // 与主链路 :629/:636 同源配对——直用 cwd 会基点×根错位)
623
+ const stats = collectNumstatByPath(resolveDiffRoot(sb, changeName, actual.form, cwd), paths, { baseRef: actual.baseAnchor })
575
624
  let recovered = 0
576
625
  rows = rows.map(r => {
577
626
  if (!r || !r.path || Number.isFinite(r.additions) || r.kind === 'binary') return r
@@ -591,6 +640,8 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
591
640
  excluded: snap.excluded && Array.isArray(snap.excluded.foreignDeclared)
592
641
  ? snap.excluded
593
642
  : { foreignDeclared: [] },
643
+ patchSha256: snap.patchSha256 || null,
644
+ patchStatus: snap.patchStatus || null,
594
645
  note: `${settleLabel}——execute --done 时点冻结快照${snap.savedAt ? '(' + String(snap.savedAt).replace('T', ' ').slice(0, 19) + ' 落盘)' : ''}:文件集封闭在 apply 时点,主仓后续新文件不进表;行数按锚 ${actual.baseAnchor.slice(0, 7)}→当前工作树 补采(同文件后续演进会计入)`,
595
646
  }
596
647
  }
@@ -606,6 +657,8 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
606
657
  excluded: snap.excluded && Array.isArray(snap.excluded.foreignDeclared)
607
658
  ? snap.excluded
608
659
  : { foreignDeclared: [] },
660
+ patchSha256: snap.patchSha256 || null,
661
+ patchStatus: snap.patchStatus || null,
609
662
  note: `${settleLabel}——execute --done 时点冻结快照${snap.savedAt ? '(' + String(snap.savedAt).replace('T', ' ').slice(0, 19) + ' 落盘)' : ''}:范围封闭在 apply 时点,主仓后续改动不反映到本表;需要看当前工作区实时状态请跑 git status`,
610
663
  }
611
664
  }
@@ -715,7 +768,7 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
715
768
  * excluded: { foreignDeclared: Array<{ file: string, sessions: string[] }> },
716
769
  * note?: string|null }>}
717
770
  */
718
- export async function computeChangeScopeAudit({ cwd, specBase, changeName, platformOpts, collectPatch } = {}) {
771
+ export async function computeChangeScopeAudit({ cwd, specBase, changeName, platformOpts, collectPatch, freshActual } = {}) {
719
772
  const empty = {
720
773
  mode: 'full-flow', ok: false, degradedReason: null, baseAnchor: null,
721
774
  totals: { files: 0, additions: 0, deletions: 0 },
@@ -746,6 +799,8 @@ export async function computeChangeScopeAudit({ cwd, specBase, changeName, platf
746
799
  ? rec.record.excluded
747
800
  : { foreignDeclared: [] },
748
801
  frozenPatchPath: rec.patchPath,
802
+ patchSha256: rec.record.patchSha256 || null,
803
+ patchStatus: rec.record.patchStatus || null,
749
804
  note: `quick 会话已收尾——记录态(quicklog/patches/${rec.qlId},${rec.record.savedAt ? String(rec.record.savedAt).replace('T', ' ').slice(0, 19) + ' 落盘' : '--done 时点冻结'})`,
750
805
  }
751
806
  }
@@ -755,7 +810,7 @@ export async function computeChangeScopeAudit({ cwd, specBase, changeName, platf
755
810
  degradedReason: `quick 会话 ${changeName} 不存在(guard 已清理且 quicklog/patches/ 无记录——会话未产生改动或早于记录机制)`,
756
811
  }
757
812
  }
758
- return await computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, collectPatch })
813
+ return await computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, collectPatch, freshActual })
759
814
  } catch (e) {
760
815
  // fail-soft 兜底(D-006 / 兼容策略):注入点只打一行提示,不阻断阶段完成
761
816
  return {
@@ -789,16 +844,36 @@ export async function getFileDiff({ cwd, specBase, changeName, platformOpts, fil
789
844
  // —— 真·当时内容比对(quick-359a48f1):已收尾变更优先冻结 patch 切片 ——
790
845
  // ① quick 记录态自带 frozenPatchPath;② full-flow 变更目录 scope-audit.patch。
791
846
  // patch 冻结在收尾时点,不含后续演进——实时锚 diff 只作 patch 缺失的兜底(口径标注)。
792
- const frozenPatch = (result.frozenPatchPath
793
- && (() => { try { return readFileSync(result.frozenPatchPath, 'utf8') } catch { return null } })())
794
- || readFrozenPatch(resolveChangeDir(sb, changeName)?.dir || null)
795
- if (frozenPatch) {
847
+ // A-F01:读取时按 json 记录的 patchSha256 校验,不匹配显式告警(防低级篡改)。
848
+ const patchRecord = result.frozenPatchPath
849
+ ? { path: result.frozenPatchPath, sha: result.patchSha256 || null }
850
+ : (() => { const d = resolveChangeDir(sb, changeName); return d ? { path: join(d.dir, 'scope-audit.patch'), sha: null } : null })()
851
+ let recordedSha = patchRecord ? patchRecord.sha : null
852
+ if (patchRecord && !recordedSha && result.rows) {
853
+ // json 里未直接带回 hash(记录态捷径不透传)→ 读 json 原文取(快照/quick 记录都存该字段)
854
+ try {
855
+ const snapRaw = JSON.parse(readFileSync(result.frozenPatchPath
856
+ ? result.frozenPatchPath.replace(/\.patch$/, '.json')
857
+ : join(resolveChangeDir(sb, changeName).dir, 'scope-audit.json'), 'utf8'))
858
+ recordedSha = snapRaw.patchSha256 || null
859
+ } catch { /* json 不可读 → 无 hash 可校验(null=不校验) */ }
860
+ }
861
+ const frozenPatch = (patchRecord
862
+ && (() => { try { return readFileSync(patchRecord.path, 'utf8') } catch { return null } })())
863
+ if (frozenPatch !== null && frozenPatch !== undefined && patchRecord) {
864
+ const integrity = verifyPatchIntegrity(recordedSha, frozenPatch)
865
+ if (integrity === false) {
866
+ return { ok: false, mode: result.mode, root: cwd, baseRef: null, anchorLabel: '冻结 patch', diff: null, note: '⚠️ 冻结 patch 与快照记录的 patchSha256 不匹配——记录可能被篡改,审计前人工核验(git 历史比对)' }
867
+ }
796
868
  const sliced = slicePatchForFile(frozenPatch, norm)
797
869
  if (sliced) {
798
870
  return { ok: true, mode: result.mode, root: cwd, baseRef: null, anchorLabel: '冻结 patch(收尾时点内容,无后续演进混入)', diff: sliced, note: null }
799
871
  }
800
872
  return { ok: true, mode: result.mode, root: cwd, baseRef: null, anchorLabel: '冻结 patch', diff: null, note: '该文件不在冻结 patch 内(窗口外文件)' }
801
873
  }
874
+ if (result.patchStatus === 'failed') {
875
+ return { ok: true, mode: result.mode, root: cwd, baseRef: null, anchorLabel: '冻结记录', diff: null, note: '冻结 patch 当时采集失败(非无改动)——退实时锚比对' }
876
+ }
802
877
 
803
878
  // 锚点解析(语义锚 head-uncommitted-window → 实际 baseRef 'HEAD';无锚同兜底并标注)
804
879
  let baseRef = 'HEAD'
@@ -0,0 +1,207 @@
1
+ /**
2
+ * semantic-guard.js — quick 语义护栏聚合模块(change: 2026-09-11-cross-change-decision-guard,
3
+ * task-03,FR-03/FR-04,D-001@v1):近因他者交付归因 + 断言重写检测 + advisory 渲染 + 总开关。
4
+ *
5
+ * 被 run/prompt.js(task-05,进场注入)与 run/quick-audit.js(task-06,--done 断言 WARNING)
6
+ * 两端消费。纯模块纪律:不 console(渲染返回字符串,输出留给消费端)、不写盘、
7
+ * 不 import run/ 下模块(避免与 task-05/06 循环依赖)。
8
+ *
9
+ * git 交互一律走 git-helper 的 safeGit(数组参数不经 shell,Windows 路径安全);失败静默
10
+ * 降级空结果不抛(acceptance:git 不可用 → 全部降级空结果)。
11
+ */
12
+ import { existsSync, readFileSync } from 'node:fs'
13
+ import { join } from 'node:path'
14
+ import { safeGit } from './git-helper.js'
15
+ import { matchDecisionsByFiles } from './knowledge-match.js'
16
+
17
+ /** 路径反斜杠归一 POSIX(Windows 调用方传 \ 分隔时不丢匹配;与 matchDecisionsByFiles 双侧归一口径一致) */
18
+ function toPosix(p) {
19
+ return String(p || '').trim().replace(/\\/g, '/')
20
+ }
21
+
22
+ // ───────────────────────── 交付归因(FR-03) ─────────────────────────
23
+
24
+ /** 提交信息里的完整变更名标记:init 生成的 change 目录名形态(2026-09-11-cross-change-decision-guard) */
25
+ const CHANGE_NAME_RE = /\d{4}-\d{2}-\d{2}-[a-z0-9][a-z0-9-]*/g
26
+ /** quicklog 条目 ID 标记:ql-YYYYMMDD-NNN-xxxx(quick 会话提交信息的归因键) */
27
+ const QL_ID_RE = /ql-\d{8}-\d{3}-[a-z0-9]{4}/g
28
+
29
+ /** 单条提交信息 → 两类标记列表(按出现位置排序;两类正则无交集,ql-ID 的日期段无连字符) */
30
+ function extractChangeMarkers(subject) {
31
+ const found = []
32
+ for (const re of [CHANGE_NAME_RE, QL_ID_RE]) {
33
+ for (const m of subject.matchAll(re)) found.push({ token: m[0], at: m.index })
34
+ }
35
+ return found.sort((a, b) => a.at - b.at).map(f => f.token)
36
+ }
37
+
38
+ /**
39
+ * 近因他者交付归因:每文件查最近 days 天提交信息(--format=%s,仅 subject),解析变更名 /
40
+ * ql-ID 两类标记,取最新一条非 currentChange 标记 → { [file]: changeName }。
41
+ * git log 输出行为提交 subject,无 porcelain 首行前导空格语义(git-helper.js:88 的
42
+ * trim 坑不影响此处),safeGit 默认 trim 安全。
43
+ * @param {{ cwd?: string, files?: string[], currentChange?: string, days?: number }} opts
44
+ * @returns {{ [file]: string }} 键 = POSIX 归一路径;无标记 / 仅本变更标记 / git 失败 /
45
+ * 第 20 个之后的文件 → 不记
46
+ */
47
+ export function collectRecentForeignDelivery({ cwd, files, currentChange, days = 7 } = {}) {
48
+ const result = {}
49
+ if (!cwd || !Array.isArray(files)) return result
50
+ const list = files.map(toPosix).filter(Boolean).slice(0, 20)
51
+ for (const file of list) {
52
+ const r = safeGit(cwd, ['log', `--since=${days}.days`, '--format=%s', '--', file])
53
+ if (r.error || !r.value) continue
54
+ // git log 新→旧:逐提交找第一条非 currentChange 标记,命中即止(取最新他者标记)
55
+ for (const subject of r.value.split('\n')) {
56
+ const foreign = extractChangeMarkers(subject).find(m => m !== currentChange)
57
+ if (foreign) {
58
+ result[file] = foreign
59
+ break
60
+ }
61
+ }
62
+ }
63
+ return result
64
+ }
65
+
66
+ // ───────────────────────── 断言重写检测(FR-04) ─────────────────────────
67
+
68
+ /** 删除侧断言 token:expect( / assert / t.equal / toBe / toEqual / strictEqual / should. */
69
+ const ASSERTION_TOKEN_RE = /expect\(|assert|t\.equal|toBe|toEqual|strictEqual|should\./
70
+
71
+ /** 测试文件判定:test/ 前缀 或 *.test.* / *_test.* 命名(路径先 POSIX 归一) */
72
+ function isTestFilePath(p) {
73
+ return p.startsWith('test/') || /\.test\./.test(p) || /_test\./.test(p)
74
+ }
75
+
76
+ /**
77
+ * 断言重写检测:测试文件跑 git diff HEAD -U0(HEAD 口径非 index——quick step3 先 git add
78
+ * 暂存的时序下裸 diff 恒空转,坑 Grill X-001),hunk 删除侧(- 行)含断言 token → 命中;
79
+ * 纯新增 hunk(无 - 行)不算;样例行封顶 5/文件。
80
+ * @param {{ cwd?: string, files?: string[] }} opts
81
+ * @returns {{ [file]: string[] }} 键 = POSIX 归一测试文件路径,值 = 删除侧含 token 的原行
82
+ * (去 - 前缀);非测试文件不查;无命中 / git 失败 → 不记
83
+ */
84
+ export function detectAssertionRewrites({ cwd, files } = {}) {
85
+ const result = {}
86
+ if (!cwd || !Array.isArray(files)) return result
87
+ for (const raw of files) {
88
+ const file = toPosix(raw)
89
+ if (!file || !isTestFilePath(file)) continue
90
+ const r = safeGit(cwd, ['diff', 'HEAD', '-U0', '--', file])
91
+ if (r.error || !r.value) continue
92
+ const samples = []
93
+ let inHunk = false
94
+ let deletedLines = []
95
+ const flushHunk = () => {
96
+ if (!inHunk || deletedLines.length === 0) return // 纯新增 hunk(无 - 行)不算
97
+ for (const line of deletedLines) {
98
+ if (samples.length >= 5) break
99
+ if (ASSERTION_TOKEN_RE.test(line)) samples.push(line)
100
+ }
101
+ }
102
+ for (const line of r.value.split('\n')) {
103
+ if (line.startsWith('@@')) {
104
+ flushHunk()
105
+ inHunk = true
106
+ deletedLines = []
107
+ continue
108
+ }
109
+ if (!inHunk) continue // 文件头(diff --git / index / --- / +++)不进 hunk 统计
110
+ if (line.startsWith('-')) deletedLines.push(line.slice(1)) // + 行与 "\ No newline" 行跳过
111
+ }
112
+ flushHunk()
113
+ if (samples.length > 0) result[file] = samples
114
+ }
115
+ return result
116
+ }
117
+
118
+ // ───────────────────────── advisory 渲染(FR-03/FR-04 组合) ─────────────────────────
119
+
120
+ /**
121
+ * advisory 文本段渲染:决策命中(matchDecisionsByFiles,knowledge 目录 = specBase/knowledge,
122
+ * task-02 契约)+ 交付归因(collectRecentForeignDelivery 默认 7 天窗)组合成注入文本,
123
+ * 供 task-05 进场注入 / task-06 --done WARNING 拼接;两类均零命中 → ''(不留空段)。
124
+ * @param {{ specBase?: string, cwd?: string, candidateFiles?: string[], currentChange?: string }} opts
125
+ * @returns {string}
126
+ */
127
+ export function renderSemanticGuardBlock({ specBase, cwd, candidateFiles, currentChange } = {}) {
128
+ if (!Array.isArray(candidateFiles) || candidateFiles.length === 0) return ''
129
+ const files = [...new Set(candidateFiles.map(toPosix).filter(Boolean))]
130
+ if (files.length === 0) return ''
131
+ const decisions = specBase
132
+ ? matchDecisionsByFiles(join(specBase, 'knowledge'), files)
133
+ : {}
134
+ const delivery = cwd
135
+ ? collectRecentForeignDelivery({ cwd, files, currentChange })
136
+ : {}
137
+ const decisionFiles = files.filter(f => Array.isArray(decisions[f]) && decisions[f].length > 0)
138
+ const deliveryFiles = files.filter(f => delivery[f])
139
+ if (decisionFiles.length === 0 && deliveryFiles.length === 0) return ''
140
+ const lines = ['【语义护栏】改动文件触及既有决策 / 近期他者交付,动手前请对照:']
141
+ if (decisionFiles.length > 0) {
142
+ lines.push(`■ 决策库命中(${decisionFiles.length} 文件)`)
143
+ for (const f of decisionFiles) {
144
+ for (const hit of decisions[f]) {
145
+ const rejected = hit.status === 'rejected'
146
+ lines.push(` - ${f} → ${hit.id} ${hit.title}[${rejected ? 'rejected,已否决,勿复潮' : hit.status || 'unknown'}]`)
147
+ lines.push(` 理由:${hit.reason || '(未记录)'}`)
148
+ if (hit.file) lines.push(` 详见:knowledge/${hit.file}`)
149
+ }
150
+ }
151
+ }
152
+ if (deliveryFiles.length > 0) {
153
+ lines.push('■ 近 7 天他者交付归因(最后交付的非本变更标记)')
154
+ for (const f of deliveryFiles) lines.push(` - ${f} ← ${delivery[f]}`)
155
+ }
156
+ return lines.join('\n')
157
+ }
158
+
159
+ // ───────────────────────── 总开关(D-001@v1) ─────────────────────────
160
+
161
+ /** YAML 布尔宽松解析:仅显式 false(含引号形态)为关,其余值一律 true(fail-open,先例 friction-tally R-05) */
162
+ function parseEnabledValue(raw) {
163
+ const v = String(raw || '').trim().replace(/^['"]|['"]$/g, '')
164
+ return v.toLowerCase() !== 'false'
165
+ }
166
+
167
+ /**
168
+ * 读 specBase/local.yaml 的 semantic_guard.enabled 总开关(D-001@v1):默认 true;
169
+ * 缺键 / 文件缺失 / 解析异常 → true(fail-open——配置读取异常不该把护栏误判为关)。
170
+ * 轻量行式解析不引 yaml 依赖(先例 friction-tally readFrictionHintEnabled 同款形态),
171
+ * 兼容嵌套(semantic_guard:\n enabled: false)与 flat(semantic_guard.enabled: false);
172
+ * 行尾先归一(Windows CRLF 残留致行式正则整体失配)。
173
+ * @param {string} specBase - spec 根(local.yaml 所在目录)
174
+ * @returns {boolean}
175
+ */
176
+ export function readSemanticGuardEnabled(specBase) {
177
+ try {
178
+ const yamlPath = join(specBase, 'local.yaml')
179
+ if (!existsSync(yamlPath)) return true
180
+ const text = String(readFileSync(yamlPath, 'utf8')).replace(/\r\n?/g, '\n')
181
+ let inSection = false
182
+ let sectionIndent = -1
183
+ for (const line of text.split('\n')) {
184
+ // flat 形态优先:键名带点,不与嵌套段头冲突
185
+ const flat = line.match(/^\s*semantic_guard\.enabled\s*:\s*(.*?)\s*(?:#.*)?$/)
186
+ if (flat) return parseEnabledValue(flat[1])
187
+ const section = line.match(/^(\s*)semantic_guard\s*:\s*(?:#.*)?$/)
188
+ if (section) {
189
+ inSection = true
190
+ sectionIndent = section[1].length
191
+ continue
192
+ }
193
+ if (!inSection) continue
194
+ if (line.trim() === '' || line.trim().startsWith('#')) continue
195
+ // 缩进回落到段头层级(或更浅)= semantic_guard 段结束,之后的 enabled 不属于它
196
+ if (/^\s*/.exec(line)[0].length <= sectionIndent) {
197
+ inSection = false
198
+ continue
199
+ }
200
+ const kv = line.match(/^\s*enabled\s*:\s*(.*?)\s*(?:#.*)?$/)
201
+ if (kv) return parseEnabledValue(kv[1])
202
+ }
203
+ return true
204
+ } catch {
205
+ return true
206
+ }
207
+ }
@@ -282,9 +282,14 @@ export class SillyHubMcpClient {
282
282
  }
283
283
 
284
284
  if (!res.ok) {
285
+ // 401 状态留痕(2026-09-11 用户实测反馈:token 失效被 probe 报成 daemon-unreachable,
286
+ // 误导排障方向——吊销/过期的 shmcp_ 是「重连再配对」能解的,不是 daemon 的问题)。
287
+ // getLastInitStatus() 供 probeSillyHub 类型化 reason;成功/其他失败置 null(不误判旧状态)。
288
+ this._lastInitHttpStatus = res.status;
285
289
  if (!quiet) console.warn(`[sillyhub-mcp] initialize → HTTP ${res.status}`);
286
290
  return false;
287
291
  }
292
+ this._lastInitHttpStatus = null;
288
293
 
289
294
  // 优先取 header mcp-session-id(FastMCP 实测返回);缺失再查 body _meta.sessionId。
290
295
  // ⚠️ 无论 header 是否有 session,都必须读完 body(消费 SSE 流)——FastMCP 在
@@ -391,6 +396,15 @@ export class SillyHubMcpClient {
391
396
  return result !== null;
392
397
  }
393
398
 
399
+ /**
400
+ * 最近一次 initialize 握手的 HTTP 状态码(null = 无记录/成功/非 HTTP 失败)。
401
+ * 消费方:probeSillyHub 的 token 失效类型化(401 → reason='mcp-token-invalid',
402
+ * 区别于 daemon-unreachable——2026-09-11 用户实测:吊销 token 被误报 daemon 不可达)。
403
+ */
404
+ getLastInitStatus() {
405
+ return this._lastInitHttpStatus ?? null;
406
+ }
407
+
394
408
  /**
395
409
  * 列出 daemon 暴露的 MCP tools(JSON-RPC `tools/list`,task-11 路径A 探测用)。
396
410
  *
package/src/spec-sync.js CHANGED
@@ -124,6 +124,9 @@ export function hashFiles(entries) {
124
124
  hash: _hashBuffer(buf),
125
125
  mtime: Math.floor(e.mtimeMs / 1000),
126
126
  absPath: e.absPath,
127
+ // 批 E-①:buf 随行携带(computeSpecOps 的 update/add 直接取用)——消除变更文件二次
128
+ // readFileSync(大文件双读)。docs 树为文本内容、量级有限,整批驻留可接受。
129
+ buf,
127
130
  };
128
131
  });
129
132
  }
@@ -177,20 +180,22 @@ export function computeSpecOps(serverManifest, localFiles) {
177
180
  return [];
178
181
  }
179
182
 
180
- // rename 检测:旧路径(服务器有、本地无)↔ 新路径(本地有、服务器无)hash 相同
183
+ // rename 检测:旧路径(服务器有、本地无)↔ 新路径(本地有、服务器无)hash 相同。
184
+ // 批 E-①:候选新路径按 hash 建索引 O(N+M)——旧双重循环 O(N×M),万级文件树明显变慢。
181
185
  const renames = [];
182
- const consumedNew = new Set();
186
+ const newByHash = new Map(); // hash → 未被服务器收录且未被消费的新路径队列
187
+ for (const [newPath, entry] of localMap) {
188
+ if (serverSet.has(newPath)) continue
189
+ const list = newByHash.get(entry.hash)
190
+ if (list) list.push(newPath); else newByHash.set(entry.hash, [newPath])
191
+ }
183
192
  for (const oldPath of serverPaths) {
184
- if (localSet.has(oldPath)) continue;
185
- const cachedEntry = server[oldPath];
186
- for (const newPath of localMap.keys()) {
187
- if (serverSet.has(newPath)) continue;
188
- if (consumedNew.has(newPath)) continue;
189
- if (localMap.get(newPath).hash === cachedEntry.hash) {
190
- renames.push({ oldPath, newPath });
191
- consumedNew.add(newPath);
192
- break;
193
- }
193
+ if (localSet.has(oldPath)) continue
194
+ const candidates = newByHash.get(server[oldPath].hash)
195
+ if (candidates && candidates.length > 0) {
196
+ const newPath = candidates.shift()
197
+ renames.push({ oldPath, newPath })
198
+ if (candidates.length === 0) newByHash.delete(server[oldPath].hash)
194
199
  }
195
200
  }
196
201
  const renamedOld = new Set(renames.map((r) => r.oldPath));
@@ -212,7 +217,7 @@ export function computeSpecOps(serverManifest, localFiles) {
212
217
  if (localSet.has(p)) {
213
218
  const localEntry = localMap.get(p);
214
219
  if (localEntry.hash !== server[p].hash) {
215
- const content = readFileSync(localEntry.absPath ?? join('.', p)).toString('base64');
220
+ const content = (localEntry.buf ?? readFileSync(localEntry.absPath ?? join('.', p))).toString('base64');
216
221
  ops.push({
217
222
  op: 'update',
218
223
  path: p,
@@ -235,7 +240,7 @@ export function computeSpecOps(serverManifest, localFiles) {
235
240
  if (renamedNew.has(p)) continue;
236
241
  if (!serverSet.has(p)) {
237
242
  const localEntry = localMap.get(p);
238
- const content = readFileSync(localEntry.absPath ?? join('.', p)).toString('base64');
243
+ const content = (localEntry.buf ?? readFileSync(localEntry.absPath ?? join('.', p))).toString('base64');
239
244
  ops.push({
240
245
  op: 'add',
241
246
  path: p,
@@ -317,10 +317,11 @@ const PLAN_RULES = [
317
317
  target: { root: 'change', path: 'design.md', scope: 'full' },
318
318
  data: {
319
319
  messageMissingList: 'design.md 缺少「文件变更清单」章节(或清单解析为空),无法做文件覆盖对账。该章节在 brainstorm 模板中为必填;请在 design.md 补上完整的文件变更清单(列出本次新增/修改/删除的源码文件)后重试。',
320
- messageUncovered: 'design.md 文件变更清单中 ${count} 个文件未被任何 task 的 allowed_paths 覆盖:\n${files}\n 这些文件在 execute 阶段将无 task 有权修改 → 必然漏改。\n 修复:为每个遗漏文件新建/补充 task 并在其 allowed_paths 声明,或在 design.md「不修改文件」章节说明不改原因。',
320
+ messageUncovered: 'design.md 文件变更清单中 ${count} 个文件未被任何 task 的 allowed_paths 覆盖:\n${files}\n 这些文件在 execute 阶段将无 task 有权修改 → 必然漏改。\n 修复:为每个遗漏文件新建/补充 task 并在其 allowed_paths 声明,或在 design.md「不修改文件」章节说明不改原因。\n 若跨仓文件被标为 [main]:多为仓变更段头未被识别(段头须为「## <repo-key> 仓变更」),先核对段头格式再补 task。',
321
+ messageMalformedHeader: 'design.md「文件变更清单」内疑似仓变更段头但格式无法解析:「${line}」\n 该段下文件会被记到上一段仓(或 main)→ 文件覆盖对账整段错位(误报未覆盖或漏检)。\n 修复:段头写成「## <repo-key> 仓变更」(repo-key 限字母数字._-,可加编号前缀与(…)备注后缀);若这是下一章节标题而非段头,请改名避免形如「<词> 仓变更…」。',
321
322
  },
322
- spec: 'design.md「文件变更清单」中的每个源码文件,必须被至少一个 task 的 allowed_paths 覆盖(前缀/glob 容差匹配),否则 execute 子代理无权改它→必然漏改。有 task 卡片但 design 缺「文件变更清单」章节也阻断(清单是覆盖对账的基准)。',
323
- failMessage: 'design.md 文件变更清单中 ${count} 个文件未被任何 task 的 allowed_paths 覆盖:\n${files}\n 这些文件在 execute 阶段将无 task 有权修改 → 必然漏改。\n 修复:为每个遗漏文件新建/补充 task 并在其 allowed_paths 声明,或在 design.md「不修改文件」章节说明不改原因。',
323
+ spec: 'design.md「文件变更清单」中的每个源码文件,必须被至少一个 task 的 allowed_paths 覆盖(前缀/glob 容差匹配),否则 execute 子代理无权改它→必然漏改。有 task 卡片但 design 缺「文件变更清单」章节也阻断(清单是覆盖对账的基准)。疑似仓变更段头但格式解析不了(repo-key 非法/仓变更后接自由文本)同样阻断并点名该行——段头错记仓会让对账整段错位。',
324
+ failMessage: 'design.md 文件变更清单中 ${count} 个文件未被任何 task 的 allowed_paths 覆盖:\n${files}\n 这些文件在 execute 阶段将无 task 有权修改 → 必然漏改。\n 修复:为每个遗漏文件新建/补充 task 并在其 allowed_paths 声明,或在 design.md「不修改文件」章节说明不改原因。\n 若跨仓文件被标为 [main]:多为仓变更段头未被识别(段头须为「## <repo-key> 仓变更」),先核对段头格式再补 task。',
324
325
  },
325
326
  // task-plan-reconciliation(custom):plan.md 声明任务 ↔ tasks/ 卡片双向对账(债单 D-2b)。
326
327
  // 修复场景:plan 列 17 个任务只生成 12 张卡(尾部文档同步类任务漏卡),execute 审计只看
@@ -13,7 +13,8 @@
13
13
  * (平台模式落 <runtimeRoot>/stage-reviews/...,与 execute-runs 同构)
14
14
  */
15
15
 
16
- import { existsSync, readFileSync, mkdirSync, readdirSync, writeFileSync } from 'fs'
16
+ import { existsSync, readFileSync, mkdirSync, readdirSync } from 'fs'
17
+ import { writeAtomicSync } from './fs-atomic.js'
17
18
  import { join } from 'path'
18
19
  import { createHash } from 'crypto'
19
20
  import jsYaml from 'js-yaml'
@@ -561,7 +562,7 @@ export function validateStageReviewWithAutoRefresh(opts) {
561
562
  reviewerNotes: `${result.review.reviewerNotes || ''}\ndocHash auto-refreshed at ${refreshedAt}(gate 检测主文档改版,机械重算;verdict 保留,结论是否仍适用需人工确认)`.trim(),
562
563
  }
563
564
  try {
564
- writeFileSync(reviewPath, JSON.stringify(refreshed, null, 2) + '\n')
565
+ writeAtomicSync(reviewPath, JSON.stringify(refreshed, null, 2) + '\n')
565
566
  } catch {
566
567
  return { result, autoRefreshed: false } // 写失败回落原失败结果,不静默放行
567
568
  }
@@ -698,7 +699,7 @@ export function registerStageReview({ changeName, stage, fromFile, cwd, platform
698
699
  reviewedFiles,
699
700
  reviewerNotes: `${existing.reviewerNotes || ''}\ndocHash refreshed at ${refreshedAt}(主文档改版后机械重算;verdict 结论是否仍适用需人工确认)`.trim(),
700
701
  }
701
- writeFileSync(existingPath, JSON.stringify(review, null, 2) + '\n')
702
+ writeAtomicSync(existingPath, JSON.stringify(review, null, 2) + '\n')
702
703
  const schemaRecheck = validateStageReviewSchema(review)
703
704
  if (!schemaRecheck.ok) {
704
705
  throw new Error(`register-stage-review: refresh 后 schema 自检失败 — ${schemaRecheck.errors.join('; ')}(原 review 字段异常,先修原文件)`)
@@ -750,7 +751,7 @@ export function registerStageReview({ changeName, stage, fromFile, cwd, platform
750
751
  const reviewDir = join(runtimeRoot, 'stage-reviews', `${stage}-${reviewRunId}`)
751
752
  const reviewPath = join(reviewDir, 'review.json')
752
753
  mkdirSync(reviewDir, { recursive: true })
753
- writeFileSync(reviewPath, JSON.stringify(review, null, 2) + '\n')
754
+ writeAtomicSync(reviewPath, JSON.stringify(review, null, 2) + '\n')
754
755
  // stage-reviews 回收不在此处(ql-20260908-005 已定分野):变更归属类证据走归档时精确
755
756
  // 回收(pruneArchivedChangeRuntime 按 reviewedFiles 归属);写入侧滚动是启发式,
756
757
  // 活跃变更的 review 可能因他端高频写而跌出 keep 窗,不进证据类目录。
@@ -760,7 +761,7 @@ export function registerStageReview({ changeName, stage, fromFile, cwd, platform
760
761
  console.warn(`⚠️ register-stage-review: marker 已存在 ${markerPath},将被覆盖为 ${reviewRunId}`)
761
762
  }
762
763
  mkdirSync(runtimeRoot, { recursive: true })
763
- writeFileSync(markerPath, reviewRunId + '\n')
764
+ writeAtomicSync(markerPath, reviewRunId + '\n')
764
765
 
765
766
  // self-check(fail-closed):刚写的 review 必过 schema + docHash 真实性。只验 mechanics,
766
767
  // 不判 verdict(verdict 是 agent/子代理的审查结论,even fail 也如实落盘,由 Stage Review Gate 裁决)。
@@ -293,7 +293,7 @@ design.md 第一行标题必须用中文:# 设计文档(Design)— <变更
293
293
  - 仅改内部实现、无对外字段变动时,说明列照常写「新增 xx 方法」即可,无需数据流。
294
294
 
295
295
  **跨仓变更写法**(本次变更涉及 local.yaml repos: 注册的其他仓时必读;纯主仓变更跳过):
296
- - 清单**按仓分段**:每个仓一个小节,标题格式固定 \`## <repo-key> 仓变更\`(如 \`## sub-grid-security 仓变更\`)。plan-postcheck 文件覆盖对账按此段头解析仓归属,写成「前端仓 / backend」等其他标题 → 对账不上
296
+ - 清单**按仓分段**:每个仓一个小节,标题格式固定 \`## <repo-key> 仓变更\`(如 \`## sub-grid-security 仓变更\`;可加编号前缀或(跨仓,X1-X4)这类括号备注后缀,其余后缀/写法解析不了)。plan-postcheck 文件覆盖对账按此段头解析仓归属,写成「前端仓 / backend」等其他标题 → 对账不上且会点名段头格式错误
297
297
  - 段内路径相对**该仓根**写(\`src/routes/x.js\`);❌ 禁止带仓库名前缀(\`sub-grid-security/src/routes/x.js\`)、❌ 禁止绝对路径/盘符路径(\`C:/repo/src/...\`)——后续 TaskCard 的 allowed_paths 与 review 对账(\`git -C <仓根> diff\` 产仓根相对路径)都用同一口径,前缀/绝对路径永不命中
298
298
  - 主仓文件放在第一个段头之前(无段头区域 = main),或单独写 \`## main 仓变更\` 段
299
299
  - repo-key 必须是 local.yaml repos: 段已注册的键(未注册先跑 \`sillyspec local register-repo <key> <仓根路径>\`,main 隐式不用注册)
@@ -1074,6 +1074,10 @@ ${crossLines}${crossWorktreeSection}${crossLegacySection}
1074
1074
  ` - task-${i.taskNum} (repo: ${i.repo}) → workdir: "${i.workdir}"`
1075
1075
  ).join('\n')
1076
1076
  worktreeSection = `
1077
+ ### task 进行中标记(中断续跑的半成品归属,2026-09-12 起)
1078
+
1079
+ 每个 task **开工/完工各跑一条命令**(低摩擦,主代理或子代理均可):开工「sillyspec task start --change {CHANGE_NAME} --task task-NN --note 一句话在做什么」;完工「sillyspec task finish --change {CHANGE_NAME} --task task-NN」。中断续跑时主代理用「sillyspec task list --change {CHANGE_NAME}」点名接管(>2h 标红为中断遗留),不再靠翻 git 工作区猜哪个半成品是谁的。
1080
+
1077
1081
  ### 工作目录(per-task)
1078
1082
 
1079
1083
  调用 Task 工具启动子代理时,**workdir 参数是强制必传的**,且本 Wave 内不同 task 的 workdir 不同(主仓 task vs 跨仓 task)。
@@ -1213,7 +1217,8 @@ ${taskList}
1213
1217
  - 最后一个 Wave 完成后做一次全量编译验证
1214
1218
  - 用户明确要求编译时
1215
1219
  4. 每个任务完成后:
1216
- - **在 worktree 内 git add -A && git commit -m "<task-NN 摘要>"**(坑 subagent-uncommitted-newfile-apply3way,2026-08-22 实证:纯新增文件不 commit 时 apply 的 git apply --3way 报 "does not exist in index" 直接炸——未 commit 的新文件不在 base commit 也不在 index,patch 生成取不到。commit 后 base..HEAD diff 完整、apply 顺畅,review.json 的 head 也有真实锚点)
1220
+ - **使用 \`sillyspec wt-commit --change <change-name> -- <task-files>\` 串行提交**(坑 wt-parallel-commit-race:同一 Wave 多子代理共享 worktree,裸 \`git add -A\` 互卷 WIP / 撞 index.lock;\`git add -A\` 还会把并行子代理未提交的半成品一并暂存——**禁 \`git add -A\`**;sillyspec wt-commit 自动处理分支切换与序列化,无需手动 git 操作;未 commit 的新文件不在 base commit 也不在 index,apply --3way 报 "does not exist in index")
1221
+ - commit 后 base..HEAD diff 完整、apply 顺畅,review.json 的 head 也有真实锚点
1217
1222
  - **写 review.json 即可**(checkbox 由 CLI 自动勾选,见下方 Task Review Gate)
1218
1223
  - **任务边界上报(每任务一次,主仓根目录)**:review write 落盘(CLI 已自动勾选)后跑一次 \`sillyspec platform sync --change <change-name>\`——以任务粒度把「最后信号」(last_pushed_at)与 tasks.md 勾选状态推上平台(变更中心「进行中」可见性);未连接平台时该命令静默跳过,无需先检查连接状态
1219
1224
  - **既跑 lint check 也跑 formatter**:凡变更涉及的源码跑项目的 lint 检查 **和** 格式化(如 \`ruff format\` / \`prettier --write\`),不要只跑 check——只 check 不 format 会把格式问题留到 commit 时被 pre-commit hook 拦截(worktree 内二进制可能缺失,先 \`which <bin>\` 确认,缺则 \`uv tool install\` / \`uv sync\`)