sillyspec 3.28.7 → 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 (51) 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/package.json +1 -1
  7. package/src/agent-session-log.js +446 -35
  8. package/src/config-cat.js +6 -3
  9. package/src/constants.js +3 -0
  10. package/src/db.js +12 -3
  11. package/src/docs-check.js +137 -12
  12. package/src/docs-gate.js +3 -0
  13. package/src/doctor-diagnostics.js +19 -7
  14. package/src/friction-tally.js +7 -2
  15. package/src/git-helper.js +6 -4
  16. package/src/hooks/worktree-guard.js +59 -29
  17. package/src/index.js +168 -64
  18. package/src/local-detect.js +30 -15
  19. package/src/local-register.js +13 -4
  20. package/src/mcp-server.js +38 -8
  21. package/src/progress/change-registry.js +41 -35
  22. package/src/progress/consistency-doctor.js +11 -4
  23. package/src/progress/stage-machine.js +3 -1
  24. package/src/progress/step-store.js +30 -17
  25. package/src/progress.js +23 -0
  26. package/src/quicklog.js +44 -18
  27. package/src/run/command.js +17 -3
  28. package/src/run/complete-handlers.js +3 -3
  29. package/src/run/complete.js +28 -9
  30. package/src/run/concurrent-detect.js +1 -1
  31. package/src/run/gate-snapshot.js +115 -5
  32. package/src/run/gates.js +35 -5
  33. package/src/run/prompt.js +9 -1
  34. package/src/run/quick-audit.js +25 -2
  35. package/src/run/shared.js +98 -10
  36. package/src/run/verify-quality-scan.js +14 -3
  37. package/src/spec-sync.js +19 -14
  38. package/src/stage-contract-spec.js +4 -3
  39. package/src/stage-review.js +6 -5
  40. package/src/stages/brainstorm.js +1 -1
  41. package/src/stages/execute.js +6 -1
  42. package/src/stages/plan-postcheck.js +57 -12
  43. package/src/sync.js +66 -4
  44. package/src/task-review.js +41 -6
  45. package/src/verify-facts-schema.js +10 -3
  46. package/src/verify-postcheck.js +21 -6
  47. package/src/verify-probes.js +8 -3
  48. package/src/worktree-apply.js +56 -18
  49. package/src/worktree-cross.js +12 -18
  50. package/src/worktree.js +138 -25
  51. package/src/wt-commit.js +128 -0
package/src/sync.js CHANGED
@@ -12,6 +12,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlink
12
12
  import { createHash } from 'crypto';
13
13
  import { writeAtomicSync } from './fs-atomic.js';
14
14
  import { join, dirname, basename, isAbsolute, relative } from 'path';
15
+ import { homedir } from 'os';
15
16
  import { resolvePlatformSpecDir } from './progress.js';
16
17
  import { safeGit } from './git-helper.js';
17
18
  import { openDatabase } from './db-engine.js';
