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
@@ -15,7 +15,8 @@
15
15
  * 测试,避免 monorepo 全量测试超 gate timeout。
16
16
  */
17
17
 
18
- import { execSync, execFileSync } from 'child_process'
18
+ import { execSync } from 'child_process'
19
+ import { gitQuiet } from './git-helper.js'
19
20
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
20
21
  import { join } from 'path'
21
22
  import { verifyApiParity } from './contract-matrix.js'
@@ -26,6 +27,18 @@ import { filterDeliverableFiles } from './worktree-apply.js'
26
27
  const TEST_TIMEOUT_MS = Number(process.env.SILLYSPEC_TEST_TIMEOUT_MS) || 10 * 60 * 1000
27
28
  const OUTPUT_TAIL_CHARS = 4000
28
29
 
30
+ /**
31
+ * 行尾归一(坑 verify-modules-crlf-blanket-fallback,2026-08-20 实证):Windows 仓的
32
+ * local.yaml 常为 CRLF(编辑器/CLI 写入),手写行扫描器的逐行正则里 `.` 不匹配 `\r`、
33
+ * `$`(无 m 标志)又要求真串尾——`\r` 残留行尾导致条目正则整条失配。实证受害:
34
+ * extractModules 返回 null(modules 映射恒失效 → 永远回退全量 → 600s 默认超时必炸)、
35
+ * extractKnownFailures 块式只捕获第一条。统一在解析入口归一,JS 的 `\r?\n` 拆分与
36
+ * yaml 库均兼容 LF 文本(零回归)。
37
+ */
38
+ function normalizeLineEndings(text) {
39
+ return String(text || '').replace(/\r\n?/g, '\n')
40
+ }
41
+
29
42
  /**
30
43
  * 从 local.yaml 文本提取 commands.test。
31
44
  * 轻量正则(与 worktree-deps/scan-postcheck 同风格,不引 yaml 依赖):
@@ -123,7 +136,7 @@ export function computeFullFallbackReason({ strategy, modulesPresent, hitCount }
123
136
  */
