sillyspec 3.33.3 → 3.33.4

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 (47) hide show
  1. package/.claude/skills/sillyspec-flow/SKILL.md +4 -0
  2. package/AGENTS.md +1 -1
  3. package/package.json +1 -1
  4. package/src/config-known-failures.js +258 -0
  5. package/src/decision-distill.js +26 -5
  6. package/src/decision-redomain.js +5 -4
  7. package/src/docs-check.js +8 -4
  8. package/src/doctor-diagnostics.js +89 -6
  9. package/src/fr-index.js +7 -3
  10. package/src/git-helper.js +82 -0
  11. package/src/handoff.js +4 -0
  12. package/src/hooks/worktree-guard.js +12 -3
  13. package/src/hot-cache.js +60 -0
  14. package/src/index.js +99 -7
  15. package/src/knowledge-graph.js +82 -2
  16. package/src/knowledge-match.js +18 -2
  17. package/src/knowledge-quicklog.js +17 -2
  18. package/src/knowledge-vector.js +5 -3
  19. package/src/machine-interface.js +34 -13
  20. package/src/progress/change-registry.js +15 -0
  21. package/src/progress/shared.js +4 -0
  22. package/src/progress/stage-machine.js +15 -2
  23. package/src/progress.js +5 -0
  24. package/src/redomain-unmapped.js +204 -0
  25. package/src/redomain.js +5 -3
  26. package/src/review-dispatch.js +109 -47
  27. package/src/run/bg-sync.js +4 -0
  28. package/src/run/code-face-key.js +4 -1
  29. package/src/run/command.js +20 -5
  30. package/src/run/complete.js +185 -14
  31. package/src/run/gates.js +30 -1
  32. package/src/run/prompt.js +9 -1
  33. package/src/run/shared.js +18 -1
  34. package/src/run/stage.js +22 -19
  35. package/src/run/verify-quality-scan.js +51 -0
  36. package/src/runtime-paths.js +3 -0
  37. package/src/sillyhub-mcp/client.js +11 -6
  38. package/src/spawn-settle.js +47 -0
  39. package/src/spec-sync.js +89 -3
  40. package/src/stages/knowledge.js +25 -3
  41. package/src/timing.js +55 -0
  42. package/src/verify-postcheck.js +73 -4
  43. package/src/watcher.js +4 -0
  44. package/src/worktree-apply.js +67 -4
  45. package/src/worktree.js +80 -11
  46. package/src/yaml-scalar-unquote.js +43 -0
  47. package/templates/agents-instruction.md +1 -1
@@ -28,6 +28,7 @@ import { createHash } from 'crypto'
28
28
  import { IR_STRICT_SINCE } from './constants.js'
29
29
  import { gitQuiet } from './git-helper.js'
30
30
  import { resolveRuntimeRoot } from './run/shared.js'
31
+ import { unquoteYamlScalar } from './yaml-scalar-unquote.js'
31
32
  import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, appendFileSync } from 'fs'
32
33
  import { join, resolve, isAbsolute } from 'path'
33
34
  import { readChangeTrace, testAnchorFile } from './test-bindings.js'