@@ -534,7 +535,20 @@ export class SyncManager {
534
535
  * 不删后两者则"disconnect 后恢复本地模式"不可达(指针健在时 resolvePlatformSpecDir
535
536
  * 仍解析平台 specRoot;声明健在时指针缺失会 fail-closed)。
536
537
  */
537
- disconnect() {
538
+ disconnect(opts = {}) {
539
+ const { keepPointer = false } = opts
540
+ // 坑 platform-sync-progress-rollback-and-db-corruption 坑1③(2026-09-08 实证):三清里的
541
+ // 指针/声明是 daemon 内嵌 CLI 的 specDir 解析依据——指针缺席期间 daemon 心跳读仓库
542
+ // .sillyspec(空)并把空状态推上平台,反而放大回滚。两道防线:
543
+ // ① keepPointer(CLI `platform disconnect --keep-pointer`):只清 local.yaml platform
544
+ // 段(自己 CLI 的同步断开),指针/声明保留给 daemon——回滚环恢复序列的正规化出口;
545
+ // ② 默认三清前检测 daemon 主目录存在 → 醒目警告(在线 daemon 会失锚)。
546
+ const daemonHome = join(homedir(), '.sillyhub', 'daemon')
547
+ if (!keepPointer && existsSync(daemonHome)) {
548
+ console.warn(`⚠️ 检测到本机存在 SillyHub daemon(${daemonHome})——三清将删除平台指针,daemon 内嵌 CLI 将失锚:`)
549
+ console.warn(` 指针缺席期间 daemon 心跳读仓库本地 .sillyspec(可能为空)并把空状态推上平台,会放大进度回滚。`)
550
+ console.warn(` 若只想断开自己 CLI 的同步(保留 daemon 锚点):sillyspec platform disconnect --keep-pointer`)
551
+ }
538
552
  const p = join(this.cwd, LOCAL_YAML);
539
553
  if (existsSync(p)) {
540
554
  const text = readFileSync(p, 'utf8');
@@ -549,6 +563,10 @@ export class SyncManager {
549
563
  }
550
564
  }
551
565
  }
566
+ if (keepPointer) {
567
+ console.log('[sync] 已断开连接(--keep-pointer:仅清 local.yaml platform 段,指针与接管声明保留给 daemon 内嵌 CLI)');
568
+ return;
569
+ }
552
570
  // 三清之二/三:指针 + 接管声明(disconnect 是声明的唯一退出路径——design.md §5.4)。
553
571
  // HUB-12 有意不清 .sillyspec-platform-cleaned marker:它防的是「重新接入平台时 init
554
572
  // 重复做残留清理」——disconnect 后重连若清掉 marker,init 会对现在装着本地数据的
@@ -1723,7 +1741,51 @@ export class SyncManager {
1723
1741
  const cf = this.readConflictFile(changeName);
1724
1742
  const specCf = this.readSpecConflictFile(changeName);
1725
1743
  if (!cf && !specCf) {
1726
- return { ok: false, resolved: false, reason: `无可解决冲突: ${changeName}(无 sync-conflict / spec-sync-conflict 文件)` };
1744
+ // 坑 platform-sync-progress-rollback-and-db-corruption 坑1②(2026-09-08 实证):回滚环
1745
+ // 中间态——冲突文件已被某次常规同步静默清除,但 DB 的 base_ts 仍落后,keep-local
1746
+ // 找不到裁决目标被拒(「无可解决冲突」),恢复序列被迫走人肉 SQL。显式出口:keep-local
1747
+ // 在无冲突文件时降级为「无冲突强推」——拉平台 last_pushed_at 推进 base_ts(与有冲突
1748
+ // 路径同款 MAX/COALESCE 单调 SQL)+ 自动重推闭环,等价完成裁决意图。take-platform
1749
+ // 依赖冲突文件里的 platform_progress 快照,无文件时仍拒绝(无可覆盖源)。
1750
+ if (mode !== 'keep-local') {
1751
+ return { ok: false, resolved: false, reason: `无可解决冲突: ${changeName}(无 sync-conflict / spec-sync-conflict 文件)` };
1752
+ }
1753
+ const platform = this._getPlatform();
1754
+ if (!platform) {
1755
+ return { ok: false, resolved: false, reason: `无可解决冲突且未连接平台: ${changeName}——keep-local 无冲突强推需拉平台 last_pushed_at` };
1756
+ }
1757
+ let pushedAt = null;
1758
+ try {
1759
+ const res = await fetchJson(
1760
+ `${platform.url}/api/changes/${encodeURIComponent(changeName)}/progress`,
1761
+ { headers: { Authorization: `Bearer ${platform.token}` } },
1762
+ );
1763
+ pushedAt = (res && res.last_pushed_at) || null;
1764
+ } catch { /* 拉取失败按 null:仅跳过 base_ts 推进,重推仍可尝试 */ }
1765
+ if (pushedAt) {
1766
+ try {
1767
+ const { ProgressManager } = await import('./progress.js');
1768
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
1769
+ const db = pm._ensureDB(this.cwd).getDb();
1770
+ db.prepare('UPDATE changes SET last_synced_platform_ts = MAX(COALESCE(?, last_synced_platform_ts), COALESCE(last_synced_platform_ts, ?)) WHERE name = ?')
1771
+ .run(pushedAt, pushedAt, changeName);
1772
+ } catch (err) {
1773
+ return { ok: false, resolved: false, reason: `无冲突 keep-local 推进 base_ts 失败: ${err.message}` };
1774
+ }
1775
+ }
1776
+ let repush = null;
1777
+ try {
1778
+ repush = await this.sync(changeName, { fromResolve: true });
1779
+ } catch (err) {
1780
+ debugLog(`[sync] 无冲突 keep-local 重推异常: ${changeName}: ${err.message}`);
1781
+ }
1782
+ if (repush && repush.synced === 1) {
1783
+ return { ok: true, resolved: true, mode: 'keep-local-noconflict', reason: '无冲突显式 keep-local:base_ts 已对齐平台最新,本地已重推——回滚环收敛出口' };
1784
+ }
1785
+ if (repush && repush.conflict) {
1786
+ return { ok: true, resolved: true, mode: 'keep-local-noconflict', reason: '无冲突显式 keep-local:base_ts 已推进;重推被拒(平台刚又有更新,已落冲突文件)——再跑一次 resolve --keep-local 即可' };
1787
+ }
1788
+ return { ok: true, resolved: true, mode: 'keep-local-noconflict', reason: '无冲突显式 keep-local:base_ts ' + (pushedAt ? '已推进' : '未获取(平台拉取失败)') + ';重推未成功(未连接/网络),请手动 sillyspec platform sync --change ' + changeName };
1727
1789
  }
1728
1790
  const platformPushedAt = (cf && cf.platform_last_pushed_at) || null;
1729
1791
 
@@ -1954,8 +2016,8 @@ export async function connect(url, token, cwd) {
1954
2016
  return new SyncManager(cwd).connect(url, token);
1955
2017
  }
1956
2018
 
1957
- export async function disconnect(cwd) {
1958
- return new SyncManager(cwd).disconnect();
2019
+ export async function disconnect(cwd, opts = {}) {
2020
+ return new SyncManager(cwd).disconnect(opts);
1959
2021
  }
1960
2022
 
1961
2023
  export async function sync(changeName, cwd, opts) {
@@ -9,11 +9,12 @@
9
9
  */
10
10
 
11
11
  import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync, statSync } from 'fs'
12
- import { join, resolve } from 'path'
12
+ import { join, resolve, basename, dirname } from 'path'
13
13
  import { git, unquoteGitPath } 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'
17
+ import { splitOwnVsForeignDiffFiles } from './foreign-declared.js'
17
18
  import { WorktreeManager } from './worktree.js'
18
19
  import { resolveRuntimeRoot } from './run/shared.js'
19
20
 
@@ -30,6 +31,16 @@ import { resolveRuntimeRoot } from './run/shared.js'
30
31
  // 跨仓 task 写 review.json 可用 v2 带 repo 字段,单仓/旧 change 仍用 v1。后续可拆分 stage-review 专用
31
32
  // 常量,本期遵循 allowed_paths 约束不扩散到 stage-review.js。
32
33
  export const REVIEW_SCHEMA_VERSION = 1
34
+
35
+ /**
36
+ * review.json 写入溯源戳(坑 review-json-mystery-overwrite,2026-09-12 驾驭第十七批①,
37
+ * 用户实证 task-04 review 被覆写成空壳、gate 报零改动伪造重写恢复——四个 writer 均有
38
+ * 覆盖守卫,无堆栈钉不死覆写者):每次写入附 writtenBy(写入通道 + PID)+ writtenAt,
39
+ * emptyDiff 伪造 error 直显两字段——下次复现自曝身份,事后可归因。
40
+ */
41
+ function reviewProvenanceStamp(channel) {
42
+ return { writtenBy: `${channel}#pid${process.pid}`, writtenAt: new Date().toISOString() }
43
+ }
33
44
  const REVIEW_SCHEMA_VERSIONS_ACCEPTED = [1, 2]
34
45
  // repo 缺省值(review.repo / task 卡 repo 缺省时统一按 'main' 处理)。design §7.2 / §7.4。
35
46
  const DEFAULT_REPO_KEY = 'main'
@@ -493,7 +504,11 @@ export function validateTaskReviews(opts) {
493
504
  }
494
505
  }
495
506
  if (reviewGitDir) {
496
- const evidence = verifyReviewGitEvidence(review, reviewGitDir, evidenceCache)
507
+ const evidence = verifyReviewGitEvidence(review, reviewGitDir, evidenceCache, {
508
+ mainGitDir: gitDir,
509
+ changeName: changeDir ? basename(changeDir) : null,
510
+ specBase: changeDir ? dirname(dirname(changeDir)) : null,
511
+ })
497
512
  for (const w of evidence.warnings) warnings.push(`${taskId}: ${w}`)
498
513
  if (!evidence.ok) {
499
514
  for (const err of evidence.errors) errors.push(`${taskId}: ${err}`)
@@ -515,7 +530,8 @@ export function validateTaskReviews(opts) {
515
530
  const scopeNote = Array.isArray(review.diffPaths) && review.diffPaths.length > 0
516
531
  ? `(diffPaths 路径限定切片为空——统一 commit 模式下 diffPaths 应为本 task 的 allowed_paths;task 确未实现请把 verdict 回 fail)`
517
532
  : ''
518
- errors.push(`${taskId}: base..head(${review.base.slice(0, 8)}..${review.head.slice(0, 8)})无任何代码变更 — 评审了一个零改动的任务,review 疑似伪造${scopeNote}`)
533
+ const prov = (review.writtenBy || review.writtenAt) ? `[写入溯源:${review.writtenBy || '?'} @ ${review.writtenAt || '?'}]` : '[无写入溯源—— older writer 产物]'
534
+ errors.push(`${taskId}: base..head(${review.base.slice(0, 8)}..${review.head.slice(0, 8)})无任何代码变更 — 评审了一个零改动的任务,review 疑似伪造${scopeNote}${prov}`)
519
535
  continue
520
536
  }
521
537
  }
@@ -622,7 +638,7 @@ function parsePorcelainFiles(statusOut) {
622
638
  * 一次校验批:working-tree status 是快照语义,跨批复用会读到陈旧状态。
623
639
  * @returns {{ ok: boolean, emptyDiff: boolean, errors: string[], warnings: string[], unavailable: boolean }}
624
640
  */
625
- export function verifyReviewGitEvidence(review, gitDir, cache = null) {
641
+ export function verifyReviewGitEvidence(review, gitDir, cache = null, opts = {}) {
626
642
  const errors = []
627
643
  const warnings = []
628
644
  const c = cache || (cache = {})
@@ -667,7 +683,7 @@ export function verifyReviewGitEvidence(review, gitDir, cache = null) {
667
683
  // 可执行指引(坑 review-head-real-commit-late,2026-08-22 实证:head 必须真实 commit 的
668
684
  // 要求撞门才知道——报错直接给操作序列:worktree 改动先 commit,取 HEAD 作 head)
669
685
  errors.push(`${field} "${hash}" 不是仓库中的真实 commit — review.json 疑似伪造。` +
670
- `head/base 必须是 git rev-parse 可解析的 commit hash:worktree 内先 git add -A && git commit -m "<task>",` +
686
+ `head/base 必须是 git rev-parse 可解析的 commit hash:worktree 内先 sillyspec wt-commit --change <变更名> -m "<task-NN>" -- <本 task 精确路径>(串行锁 + pathspec,防卷入并行兄弟 WIP;退路 git add -- <精确路径> && git commit,禁 add -A),` +
671
687
  `再取 git rev-parse HEAD 作 head(base 用 task 卡锡点或基线 commit);不要填分支名/伪 hash/working-tree 描述`)
672
688
  }
673
689
  }
@@ -703,7 +719,22 @@ export function verifyReviewGitEvidence(review, gitDir, cache = null) {
703
719
  c.wtStatus.set(gitDir, wtStatus)
704
720
  }
705
721
  if (wtStatus && wtStatus.trim().length > 0) {
706
- const wtFiles = parsePorcelainFiles(wtStatus)
722
+ let wtFiles = parsePorcelainFiles(wtStatus)
723
+ // 归属过滤(2026-09-12 审查批 A-①,坑 task-review-foreign-wip-blindspot):主仓 in-place
724
+ // 模式下 wtStatus 含**全部**并行会话的未提交文件——他者 WIP 稀释 diffFiles 使 emptyDiff
725
+ // (零改动→伪造 ERROR)在共享仓永不触发、交叉比对更易混过。对齐 verify-postcheck
726
+ // resolveMainChangedFiles 的 splitOwnVsForeignDiffFiles 剔除口径(他者声明文件归他者)。
727
+ // 仅主仓 gitDir 过滤(跨仓/worktree 无主 specBase 声明源,行为不变);无 opts 零回归。
728
+ if (wtFiles.length > 0 && opts.mainGitDir && opts.changeName
729
+ && resolve(gitDir) === resolve(opts.mainGitDir)) {
730
+ try {
731
+ const { own, foreign } = splitOwnVsForeignDiffFiles(gitDir, opts.changeName, wtFiles, { specBase: opts.specBase })
732
+ if (foreign.length > 0) {
733
+ wtFiles = own
734
+ warnings.push(`working-tree 并入已排除 ${foreign.length} 个并行会话声明的文件(${foreign.slice(0, 3).map(x => x.file).join(', ')}${foreign.length > 3 ? ' 等' : ''})`)
735
+ }
736
+ } catch { /* 归属过滤失败退回全量并入(fail-open 与旧口径一致) */ }
737
+ }
707
738
  if (wtFiles.length > 0) diffFiles = diffFiles.concat(wtFiles)
708
739
  if (commitDiffFiles.length === 0) {
709
740
  warnings.push(`base..head 无 commit diff(${review.base.slice(0, 8)}..${review.head.slice(0, 8)}),交叉对账并入 working-tree 未提交改动 ${wtFiles.length} 个文件 —— 按有效改动处理,不判零改动伪造`)
@@ -1321,6 +1352,7 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
1321
1352
  ';verdict=未评审(无归属 diff 兜底草稿,坑 task-review-draft-skip-leak)',
1322
1353
  }
1323
1354
  if (draftRepo) draft.repo = draftRepo
1355
+ Object.assign(draft, reviewProvenanceStamp('generateTaskReviewDrafts:no-attribution'))
1324
1356
  // fail-open(对齐函数头契约 + execute-run-dir-fail-loud 测试锁定的「写失败返回统计不抛」):
1325
1357
  // 无归属草稿写入失败(如 tasks/ 被普通文件占位 ENOTDIR)降级 skipped,不崩整个草稿生成
1326
1358
  try {
@@ -1352,6 +1384,7 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
1352
1384
  reviewerNotes: 'auto-generated draft from git diff ' + taskBase.slice(0, 8) + '..' + taskHead.slice(0, 8) + ';verdict=未评审(worktree execute 主 agent 实现模式兜底,坑2)',
1353
1385
  }
1354
1386
  if (draftRepo) draft.repo = draftRepo
1387
+ Object.assign(draft, reviewProvenanceStamp('generateTaskReviewDrafts'))
1355
1388
  mkdirSync(reviewDir, { recursive: true })
1356
1389
  writeFileSync(reviewPath, JSON.stringify(draft, null, 2) + '\n')
1357
1390
  generated++
@@ -1568,6 +1601,7 @@ export async function adoptTaskReviewMechanics({ changeName, cwd, platformOpts =
1568
1601
 
1569
1602
  try {
1570
1603
  if (mechanicsChanged || verdictTouched || evidenceFilled) {
1604
+ Object.assign(merged, reviewProvenanceStamp('adoptTaskReviewMechanics'))
1571
1605
  writeFileSync(reviewPath, JSON.stringify(merged, null, 2) + '\n')
1572
1606
  adopted++
1573
1607
  } else {
@@ -1770,6 +1804,7 @@ export async function writeTaskReview({
1770
1804
  ...(requiredEvidence.filter(Boolean).length > 0 ? { requiredEvidence: requiredEvidence.filter(Boolean) } : {}),
1771
1805
  reviewerNotes: reviewerNotes || `written by sillyspec review write (${base.slice(0, 8)}..${head.slice(0, 8)})`,
1772
1806
  }
1807
+ Object.assign(review, reviewProvenanceStamp('writeTaskReview' + (force ? ':force' : '')))
1773
1808
  const schemaResult = validateReviewSchema(review)
1774
1809
  if (!schemaResult.ok) {
1775
1810
  return { ok: false, errors: schemaResult.errors, warnings }
@@ -42,8 +42,15 @@ export function classifyVerifiedFile(path) {
42
42
  /** 证据账 task 行:`- task-NN: <status> | verifiedFiles: a, b(豁免:…)`——占位 <待填:*> 不命中(fail-closed) */
43
43
  const EVIDENCE_LINE_RE = /^- (task-\d+):\s*(satisfied|missing|partial)\b([^\n]*)$/
44
44
 
45
- /** 回执行:`- claim: … | command: … | exit: 0 | log: path`——四字段行首锚定,占位不命中 */
46
- const RECEIPT_LINE_RE = /^- claim:\s*([^|]+)\|\s*command:\s*([^|]+)\|\s*exit:\s*(\d+)\s*\|\s*log:\s*([^\s|]+)/
45
+ /**
46
+ * 回执行:`- claim: … | command: … | exit: 0 | log: path`——四字段行首锚定,占位不命中。
47
+ * 双宽度容收(坑 receipt-fullwidth-parse,2026-09-12 驾驭第十四批③,用户上一变更实证、
48
+ * 本次手动避开后要求修根:①分隔符同时认半角 | 与全角 |(中文输入法手写回执高频形态,
49
+ * 全角形态旧正则整行不命中 → 回执槽 0 条 → integration-critical 误报无绿回执);
50
+ * ②log 字段 rest-of-line(旧 [^\s|+] 遇含空格/全角括号路径截断——`日志(第 1 次).log`
51
+ * 被截成 `日志(第` → 「日志不存在」假红);log 是末字段,行内若有 |/| 尾注取首段剥注。
52
+ */
53
+ const RECEIPT_LINE_RE = /^- claim:\s*([^||\n]+)[||]\s*command:\s*([^||\n]+)[||]\s*exit:\s*(\d+)\s*[||]\s*log:\s*(.+)$/
47
54
 
48
55
  /**
49
56
  * 解析 verify-result.md 的两个受控槽段(D-001@v2 slot-backfill 录入界面)。
@@ -101,7 +108,7 @@ export function parseEvidenceSlots(mdText) {
101
108
  claim: m[1].trim(),
102
109
  command: m[2].trim(),
103
110
  exitCode: Number(m[3]),
104
- logPath: m[4].trim().replace(/^`|`$/g, ''),
111
+ logPath: m[4].split(/[||]/)[0].trim().replace(/^`|`$/g, ''),
105
112
  })
106
113
  }
107
114
  }
@@ -88,7 +88,11 @@ export function extractTestCommand(yamlText) {
88
88
  if (quoted && quoted[1]) {
89
89
  return quoted[1].toLowerCase() === 'unavailable' ? null : quoted[1].trim()
90
90
  }
91
- const bare = yamlText.match(/^\s*test:\s*([^\n#"']+?)\s*(?:#.*)?$/m)
91
+ // bare 值不得排除引号字符(坑 verify-bare-quote-miss,2026-09-12 审查批 B-③):
92
+ // `test: cd "C:\proj" && npm test` 是合法 YAML 裸标量,旧 char class [^\n#"'] 对含引号值
93
+ // 整体不匹配 → 判「未配置」→ 实测硬门静默 skipped(fail-open)。全引号形态已被上方
94
+ // quoted 分支先行消费,此处的引号只可能是嵌在值中部的。同款修正在 extractLintCommand。
95
+ const bare = yamlText.match(/^\s*test:[ \t]*([^\n#]+?)[ \t]*(?:#.*)?$/m)
92
96
  if (bare && bare[1]) {
93
97
  const cmd = bare[1].trim()
94
98
  return cmd.toLowerCase() === 'unavailable' ? null : cmd
@@ -109,7 +113,7 @@ function extractLintCommand(yamlText) {
109
113
  if (quoted && quoted[1]) {
110
114
  return quoted[1].toLowerCase() === 'unavailable' ? null : quoted[1].trim()
111
115
  }
112
- const bare = yamlText.match(/^\s*lint:\s*([^\n#"']+?)\s*(?:#.*)?$/m)
116
+ const bare = yamlText.match(/^\s*lint:[ \t]*([^\n#]+?)[ \t]*(?:#.*)?$/m)
113
117
  if (bare && bare[1]) {
114
118
  const cmd = bare[1].trim()
115
119
  return cmd.toLowerCase() === 'unavailable' ? null : cmd
@@ -154,7 +158,7 @@ function recordVerifyLintTally({ specBase, result }) {
154
158
  * advisory 起步:失败只打印不阻断(test 门已 fail-closed,lint 门观察期后再升级)。
155
159
  * 信任边界与 runVerifyTestCheck 一致:命令只来源于主仓 .sillyspec/local.yaml。
156
160
  */
157
- export function runVerifyLintCheck({ cwd, specBase }) {
161
+ export function runVerifyLintCheck({ cwd, specBase, timeoutMs } = {}) {
158
162
  const localYamlPath = join(specBase, 'local.yaml')
159
163
  const yamlText = existsSync(localYamlPath) ? readFileSync(localYamlPath, 'utf8') : null
160
164
  const command = extractLintCommand(yamlText)
@@ -172,7 +176,9 @@ export function runVerifyLintCheck({ cwd, specBase }) {
172
176
  }
173
177
  }
174
178
 
175
- const LINT_TIMEOUT_MS = Number(process.env.SILLYSPEC_LINT_TIMEOUT_MS) || 3 * 60 * 1000
179
+ // timeoutMs(2026-09-12 驾驭第十七批 dogfood):门禁快照内 node_modules 经 junction I/O 较慢,
180
+ // 3min 默认预算实测被超时杀(180.1s 假败)——快照路径显式传 5min;优先级 显式参 > env > 默认
181
+ const LINT_TIMEOUT_MS = timeoutMs || Number(process.env.SILLYSPEC_LINT_TIMEOUT_MS) || 3 * 60 * 1000
176
182
  const startedAt = Date.now()
177
183
  let exitCode = 0
178
184
  let output = ''
@@ -577,7 +583,12 @@ export function extractModules(yamlText) {
577
583
  */
578
584
  function parseFlowValue(flowText, key) {
579
585
  // 键可能带引号也可能不带:path: "x" 或 "path": "x"
580
- const re = new RegExp(String.raw`(?:^|[{,]\s*)"?${key}"?\s*:\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|(?:[^,}]+?))\s*(?=[,}]|$)`)
586
+ // bare 值的逗号终止是「下一键开始的逗号」而非任意逗号(坑 flow-value-comma-truncate,
587
+ // 2026-09-12 审查批 C-③):`test: pytest -k a,b` 旧 [^,}]+? 在第一个逗号截断 → 实测跑
588
+ // 残损命令 `pytest -k a`(假红/碰巧假绿)。bare 分支与终止 lookahead 双侧同口径:只把
589
+ // 「后随键形态的逗号」当分隔符——消费侧负向前瞻排除它,终止侧正向前瞻要求它(或 } / 行尾)。
590
+ const sepComma = String.raw`,\s*"?[A-Za-z0-9_.\-]+"?\s*:`
591
+ const re = new RegExp(String.raw`(?:^|[{,]\s*)"?${key}"?\s*:\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|(?:[^,}]|,(?!${sepComma.slice(1)}))+?)\s*(?=\}|${sepComma}|$)`)
581
592
  const m = flowText.match(re)
582
593
  if (!m) return null
583
594
  let v = m[1].trim()
@@ -666,7 +677,11 @@ export function extractKnownFailures(yamlText) {
666
677
  // CRLF 归一(坑 verify-modules-crlf-blanket-fallback):块式正则的 `.+`/`\n?` 在 CRLF 行
667
678
  // 间失配,只捕获到第一条豁免项就停——归一后整块捕获恢复
668
679
  const yaml = normalizeLineEndings(yamlText)
669
- const inline = yaml.match(/^known_failures:\s*\[([^\]]*)\]\s*(?:#.*)?$/m)
680
+ // inline 捕获组用贪婪 .*(坑 known-failures-inline-nested-bracket,2026-09-12 审查批 C-④):
681
+ // 旧 [^\]]* 在值内首个 ] 停——豁免项是 glob/pytest id 可含 [] 字符类(tests/\[x\]::case),
682
+ // 旧版整表静默清空 → 预存失败全落 remaining 假红且难归因。贪婪 .* + 后随 \] 行尾锚,
683
+ // 吃到最后一个 ](行尾注释里含 ] 的极端形态会被并入值,远优于静默清空)。
684
+ const inline = yaml.match(/^known_failures:\s*\[(.*)\]\s*(?:#.*)?$/m)
670
685
  if (inline) {
671
686
  return inline[1].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
672
687
  }
@@ -121,9 +121,14 @@ export function runVerifyProbes({ cwd, changeName, specDir = null }) {
121
121
  probe1.globEntries.push(e.path)
122
122
  continue
123
123
  }
124
- let abs = join(cwd, e.path)
124
+ // NEW: 待建前缀剥离(坑 probe1-new-prefix-miss,2026-09-12 驾驭第十七批③,用户实证
125
+ // 「NEW: 前缀文件已合入主仓」时 existsSync(join(cwd,'NEW:src/x')) 恒 miss 落 skippedFiles
126
+ // 留 ⚠️ 噪声):design 清单的 NEW: 是「新建意图」标记,文件 apply 后已是无前缀实体——
127
+ // 探针按目标文件扫(与 pathMatches 比对侧剥除同源)。
128
+ const probePath = String(e.path).replace(/^NEW:\s*/, '')
129
+ let abs = join(cwd, probePath)
125
130
  if (!existsSync(abs)) {
126
- const wtAbs = wtRoot ? join(wtRoot, e.path) : null
131
+ const wtAbs = wtRoot ? join(wtRoot, probePath) : null
127
132
  if (wtAbs && existsSync(wtAbs)) {
128
133
  abs = wtAbs
129
134
  probe1.worktreeHits++
@@ -136,7 +141,7 @@ export function runVerifyProbes({ cwd, changeName, specDir = null }) {
136
141
  const lines = readFileSync(abs, 'utf8').split('\n')
137
142
  lines.forEach((line, i) => {
138
143
  if (probe1.matches.length >= PROBE1_MAX_MATCHES) return
139
- if (TODO_MARKER_RE.test(line)) probe1.matches.push({ file: e.path, line: i + 1, content: line.trim().slice(0, 160) })
144
+ if (TODO_MARKER_RE.test(line)) probe1.matches.push({ file: probePath, line: i + 1, content: line.trim().slice(0, 160) })
140
145
  })
141
146
  } catch {
142
147
  probe1.skippedFiles.push(e.path)
@@ -131,9 +131,18 @@ export function mergeDirtyOverlapThreeWay({ projectRoot, worktreePath, baseHash
131
131
  if (code < 0) { conflicts.push(f); continue } // merge-file 本身不可用 → 原拦截
132
132
  }
133
133
  if (code === 0) {
134
+ // 覆写前备份主仓在途原文(2026-09-12 审查批 D-②):合并写回发生在 apply 整体成败
135
+ // 判定之前——后续步骤(5a/7)失败返回时主仓已是合并版,而原文从未进任何 git 对象
136
+ // (也未 commit),不留备份即不可恢复。备份落 <projectRoot>/.sillyspec/.runtime/
137
+ // merge-backups/(运行时目录,doctor/hygiene 常规清理面,不污染工作区)。
138
+ try {
139
+ const backupDir = join(projectRoot, '.sillyspec', '.runtime', 'merge-backups')
140
+ mkdirSync(backupDir, { recursive: true })
141
+ writeFileSync(join(backupDir, `${Date.now()}-${f.replace(/[\\/]/g, '__')}`), mainRaw)
142
+ } catch { /* 备份失败不阻断合并(原文仍可经 rescue 出口重取,不静默弃救 */ }
134
143
  writeFileSync(join(projectRoot, f), outBuf)
135
144
  merged.push(f)
136
- console.log(`🔀 EXCLUDE-DIRTY 三方合并成功(clean): ${f}——主仓在途改动与 worktree 交付均已保留`)
145
+ console.log(`🔀 EXCLUDE-DIRTY 三方合并成功(clean): ${f}——主仓在途改动与 worktree 交付均已保留(覆写前原文已备份至 .sillyspec/.runtime/merge-backups/)`)
137
146
  } else {
138
147
  conflicts.push(f)
139
148
  }
@@ -242,19 +251,39 @@ export function resolvePatchFiles(changedFiles, allowSet, hasAllowList) {
242
251
  function getBlobHashMap(cwd, treeish, files) {
243
252
  const map = new Map();
244
253
  if (files.length === 0) return map; // 空 pathspec 会让 ls-tree 列出整棵树,必须拦截
245
- const raw = gitQuiet(cwd, ['ls-tree', treeish, '--', ...files]);
246
- if (!raw) return map;
247
- for (const line of raw.split('\n')) {
248
- if (!line) continue;
249
- const tabIdx = line.indexOf('\t');
250
- if (tabIdx === -1) continue;
251
- const filePath = line.slice(tabIdx + 1);
252
- const hash = line.slice(0, tabIdx).split(' ')[2]; // "<mode> <type> <hash>"
253
- if (hash) map.set(filePath, hash);
254
+ for (const batch of chunkPaths(files)) {
255
+ const raw = gitQuiet(cwd, ['ls-tree', treeish, '--', ...batch]);
256
+ if (!raw) continue
257
+ for (const line of raw.split('\n')) {
258
+ if (!line) continue;
259
+ const tabIdx = line.indexOf('\t');
260
+ if (tabIdx === -1) continue;
261
+ const filePath = line.slice(tabIdx + 1);
262
+ const hash = line.slice(0, tabIdx).split(' ')[2]; // "<mode> <type> <hash>"
263
+ if (hash) map.set(filePath, hash);
264
+ }
254
265
  }
255
266
  return map;
256
267
  }
257
268
 
269
+ /**
270
+ * 按估算 argv 长度切批(2026-09-12 审查批 D-③):Windows CreateProcess 命令行 32767 字符
271
+ * 上限——数百个长路径(Windows 深路径 160+ 字符 × 200 文件)全量展开时 spawn 直接失败,
272
+ * add/reset/diff 整体炸(hash-object 同病灶在 worktree.js,本文件先行收口)。保守 8000
273
+ * 字符/批,预留 git 参数与编码余量。返回至少一批(空输入返回单空批)。
274
+ */
275
+ export function chunkPaths(paths, maxChars = 8000) {
276
+ const batches = [[]]
277
+ let len = 0
278
+ for (const p of paths) {
279
+ const l = String(p).length + 1
280
+ if (batches[batches.length - 1].length > 0 && len + l > maxChars) { batches.push([]); len = 0 }
281
+ batches[batches.length - 1].push(p)
282
+ len += l
283
+ }
284
+ return batches
285
+ }
286
+
258
287
  /**
259
288
  * apply 的文件清单 = design §6 清单 ∪ plan TaskCard allowed_paths(execute 复盘 c)。
260
289
  *
@@ -676,14 +705,24 @@ function applyCrossRepoWorktrees(changeName, projectRoot, ctx, { checkOnly = fal
676
705
  const untrackedPatchFiles = patchFiles.filter(f => !trackedFiles.includes(f));
677
706
  const patchParts = [];
678
707
  if (trackedFiles.length > 0) {
679
- patchParts.push(git(cm.worktreePath, ['diff', '--binary', deliverableBase, '--', ...trackedFiles], { encoding: 'buffer', timeout: 60000 }));
708
+ // argv 分批(批 D-③):数百长路径全量展开超 Windows 命令行上限;diff 输出按批拼接
709
+ // (各批均为完整 patch 文档,concat 对 git apply 合法)
710
+ for (const batch of chunkPaths(trackedFiles)) {
711
+ patchParts.push(git(cm.worktreePath, ['diff', '--binary', deliverableBase, '--', ...batch], { encoding: 'buffer', timeout: 60000 }));
712
+ }
680
713
  }
681
714
  if (untrackedPatchFiles.length > 0) {
682
- git(cm.worktreePath, ['add', '--', ...untrackedPatchFiles]);
715
+ for (const batch of chunkPaths(untrackedPatchFiles)) {
716
+ git(cm.worktreePath, ['add', '--', ...batch]);
717
+ }
683
718
  try {
684
- patchParts.push(git(cm.worktreePath, ['diff', '--binary', '--cached', '--', ...untrackedPatchFiles], { encoding: 'buffer', timeout: 60000 }));
719
+ for (const batch of chunkPaths(untrackedPatchFiles)) {
720
+ patchParts.push(git(cm.worktreePath, ['diff', '--binary', '--cached', '--', ...batch], { encoding: 'buffer', timeout: 60000 }));
721
+ }
685
722
  } finally {
686
- gitQuiet(cm.worktreePath, ['reset', 'HEAD', '--', ...untrackedPatchFiles]);
723
+ for (const batch of chunkPaths(untrackedPatchFiles)) {
724
+ gitQuiet(cm.worktreePath, ['reset', 'HEAD', '--', ...batch]);
725
+ }
687
726
  }
688
727
  }
689
728
  const patchContent = Buffer.concat(patchParts);
@@ -1232,10 +1271,9 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
1232
1271
  // 回滚准备:记录 patch 涉及的 tracked 文件(--3way 冲突后需恢复 HEAD 版,不留半成品冲突标记)。
1233
1272
  // tracked 文件冲突 → git checkout -- <f> 还原;untracked 新建文件(--3way 不会对其冲突)不在回滚范围,
1234
1273
  // 但若 --3way 部分成功后冲突,已创建的新文件需删,故记录 untracked 集合。
1235
- const trackedPatchFiles = patchFiles.filter(f => {
1236
- // 该文件在主仓库 HEAD 存在 → 是 tracked(--3way 冲突时留标记需 checkout 还原)
1237
- return gitQuiet(projectRoot, ['cat-file', '-e', `HEAD:${f}`]) === null ? false : true;
1238
- });
1274
+ // 批 D-③:per-file cat-file N+1 spawn 改单次 ls-tree blob 哈希表(同 :1188 既有口径复用)
1275
+ const headBlobMap = getBlobHashMap(projectRoot, 'HEAD', patchFiles)
1276
+ const trackedPatchFiles = patchFiles.filter(f => headBlobMap.has(f));
1239
1277
  const newPatchFiles = patchFiles.filter(f => !trackedPatchFiles.includes(f));
1240
1278
 
1241
1279
  // apply --3way 正式应用(主干已提交推进时自动三路合并)
@@ -198,23 +198,16 @@ export function ensureCrossWorktrees({ cwd, changeName, specBase }) {
198
198
  };
199
199
  writeAtomicSync(join(worktreePath, META_FILE), JSON.stringify(meta, null, 2) + '\n');
200
200
 
201
- // dirty baseline overlay:跨仓主工作副本在途改动 → worktree baseline(借主仓同款实现,
202
- // 只用其参数化逻辑,不用实例路径状态)
203
- try {
204
- const wmHelper = new WorktreeManager({ cwd: repoRoot });
205
- const baselineResult = wmHelper._overlayBaseline(repoRoot, worktreePath);
206
- meta.baselineFiles = baselineResult.files;
207
- meta.baselineHash = baselineResult.baselineHash;
208
- if (baselineResult.files.length > 0) {
209
- meta.baselineCommit = wmHelper._createBaselineCheckpoint(worktreePath, `${changeName}--${key}`, baselineResult.files);
210
- }
211
- if (baselineResult.errors && baselineResult.errors.length > 0) {
212
- console.warn(`⚠️ 跨仓 ${key} baseline overlay 部分失败(${baselineResult.errors.join('; ')})——在途改动未全量进 baseline,apply 前请人工核对跨仓主工作副本`);
213
- }
214
- writeAtomicSync(join(worktreePath, META_FILE), JSON.stringify(meta, null, 2) + '\n');
215
- } catch (e) {
216
- console.warn(`⚠️ 跨仓 ${key} baseline overlay 失败(${e.message})——跨仓主工作副本如有在途改动,worktree 不含它们`);
217
- }
201
+ // 跨仓仓跳过 dirty baseline overlay + checkpoint(坑 cross-baseline-checkpoint-foreign-files,
202
+ // 2026-09-12 驾驭第十三批②,用户实证:跨仓主副本在途的外来文件——并行会话的 2 个 pptx +
203
+ // meta.json——被 checkpoint 带进跨仓分支,污染 apply 锚点对账被迫 cherry-pick 重建):
204
+ // 跨仓模型 = 子代理直接 commit 跨仓分支(NG-3),跨仓主副本的 WIP 不属于本变更隔离面,
205
+ // 不该被固化进分支。meta.baselineFiles=[] 显式记录「无 checkpoint」,apply 侧以 baseHash
206
+ // (跨仓仓 HEAD)为锚。
207
+ meta.baselineFiles = [];
208
+ meta.baselineHash = baseHash;
209
+ console.log(`ℹ️ 跨仓 ${key}: 跳过 dirty baseline checkpoint(跨仓主副本在途改动不进本变更分支——含并行会话外来文件时防污染;锚点 = 跨仓仓 HEAD ${String(baseHash).slice(0, 8)})`);
210
+ writeAtomicSync(join(worktreePath, META_FILE), JSON.stringify(meta, null, 2) + '\n');
218
211
 
219
212
  // deps 供给:specBase 传 null —— sniff worktree 自身(主仓 local.yaml 的 project.type/install
220
213
  // 描述的是主仓,跨仓仓类型常不同,如实测 maven 主仓 + nodejs 前端仓,沿用会把 mvn 命令
@@ -284,7 +277,8 @@ export function cleanupCrossWorktrees({ cwd, changeName, specBase, force = false
284
277
  git(repoRoot, ['worktree', 'remove', '--force', wtPath], { timeout: 60000 });
285
278
  } catch {
286
279
  // remove 失败(脏文件/锁):解链后安全删目录 + prune 注册
287
- try { safeRemoveWorktreeDir(wtPath, meta) } catch (e) { residual.push(`目录残留 ${wtPath}: ${e.message}`) }
280
+ // allowCross:true——本函数就是跨仓清理的显式路径(主仓守卫放行;见 safeRemoveWorktreeDir 注释)
281
+ try { safeRemoveWorktreeDir(wtPath, meta, { allowCross: true }) } catch (e) { residual.push(`目录残留 ${wtPath}: ${e.message}`) }
288
282
  try { gitQuiet(repoRoot, ['worktree', 'prune'], { timeout: 30000 }) } catch { residual.push(`git worktree prune 失败(${repoRoot})`) }
289
283
  }
290
284
  details.push(`分支 ${meta.branch} 保留作 review 锚点(确认无需回溯后可 git -C ${repoRoot} branch -D ${meta.branch})`);