sillyspec 3.26.15 → 3.27.1

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.
package/src/run/gates.js CHANGED
@@ -19,8 +19,10 @@
19
19
  *
20
20
  * 不搬:ensureDepsFreshness(execute 入口 deps 自检,调用方 runStage 非 completeStep,归属未来 execute-handler)
21
21
  */
22
- import { basename, join } from 'node:path'
23
- import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync } from 'node:fs'
22
+ import { basename, join, relative, dirname } from 'node:path'
23
+ import { createHash } from 'node:crypto'
24
+ import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs'
25
+ import { writeAtomicSync } from '../fs-atomic.js'
24
26
  import { triggerSync, resolveChangeDir, resolveRuntimeRoot } from './shared.js'
25
27
  import { runValidators } from '../stage-contract.js'
26
28
  import { handleScanStageCompleted, handleExecuteWorktreeCleanup } from './complete-handlers.js'
@@ -89,9 +91,17 @@ export function validateSymbolImpactReport({ reportContent, planContent }) {
89
91
  export function generateSymbolImpactSkeleton(tasksContent) {
90
92
  const taskIds = extractTaskIdsFromRegistry(tasksContent)
91
93
  if (taskIds.length === 0) return null
94
+ // tasks.md 内容指纹(token 成本优化 P2a):中断续跑/阶段重开时若指纹一致且结论完整,
95
+ // 「加载上下文」步按 prompt 指引直接沿用既有报告,不重做调用点扫描(team-session-unify
96
+ // 实测该步 3h06min,重入重做是纯浪费)。指纹只作提示锚,gate 校验逻辑不变。
97
+ let fingerprint = ''
98
+ try {
99
+ fingerprint = createHash('sha256').update(String(tasksContent)).digest('hex').slice(0, 16)
100
+ } catch { /* 指纹失败不阻断骨架生成 */ }
92
101
  const lines = [
93
102
  '# 符号影响面报告',
94
103
  '',
104
+ `> tasks.md 内容指纹(生成时): ${fingerprint}——重入本步时若与当前 tasks.md 指纹一致且结论已填全,直接沿用不重做扫描。`,
95
105
  '> 骨架由 CLI 生成(`sillyspec symbol-impact --change <变更名>`,gate 失败时也会自动落一份)。',
96
106
  '> 逐行把 `<!--TODO-->` 替换为真实结论:涉及签名级变更(构造函数参数/接口/DTO/方法签名增删改)',
97
107
  '> 写变更类型 + 受影响调用点 + 是否在任务范围内;无签名级变更也要显式写「无签名级变更」。',
@@ -104,7 +114,8 @@ export function generateSymbolImpactSkeleton(tasksContent) {
104
114
 
105
115
  /**
106
116
  * 符号影响面报告硬门:execute「加载上下文」步 --done 时校验 symbol-impact.md 落盘 + task 覆盖度。
107
- * fail-closed:不通过 → 当前步标 blocked + exit 1(进度不推进)。非目标步骤/阶段直接放行。
117
+ * fail-closed:不通过 → exit 1(进度不推进;步骤状态保持 pending,无 steps 句柄不标 blocked)。
118
+ * 非目标步骤/阶段直接放行。
108
119
  */
109
120
  export async function enforceSymbolImpactGate(stageName, changeName, currentStepName, specBase) {
110
121
  if (stageName !== 'execute') return
@@ -129,7 +140,7 @@ export async function enforceSymbolImpactGate(stageName, changeName, currentStep
129
140
  const skeleton = generateSymbolImpactSkeleton(tasksContent)
130
141
  if (skeleton) {
131
142
  try {
132
- writeFileSync(reportPath, skeleton)
143
+ writeAtomicSync(reportPath, skeleton)
133
144
  skeletonNote = `\n 📄 已代生成逐 task 骨架:${reportPath}(逐行替换 <!--TODO--> 为结论,无签名级变更也显式写「无」)`
134
145
  } catch { /* 写失败只影响提示,不掩盖原始错误 */ }
135
146
  }
@@ -286,9 +297,10 @@ export async function enforceAlignExecuteReviewGate({ cwd, changeName, specBase,
286
297
  /**
287
298
  * execute deps 验证硬门(change 2026-06-28-worktree-deps-provision / D-001@v1, D-003@v1, D-006@v2)。
288
299
  * depsStatus 不达标且非 wave 级 opt-out 时阻断 --done:置 step=blocked + exit(1),与 requiresWait 同范式。
289
- * 放行返回 true;阻断时 process.exit(1) 不返回。
300
+ * 放行返回 true;阻断时经 persist 回调落盘 blocked 后 exit(1)(此前只改内存不落库,
301
+ * progress show/doctor 看到的仍是 pending,与 docstring 承诺不符、误导诊断)。
290
302
  */
291
- export async function enforceDepsGate(stageName, cwd, changeName, step, steps, currentIdx, specBase, platformOpts) {
303
+ export async function enforceDepsGate(stageName, cwd, changeName, step, steps, currentIdx, specBase, platformOpts, persist) {
292
304
  if (stageName !== 'execute') return true
293
305
  let meta = null
294
306
  let wm = null
@@ -324,6 +336,7 @@ export async function enforceDepsGate(stageName, cwd, changeName, step, steps, c
324
336
  console.error(' 或在 worktree 内手动安装依赖后重试。')
325
337
  if (meta?.depsError) console.error(` 上次供给错误:${meta.depsError}`)
326
338
  }
339
+ if (persist) { try { await persist() } catch { /* 落盘失败不吞阻断语义 */ } }
327
340
  process.exit(1)
328
341
  }
329
342
 
@@ -333,10 +346,11 @@ export async function enforceDepsGate(stageName, cwd, changeName, step, steps, c
333
346
  * Task Review Gate(validateTaskReviews)只在 execute 整阶段完成时跑(complete.js 阶段完成分支的
334
347
  * actualCompleted===actualTotal 守卫),单 task --done 不校验 → 子代理勾 checkbox 却漏写/漏字段
335
348
  * review.json,要到收尾才暴露,用户被迫事后批量补。本门提前到每次 --done:校验 plan 里所有已勾
336
- * task 的 review.json,缺字段/不存在/JSON 坏 → 置 step=blocked + exit(1),与 enforceDepsGate 同范式。
349
+ * task 的 review.json,缺字段/不存在/JSON 坏 → 置 step=blocked + exit(1),与 enforceDepsGate 同范式
350
+ * (阻断前经 persist 回调落盘 blocked)。
337
351
  * 未勾 task 不校验(还没做)。平台模式/无 marker/无 plan 时放行(下游 Task Review Gate 兜底)。
338
352
  */
339
- export async function enforceReviewJsonGate(stageName, cwd, changeName, step, steps, currentIdx, specBase, platformOpts) {
353
+ export async function enforceReviewJsonGate(stageName, cwd, changeName, step, steps, currentIdx, specBase, platformOpts, persist) {
340
354
  if (stageName !== 'execute' || !changeName) return true
341
355
  // head 锡点自动落盘(2026-08-21 审计项③,D-010 补对称):跨仓 task base_commit 已在派发时
342
356
  // CLI 落盘,head_commit 此前靠主 agent 按 prompt 手跑 rev-parse 手写(漏抄/抄错炸 review gate)。
@@ -399,6 +413,7 @@ export async function enforceReviewJsonGate(stageName, cwd, changeName, step, st
399
413
  for (const e of f.errors) console.error(` - ${e}`)
400
414
  }
401
415
  console.error(' 修复:mechanics 字段(base/head/changedFiles/schemaVersion)缺错可跑 sillyspec backfill-reviews --change ' + changeName + ' --adopt 一键代填(verdict 保留);verdict/证据缺失需人工补全后重跑 execute --done。')
416
+ if (persist) { try { await persist() } catch { /* 落盘失败不吞阻断语义 */ } }
402
417
  process.exit(1)
403
418
  }
404
419
 
@@ -486,6 +501,14 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
486
501
  console.error(' 请修复失败的测试并更新 verify-result.md 后重新完成此步骤。')
487
502
  return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
488
503
  }
504
+ // lint 对账(2026-08-21 审查 CLI-1):test 侧"自报告 PASS 但实测失败→阻断"已闭环,
505
+ // lint 侧此前纯口头——CLI 亲自执行 commands.lint,advisory 起步(失败打印不阻断,观察期后升级)
506
+ const { runVerifyLintCheck, printVerifyLintCheck } = await import('../verify-postcheck.js')
507
+ const lintCheck = runVerifyLintCheck({ cwd, specBase })
508
+ if (lintCheck.status !== 'skipped') {
509
+ console.log(`\n⏳ Verify lint 对账:CLI 亲自执行 local.yaml 的 commands.lint…`)
510
+ }
511
+ printVerifyLintCheck(lintCheck)
489
512
  // 契约 parity 对账:扫前端 API 调用 vs execute 提取的 provider endpoint artifact。
490
513
  // 接线自 contract-matrix pipeline(verifyApiParity 的 CLI 入口)。
491
514
  const { runVerifyParityCheck, printVerifyParityCheck } = await import('../verify-postcheck.js')
@@ -520,6 +543,31 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
520
543
  }
521
544
  }
522
545
  }
546
+ // ── verify 服务进程回收(坑 verify-service-process-leak,2026-08-21 实证:真实启动验证
547
+ // 起的 uvicorn 漏挂一天多)。verify prompt 要求长驻服务 PID 登记 .runtime/verify-services.pids,
548
+ // 此处收尾逐个 kill + 清文件。best-effort:ESRCH(已退出)静默,kill 失败 warn 不阻断 verify。
549
+ try {
550
+ const servicesPath = join(resolveRuntimeRoot(platformOpts, specBase), 'verify-services.pids')
551
+ if (existsSync(servicesPath)) {
552
+ const pids = readFileSync(servicesPath, 'utf8').split('\n').map(l => l.trim()).filter(l => /^\d+$/.test(l))
553
+ const reaped = []; const failed = []
554
+ for (const pid of pids) {
555
+ try { process.kill(parseInt(pid, 10), 'SIGTERM'); reaped.push(pid) }
556
+ catch (e) { if (e.code !== 'ESRCH') failed.push(`${pid}(${e.code || e.message})`) }
557
+ }
558
+ try { unlinkSync(servicesPath) } catch {}
559
+ if (reaped.length > 0) console.log(`🧹 verify 服务进程已回收 ${reaped.length} 个(PID: ${reaped.join(', ')})`)
560
+ // CLI 回执(坑 verify-literal-evidence-mismatch):回收即真实启动的结构化证据——
561
+ // checkIntegrationEvidence(stage-contract)读回执注入匹配,agent 措辞差异不再误拦
562
+ try {
563
+ writeFileSync(join(dirname(servicesPath), 'verify-services.receipt.json'), JSON.stringify({
564
+ change: changeName || null, reapedPidCount: reaped.length, reapedAt: new Date().toISOString(),
565
+ }, null, 2) + '\n')
566
+ } catch {}
567
+ if (failed.length > 0) console.warn(`⚠️ 服务进程回收失败 ${failed.length} 个:${failed.join(', ')} — 手动处理:taskkill /PID <pid> /F(Windows)/ kill -9 <pid>`)
568
+ }
569
+ } catch (e) { console.warn(`⚠️ verify 服务进程回收异常(不阻断): ${e.message}`) }
570
+
523
571
  console.log('\n✅ 验证通过,下一步:sillyspec run archive')
524
572
  }
525
573
 
@@ -581,7 +629,7 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
581
629
  reviewRunId = generateStageReviewRunId()
582
630
  try {
583
631
  mkdirSync(runtimeRoot, { recursive: true })
584
- writeFileSync(stageReviewMarkerPath(runtimeRoot, stageName, changeName), reviewRunId + '\n')
632
+ writeAtomicSync(stageReviewMarkerPath(runtimeRoot, stageName, changeName), reviewRunId + '\n')
585
633
  } catch {}
586
634
  }
587
635
  const reviewType = stageName === 'brainstorm' ? 'design'
@@ -601,6 +649,26 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
601
649
  }
602
650
  }
603
651
 
652
+ // ── module-impact pending 死信 execute 收尾提示(坑 module-impact-debt-late-warn,
653
+ // 2026-08-22 实证:死信门在 verify 才硬拦,文档债拖到 verify 末尾才暴露、上次也拦)。
654
+ // execute --done 即 advisory 提示(不阻断 execute——execute 期间可能仍在迭代模块文档;
655
+ // verify 的硬拦不变),让 agent 当场清债而非攒到 verify 撞门。
656
+ if (stageName === 'execute') {
657
+ try {
658
+ const execChangeDir = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
659
+ const execImpactPath = execChangeDir ? join(execChangeDir, 'module-impact.md') : null
660
+ if (execImpactPath && existsSync(execImpactPath)) {
661
+ const { extractPendingDocSyncRows } = await import('./complete-handlers.js')
662
+ const execPendingRows = extractPendingDocSyncRows(readFileSync(execImpactPath, 'utf8'))
663
+ if (execPendingRows.length > 0) {
664
+ console.warn(`\n⚠️ module-impact.md「更新结果」表有 ${execPendingRows.length} 个 pending/待办项(文档同步债)——verify 阶段会硬拦:`)
665
+ for (const row of execPendingRows) console.warn(` - ${row}`)
666
+ console.warn(' 建议 execute 收尾就完成模块文档同步并回填 done/skipped(说明原因),别把债拖到 verify 末尾。')
667
+ }
668
+ }
669
+ } catch { /* 提示失败不阻断 execute */ }
670
+ }
671
+
604
672
  // ── Execute Task Review Gate:所有 task 必须有 review.json 且 verdict 通过 ──
605
673
  if (stageName === 'execute') {
606
674
  try {
@@ -645,7 +713,7 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
645
713
  // marker 在则目录在)。不 try/catch——异常直穿外层 catch 走 fail-closed 阻断
646
714
  //(gate 自身写 run 目录失败不能静默放行完成)。
647
715
  mkdirSync(join(runtimeRoot, 'execute-runs', executeRunId, 'tasks'), { recursive: true })
648
- writeFileSync(runIdFile, executeRunId + '\n')
716
+ writeAtomicSync(runIdFile, executeRunId + '\n')
649
717
  stampExecuteRunChange(runtimeRoot, executeRunId, changeName)
650
718
  }
651
719
  }
@@ -753,7 +821,7 @@ export function validateMetadata(cwd, stageName, specBase) {
753
821
  const unique = [...new Set(missing)]
754
822
  if (unique.length > 0) {
755
823
  console.log(`\n⚠️ 以下文件缺少 author 或 created_at 元数据:`)
756
- unique.forEach(f => console.log(` - ${f.replace(cwd + '/', '')}`))
824
+ unique.forEach(f => console.log(` - ${relative(cwd, f) || f}`))
757
825
  console.log('请在文件头部添加 author(git 用户名)和 created_at(精确到秒)')
758
826
  }
759
827
  }
@@ -794,13 +862,14 @@ export function validateFileLocations(cwd, stageName, progress, changeName, spec
794
862
 
795
863
  if (missing.length > 0) {
796
864
  console.log(`\n⚠️ 文件位置验证:以下文件未在变更目录中找到`)
797
- console.log(` 变更目录:${changeDir.replace(cwd + '/', '')}/`)
865
+ // relative(cwd, x):Windows 下 join() 产物是反斜杠绝对路径,正斜杠拼接的 cwd 前缀裁剪永不命中
866
+ console.log(` 变更目录:${relative(cwd, changeDir) || changeDir}/`)
798
867
  for (const f of missing) {
799
868
  // 检查是否写到了错误的位置
800
869
  const wrongPath = join(specBase, 'changes', 'change', effectiveChange, f)
801
870
  if (existsSync(wrongPath)) {
802
- console.log(` ❌ ${f} — 不存在,但发现了错误路径:${wrongPath.replace(cwd + '/', '')}`)
803
- console.log(` 提示:应该写入 ${changeDir.replace(cwd + '/', '')}/${f}`)
871
+ console.log(` ❌ ${f} — 不存在,但发现了错误路径:${relative(cwd, wrongPath) || wrongPath}`)
872
+ console.log(` 提示:应该写入 ${relative(cwd, changeDir) || changeDir}/${f}`)
804
873
  } else {
805
874
  console.log(` ⬜ ${f} — 未找到(该阶段可能未产出此文件)`)
806
875
  }
package/src/run/prompt.js CHANGED
@@ -510,6 +510,21 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
510
510
  }
511
511
  }
512
512
 
513
+ // 模块卡分级表注入(token 成本优化 P0a,2026-08-22-token-cost-optimization):execute
514
+ // 「加载上下文」步 {MODULE_RESOLVE_TABLE} → resolveChangeModuleCards(tasks 卡 allowed_paths
515
+ // × 全部 _module-map.yaml 跨层最长前缀匹配,子项目细卡优先于根层大卡)。全降级不抛
516
+ // (异常 → 单行说明),无 map 由渲染函数给出跳过提示——占位符恒被替换。
517
+ if (stageName === 'execute' && promptText.includes('{MODULE_RESOLVE_TABLE}')) {
518
+ try {
519
+ const { renderModuleResolveTable } = await import('../module-resolve.js')
520
+ const mrSpecBase = resolvePromptSpecBase(platformOpts, cwd)
521
+ const table = renderModuleResolveTable({ cwd, specBase: mrSpecBase, changeName })
522
+ promptText = promptText.replace(/\{MODULE_RESOLVE_TABLE\}/g, table)
523
+ } catch (e) {
524
+ promptText = promptText.replace(/\{MODULE_RESOLVE_TABLE\}/g, `[modules-resolve] 模块卡分级解析异常(${e.message}),跳过——可手跑 sillyspec modules resolve --change <变更名>`)
525
+ }
526
+ }
527
+
513
528
  // Execute: 注入 currentExecuteRunId(从变更专属标记文件读取)
514
529
  if (stageName === 'execute' && promptText.includes('{EXECUTE_RUN_ID}')) {
515
530
  let runId = ''
@@ -782,6 +797,5 @@ export function applyRootPlaceholders(text, roots) {
782
797
  .replaceAll('{DOCS_ROOT}', roots.docsRoot)
783
798
  .replaceAll('{PROJECTS_ROOT}', roots.projectsRoot)
784
799
  .replaceAll('{WORKFLOWS_ROOT}', roots.workflowsRoot)
785
- .replaceAll('{KNOWLEDGE_ROOT}', roots.knowledgeRoot)
786
- }
800
+ .replaceAll('{KNOWLEDGE_ROOT}', roots.knowledgeRoot)}
787
801
 
@@ -7,9 +7,10 @@
7
7
  * 路径修正(相对 src/run/):
8
8
  * - resolveQuickLinkedChanges 的 quick-recommend.js 动态 import './quick-recommend.js' → '../quick-recommend.js'
9
9
  * - 脏文件信号改用 safeGit(带 safe.directory,Q3);不再 import execSync
10
- * - parsePorcelainPath 从 ./shared.js 重 import;checkbox 顶部 import
10
+ * - parsePorcelainPath 从 ./shared.js 重 import;checkbox 改交互分支内动态 import——
11
+ * 本模块被 command.js 静态 import,顶部静态拉 @inquirer/prompts 会进**每条** run 命令
12
+ * 启动路径(实测冷加载 100-150ms),而 checkbox 仅"≥2 活跃变更 + TTY"分支用到
11
13
  */
12
- import { checkbox } from '@inquirer/prompts'
13
14
  import { parsePorcelainPath, safeGit } from './shared.js'
14
15
 
15
16
  /**
@@ -150,6 +151,8 @@ export async function resolveQuickLinkedChanges({ pm, cwd, specDir, quickFiles,
150
151
  if (recommendedSet.size > 0) {
151
152
  console.log(' ⭐ = 基于脏文件/任务描述推荐,已默认勾选')
152
153
  }
154
+ // 动态 import:@inquirer/prompts 只在本交互分支加载,不进 run 命令通用启动路径
155
+ const { checkbox } = await import('@inquirer/prompts')
153
156
  const selected = await checkbox({ message: '关联变更(空格切换,回车确认)', choices })
154
157
  return selected
155
158
  }
package/src/run/shared.js CHANGED
@@ -17,9 +17,10 @@ import os from 'node:os'
17
17
 
18
18
  // safeGit 收口至 src/git-helper.js:import 建本地词法绑定(本模块内部 L128/130/431 调用)+
19
19
  // re-export 供 run/ 层现有调用方继续从 shared.js 引用(pure re-export 不建本地绑定,会致内部 ReferenceError)。
20
- import { safeGit } from '../git-helper.js'
20
+ // unquoteGitPath 同理:parsePorcelainPath 内部消费 + re-export。
21
+ import { safeGit, unquoteGitPath } from '../git-helper.js'
21
22
  import { createHash } from 'node:crypto'
22
- export { safeGit }
23
+ export { safeGit, unquoteGitPath }
23
24
 
24
25
  const __dirname = dirname(fileURLToPath(import.meta.url))
25
26
 
@@ -133,23 +134,41 @@ export function resolveSpecDir(cwd, opts = {}) {
133
134
  * 被 countAncestorSpecDirs(漂移提醒 warn)与 locateQuickSessionGuard(quick 漂移 fail-fast
134
135
  * 守卫,坑 quick-cwd-drift-splits-specdir)共用 —— 单一真相源,避免两处各自重写祖先枚举漂移。
135
136
  */
137
+ // 祖先链 ceiling(git 主仓根)按 cwd 进程级缓存:每条 stage 命令必经(countAncestorSpecDirs
138
+ // 漂移提醒 + quick 漂移守卫两条路径),此前固定 2 次串行 git spawn(Windows 60-200ms)。
139
+ // 只缓存"git root 已找到"的结果——非 git 仓库不缓存,防测试流程中途 git init 后读到陈旧 null。
140
+ const _ancestorCeilingCache = new Map()
141
+
142
+ function resolveAncestorCeiling(resolved) {
143
+ if (_ancestorCeilingCache.has(resolved)) return _ancestorCeilingCache.get(resolved)
144
+ // 一次 spawn 拿两值(rev-parse 按参数序输出,每值一行)
145
+ const out = safeGit(resolved, ['rev-parse', '--show-toplevel', '--git-common-dir']).value
146
+ let ceiling = null
147
+ if (out) {
148
+ const lines = out.split('\n')
149
+ const topLevel = (lines[0] || '').trim()
150
+ if (topLevel) ceiling = resolve(topLevel)
151
+ const commonDir = (lines[1] || '').trim()
152
+ if (commonDir) {
153
+ const absCommonDir = resolve(resolved, commonDir)
154
+ if (existsSync(absCommonDir) && statSync(absCommonDir).isDirectory()) {
155
+ const mainRoot = dirname(absCommonDir)
156
+ // 主仓根是 worktree 根的祖先时取它(更靠上);monorepo/单仓两者相等不替换,行为不变
157
+ if (!ceiling || resolve(ceiling).startsWith(resolve(mainRoot) + sep)) ceiling = mainRoot
158
+ }
159
+ }
160
+ }
161
+ if (ceiling) _ancestorCeilingCache.set(resolved, ceiling)
162
+ return ceiling
163
+ }
164
+
136
165
  export function ancestorSpecDirs(cwd) {
137
166
  const resolved = resolve(cwd)
138
167
  // 上界 = git root。但 linked worktree 内 --show-toplevel 返回 worktree 根(非主仓根),
139
168
  // 祖先链到不了主仓 .sillyspec → 漂移提醒/quick 守卫全哑(坑 worktree-execute-spec-drift)。
140
169
  // 复刻 worktree.js _resolveMainRepoRoot:--git-common-dir 的 dirname 才是主仓根,取更靠上者作 ceiling。
141
170
  // (git 可能返回相对路径,须 resolve(cwd, commonDir) 绝对化,否则相对 process.cwd 误解析。)
142
- const topLevel = safeGit(resolved, ['rev-parse', '--show-toplevel']).value
143
- let ceiling = topLevel ? resolve(topLevel) : null
144
- const commonDir = safeGit(resolved, ['rev-parse', '--git-common-dir']).value
145
- if (commonDir) {
146
- const absCommonDir = resolve(resolved, commonDir)
147
- if (existsSync(absCommonDir) && statSync(absCommonDir).isDirectory()) {
148
- const mainRoot = dirname(absCommonDir)
149
- // 主仓根是 worktree 根的祖先时取它(更靠上);monorepo/单仓两者相等不替换,行为不变
150
- if (!ceiling || resolve(ceiling).startsWith(resolve(mainRoot) + sep)) ceiling = mainRoot
151
- }
152
- }
171
+ const ceiling = resolveAncestorCeiling(resolved)
153
172
  const dirs = []
154
173
  let dir = resolved
155
174
  while (true) {
@@ -499,6 +518,12 @@ export async function triggerSync(cwd, changeName, platformOpts = {}, opts = {})
499
518
  */
500
519
  const AUTO_PULL_THROTTLE_MS = 10_000
501
520
 
521
+ // 自动 pull 专属熔断(2026-08-21 性能审查 PERF-04):自动注入点(stage 命令分发前 / approve 前)
522
+ // 在打印任何输出前 await,daemon 慢/挂时每 10s 窗口首条命令硬等上限即体感 hang——降为 2s。
523
+ // 手动 `platform pull` 不经此路径(用户显式发起,等待 8s 合理)。超时语义与 8s 版一致:
524
+ // abort 在飞请求(HUB-09),错过窗口的平台更新由下一条 >10s 命令或 push 409 自愈兜底。
525
+ const AUTO_PULL_TIMEOUT_MS = 2_000
526
+
502
527
  function _autoPullThrottlePath(cwd) {
503
528
  return join(cwd, '.sillyspec', '.runtime', 'auto-pull-throttle.json')
504
529
  }
@@ -521,7 +546,7 @@ function _stampAutoPull(cwd) {
521
546
 
522
547
  /**
523
548
  * 触发 pull(下行同步,task-10 / D-009 / FR-04 / FR-06)。
524
- * 复用 triggerSync 的 8s 熔断与 Best Effort 语义;未连接平台静默跳过(与现状一致)。
549
+ * 熔断走 AUTO_PULL_TIMEOUT_MS(2s,见常量注释)与 Best Effort 语义;未连接平台静默跳过(与现状一致)。
525
550
  * 注入时机:stage 命令启动(顶层别名 + case 'run',ql-20260818-008 补齐后者)+ 关键决策点
526
551
  * (approve 前)+ 手动 platform pull。不在每步 pull(避免高频写入与网络压力),仅低频边界点。
527
552
  * 自动注入走 skipIfLocalDirty 保守守卫:本地有未同步改动时 pull 内部跳过 import,
@@ -541,7 +566,7 @@ export async function triggerPull(cwd, changeName, platformOpts = {}, opts = {})
541
566
  if (!sm._getPlatform()) return
542
567
  if (_autoPullRecently(cwd)) return
543
568
  _stampAutoPull(cwd)
544
- await raceWithAbort((sig) => sm.pull(changeName, { skipIfLocalDirty: true, signal: sig.signal }), opts.timeoutMs)
569
+ await raceWithAbort((sig) => sm.pull(changeName, { skipIfLocalDirty: true, signal: sig.signal }), opts.timeoutMs ?? AUTO_PULL_TIMEOUT_MS)
545
570
  } catch (e) {
546
571
  // pull 失败静默跳过(Best Effort,失败不影响正确性)
547
572
  console.warn('⚠️ 拉取失败:', e.message)
@@ -578,10 +603,10 @@ export async function triggerPullActiveChange(cwd, platformOpts = {}) {
578
603
  // progress 不可达则跳过(Best Effort)
579
604
  }
580
605
  if (!cn) return
581
- // 已确认连接 + 单活跃变更,调 pull(复用 8s 熔断 + abort(HUB-09);skipIfLocalDirty 保守守卫
606
+ // 已确认连接 + 单活跃变更,调 pull(自动注入 2s 熔断 + abort(HUB-09);skipIfLocalDirty 保守守卫
582
607
  // 同 triggerPull——本地脏时跳过 import,防平台旧快照覆盖本地领先进度,ql-20260818-008)
583
608
  try {
584
- await raceWithAbort((sig) => sm.pull(cn, { skipIfLocalDirty: true, signal: sig.signal }))
609
+ await raceWithAbort((sig) => sm.pull(cn, { skipIfLocalDirty: true, signal: sig.signal }), AUTO_PULL_TIMEOUT_MS)
585
610
  } catch (e) {
586
611
  console.warn('⚠️ 拉取失败:', e.message)
587
612
  }
@@ -639,7 +664,10 @@ export function parsePorcelainPath(line) {
639
664
  if (!line) return ''
640
665
  let path = line.slice(3).trim()
641
666
  if (path.length >= 2 && path.startsWith('"') && path.endsWith('"')) {
642
- path = path.slice(1, -1).replace(/\\(.)/g, (_, c) => c)
667
+ // unquoteGitPath:\\ \" \n \t 与 \NNN 八进制字节聚合 UTF-8 解码——原 \\(.) 逐字符
668
+ // 解引会把 "\346\226\207.md" 拆成 "346226207.md"。git-helper 已统一 core.quotepath=false
669
+ // (非 ASCII 路径裸输出),此处兜底仍会被引号转义的路径(含控制字符等)。
670
+ path = unquoteGitPath(path.slice(1, -1))
643
671
  }
644
672
  const arrow = path.indexOf(' -> ')
645
673
  if (arrow !== -1) path = path.slice(arrow + 4)
package/src/run/stage.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * - 'child_process'(execSync)裸模块名不变
18
18
  */
19
19
  import { join, dirname } from 'node:path'
20
- import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
20
+ import { existsSync, readdirSync, readFileSync, mkdirSync } from 'node:fs'
21
21
  import { writeAtomicSync } from '../fs-atomic.js'
22
22
  import { resolveSpecDir, resolveChangeDir, resolveRuntimeRoot, resolveQuickSessionsDir, triggerSync, safeGit, parsePorcelainPath, formatWaitOptions, checkApproval, getStageSteps, warnApprovalUnknown } from './shared.js'
23
23
  import { computeScanProfile, applyScanProfileSteps, executeScanPreflight, executeScanPostcheck } from './scan-profile.js'
@@ -122,7 +122,7 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
122
122
  throw new Error(`execute run 目录创建失败(${join(runtimeRoot, 'execute-runs', currentExecuteRunId)}): ${e.message};` +
123
123
  `请检查该路径是否为普通文件/只读,清理后重跑(sillyspec run execute --change ${changeName} --skip-approval)`)
124
124
  }
125
- writeFileSync(runIdFile, currentExecuteRunId + '\n')
125
+ writeAtomicSync(runIdFile, currentExecuteRunId + '\n')
126
126
  // change 归属戳(坑 worktree-cleanup-marker-chain 根治):run 目录自带变更身份,
127
127
  // marker 断裂(worktree cleanup / 归档清理 / 并行误删)后按戳归属,不再错配其他变更的 run
128
128
  const { stampExecuteRunChange } = await import('../task-review.js')
@@ -130,6 +130,38 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
130
130
  }
131
131
  }
132
132
 
133
+ // ── 跨变更文件冲突预警(坑 cross-change-conflict-no-warning,2026-08-22 实证:并行变更改
134
+ // 同一文件靠人工发现——f578a08e 的「锚点更新」是好实践但工具无预警)。execute 启动时把本变更
135
+ // 的 diff 文件集与其他活跃变更的 diff 文件集求交集,advisory warn 交集与对端变更名 + 锚点
136
+ // 更新提示。best-effort:任何异常静默跳过(预警不阻断 execute 启动)。
137
+ if (stageName === 'execute' && changeName) {
138
+ try {
139
+ const { resolveVerifyChangedFiles } = await import('../verify-postcheck.js')
140
+ const activeChanges = (pm.listChanges(cwd) || []).filter(c => c && c !== changeName)
141
+ if (activeChanges.length > 0) {
142
+ const myFiles = resolveVerifyChangedFiles(cwd, changeName, null, { includeWorkingTree: true }) || []
143
+ const mySet = new Set(myFiles.map(f => f.replace(/\\/g, '/')).filter(f => !f.startsWith('.sillyspec/')))
144
+ if (mySet.size > 0) {
145
+ const conflicts = []
146
+ for (const other of activeChanges) {
147
+ const otherFiles = resolveVerifyChangedFiles(cwd, other, null, { includeWorkingTree: true }) || []
148
+ const overlap = [...new Set(otherFiles.map(f => f.replace(/\\/g, '/')))]
149
+ .filter(f => mySet.has(f))
150
+ if (overlap.length > 0) conflicts.push({ other, overlap })
151
+ }
152
+ if (conflicts.length > 0) {
153
+ console.warn(`⚠️ 跨变更文件冲突预警:本变更(${changeName}})与其他活跃变更改动重叠——`)
154
+ for (const c of conflicts) {
155
+ console.warn(` 与 ${c.other} 重叠 ${c.overlap.length} 个文件:${c.overlap.slice(0, 5).join(', ')}${c.overlap.length > 5 ? ` …(共 ${c.overlap.length})` : ''}`)
156
+ }
157
+ console.warn(` 并行 apply/归档时重叠文件会互相覆盖或冲突。实践:后完成方在其 verify/design 里记录「锚点更新」`)
158
+ console.warn(` (说明基于对方已合入的版本重新锚定),并考虑串行化这两个变更的 apply。`)
159
+ }
160
+ }
161
+ }
162
+ } catch { /* 预警失败不阻断 execute */ }
163
+ }
164
+
133
165
  // 自动探测 currentChange
134
166
  if (autoDetectChange(progress, cwd)) {
135
167
  progress.lastActive = new Date().toLocaleString('zh-CN', { hour12: false })
@@ -207,7 +239,9 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
207
239
  }
208
240
  const guardFile = join(specBase, '.runtime', 'scan-guard.json')
209
241
  mkdirSync(dirname(guardFile), { recursive: true })
210
- writeFileSync(guardFile, JSON.stringify(scanGuard, null, 2) + '\n')
242
+ // 原子写:hook 进程(worktree-guard 的 readScanGuard)并发读,裸 truncate→write 的
243
+ // 半截窗口会被 JSON.parse 吞成 null → 覆盖保护静默失效(fail-open)
244
+ writeAtomicSync(guardFile, JSON.stringify(scanGuard, null, 2) + '\n')
211
245
  if (scanGuard.forceRescan) {
212
246
  console.log('🛡️ scan 覆盖保护已记录: --force-rescan 已开启')
213
247
  } else {
@@ -480,7 +480,20 @@ function validateVerifyOutputs(cwd, changeName, context = {}) {
480
480
  warnings.push(`[${changeRiskProfile.level}] 结论 PASS WITH NOTES:design frontmatter 显式声明 risk_level=${changeRiskProfile.level},残留项须为真实集成证据缺口,并在 verify-result.md 如实说明`)
481
481
  }
482
482
  if (requiresEvidence) {
483
- const evidenceCheck = checkIntegrationEvidence(verify, changeRiskProfile.requiredVerification)
483
+ // CLI 回执注入(坑 verify-literal-evidence-mismatch,2026-08-22 实证:证据第一轮就齐
484
+ // 但自然措辞不含字面词被误拦三轮):verify 服务回收器落的回执 = 真实启动+PID 登记
485
+ // 的结构化证据,作为附加匹配文本——有回执不依赖 agent 措辞;无回执维持原字面匹配
486
+ let receiptText = ''
487
+ try {
488
+ const receiptPath = join(cwd, '.sillyspec', '.runtime', 'verify-services.receipt.json')
489
+ if (existsSync(receiptPath)) {
490
+ const receipt = JSON.parse(readFileSync(receiptPath, 'utf8'))
491
+ if (receipt && (receipt.reapedPidCount || 0) > 0 && (!receipt.change || receipt.change === changeName)) {
492
+ receiptText = `CLI 回执:verify 服务进程已回收 ${receipt.reapedPidCount} 个(PID 已登记,真实启动,运行时证据 reapedAt=${receipt.reapedAt})`
493
+ }
494
+ }
495
+ } catch { /* 回执损坏按无回执处理(fail 回原字面匹配) */ }
496
+ const evidenceCheck = checkIntegrationEvidence(verify, changeRiskProfile.requiredVerification, { extraEvidenceText: receiptText })
484
497
  if (!evidenceCheck.ok) {
485
498
  // A: 报错说人话 —— 把「缺哪一项、要写/做什么、判级原因」逐条列出,
486
499
  // 让 agent 不必靠改结论文案撞墙。detail 指明真实启动须是本变更实际改动的
@@ -82,12 +82,14 @@ export const definition = {
82
82
  3. 如果标记缺失或重复 → 在 _module-map.yaml 中标记 needs_review: true
83
83
 
84
84
  #### 新建模块卡片模板
85
- 一级标题格式 \`# <中文名>(<module-id>)\`——中文名从模块职责提炼 2-8 字,括号内保留 module-id 原样(sillyhub 平台解析识别用,与 scan 生成约定一致)。
85
+ 一级标题格式 \`# <中文名>(<module-id>)\`——中文名从模块职责提炼 2-8 字,括号内保留 module-id 原样(sillyhub 平台解析识别用,与 scan 生成约定一致)。frontmatter 已带 author/created_at 真值(<git-user>/<now-datetime> 占位符由 CLI 每步替换),照抄即过元数据校验、勿删。
86
86
  \`\`\`markdown
87
87
  ---
88
88
  schema_version: 1
89
89
  doc_type: module-card
90
90
  module_id: <module-id>
91
+ author: <git-user>
92
+ created_at: <now-datetime>
91
93
  ---
92
94
 
93
95
  # <中文名>(<module-id>)