@@ -975,7 +976,10 @@ export function extractKnownFailures(yamlText) {
975
976
  // 吃到最后一个 ](行尾注释里含 ] 的极端形态会被并入值,远优于静默清空)。
976
977
  const inline = yaml.match(/^known_failures:\s*\[(.*)\]\s*(?:#.*)?$/m)
977
978
  if (inline) {
978
- return inline[1].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
979
+ // 条目规范化走 yaml-scalar-unquote 单点(2026-10-21-known-failures-unescape):双引号条目
980
+ // 反转义——config known-failures add 写侧按 YAML 规范转义 \\.,旧读侧只剥引号不反转义,
981
+ // 含反斜杠的锚定豁免模式带着字面双反斜杠进 buildExemptPats,正则语义漂移、豁免静默失配。
982
+ return inline[1].split(',').map(s => unquoteYamlScalar(s)).filter(Boolean)
979
983
  }
980
984
  // 坑 verify-known-failures-comment-line-truncation(2026-08-28 连续踩两次)→ 坑
981
985
  // verify-known-failures-block-fragile-chain(2026-09-07 工具复盘升级修复):块式捕获曾要求
@@ -1002,10 +1006,12 @@ export function extractKnownFailures(yamlText) {
1002
1006
  .map(s => {
1003
1007
  const item = s.trim()
1004
1008
  const full = item.match(/^(['"])(.*)\1$/)
1005
- if (full) return full[2] // 整体引号值原样保留(内部 # 不是注释)
1009
+ // 引号值经 unquoteYamlScalar 反转义(2026-10-21-known-failures-unescape:与流式同口径,
1010
+ // 双引号 \\.→\. / ''→';裸值照旧只剥行尾注释)
1011
+ if (full) return unquoteYamlScalar(item)
1006
1012
  const stripped = item.replace(/[ \t]+#.*$/, '').trim() // 行尾注释须 # 前有空白(YAML 语义),裸 # 不截
1007
1013
  const q2 = stripped.match(/^(['"])(.*)\1$/)
1008
- return q2 ? q2[2] : stripped
1014
+ return q2 ? unquoteYamlScalar(stripped) : stripped
1009
1015
  })
1010
1016
  .filter(Boolean)
1011
1017
  }
@@ -2809,6 +2815,64 @@ function inferFullTestCommand(projectRoot) {
2809
2815
  return null
2810
2816
  }
2811
2817
 
2818
+ /**
2819
+ * 运行器尾随 flag 保留(2026-10-21-deps-batch-runner-flags,dsh 0ff6f1c8 补充诊断取证)。
2820
+ * buildDepsBatches 的运行器提取正则「截到 pytest 一词为止」,模块命令(local.yaml modules
2821
+ * 命中)里用户显式配置的尾随 flag(-q --no-cov -n auto 等)被静默丢弃——大依赖批单核
2822
+ * 串行实测 1183s(同仓全量带 -n auto 仅 357s),超时=失败再叠整轮重跑,六轮累计空耗约 1h。
2823
+ *
2824
+ * 本函数从命令串中 runner 词之后、段边界(& | ; 或串尾)之前的 token 流里保留 flag:
2825
+ * - 裸 flag(-q/--no-cov)与含等号一体 flag(--cov-report=xml)整 token 保留;
2826
+ * - 已知吃值 flag(-n/--numprocesses/-k/-m/--maxfail/--timeout/--durations/--junitxml/-p/
2827
+ * --config/--reporter 等)连同其值保留;值 token 带未闭合引号时消费到引号闭合(-k "a and b");
2828
+ * - 位置参数(不以 - 开头且非吃值 flag 之值——原命令的文件/目录面)丢弃:批文件面由
2829
+ * buildDepsBatches 重定基供给,混入会跑面失控(目录参数把整个目录收进来,子集退化全量)。
2830
+ * 白名单外吃值 flag 的值按位置参数丢弃(如 --cov-report xml 丢 xml 留 --cov-report)——
2831
+ * 温和退化不炸批;漏网成本是性能/报告形态,不是假绿假红。
2832
+ * fail-open:runner 不在命令中/无 flag → ''(调用方拼接零变化)。
2833
+ * @returns {string} 保留 flag 串(含前导空格);无可保留返回 ''
2834
+ */
2835
+ const RUNNER_VALUE_FLAGS = new Set([
2836
+ '-n', '--numprocesses', '-k', '-m', '--maxfail', '--timeout', '--durations',
2837
+ '--junitxml', '--junit-xml', '-p', '--rootdir', '--config', '--reporter', '--reporters',
2838
+ ])
2839
+ export function extractRunnerTrailingFlags({ command, runner }) {
2840
+ if (!command || !runner) return ''
2841
+ const cmd = String(command)
2842
+ const runnerStr = String(runner)
2843
+ const runnerIdx = cmd.indexOf(runnerStr)
2844
+ if (runnerIdx === -1) return ''
2845
+ let after = cmd.slice(runnerIdx + runnerStr.length)
2846
+ const segEnd = after.search(/[&|;]/)
2847
+ if (segEnd !== -1) after = after.slice(0, segEnd)
2848
+ const tokens = after.trim().split(/\s+/).filter(Boolean)
2849
+ if (tokens.length === 0) return ''
2850
+ const kept = []
2851
+ for (let i = 0; i < tokens.length; i++) {
2852
+ const t = tokens[i]
2853
+ if (!t.startsWith('-')) continue // 位置参数(原命令文件面)丢弃
2854
+ kept.push(t)
2855
+ const bare = t.replace(/^["']+|["']+$/g, '')
2856
+ if (bare.includes('=')) continue // 含等号一体值,无需吃下一 token
2857
+ if (!RUNNER_VALUE_FLAGS.has(bare) || i + 1 >= tokens.length) continue
2858
+ const value = tokens[i + 1]
2859
+ kept.push(value)
2860
+ i++
2861
+ // 值 token 引号未闭合(-k "fast and not slow")→ 消费到引号闭合(空格分隔的多 token 值)
2862
+ const openQ = (value.match(/["']/g) || []).length
2863
+ if (value.startsWith('"') || value.startsWith("'")) {
2864
+ let quotes = openQ
2865
+ while (quotes % 2 === 1 && i + 1 < tokens.length) {
2866
+ const rest = tokens[i + 1]
2867
+ kept.push(rest)
2868
+ quotes += (rest.match(/["']/g) || []).length
2869
+ i++
2870
+ }
2871
+ }
2872
+ }
2873
+ return kept.length ? ' ' + kept.join(' ') : ''
2874
+ }
2875
+
2812
2876
  /**
2813
2877
  * deps(auto) 运行器推断 + 公平组卷(2026-09-24 R10 实证修复):
2814
2878
  * - R10 缺陷①:deps 一律 `node --test` 执行——.py 依赖测试全 SyntaxError 伪败(只能靠豁免放行)。
@@ -2898,11 +2962,16 @@ function buildDepsBatches({ deps, changedFiles = [], hits = [], cwd = null, prio
2898
2962
  // 整批 exit 2 假败)。每个 .py 文件按最近 pyproject/uv.lock 祖先归组(与 runner 推断
2899
2963
  // 同款就近原则——monorepo 子项目各自独立环境);组内共用该包根的 runner 命令与重定基。
2900
2964
  // 显式命中命令串(hits)优先且整批共用(用户显式配置语义优先,不拆组)。
2965
+ // 尾随 flag 保留(2026-10-21-deps-batch-runner-flags,dsh 0ff6f1c8 补充诊断取证):提取
2966
+ // 正则「截到 pytest 一词为止」曾把 local.yaml 模块命令显式配置的 -q --no-cov -n auto 静默
2967
+ // 丢弃——deps(auto-py) 批 124 文件(2081 例)单核串行 1183s,同仓全量 4771 例带 -n auto
2968
+ // 仅 357s(6-10 倍差),600s 超时截在 69% 判失败、六轮累计空耗约 1h。runner 提取后追加
2969
+ // extractRunnerTrailingFlags 保留 flag 串(位置参数仍丢弃——批文件面由重定基供给)。
2901
2970
  let pyRunner = null
2902
2971
  for (const h of hits || []) {
2903
2972
  const cmd = String(h.test || '')
2904
2973
  const m = /^((?:cd\s+[^&|;]+&&\s*)*[^&|;]*?pytest)/.exec(cmd) || /(?:^|&&|;|\|\|)\s*([^&|;]*pytest)/.exec(cmd)
2905
- if (m) { pyRunner = m[1].trim(); break }
2974
+ if (m) { pyRunner = m[1].trim() + extractRunnerTrailingFlags({ command: cmd, runner: m[1] }); break }
2906
2975
  }
2907
2976
  // pyGroups: Map<dirKey, { files, command }>;dirKey null = 主仓根(无 cd 前缀)
2908
2977
  const pyGroups = new Map()
package/src/watcher.js CHANGED
@@ -904,6 +904,10 @@ export async function spawnWatcher(cwd, changeName, opts = {}) {
904
904
  env: childEnv,
905
905
  });
906
906
  child.unref?.();
907
+ // 孵化稳定化(FR-08 / dsh 实证 libuv 退出断言):等 spawn 事件落定(≤150ms 兜底)再放行,
908
+ // 压掉「父进程在子进程句柄建立中途退出」的崩溃竞态窗口。
909
+ const { awaitSpawnSettled } = await import('./spawn-settle.js');
910
+ await awaitSpawnSettled(child);
907
911
  } finally {
908
912
  if (logFd !== null) {
909
913
  try { closeSync(logFd); } catch { /* 父侧 fd 副本关闭;子进程持有继承副本 */ }
@@ -2260,8 +2260,45 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
2260
2260
  return result;
2261
2261
  } finally {
2262
2262
  // stash 恢复(含 --merge 早退 / 各拦截 return / 异常路径——finally 全覆盖,绝不遗漏)
2263
- if (stashInfo) restoreMainStash(projectRoot, stashInfo);
2263
+ if (stashInfo) {
2264
+ const restored = restoreMainStash(projectRoot, stashInfo);
2265
+ // 恢复步冲突标记检出(FR-02 / dsh z2 实证:apply 输出声称 clean,主仓 tsc 才暴露 stash 冲突
2266
+ // 标记)——机械扫描命中即 fail-closed:错误入 result.errors(apply exit 非零),文件清单与
2267
+ // 处置指引已在恢复处打印。finally 内改写 result 对仍在返回中的同一对象生效。
2268
+ if (restored.conflictFiles && restored.conflictFiles.length > 0) {
2269
+ result.conflictMarkerFiles = restored.conflictFiles;
2270
+ result.errors.push(
2271
+ `--stash-dirty 恢复后检出 git 冲突标记(${restored.conflictFiles.length} 个文件,stash 条目已保留未丢弃):\n ${restored.conflictFiles.join('\n ')}\n` +
2272
+ `逐文件解决冲突标记(<<<<<<< / ======= / >>>>>>>)后 git add,再手动 git stash drop;处置明细见上方恢复输出。`
2273
+ );
2274
+ }
2275
+ }
2276
+ }
2277
+ }
2278
+
2279
+ /** git 冲突标记行首三形态(FR-02 / dsh z2 实证:stash 恢复留 `<<<<<<< Updated upstream` 类标记静默落地,主仓 tsc 才暴露)。
2280
+ * 行首锚定 + `<{7} `/`>{7} ` 带标签(git 冲突标签恒随标记出现)、`={7}` 独占行——文档正文中的
2281
+ * 示例标记(缩进/行中)不触发;CRLF 先归一再匹配。 */
2282
+ const CONFLICT_MARKER_RE = /^<{7} .*$|^={7}$|^>{7} .*$/m;
2283
+
2284
+ /**
2285
+ * 机械扫描文件集的 git 冲突标记(FR-02)。
2286
+ * 只读;文件缺失/不可读跳过(stash 面里的已删文件不在扫描面)。限定调用方传入的文件集
2287
+ * (stash 面口径),不扫全仓——防把规范文档里描述冲突标记的示例文本误报。
2288
+ * @param {string} projectRoot
2289
+ * @param {string[]} files
2290
+ * @returns {string[]} 命中文件(相对 projectRoot 路径,原序)
2291
+ */
2292
+ export function scanConflictMarkers(projectRoot, files) {
2293
+ const hits = [];
2294
+ for (const f of (Array.isArray(files) ? files : [])) {
2295
+ if (!f) continue;
2296
+ try {
2297
+ const text = readFileSync(join(projectRoot, f), 'utf8');
2298
+ if (CONFLICT_MARKER_RE.test(text.replace(/\r\n/g, '\n'))) hits.push(f);
2299
+ } catch { /* 已删/不可读——不在扫描面 */ }
2264
2300
  }
2301
+ return hits;
2265
2302
  }
2266
2303
 
2267
2304
  /**
@@ -2273,16 +2310,28 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
2273
2310
  * 形态,退出码不可尽信);
2274
2311
  * - apply --index 冲突/异常 → stash 条目保留(git apply 失败不 drop),大字打印 SHA 兜底,
2275
2312
  * 绝不自动丢弃、绝不盲 pop 栈顶(并行外部操作可能插队,先按 SHA 校验栈顶归属)。
2313
+ * - 恢复动作后机械扫描 stash 面冲突标记(FR-02 / dsh z2 实证:两级恢复的 --index 尝试可先落
2314
+ * 标记再失败、普通恢复可带着标记以 0 退出——git 退出码不可尽信):命中 → 不 drop、打印文件
2315
+ * 清单与处置指引、返回 conflictFiles(调用方 fail-closed 进 errors 使 apply exit 非零)。
2276
2316
  * @param {string} projectRoot
2277
2317
  * @param {{ sha: string, marker: string }} stashInfo
2318
+ * @returns {{ conflictFiles: string[] }}
2278
2319
  */
2279
- function restoreMainStash(projectRoot, { sha, marker }) {
2320
+ export function restoreMainStash(projectRoot, { sha, marker }) {
2280
2321
  // 栈顶归属校验按 SHA(git stash list 行不含 SHA,须 rev-parse 对比)——防并行外部操作插队后误恢复他者条目
2281
2322
  const headSha = (gitQuiet(projectRoot, ['rev-parse', '--verify', 'stash@{0}']) || '').trim();
2282
2323
  if (headSha !== sha) {
2283
2324
  console.error(`⚠️ stash 恢复跳过:栈顶 stash@{0} 已非本次条目(本次 ${sha},栈顶 ${headSha || '空'})——可能被并行操作消费/弹出。若本次条目仍在栈中:git stash list 找 ${sha.slice(0, 8)} 后 git stash apply --index <该ref>`);
2284
- return;
2325
+ return { conflictFiles: [] };
2285
2326
  }
2327
+ // stash 面文件清单(冲突标记扫描口径):恢复动作前取(条目此刻必在栈顶)。
2328
+ // stash show 只列 tracked 面(--include-untracked 的第三 parent 不在内)——未跟踪文件
2329
+ // 恢复冲突走 already exists 报错路径(非标记形态),不在扫描面。
2330
+ let stashFace = [];
2331
+ try {
2332
+ stashFace = (gitQuiet(projectRoot, ['stash', 'show', '--name-only', 'stash@{0}']) || '')
2333
+ .split('\n').map((s) => s.trim()).filter(Boolean);
2334
+ } catch { /* 面取失败 → 空面,恢复报错路径兜底 */ }
2286
2335
  // 两级恢复:① apply --index(保暂存区状态)——但 apply 本身会把变更留在工作区未提交,stash 树是
2287
2336
  // 全量快照,--index 的合并对「stash 树内但未被 stash 改动的脏文件」(正是刚 apply 落地的文件)
2288
2337
  // 会报 local changes would be overwritten;② 退普通 apply(内容保真恢复,staged 扁平化为
@@ -2297,12 +2346,25 @@ function restoreMainStash(projectRoot, { sha, marker }) {
2297
2346
  restored = 'plain';
2298
2347
  console.warn(`⚠️ 暂存区状态未能完整还原(apply --index 与本次 apply 落地的未提交变更互斥),已退普通恢复——内容保真,staged 文件被扁平化为 unstaged,如需请重新 git add。原因:${String(eIndex.message || eIndex).split('\n').slice(-2)[0] || eIndex}`);
2299
2348
  } catch (e) {
2349
+ // 恢复失败的现场也可能残留标记(--index 尝试先行落标记后抛错)——扫描补报,处置信息更完整
2350
+ const failConflicts = scanConflictMarkers(projectRoot, stashFace);
2300
2351
  console.error(`⚠️ stash 恢复失败(apply 冲突或异常)——stash 条目已保留(未丢弃),SHA 兜底:${sha}`);
2301
2352
  console.error(` 处理:git stash list 定位条目(marker 含 "${String(marker || '').slice(0, 50)}");解决工作区冲突后 git stash apply stash@{N} 重试或人工合并;确认无需后再 git stash drop。未跟踪文件若报 already exists,比对内容后手动处理。`);
2353
+ if (failConflicts.length > 0) {
2354
+ console.error(` 恢复失败但工作区已残留冲突标记(${failConflicts.length} 个文件):${failConflicts.join('、')}——逐文件解决标记后 git add,再 git stash apply stash@{0} 重试`);
2355
+ }
2302
2356
  console.error(` git 输出: ${String(e.message || e).split('\n').slice(0, 3).join('\n ')}`);
2303
- return;
2357
+ return { conflictFiles: failConflicts };
2304
2358
  }
2305
2359
  }
2360
+ // 恢复后机械扫描(FR-02):命中 → 不 drop、fail-closed(git 退出码 0 ≠ 无标记,dsh z2 实证)
2361
+ const conflictFiles = scanConflictMarkers(projectRoot, stashFace);
2362
+ if (conflictFiles.length > 0) {
2363
+ console.error(`🚫 stash 恢复后检出 git 冲突标记(${conflictFiles.length} 个文件)——stash 条目保留未丢弃(SHA 兜底:${sha}),本次 apply 记为失败:`);
2364
+ for (const f of conflictFiles) console.error(` - ${f}`);
2365
+ console.error(` 处置:逐文件解决冲突标记(<<<<<<< / ======= / >>>>>>> 段落二选一或手工合并)后 git add -- <文件>;或 git checkout -- <文件> 放弃恢复态后 git stash apply stash@{0} 重试;确认完成后手动 git stash drop stash@{0}。`);
2366
+ return { conflictFiles };
2367
+ }
2306
2368
  try {
2307
2369
  git(projectRoot, ['stash', 'drop', 'stash@{0}'], { timeout: 60000 });
2308
2370
  // 防静默不落地(用户实证②):drop 后栈顶不得仍是本 SHA
@@ -2315,6 +2377,7 @@ function restoreMainStash(projectRoot, { sha, marker }) {
2315
2377
  } catch (e) {
2316
2378
  console.error(`⚠️ stash drop 失败(改动应已恢复)——手动 git stash drop stash@{0}(${sha}):${e.message}`);
2317
2379
  }
2380
+ return { conflictFiles: [] };
2318
2381
  }
2319
2382
 
2320
2383
  /**
package/src/worktree.js CHANGED
@@ -331,17 +331,42 @@ export function detectIsolation(cwd = process.cwd()) {
331
331
  }
332
332
 
333
333
  /**
334
- * 检查 worktree 存储目录是否被 .gitignore 忽略
335
- * @param {string} cwd - 项目根目录
336
- * @returns {{ ignored: boolean, path: string }}
334
+ * 检查 worktree 存储目录是否被 .gitignore 忽略(2026-10-21-p0-p1-hardening FR-06 三态分流)。
335
+ *
336
+ * 三态结局(dsh z2 实证:沙箱下 spawnSync git 抛 EPERM 被旧实现当成「未忽略」误报,阻断
337
+ * worktree 创建 27 分钟并迫使用户提权——git 退出码与探测失败必须分流):
338
+ * - { ignored: true } git 判定已忽略(exit 0);
339
+ * - { ignored: false } git 判定确未忽略(check-ignore exit 1)——调用方照旧阻断;
340
+ * - { ignored: null, probeError } 探测失败(spawn 异常 EPERM/ENOENT/超时等,git 未给出
341
+ * 判定)——立即重试一次仍失败时按「无法证实未忽略」上报,调用方 fail-open 放行+警告。
342
+ * runner 可注入供测试(缺省本模块 git)。
343
+ * @param {string} cwd 项目根目录
344
+ * @param {Function} [runner] 注入式 git 执行器((cwd, args, opts) => string)
345
+ * @returns {{ ignored: boolean|null, path: string, probeError?: string, retried?: boolean }}
337
346
  */
338
- function checkWorktreeDirIgnored(cwd = process.cwd()) {
347
+ export function checkWorktreeDirIgnored(cwd = process.cwd(), runner = git) {
339
348
  const relPath = WORKTREES_REL;
349
+ const attempt = () => {
350
+ try {
351
+ runner(cwd, ['check-ignore', '-q', relPath]);
352
+ return { ignored: true };
353
+ } catch (e) {
354
+ // git 正常判定「未忽略」= exit 1(e.status 数字)——与 spawn 失败(e.status 缺失,
355
+ // e.code=EPERM/ENOENT/ETIMEDOUT 等)分流:前者是判定结果,后者是探测故障。
356
+ if (e && typeof e.status === 'number') return { ignored: false };
357
+ throw e;
358
+ }
359
+ };
340
360
  try {
341
- git(cwd, ['check-ignore', '-q', relPath]);
342
- return { ignored: true, path: relPath };
343
- } catch {
344
- return { ignored: false, path: relPath };
361
+ return { ...attempt(), path: relPath };
362
+ } catch (first) {
363
+ try {
364
+ const r = attempt();
365
+ return { ...r, path: relPath, retried: true };
366
+ } catch (second) {
367
+ const probeError = String((second && (second.code || second.message)) || second).split('\n')[0];
368
+ return { ignored: null, path: relPath, probeError, retried: true };
369
+ }
345
370
  }
346
371
  }
347
372
 
@@ -669,6 +694,23 @@ export class WorktreeManager {
669
694
  return parseJSON(readFileSync(metaPath, 'utf8'));
670
695
  }
671
696
 
697
+ /**
698
+ * 确保 worktree 存在(幂等入口,2026-10-21-brainstorm-proto-worktree)
699
+ * 头脑风暴原型协议的复用语义收敛点:变更 key 已出后可先 create(或 native 复用),
700
+ * 后续 run execute 再走本入口——meta 已登记即复用,不重建不重新 overlay。
701
+ * 与 ensureCrossWorktrees 同款「命中即复用」可测先例。
702
+ * 返回完整 meta 透传(调用方如 ensureDepsFreshness 会整包 spread 写回,瘦身返回会丢字段)。
703
+ * @param {string} changeName - 变更名
704
+ * @param {{ base?: string, adoptBranch?: boolean }} opts - 同 create
705
+ * @returns {{ ...meta, existing: boolean }} 完整 meta + existing 标志(未登记走 create 时为 create 返回值)
706
+ * @throws {Error} create 失败时原样上抛(git 不可用、changeName 为空等)
707
+ */
708
+ ensure(changeName, opts = {}) {
709
+ const existing = this.getMeta(changeName);
710
+ if (existing) return { ...existing, existing: true };
711
+ return { ...this.create(changeName, opts), existing: false };
712
+ }
713
+
672
714
  /**
673
715
  * 创建 worktree
674
716
  * @param {string} changeName - 变更名
@@ -708,9 +750,10 @@ export class WorktreeManager {
708
750
  });
709
751
  }
710
752
 
711
- // 1. 检查 worktree 目录是否被 gitignore
753
+ // 1. 检查 worktree 目录是否被 gitignore(三态:已忽略放行 / 确未忽略阻断 / 探测失败
754
+ // fail-open 放行+警告——FR-06 / dsh z2 实证 EPERM 误报「未忽略」阻断创建 27 分钟)
712
755
  const ignoreStatus = checkWorktreeDirIgnored(this.cwd);
713
- if (!ignoreStatus.ignored) {
756
+ if (ignoreStatus.ignored === false) {
714
757
  throw new Error(
715
758
  `worktree 存储目录 ${ignoreStatus.path} 未被 .gitignore 忽略,` +
716
759
  `创建 worktree 可能导致内容被误提交。\n` +
@@ -718,6 +761,12 @@ export class WorktreeManager {
718
761
  `或运行 sillyspec doctor 检查修复。`
719
762
  );
720
763
  }
764
+ if (ignoreStatus.ignored === null) {
765
+ console.warn(
766
+ `⚠️ worktrees gitignore 探测失败(${ignoreStatus.probeError}${ignoreStatus.retried ? ',已重试 1 次仍失败' : ''}——沙箱/杀软拦 git 子进程常见)——无法证实「未忽略」,按已忽略继续(不阻断创建)。` +
767
+ `人工复核:git check-ignore -q ${ignoreStatus.path}(exit 0=已忽略);若确实未忽略请补 .gitignore: ${ignoreStatus.path}/`
768
+ );
769
+ }
721
770
 
722
771
  // 2. 检查 worktree 是否已存在
723
772
  if (existsSync(worktreePath)) {
@@ -1306,8 +1355,28 @@ export class WorktreeManager {
1306
1355
  // 探针二对象显式主仓根(_resolveMainRepoRoot)——native-worktree 复用路径下 cwd 是
1307
1356
  // worktree、其 HEAD 即分支自身,按 cwd 探会「永不锚定」fail-open。
1308
1357
  const mainRoot = this._resolveMainRepoRoot();
1358
+ // ── 锚集批量化(2026-10-21-protocol-tax task-07 / FR-07)──
1359
+ // 锚定判定 =「可达自 branch」∧「不可达自主仓 HEAD」,数学上等价于 hash ∈
1360
+ // rev-list(branch --not mainHEAD)(分支独有集,sillyspec/* 变更分支恒小)。旧实现逐
1361
+ // hash 双 merge-base 探针(Windows spawn 贵,doctor 实测 8.2s/21%);两 spawn 等价替换。
1362
+ // 等价性:is-ancestor(hash,branch) ⟺ hash ∈ rev-list(branch);探针二排除语义 ⟺ --not。
1363
+ // 任一 spawn 失败(null)→ anchorSet 置空回退旧逐对探针,fail-closed 语义原样保留。
1364
+ let anchorSet = null;
1365
+ try {
1366
+ const mainHead = gitQuiet(mainRoot, ['rev-parse', 'HEAD']);
1367
+ if (mainHead) {
1368
+ const unique = gitQuiet(this.cwd, ['rev-list', branch, '--not', mainHead]);
1369
+ if (unique !== null) anchorSet = new Set(unique ? unique.split('\n').filter(Boolean) : []);
1370
+ }
1371
+ } catch { /* 回退逐对探针 */ }
1309
1372
  for (const [hash, paths] of candidates) {
1310
- // 探针一(语义不变):merge-base --is-ancestor 祖先或自身 → exit 0 输出空串(gitQuiet
1373
+ if (anchorSet) {
1374
+ // 查锚集:在分支独有集内 → 锚定;否则不计入(含未知/畸形 hash——非真实对象无可
1375
+ // 悬空链,同旧探针一 null→continue 语义)
1376
+ if (anchorSet.has(hash)) refs.push(...paths);
1377
+ continue;
1378
+ }
1379
+ // 探针一(语义不变,批量化失败回退路径):merge-base --is-ancestor 祖先或自身 → exit 0 输出空串(gitQuiet
1311
1380
  // 返回 ''≠null);非祖先 → exit 1 → null;未知/畸形 hash → 报错非零 → null(不在分支
1312
1381
  // 内,语义正确——非真实对象无可悬空链)
1313
1382
  if (gitQuiet(this.cwd, ['merge-base', '--is-ancestor', hash, branch]) === null) continue;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * yaml-scalar-unquote —— local.yaml known_failures 条目的 YAML 标量规范化单点
3
+ * (2026-10-21-known-failures-unescape)。
4
+ *
5
+ * 背景(2026-10-21-verify-reuse-large-repo 收口实战):写侧(config-known-failures
6
+ * renderKey)按 YAML 规范把含反斜杠的锚定豁免模式转义成双引号形态,三个读侧
7
+ * (verify-postcheck extractKnownFailures / docs-check / config-known-failures
8
+ * parseKnownFailureKeys)各自拷贝的「剥引号」提取不反转义——模式带着字面双反斜杠
9
+ * 到达豁免匹配层,正则语义漂移为「字面反斜杠+任意字符」,豁免静默永不命中;CLI
10
+ * remove 同因必未命中。本模块把「条目级规范化」收敛为单点,三读侧统一 import。
11
+ *
12
+ * 零依赖叶子(三消费方刻意轻量化不引重链——js-yaml 整解析会动既有「断链容忍/
13
+ * 注释容忍」行为面,超出对称性修复);分段/注释容忍结构归各读侧既有实现。
14
+ */
15
+
16
+ /**
17
+ * 双引号标量内文反转义(YAML 双引号转义集;未知转义回落字面字符——键面无 \x 八进制等
18
+ * 罕见形态,宽松回落优于抛错)。传 inner(不含两侧引号)。
19
+ * @param {string} inner
20
+ * @returns {string}
21
+ */
22
+ export function unescapeYamlDoubleQuoted(inner) {
23
+ return String(inner ?? '').replace(/\\(.)/gs, (m, c) => {
24
+ if (c === 'n') return '\n'
25
+ if (c === 't') return '\t'
26
+ if (c === 'r') return '\r'
27
+ if (c === '0') return '\0'
28
+ return c // \\ → \、\" → "、\' → ';未知转义回落字面
29
+ })
30
+ }
31
+
32
+ /**
33
+ * 条目标量规范化(token 可带/不带引号):双引号 → 反转义;单引号 → '' → '(YAML 单引号
34
+ * 唯一转义);裸值 → trim 原样。不剥行尾注释(归调用方既有结构)。
35
+ * @param {string} s
36
+ * @returns {string}
37
+ */
38
+ export function unquoteYamlScalar(s) {
39
+ const t = String(s ?? '').trim()
40
+ if (t.length >= 2 && t.startsWith('"') && t.endsWith('"')) return unescapeYamlDoubleQuoted(t.slice(1, -1))
41
+ if (t.length >= 2 && t.startsWith("'") && t.endsWith("'")) return t.slice(1, -1).replace(/''/g, "'")
42
+ return t
43
+ }
@@ -32,7 +32,7 @@ sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句 10-30 字中文
32
32
  1. **禁止绕过本文件规则和 SillySpec 流程**。所有变更走 sillyspec 流程,不裸改裸提交。
33
33
  2. **改代码前必须先说明依据**——依据的文档路径(design.md / 模块文档 / file-lifecycle.md(如有))或现有代码依据,无依据不改。
34
34
  3. **执行顺序**:文档 → 读代码 → 写测试 → 写实现 → 跑测试 → 验收 → 更新文档(轻量道即:FR 绑定+design 四节+用户批准先于动码,读代码探究不算动码)。测试按层适用:纯逻辑先写测试,行为契约断言用户可见行为,视觉样式与纯配置/文档不写测试(记「不适用:理由」,验收靠规范与人工)。
35
- 4. **实证核验再收口**:触及 `src`/`test` 的改动,CLI 会在收口时亲自实测(实测失败阻断收口;纯 doc/配置自动跳过)。测试面按变更动态推断(本变更测试 ∪ FR 关联回归 ∪ import 依赖,runner 自项目结构推断——无需配置);lint 走 `.sillyspec/local.yaml` 的 `commands.lint`。以落盘文件与测试结果为准,不信口头"已完成"。自验口径=变更测试面(新增测试+受影响既有面)+ lint 即收口证据——全量套件归 pre-push 门,勿每变更重复手跑(共享仓全量红先做归属判别,非本变更引入如实留痕不阻断);跑批输出落 `.sillyspec/.runtime/logs/` 日志回看尾部,勿整段吞掉。
35
+ 4. **实证核验再收口**:触及 `src`/`test` 的改动,CLI 会在收口时亲自实测(实测失败阻断收口;纯 doc/配置自动跳过)。测试面按变更动态推断(本变更测试 ∪ FR 关联回归 ∪ import 依赖,runner 自项目结构推断——无需配置);lint 走 `.sillyspec/local.yaml` 的 `commands.lint`(未配置/标 `unavailable` → 收口跳过并留痕理由,归 pre-push/CI 门——pre-push 已跑同款 lint 的仓可标 unavailable 免重复)。以落盘文件与测试结果为准,不信口头"已完成"。自验口径=变更测试面(新增测试+受影响既有面)即收口证据(lint 已配置时一并实测;标 `unavailable` 的仓归 git 钩子/CI 门)——全量套件归 pre-push 门,勿每变更重复手跑(共享仓全量红先做归属判别,非本变更引入如实留痕不阻断);跑批输出落 `.sillyspec/.runtime/logs/` 日志回看尾部,勿整段吞掉。
36
36
  5. **非测试逻辑本身有误时,禁止改测试来"通过"**——修逻辑,不修测试。
37
37
  6. **git hook 拦截提交时禁止跳过**(如 `.husky/pre-push`),修复问题后再提交。
38
38
  7. **代码必须兼容 Windows / Linux / macOS**(路径 / 换行 / 并发都要顾)。