124
137
  export function extractModules(yamlText) {
125
138
  if (!yamlText) return null
126
- const lines = yamlText.split('\n')
139
+ const lines = normalizeLineEndings(yamlText).split('\n')
127
140
 
128
141
  // 定位 modules: 起始行(必须是顶层 key,行首无缩进或仅注释后顶层)
129
142
  let startIdx = -1
@@ -167,8 +180,15 @@ function parseFlowValue(flowText, key) {
167
180
  const m = flowText.match(re)
168
181
  if (!m) return null
169
182
  let v = m[1].trim()
170
- if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
171
- v = v.slice(1, -1)
183
+ if (v.startsWith('"') && v.endsWith('"')) {
184
+ // 双引号值剥壳后解开转义(坑 verify-modules-crlf-blanket-fallback 次生发现):modules 的
185
+ // test 命令常含嵌套引号(node -e "…" / pytest "…"),yaml 源里写作 \";此前只剥外层引号
186
+ // 不解 \\ 与 \",解析结果残留字面反斜杠 → 命令直接坏。其余冷门转义(\n 等)按 YAML 规范
187
+ // 应真转义,但保守留字面(可见优于静默变换,测试命令不含它们)
188
+ v = v.slice(1, -1).replace(/\\(["\\])/g, '$1')
189
+ } else if (v.startsWith("'") && v.endsWith("'")) {
190
+ // 单引号值的 YAML 转义是 '' → '
191
+ v = v.slice(1, -1).replace(/''/g, "'")
172
192
  }
173
193
  return v.length > 0 ? v : null
174
194
  }
@@ -227,11 +247,14 @@ export function aggregateStatus(results) {
227
247
  */
228
248
  export function extractKnownFailures(yamlText) {
229
249
  if (!yamlText) return []
230
- const inline = yamlText.match(/^known_failures:\s*\[([^\]]*)\]\s*(?:#.*)?$/m)
250
+ // CRLF 归一(坑 verify-modules-crlf-blanket-fallback):块式正则的 `.+`/`\n?` 在 CRLF 行
251
+ // 间失配,只捕获到第一条豁免项就停——归一后整块捕获恢复
252
+ const yaml = normalizeLineEndings(yamlText)
253
+ const inline = yaml.match(/^known_failures:\s*\[([^\]]*)\]\s*(?:#.*)?$/m)
231
254
  if (inline) {
232
255
  return inline[1].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
233
256
  }
234
- const block = yamlText.match(/^known_failures:\s*\n((?:[ \t]+-[ \t].+\n?)+)/m)
257
+ const block = yaml.match(/^known_failures:\s*\n((?:[ \t]+-[ \t].+\n?)+)/m)
235
258
  if (block) {
236
259
  return (block[1].match(/^[ \t]+-[ \t]+(.+)/gm) || [])
237
260
  .map(s => s.replace(/^[ \t]+-[ \t]+/, '').trim().replace(/^['"]|['"]$/g, '').replace(/#.*$/, '').trim())
@@ -381,44 +404,29 @@ function assertSafeRefSpec(refSpec) {
381
404
  /**
382
405
  * 在指定 cwd 跑 `git diff --name-only <refSpec>`,返回相对仓库根的文件列表。
383
406
  * git 不可用 / 非仓库 / ref 无效 → 返回 null(调用方 fallback)。
407
+ * QUAL-01 收口:本地 execFileSync 裸调 → git-helper gitQuiet(统一 safe.directory),timeout 30s 保留。
384
408
  */
385
409
  function runGitDiffNameOnly(cwd, refSpec) {
386
- try {
387
- const ref = refSpec ? assertSafeRefSpec(refSpec) : null
388
- if (refSpec && !ref) return null
389
- const args = ['diff', '--name-only']
390
- if (ref) args.push(ref)
391
- const out = execFileSync('git', args, {
392
- cwd,
393
- encoding: 'utf8',
394
- timeout: 30 * 1000,
395
- stdio: ['ignore', 'pipe', 'pipe'],
396
- })
397
- return out.split('\n').map(l => l.trim()).filter(Boolean)
398
- } catch {
399
- return null
400
- }
410
+ const ref = refSpec ? assertSafeRefSpec(refSpec) : null
411
+ if (refSpec && !ref) return null
412
+ const args = ['diff', '--name-only']
413
+ if (ref) args.push(ref)
414
+ const out = gitQuiet(cwd, args, { timeout: 30 * 1000, trim: false })
415
+ if (out === null) return null
416
+ return out.split('\n').map(l => l.trim()).filter(Boolean)
401
417
  }
402
418
 
403
419
  /**
404
420
  * 在指定 cwd 跑 `git diff --name-status <refSpec>`,返回原始文本(调用方按行解析,
405
421
  * 保留状态字母 D/R/C 供删除探针识别删除/重命名)。git 不可用 / 非仓库 / ref 无效 → 返回 null。
422
+ * QUAL-01 收口:同上走 gitQuiet(trim:false 保留原始文本)。
406
423
  */
407
424
  function runGitDiffNameStatus(cwd, refSpec) {
408
- try {
409
- const ref = refSpec ? assertSafeRefSpec(refSpec) : null
410
- if (refSpec && !ref) return null
411
- const args = ['diff', '--name-status']
412
- if (ref) args.push(ref)
413
- return execFileSync('git', args, {
414
- cwd,
415
- encoding: 'utf8',
416
- timeout: 30 * 1000,
417
- stdio: ['ignore', 'pipe', 'pipe'],
418
- })
419
- } catch {
420
- return null
421
- }
425
+ const ref = refSpec ? assertSafeRefSpec(refSpec) : null
426
+ if (refSpec && !ref) return null
427
+ const args = ['diff', '--name-status']
428
+ if (ref) args.push(ref)
429
+ return gitQuiet(cwd, args, { timeout: 30 * 1000, trim: false })
422
430
  }
423
431
 
424
432
  /**
@@ -555,6 +563,11 @@ function resolveMainChangedFiles(cwd, changeName) {
555
563
  * }}
556
564
  */
557
565
  export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = null }) {
566
+ // 信任边界声明(体检 SEC-02 核实):本函数三处 execSync(command) 的命令只来源于
567
+ // ① 主仓 specBase(resolveSpecDir → 主仓 .sillyspec/local.yaml)② 跨仓仓根
568
+ // <repo>/.sillyspec/local.yaml——均为仓库自有配置(与 `npm test` 同信任级:跑测试
569
+ // 本就是执行项目代码)。永不读取 agent 可写的 worktree 副本(对照 worktree-deps.js
570
+ // SEC-01 的源级分流);修改 yaml 来源时必须维持此边界。
558
571
  const localYamlPath = join(specBase, 'local.yaml')
559
572
  const yamlText = existsSync(localYamlPath) ? readFileSync(localYamlPath, 'utf8') : null
560
573
 
@@ -12,7 +12,6 @@
12
12
  * 8. 成功后自动 cleanup
13
13
  */
14
14
 
15
- import { execFileSync } from 'child_process';
16
15
  import { existsSync, unlinkSync, writeFileSync, mkdtempSync, rmSync, readdirSync, readFileSync } from 'fs';
17
16
  import { join, resolve } from 'path';
18
17
  import { tmpdir } from 'os';
@@ -487,12 +486,17 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
487
486
  return applyByMerge(result, changeName, projectRoot, wm);
488
487
  }
489
488
 
490
- // --- 4.5 校验:主工作区是否有「未提交」脏改动(未提交 dirty 是 git apply --3way 危险区,必须拦)---
491
- // 分工:4.5(排除规则下当前 dirty)+ 5a(脏∩changedFiles)挡「未提交」dirty;5b 管「已提交」HEAD 分叉(已放宽,见下)。
492
- // 实测:主干未提交 dirty 时,git apply --3way 报 `does not match index` 且行为不一致(可能报错/可能半应用,
493
- // 哪怕脏文件与 patch 不重叠)——这是 Windows/autocrlf 的 CRLF 副作用(非 git 本质限制:
494
- // autocrlf off 时 --3way 在不重叠 dirty 树能 Applied cleanly,on 时才报 does not match index)。
495
- // 但仓库 CRLF 混用 + 规则13 要求 Windows 兼容,fail-loud 在 Windows 仍有据,故此处友好拦截,引导用户先 commit/stash。
489
+ // --- 4.5 校验:主工作区「未提交」脏文件是否与本次变更重叠(overlap-only 拦截)---
490
+ // 分工:4.5(排除规则下 dirty∩changedFiles)+ 5a(更宽口径的同一交集)挡「未提交」dirty 重叠;
491
+ // 5b 管「已提交」HEAD 分叉(已放宽)。
492
+ // 2026-08-20 放宽(原全量拦截 → 只拦重叠):原「排除规则下任何未提交文件即整体拒绝」会把与本次
493
+ // 变更无关的脏文件也硬挡,用户被迫走 rescue cp 手动路径。重叠(dirtyFiles ∩ changedFiles,
494
+ // changedFiles 已含删除文件与 worktree 新增同名文件)才是 git apply 无法安全应用的实际危险区。
495
+ // 残余风险与兜底(Windows/autocrlf):实测 autocrlf on 时 git apply --3way 对 dirty 工作区可报
496
+ // `does not match index`(哪怕脏文件与 patch 不重叠;autocrlf off 时能干净应用,非 git 本质限制)。
497
+ // 交集空前提下该失败是安全的:step7 catch → rollbackApply 只 checkout/删除 patch 涉及文件
498
+ // (这些文件 apply 前无未提交修改,还原 = apply 前状态),无关脏文件不在 patch/rollback 范围
499
+ // 不受影响 → fail-safe,报错后 stash 重试即可。放行时 warning 提示此路径。
496
500
  // 排除非交付物的元数据/文档 churn(execute 自身改的 + 多操作者常改的 agent 指引/文档),
497
501
  // 否则别人改 CLAUDE.md/docs/.claude → 判定 dirty → apply 误阻断(多操作者仓库高频踩坑)。
498
502
  // 注意:排除规则必须和 computeBaselineHash (worktree.js) 一致(虽已不比对 hash,仍用同一口径判当前 dirty)。
@@ -503,38 +507,47 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
503
507
  const staged = gitQuiet(projectRoot, ['diff', '--cached', ...exclude]) || '';
504
508
  const unstaged = gitQuiet(projectRoot, ['diff', ...exclude]) || '';
505
509
  const untracked = gitQuiet(projectRoot, ['ls-files', '--others', '--exclude-standard', ...exclude]) || '';
506
- // 意图(与 computeBaselineHash 注释一致):只挡「未提交 dirty」——git apply --3way 对 dirty 工作区不稳。
510
+ // 意图(与 computeBaselineHash 注释一致):只看「未提交 dirty」——git apply --3way 对 dirty 工作区不稳。
507
511
  // 不比对 hash 是否等于 execute 启动时 baselineHash:主仓 dirty→clean(execute 期间 commit 无关文件)后
508
512
  // hash 必变,若仍比对会永久死锁(须手改 meta.baselineHash)。改判「排除规则下当前是否有未提交 dirty」。
509
513
  const hasUncommittedDirty = staged !== '' || unstaged !== '' || untracked !== '';
510
514
  if (hasUncommittedDirty) {
511
- // 未提交 dirty 拦截:列脏文件 + 指引先 commit/stash(git --3way 对 dirty 工作区不稳,merge 同理,故不再提 --merge)
512
515
  const dirtyFiles = [...new Set(
513
516
  ((gitQuiet(projectRoot, ['diff', '--name-only', 'HEAD']) || '').split('\n').filter(Boolean))
514
517
  .concat((gitQuiet(projectRoot, ['ls-files', '--others', '--exclude-standard']) || '').split('\n').filter(Boolean))
515
518
  )].filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json');
516
- // rescue:dirty 拦截触发时生成逐文件 cp 安全子集(旁路 git apply),写 result.rescueCommands 供 assess/打印器消费
517
- const rescueDirty = computeRescueDirtyFiles(projectRoot);
518
- result.rescueCommands = generateRescueCommands({
519
- changedFiles,
520
- dirtyFiles: rescueDirty,
521
- hashMismatchFiles: result.hashMismatchFiles,
522
- deletedFiles: result.deletedFiles,
523
- worktreePath,
524
- projectRoot,
525
- });
526
- result.errors.push(
527
- `主工作区有未提交的改动,git apply 无法安全应用。\n` +
528
- (dirtyFiles.length > 0 ? `未提交文件:\n ${dirtyFiles.join('\n ')}\n` : '') +
529
- `请先提交或暂存这些改动,再重新 apply:\n git add -A && git commit -m "..." 或 git stash\n` +
530
- (result.rescueCommands.commands.length > 0 || result.rescueCommands.warnings.length > 0
531
- ? `或手动 rescue(旁路 git apply,逐文件 cp 安全子集;cp 后需手动 sillyspec worktree cleanup ${changeName}):\n` +
532
- result.rescueCommands.commands.map(c => ` ${c}`).join('\n') +
533
- (result.rescueCommands.warnings.length > 0 ? '\n' + result.rescueCommands.warnings.map(w => ` ${w}`).join('\n') : '') +
534
- `\n (共 ${result.rescueCommands.cpFileCount} 个可安全 cp,${result.rescueCommands.excludedCount} 个被排除)\n`
535
- : '')
536
- );
537
- if (!checkOnly) return result; // checkOnly 收集不短路(一次报全);真实 apply 短路
519
+ const overlapDirty = dirtyFiles.filter(f => changedFiles.includes(f));
520
+ if (overlapDirty.length > 0) {
521
+ // 重叠拦截:只有与本次变更同文件的未提交改动才无法安全 apply(列重叠文件,非全部脏文件)
522
+ const rescueDirty = computeRescueDirtyFiles(projectRoot);
523
+ result.rescueCommands = generateRescueCommands({
524
+ changedFiles,
525
+ dirtyFiles: rescueDirty,
526
+ hashMismatchFiles: result.hashMismatchFiles,
527
+ deletedFiles: result.deletedFiles,
528
+ worktreePath,
529
+ projectRoot,
530
+ });
531
+ result.errors.push(
532
+ `主工作区以下未提交文件与本次 apply 的变更重叠,git apply 无法安全应用:\n ${overlapDirty.join('\n ')}\n` +
533
+ `请先提交或暂存这些改动,再重新 apply:\n git add -A && git commit -m "..." 或 git stash\n` +
534
+ (result.rescueCommands.commands.length > 0 || result.rescueCommands.warnings.length > 0
535
+ ? `或手动 rescue(旁路 git apply,逐文件 cp 安全子集;cp 后需手动 sillyspec worktree cleanup ${changeName}):\n` +
536
+ result.rescueCommands.commands.map(c => ` ${c}`).join('\n') +
537
+ (result.rescueCommands.warnings.length > 0 ? '\n' + result.rescueCommands.warnings.map(w => ` ${w}`).join('\n') : '') +
538
+ `\n (共 ${result.rescueCommands.cpFileCount} 个可安全 cp,${result.rescueCommands.excludedCount} 个被排除)\n`
539
+ : '')
540
+ );
541
+ if (!checkOnly) return result; // checkOnly 收集不短路(一次报全);真实 apply 短路
542
+ } else {
543
+ // 无关脏文件放行(只校验重叠文件):--3way 若因 autocrlf 报 does not match index 会被
544
+ // step7 catch 回滚(交集空前提下无损,见上注释),stash 后重试即可——不再硬挡 rescue 手动路径
545
+ result.warnings = (result.warnings || []).concat([
546
+ `主工作区有 ${dirtyFiles.length} 个与本次 apply 无关的未提交文件,已放行(只校验重叠文件):` +
547
+ `${dirtyFiles.slice(0, 5).join(', ')}${dirtyFiles.length > 5 ? ' 等' : ''}` +
548
+ `——若 apply --3way 因 CRLF/autocrlf 报 does not match index,可 git stash 后重试`
549
+ ]);
550
+ }
538
551
  }
539
552
  }
540
553
 
@@ -656,6 +669,17 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
656
669
  // FR-06:解析 git 原始 stderr 提取冲突文件列表(不静默吞掉)
657
670
  const rawStderr = e.message || '';
658
671
  const rollback = rollbackApply(projectRoot, trackedPatchFiles, newPatchFiles, rawStderr);
672
+ // autocrlf 副作用单独点名:主仓无关未提交 dirty + CRLF 转换可触发 does not match index
673
+ // (非文件内容冲突,交集空时 step4.5 已放行到这里)——回滚无损,stash 后重试即可
674
+ if (/does not match index/i.test(rawStderr)) {
675
+ result.errors.push(
676
+ `apply --3way 失败(does not match index):多为主仓未提交文件 + Windows autocrlf 的 CRLF 副作用触发,非文件内容冲突。\n` +
677
+ `已回滚工作区到 apply 前状态(无损,无关未提交文件未受影响)。\n` +
678
+ `处理:git stash 后重试 sillyspec worktree apply ${changeName},或 --merge 兜底。`
679
+ );
680
+ if (rollback.error) result.warnings = (result.warnings || []).concat([`回滚警告: ${rollback.error}`]);
681
+ return result;
682
+ }
659
683
  result.errors.push(
660
684
  `apply --3way 冲突:以下文件与主干「已提交」推进重叠,无法自动合并:\n` +
661
685
  ` ${rollback.conflicts.length > 0 ? rollback.conflicts.join('\n ') : '(未能获取冲突文件列表)'}\n` +
@@ -1174,9 +1198,9 @@ export function formatExecuteSummary({ changeName, stepsCompleted, stepsTotal, a
1174
1198
  // worktree 还在,用 baselineCommit 或 baseHash 做 diff
1175
1199
  try {
1176
1200
  const diffBase = meta.baselineCommit || meta.baseHash;
1177
- // execFileSync 数组形式:路径无需引号化(含空格也安全);stdio stderr=ignore
1178
- // 替代 `2>/dev/null`(Windows cmd.exe 无法解析该重定向,导致 Windows 上恒抛错→Changed Files 空)
1179
- const filesRaw = execFileSync('git', ['-C', meta.worktreePath, 'diff', '--name-only', diffBase], { encoding: 'utf8', stdio: ['ignore','pipe','ignore'] });
1201
+ // QUAL-01 收口:裸 execFileSync → git-helper git(stderr 由 helper stdio 配置吞掉,
1202
+ // 替代 `2>/dev/null`——Windows cmd.exe 无法解析该重定向,导致 Windows 上恒抛错→Changed Files 空)
1203
+ const filesRaw = git(meta.worktreePath, ['diff', '--name-only', diffBase], { timeout: 30000 });
1180
1204
  const files = filesRaw ? filesRaw.trim().split('\n').filter(Boolean) : [];
1181
1205
  if (files.length > 0) {
1182
1206
  lines.push(``);
@@ -9,8 +9,8 @@
9
9
  */
10
10
 
11
11
  import { existsSync, readFileSync, realpathSync } from 'fs';
12
- import { join } from 'path';
13
- import { execSync, execFileSync } from 'child_process';
12
+ import { join, isAbsolute, resolve as resolvePath, sep as pathSep } from 'path';
13
+ import { execFileSync } from 'child_process';
14
14
  import { createHash } from 'crypto';
15
15
 
16
16
  const LOCKFILES = ['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock'];
@@ -44,16 +44,28 @@ function detectLockfile(dir) {
44
44
  return null;
45
45
  }
46
46
 
47
- /** 读取 local.yaml 文本(specBase 优先,回退 worktreePath/.sillyspec;不读 process.cwd 避免环境配置泄漏)*/
48
- function readLocalYaml(specBase, worktreePath) {
49
- const candidates = [
50
- specBase ? join(specBase, 'local.yaml') : null,
51
- worktreePath ? join(worktreePath, '.sillyspec', 'local.yaml') : null,
52
- ].filter(Boolean);
53
- for (const p of candidates) {
54
- if (existsSync(p)) return readFileSync(p, 'utf8');
47
+ /**
48
+ * 读取 local.yaml 文本(specBase 优先,回退 worktreePath/.sillyspec;不读 process.cwd 避免环境配置泄漏)。
49
+ * 返回 { text, source }——source 标记信任级(体检 SEC-01):
50
+ * - 'specBase':主仓配置,可信(其 commands.install 允许执行,仍过白名单+元字符门)
51
+ * - 'worktree':worktree 内副本,agent/子代理可写区——只作 project.type / modules 等
52
+ * 只读探测,其 commands.install 永不执行(否则绕过 worktree-guard 的危险命令拦截)
53
+ */
54
+ function readLocalYamlSourced(specBase, worktreePath) {
55
+ if (specBase) {
56
+ const p = join(specBase, 'local.yaml');
57
+ if (existsSync(p)) return { text: readFileSync(p, 'utf8'), source: 'specBase' };
55
58
  }
56
- return null;
59
+ if (worktreePath) {
60
+ const p = join(worktreePath, '.sillyspec', 'local.yaml');
61
+ if (existsSync(p)) return { text: readFileSync(p, 'utf8'), source: 'worktree' };
62
+ }
63
+ return { text: null, source: null };
64
+ }
65
+
66
+ /** 兼容旧签名:只要文本 */
67
+ function readLocalYaml(specBase, worktreePath) {
68
+ return readLocalYamlSourced(specBase, worktreePath).text;
57
69
  }
58
70
 
59
71
  /** 从 local.yaml 文本提取 commands.install(轻量正则,不引 yaml 依赖,与 scan-postcheck 一致)*/
@@ -93,6 +105,10 @@ export function inferInstallCommand(projectType, worktreePath, userInstall) {
93
105
  case 'maven':
94
106
  return 'mvn -o test';
95
107
  case 'gradle':
108
+ // win32 cmd.exe 跑不了 ./gradlew(体检 BUG-08 同型):优先 gradlew.bat,否则全局 gradle
109
+ if (process.platform === 'win32') {
110
+ return existsSync(join(worktreePath, 'gradlew.bat')) ? 'gradlew.bat test' : 'gradle test';
111
+ }
96
112
  return './gradlew test';
97
113
  case 'python':
98
114
  // uv 优先(现代 Python 工具链,pyproject.toml/uv.lock 项目走 uv sync 建 .venv + 装依赖,
@@ -130,15 +146,80 @@ function tryLink(mainNodeModules, linkPath) {
130
146
  }
131
147
  }
132
148
 
133
- /** 执行 install 命令(带超时)*/
149
+ /**
150
+ * 执行 install 命令(带超时)。
151
+ *
152
+ * 安全收口(体检 SEC-01):commands.install 来自 local.yaml 配置,若经 execSync(shell)
153
+ * 原样执行,yaml 里的 `$(...)`/反引号/`;` 必然被解释——且 worktree 副本属 agent 可写区。
154
+ * 三道门:
155
+ * 1. 调用方保证来源可信(provisionDeps 只对 source==='specBase' 的 userInstall 走到这里)
156
+ * 2. 可执行前缀白名单(包管理器家族)
157
+ * 3. shell 元字符黑名单(win32 额外拦 %,cmd.exe 会展开 %VAR%),通过后拆 argv 数组执行不经 shell
158
+ *
159
+ * 链式命令支持(坑 worktree-install-whitelist-monorepo-chain,2026-08-20 实证):monorepo 的
160
+ * commands.install 常为链式写法(`cd web && pnpm install` / `npm install && npm run build:pkg`),
161
+ * 旧实现整条过白名单 + 元字符门,`&&` 必拒 → depsStatus=failed → execute deps 门控卡死且无自愈
162
+ * 路径(改 local.yaml 成单命令对需要 post-install build 的 monorepo 不可行)。现按 `&&` 拆段
163
+ * 逐段校验执行:每段独立过白名单 + 元字符门(拆分后残余的单 `&`/`|`/`;` 仍被元字符门拦),
164
+ * 段内允许 `cd <相对子路径>`(resolve 后必须仍在 worktree 根内,防越界建产物/读外部),
165
+ * 全程 argv 数组执行不经 shell、任一段失败即停(&& 语义)。安全面不变:每个执行段仍是
166
+ * 白名单包管理器命令,`||`/管道/`;`/后台均不支持。
167
+ */
168
+ const INSTALL_BINARY_WHITELIST = /^(?:\.\/)?(?:pnpm|npm|yarn|bun|mvn|gradle|gradlew|uv|pip|pip3|python|python3|poetry|make)(?:\.cmd|\.bat|\.exe)?(?:\s|$)/;
169
+ const SHELL_METACHARS = /[;&|<>$`\n]/;
170
+ const WIN_SHELL_METACHARS = /[;&|<>$`\n%]/;
171
+ const CD_SEGMENT_RE = /^cd\s+([^\s]+)$/;
172
+
134
173
  function tryInstall(cmd, cwd, timeout) {
135
- try {
136
- execSync(cmd, { cwd, timeout, stdio: ['pipe', 'pipe', 'pipe'] });
137
- return { ok: true };
138
- } catch (e) {
139
- const msg = e.killed ? `timeout after ${timeout}ms` : ((e.stderr && e.stderr.toString()) || e.message);
140
- return { ok: false, error: `${cmd} failed: ${msg}` };
174
+ const trimmed = String(cmd || '').trim();
175
+ if (!trimmed) {
176
+ return { ok: false, error: 'install 命令为空' };
177
+ }
178
+ const metaRe = process.platform === 'win32' ? WIN_SHELL_METACHARS : SHELL_METACHARS;
179
+ // && 拆段(单段命令与原路径完全一致,零回归);空段(`a &&`/`&& b`)视为畸形命令拒绝
180
+ const segments = trimmed.split('&&').map(s => s.trim());
181
+ if (segments.some(s => s === '')) {
182
+ return { ok: false, error: `install 链式命令含空段,拒绝执行: ${trimmed}` };
141
183
  }
184
+ let curCwd = cwd;
185
+ const root = resolvePath(cwd);
186
+ const planned = [];
187
+ for (const seg of segments) {
188
+ // 白名单先于元字符(保持原判定顺序):`curl … | sh` 报非白名单而非元字符,
189
+ // `npm install; rm …` 过白名单后被元字符门拦——两类拒绝语义与原实现一致
190
+ const cd = seg.match(CD_SEGMENT_RE);
191
+ if (!cd && !INSTALL_BINARY_WHITELIST.test(seg)) {
192
+ return { ok: false, error: `install 命令不在包管理器白名单内,拒绝执行: ${seg}(支持 && 链式与 cd <子目录> 段,各执行段仍须为白名单包管理器命令)` };
193
+ }
194
+ if (metaRe.test(seg)) {
195
+ return { ok: false, error: `install 命令段含 shell 元字符,拒绝执行: ${seg}(链式仅支持 &&;|| / 管道 / ; / 后台不支持)` };
196
+ }
197
+ if (cd) {
198
+ const target = resolvePath(curCwd, cd[1]);
199
+ if (target !== root && !target.startsWith(root + pathSep)) {
200
+ return { ok: false, error: `install 命令的 cd 段越出 worktree 根,拒绝执行: ${seg}(cwd ${cwd})` };
201
+ }
202
+ curCwd = target;
203
+ continue;
204
+ }
205
+ planned.push(seg);
206
+ }
207
+ for (const seg of planned) {
208
+ const argv = seg.split(/\s+/);
209
+ try {
210
+ if (process.platform === 'win32') {
211
+ // Windows 包管理器是 .cmd 垫片,无 shell 的 spawn 无法解析——经 cmd.exe /c 传参;
212
+ // 元字符(含 %)已拦,残余风险与 tryLink 的 mklink 同款
213
+ execFileSync('cmd.exe', ['/c', ...argv], { cwd: curCwd, timeout, stdio: ['pipe', 'pipe', 'pipe'] });
214
+ } else {
215
+ execFileSync(argv[0], argv.slice(1), { cwd: curCwd, timeout, stdio: ['pipe', 'pipe', 'pipe'] });
216
+ }
217
+ } catch (e) {
218
+ const msg = e.killed ? `timeout after ${timeout}ms` : ((e.stderr && e.stderr.toString()) || e.message);
219
+ return { ok: false, error: `${seg} failed (cwd ${curCwd}): ${msg}` };
220
+ }
221
+ }
222
+ return { ok: true };
142
223
  }
143
224
 
144
225
  /** 目录是否含 nodejs 标记(package.json 或任一 lockfile)——识别 monorepo 里的 nodejs 子模块 */
@@ -174,6 +255,16 @@ function extractModulePaths(yamlText) {
174
255
  return [...new Set(paths)];
175
256
  }
176
257
 
258
+ /**
259
+ * modules 块 path 安全校验(体检 SEC-07):只允许 worktree 内的相对子路径——
260
+ * 拒绝绝对路径与 `..` 段,防止 local.yaml 配置把 junction/symlink 建到 worktree 之外。
261
+ */
262
+ function isSafeModulePath(p) {
263
+ if (!p || isAbsolute(p)) return false;
264
+ const segs = p.split(/[\\/]+/);
265
+ return !segs.includes('..');
266
+ }
267
+
177
268
  /**
178
269
  * 对单个子模块目录 tryLink main 的 node_modules → wt 的 node_modules(modules 子模块专用)。
179
270
  * 仅走 link 快路径(不 install——子模块 install 慢且易失败;lockfile 不一致时交给用户 pnpm install)。
@@ -211,12 +302,17 @@ function linkOneDir(wtDir, mainDir) {
211
302
  export function provisionDeps(worktreePath, mainCwd, opts = {}) {
212
303
  const { specBase = null, timeout = DEFAULT_TIMEOUT_MS, force = false } = opts;
213
304
  const depsCheckedAt = new Date().toISOString();
214
- const yamlText = readLocalYaml(specBase, worktreePath);
305
+ const { text: yamlText, source: yamlSource } = readLocalYamlSourced(specBase, worktreePath);
215
306
  const wtHash = lockfileHash(worktreePath);
216
307
 
217
308
  // ── 1. 根目录供给 ──
218
309
  const projectType = detectProjectType(worktreePath, specBase);
219
- const userInstall = extractUserInstall(yamlText);
310
+ // SEC-01:commands.install 只信主仓 specBase 的 local.yaml——worktree 副本是 agent 可写区,
311
+ // 其 install 命令若执行等于绕过 worktree-guard 的危险命令拦截
312
+ const userInstall = yamlSource === 'specBase' ? extractUserInstall(yamlText) : null;
313
+ if (yamlSource === 'worktree' && extractUserInstall(yamlText)) {
314
+ console.warn('⚠️ worktree 内 local.yaml 的 commands.install 被忽略(agent 可写区,只信主仓 .sillyspec/local.yaml)');
315
+ }
220
316
  const installCmd = inferInstallCommand(projectType, worktreePath, userInstall);
221
317
 
222
318
  let result;
@@ -257,6 +353,10 @@ export function provisionDeps(worktreePath, mainCwd, opts = {}) {
257
353
  const modulePaths = extractModulePaths(yamlText);
258
354
  const moduleResults = [];
259
355
  for (const mp of modulePaths) {
356
+ if (!isSafeModulePath(mp)) {
357
+ moduleResults.push({ path: mp, status: 'skipped', reason: 'path 越界(绝对路径或 .. 段),拒绝 link' });
358
+ continue;
359
+ }
260
360
  const wtDir = join(worktreePath, mp);
261
361
  const mainDir = mainCwd ? join(mainCwd, mp) : null;
262
362
  if (!existsSync(wtDir) || !mainDir || !hasNodeMarker(wtDir)) continue;
package/src/worktree.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * 分支命名:sillyspec/<change-name>
9
9
  */
10
10
 
11
- import { execSync, execFileSync } from 'child_process';
11
+ import { execFileSync } from 'child_process';
12
12
  import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, readlinkSync, unlinkSync } from 'fs';
13
13
  import { join, resolve, dirname, relative, isAbsolute } from 'path';
14
14
  import { createHash } from 'crypto';
@@ -463,6 +463,12 @@ export class WorktreeManager {
463
463
  if (!check.supported) {
464
464
  throw new Error(`git worktree add 失败: ${e.stderr || e.message}\n\n${check.reason ? `原因: ${check.reason}` : ''}\n建议: 升级 git 到 >= 2.15;或运行 \`sillyspec worktree doctor --fix\` 检查 worktree 状态。`);
465
465
  }
466
+ // 体检 BUG-16 并发竞态防御:多 agent 同时首建同名 change 时,败者的 add 失败可能只是
467
+ // 「赢家刚建成目录/分支」。此时降级 in-place 会造成一个 worktree + 一个 in-place 的
468
+ // 分裂状态(两 agent 各写各的基线)——先重查,并发赢家已建成则按 already exists 抛错。
469
+ if (existsSync(worktreePath) || gitQuiet(this.cwd, ['rev-parse', '--verify', `refs/heads/${branch}`])) {
470
+ throw new Error(`worktree already exists: ${name}(并发创建,另一进程已抢先建成,本进程不降级 in-place). Run cleanup first.`);
471
+ }
466
472
  // sandbox/permission fallback: 降级为 in-place + baseline protection
467
473
  console.log(`⚠️ git worktree add 失败(可能是沙箱权限限制),降级为 in-place 模式 + baseline protection`);
468
474
  console.log(` 原因: ${e.stderr || e.message}`);
@@ -928,7 +934,11 @@ export class WorktreeManager {
928
934
  const residual = [];
929
935
  if (!isInPlace && existsSync(worktreePath)) residual.push(`worktree dir: ${worktreePath}`);
930
936
  if (existsSync(metaDir)) residual.push(`meta dir: ${metaDir}`);
931
- if (!isInPlace && gitQuiet(this.cwd, ['worktree', 'list'], { timeout: 30000 })?.includes(worktreePath)) {
937
+ // 斜杠方向归一后比较(体检 BUG-07):Windows 下 git worktree list 输出正斜杠、
938
+ // worktreePath 是反斜杠,直接 includes 恒 false → 残留场景漏报、cleanup 误报 cleaned
939
+ const toPosixNorm = (p) => String(p || '').replace(/\\/g, '/').toLowerCase()
940
+ const wtListOut = gitQuiet(this.cwd, ['worktree', 'list'], { timeout: 30000 })
941
+ if (!isInPlace && wtListOut && toPosixNorm(wtListOut).includes(toPosixNorm(worktreePath))) {
932
942
  residual.push('git worktree list still references this worktree');
933
943
  }
934
944
  if (residual.length > 0) {
@@ -1033,11 +1043,32 @@ export class WorktreeManager {
1033
1043
  * 不传则全量扫(兼容现有行为)。
1034
1044
  * @returns {{ issues: Array<{ type: string, name: string, detail: string, fixable: boolean }>, fixed: string[], unfixable: string[] }}
1035
1045
  */
1036
- doctor({ fix = false, staleHours = 24, changeName = null } = {}) {
1046
+ async doctor({ fix = false, staleHours = 24, changeName = null } = {}) {
1037
1047
  const issues = [];
1038
1048
  const fixed = [];
1039
1049
  const unfixable = [];
1040
1050
 
1051
+ // 活跃变更注册表(坑 doctor-fix-orphan-branch-parallel-active,2026-08-20 实证):孤儿分支
1052
+ // 判定原只看本地 meta 目录注册表(metaNames),与变更活跃态的权威注册表(进度库 changes 表)
1053
+ // 数据源不一致——并行会话的活跃变更(meta 已清/in-place/平台模式 meta 在别处)分支会被全局
1054
+ // doctor --fix 误删。删分支前交叉核对:分支名 ∈ 活跃变更 → 保留(fixable:false 提示人工确认)。
1055
+ // 先探 DB 文件存在再实例化 ProgressManager(坑7 读路径建库:_ensureDB 不存在会建库污染)。
1056
+ // null = 进度库存在但读失败(无法判定活跃态,保守不自动删);空 Set = 无库/无活跃变更
1057
+ // (git-only 工作流零回归,孤儿分支照删)。
1058
+ let activeChanges = null;
1059
+ try {
1060
+ const specBaseOfWt = dirname(dirname(this.worktreeBase)); // <specBase>(.runtime/worktrees 上两级)
1061
+ if (existsSync(join(specBaseOfWt, '.runtime', 'sillyspec.db'))) {
1062
+ const { ProgressManager } = await import('./progress.js');
1063
+ activeChanges = new Set(new ProgressManager({ specDir: specBaseOfWt }).listChanges(this.cwd));
1064
+ } else {
1065
+ activeChanges = new Set();
1066
+ }
1067
+ } catch (e) {
1068
+ console.warn(`⚠️ doctor 进度库读取失败,孤儿分支的活跃态交叉核对不可用(保守不自动删): ${e.message}`);
1069
+ activeChanges = null;
1070
+ }
1071
+
1041
1072
  // 1. 列出 git worktree list 中的条目
1042
1073
  let gitWorktreeList = [];
1043
1074
  try {
@@ -1167,16 +1198,30 @@ export class WorktreeManager {
1167
1198
  }
1168
1199
  }
1169
1200
 
1170
- // 5. 检查 SillySpec 分支残留
1201
+ // 5. 检查 SillySpec 分支残留(活跃变更交叉核对,见方法头 activeChanges 注释)
1171
1202
  try {
1172
- const branches = execFileSync('git', ['branch', '--list', `${BRANCH_PREFIX}*`], { cwd: this.cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
1203
+ // QUAL-01 收口:裸 execFileSync → git-helper git(原调用点未传 timeout,统一入口默认 5s 足够 branch --list)
1204
+ const branches = git(this.cwd, ['branch', '--list', `${BRANCH_PREFIX}*`]);
1173
1205
  if (branches) {
1174
1206
  for (const line of branches.split('\n').filter(Boolean)) {
1175
1207
  const branch = line.replace(/^\*?\s+/, '').trim();
1176
1208
  const name = branch.replace(BRANCH_PREFIX, '');
1177
1209
  if (changeName && name !== changeName) continue; // --change 过滤:仅扫指定 change
1178
1210
  if (!metaNames.has(name)) {
1179
- issues.push({ type: 'orphan-branch', name, detail: `分支残留(无对应 meta): ${branch}`, fixable: true });
1211
+ if (activeChanges && activeChanges.has(name)) {
1212
+ // 变更仍注册活跃:分支大概率是并行会话在用(或 apply 后被 review 引用审计保护保留)
1213
+ // ——数据源不一致的正解是保留 + 人工确认,不是删
1214
+ issues.push({ type: 'active-branch', name, fixable: false,
1215
+ detail: `分支 ${branch} 无本地 meta,但变更 "${name}" 在进度库仍注册为活跃(并行会话在跑 / meta 已清分支在用)——保留。确认废弃后:先归档/结束该变更,或手动 git branch -D ${branch}` });
1216
+ continue;
1217
+ }
1218
+ if (activeChanges === null) {
1219
+ // 进度库读失败:无法判定活跃态,保守不自动删(宁可漏删不可误删并行会话分支)
1220
+ issues.push({ type: 'orphan-branch', name, fixable: false,
1221
+ detail: `分支疑似残留(无对应 meta,且进度库不可读无法交叉核对活跃态): ${branch}——保守不自动删,人工确认后 git branch -D ${branch}` });
1222
+ continue;
1223
+ }
1224
+ issues.push({ type: 'orphan-branch', name, detail: `分支残留(无对应 meta,且非活跃变更): ${branch}`, fixable: true });
1180
1225
  if (fix) {
1181
1226
  try { gitQuiet(this.cwd, ['branch', '-D', branch]); fixed.push(`deleted orphan branch: ${branch}`); } catch { unfixable.push(`branch delete failed: ${branch}`); }
1182
1227
  }
@@ -1357,7 +1402,8 @@ export class WorktreeManager {
1357
1402
  if (staged) {
1358
1403
  try {
1359
1404
  // 用 Buffer 模式读取,避免二进制 patch 被 UTF-8 解码损坏
1360
- const patchBuf = execFileSync('git', ['diff', '--cached', '--binary'], { cwd: mainCwd, stdio: ['pipe','pipe','pipe'] });
1405
+ // QUAL-01 收口:git-helper 新增 encoding:'buffer' 支持(二进制输出专用,跳过 trim)
1406
+ const patchBuf = git(mainCwd, ['diff', '--cached', '--binary'], { encoding: 'buffer', timeout: 30000 });
1361
1407
  if (patchBuf && patchBuf.length > 0) {
1362
1408
  const patchFile = join(worktreePath, '.sillyspec-baseline-staged.patch');
1363
1409
  try {
@@ -1379,8 +1425,8 @@ export class WorktreeManager {
1379
1425
  const unstaged = gitQuiet(mainCwd, ['diff', '--name-only'], { timeout: 30000 }) || '';
1380
1426
  if (unstaged) {
1381
1427
  try {
1382
- // 用 Buffer 模式读取,避免二进制 patch 被 UTF-8 解码损坏
1383
- const patchBuf = execFileSync('git', ['diff', '--binary'], { cwd: mainCwd, stdio: ['pipe','pipe','pipe'] });
1428
+ // 用 Buffer 模式读取,避免二进制 patch 被 UTF-8 解码损坏(QUAL-01 收口,同上)
1429
+ const patchBuf = git(mainCwd, ['diff', '--binary'], { encoding: 'buffer', timeout: 30000 });
1384
1430
  if (patchBuf && patchBuf.length > 0) {
1385
1431
  const patchFile = join(worktreePath, '.sillyspec-baseline-unstaged.patch');
1386
1432
  try {
package/.npmrc-publish DELETED
@@ -1,2 +0,0 @@
1
- //registry.npmjs.org/:_authToken=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
2
- always-auth=true