sillyspec 3.28.6 → 3.28.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.claude/skills/sillyspec-execute/SKILL.md +224 -222
  2. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  3. package/.claude/skills/sillyspec-quick/SKILL.md +117 -116
  4. package/.husky/pre-push +3 -1
  5. package/.patch16-tmp.mjs +169 -0
  6. package/attachments/2ab2fd87cfa568ca6acadbb934a651d293a0ad8f3227c66a70b56dcf53a0ea3d.pptx +0 -0
  7. package/attachments/dc4c2b314ee0c295e1fe5243073eb5c7df7e429074ad7c14424523fbe86e6193.pptx +0 -0
  8. package/meta.json +11 -0
  9. package/package.json +1 -1
  10. package/src/agent-session-log.js +446 -35
  11. package/src/config-cat.js +6 -3
  12. package/src/config-schema.js +13 -0
  13. package/src/constants.js +3 -0
  14. package/src/db.js +12 -3
  15. package/src/decision-distill.js +6 -2
  16. package/src/dispatch/probe.js +8 -1
  17. package/src/docs-check.js +137 -12
  18. package/src/docs-gate.js +3 -0
  19. package/src/doctor-diagnostics.js +19 -7
  20. package/src/friction-tally.js +7 -2
  21. package/src/git-helper.js +6 -4
  22. package/src/hooks/worktree-guard.js +59 -29
  23. package/src/index.js +168 -64
  24. package/src/knowledge-match.js +90 -6
  25. package/src/local-detect.js +30 -15
  26. package/src/local-register.js +13 -4
  27. package/src/mcp-server.js +38 -8
  28. package/src/progress/change-registry.js +58 -26
  29. package/src/progress/consistency-doctor.js +11 -4
  30. package/src/progress/stage-machine.js +3 -1
  31. package/src/progress/step-store.js +30 -17
  32. package/src/progress.js +26 -0
  33. package/src/quicklog.js +44 -18
  34. package/src/review-dispatch.js +7 -1
  35. package/src/run/command.js +17 -3
  36. package/src/run/complete-handlers.js +40 -10
  37. package/src/run/complete.js +48 -17
  38. package/src/run/concurrent-detect.js +1 -1
  39. package/src/run/gate-snapshot.js +115 -5
  40. package/src/run/gates.js +35 -5
  41. package/src/run/prompt.js +61 -1
  42. package/src/run/quick-audit.js +80 -4
  43. package/src/run/shared.js +98 -10
  44. package/src/run/verify-quality-scan.js +14 -3
  45. package/src/scope-audit.js +89 -14
  46. package/src/semantic-guard.js +207 -0
  47. package/src/sillyhub-mcp/client.js +14 -0
  48. package/src/spec-sync.js +19 -14
  49. package/src/stage-contract-spec.js +4 -3
  50. package/src/stage-review.js +6 -5
  51. package/src/stages/brainstorm.js +1 -1
  52. package/src/stages/execute.js +6 -1
  53. package/src/stages/plan-postcheck.js +57 -12
  54. package/src/sync.js +66 -4
  55. package/src/task-review.js +41 -6
  56. package/src/verify-facts-schema.js +10 -3
  57. package/src/verify-postcheck.js +21 -6
  58. package/src/verify-probes.js +8 -3
  59. package/src/worktree-apply.js +56 -18
  60. package/src/worktree-cross.js +12 -18
  61. package/src/worktree.js +138 -25
  62. package/src/wt-commit.js +128 -0
package/src/run/gates.js CHANGED
@@ -485,8 +485,14 @@ export async function enforceReviewJsonGate(stageName, cwd, changeName, step, st
485
485
  * 若校验失败不回滚,DB 会与真实产物不一致(hook/doctor/下游阶段全部误判),
486
486
  * 且所有步骤都是 completed 时 agent 无法重新 --done("没有待完成的步骤")。
487
487
  * 此处将 stage 回滚为 in-progress,最后一步重置为 pending,供修复产物后重做。
488
+ *
489
+ * 批量乐观标记一并回滚(坑 execute-batch-rollback-half-state,2026-08-29 用户实证):execute
490
+ * 批量完成(detectExecuteBatchFinish)把剩余步骤批量标 completed 后任一收尾 gate 失败,原先只
491
+ * 回滚 currentIdx → 落库「步骤 11 pending + 12-16 completed」半套状态,步骤序倒挂、要再补一次
492
+ * --done 才对齐。批量标记打 _batchAligned 戳(内存字段不落库),gate 失败 = 批量完成整体不成立,
493
+ * 戳上的步骤随当前步一起回滚 pending,下次 --done 满足条件再重新批量标记。
488
494
  */
489
- function rollbackStageCompletion(stageData, steps, currentIdx) {
495
+ export function rollbackStageCompletion(stageData, steps, currentIdx) {
490
496
  // 辅助阶段在 validator 前已被重置为 pending(steps 也换成了新数组),不要覆盖
491
497
  if (stageData.status === 'completed') {
492
498
  stageData.status = 'in-progress'
@@ -496,6 +502,14 @@ function rollbackStageCompletion(stageData, steps, currentIdx) {
496
502
  steps[currentIdx].status = 'pending'
497
503
  steps[currentIdx].completedAt = null
498
504
  }
505
+ for (const s of steps) {
506
+ if (!s) continue
507
+ if (s._batchAligned && s.status === 'completed') {
508
+ s.status = 'pending'
509
+ s.completedAt = null
510
+ }
511
+ s._batchAligned = false
512
+ }
499
513
  }
500
514
 
501
515
  /**
@@ -511,7 +525,7 @@ function rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx,
511
525
  // friction 尾参(friction-signal-hint task-02):gate 失败摩擦埋点标签 {type, detail}——各调用点
512
526
  // 按所属 gate 段传来源标签(审查两段走 review_rejected),未传兜底通用 gate-cascade。
513
527
  // recordFrictionEvent 自带静默降级(非法类型/写失败返回 null),不影响回滚/返回语义。
514
- recordFrictionEvent({ cwd, changeName, platformOpts, ...(friction || { type: 'gate_rollback', detail: 'gate-cascade' }) })
528
+ void recordFrictionEvent({ cwd, changeName, platformOpts, ...(friction || { type: 'gate_rollback', detail: 'gate-cascade' }) })
515
529
  rollbackStageCompletion(stageData, steps, currentIdx)
516
530
  progress.lastActive = new Date().toLocaleString('zh-CN', { hour12: false })
517
531
  pm._write(cwd, progress, changeName)
@@ -646,7 +660,7 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
646
660
  const { createVerifyGateSnapshot } = await import('./gate-snapshot.js')
647
661
  verifyGateSnap = await createVerifyGateSnapshot({ cwd, changeName, specBase, platformOpts })
648
662
  if (verifyGateSnap) {
649
- console.log(`🧪 Verify 实测隔离快照:HEAD + 本变更 ${verifyGateSnap.changeFileCount} 个文件${verifyGateSnap.sourceRoot ? '(overlay 自 worktree——本变更分支内容定向跑)' : ''}(并行会话脏文件不参与门判定)`)
663
+ console.log(`🧪 Verify 实测隔离快照(根:${verifyGateSnap.snapshotRoot}):HEAD + 本变更 ${verifyGateSnap.changeFileCount} 个文件${verifyGateSnap.sourceRoot ? '(overlay 自 worktree——本变更分支内容定向跑)' : ''}(并行会话脏文件不参与门判定)`)
650
664
  }
651
665
  } catch { /* 快照链路异常 → 主仓现行为 */ }
652
666
  }
@@ -679,7 +693,11 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
679
693
  }
680
694
  } catch { /* fail-soft:回填失败不影响门禁 */ }
681
695
  if (testCheck.status === 'failed') {
682
- if (verifyGateSnap) { try { verifyGateSnap.cleanup() } catch {} ; verifyGateSnap = null }
696
+ if (verifyGateSnap) {
697
+ try { const { printSnapshotFailureHint } = await import('./gate-snapshot.js')
698
+ printSnapshotFailureHint(verifyGateSnap) } catch { /* 提示失败不影响阻断 */ }
699
+ try { verifyGateSnap.cleanup() } catch {} ; verifyGateSnap = null
700
+ }
683
701
  console.error('\n❌ verify 阶段被阻断:verify-result.md 自报告通过,但 CLI 实测测试失败。')
684
702
  // 并行 WIP 归因鉴别(坑 verify-reconcile-foreign-wip):单一实现迁至 verify-quality-scan.js
685
703
  // renderVerifyTestAttribution(P0-1 前置二:noAI 质量扫描失败路径与本 --done 路径共用,
@@ -700,13 +718,21 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
700
718
  if (lintCheck) {
701
719
  console.log(`\n♻️ Verify lint 对账:复用 noAI 质量扫描步的实测结果(代码指纹匹配,免重跑)`)
702
720
  } else {
703
- lintCheck = runVerifyLintCheck({ cwd: gateCwd, specBase: gateSpecBase })
721
+ lintCheck = runVerifyLintCheck({ cwd: gateCwd, specBase: gateSpecBase, timeoutMs: verifyGateSnap ? 5 * 60 * 1000 : undefined })
722
+ // 快照 lint 超时回退主仓(2026-09-12 dogfood 两连实证:junction I/O 病态慢,3min/5min 均被
723
+ // 超时杀——主仓 60~110s 正常。按用户建议「自动回退」而非假败/advisory:主仓复跑保硬门,
724
+ // 并行噪声混入时失败输出带归属鉴定提示)
725
+ if (verifyGateSnap && lintCheck.status === 'failed' && /超时/.test(String(lintCheck.reason || ''))) {
726
+ console.warn('⚠️ 快照 lint 超时(node_modules junction I/O 慢)→ 主仓复跑 lint(并行噪声可能混入——失败先做归属鉴定)')
727
+ lintCheck = runVerifyLintCheck({ cwd, specBase })
728
+ }
704
729
  if (lintCheck.status !== 'skipped') {
705
730
  console.log(`\n⏳ Verify lint 对账:CLI 亲自执行 local.yaml 的 commands.lint…`)
706
731
  }
707
732
  }
708
733
  // 快照收尾(test+lint 两检查都已完成;parity/删除探针走主仓 git 事实不依赖快照)
709
734
  const usedSnapshot = Boolean(verifyGateSnap)
735
+ const usedSnapInfo = verifyGateSnap ? { snapshotRoot: verifyGateSnap.snapshotRoot, changeFileCount: verifyGateSnap.changeFileCount, sourceRoot: verifyGateSnap.sourceRoot } : null
710
736
  if (verifyGateSnap) { try { verifyGateSnap.cleanup() } catch {} ; verifyGateSnap = null }
711
737
  printVerifyLintCheck(lintCheck)
712
738
  // lint 硬门(2026-09-09 升硬:观察期 14 次 5 败全真阳性,pre-push 本就硬拦——fail-fast 前移;
@@ -714,6 +740,10 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
714
740
  const { shouldBlockVerifyLint } = await import('../verify-postcheck.js')
715
741
  if (shouldBlockVerifyLint(lintCheck)) {
716
742
  console.error('\n❌ verify 阶段被阻断:CLI 亲自实测 lint 失败(agent 的 lint 自报告与实测不符时以实测为准)。')
743
+ if (usedSnapInfo) {
744
+ try { const { printSnapshotFailureHint } = await import('./gate-snapshot.js')
745
+ printSnapshotFailureHint(usedSnapInfo) } catch { /* 提示失败不影响阻断 */ }
746
+ }
717
747
  if (lintCheck.outputTail) {
718
748
  const tail = lintCheck.outputTail.split('\n').slice(-8).join('\n')
719
749
  console.error(' 输出(末尾):')
package/src/run/prompt.js CHANGED
@@ -22,7 +22,8 @@ import { basename, join } from 'node:path'
22
22
  import { existsSync, readFileSync, mkdirSync, readdirSync } from 'node:fs'
23
23
  import { writeAtomicSync } from '../fs-atomic.js'
24
24
  import { stageRegistry } from '../stages/index.js'
25
- import { resolvePromptIncludes, resolveRuntimeRoot, safeGit, WAIT_MARKER_RE, QUICK_SID_RE } from './shared.js'
25
+ import { resolvePromptIncludes, resolveRuntimeRoot, safeGit, parsePorcelainPath, WAIT_MARKER_RE, QUICK_SID_RE, triggerStepStartSync } from './shared.js'
26
+ import { renderSemanticGuardBlock, readSemanticGuardEnabled } from '../semantic-guard.js'
26
27
  import { renderStageContract } from '../stage-contract-spec.js'
27
28
  import { nowWallClock } from '../datetime.js'
28
29
  import { parseModuleMapSimple } from '../modules.js'
@@ -181,6 +182,44 @@ export function buildQuickContextDigest(specBase, projectName) {
181
182
  }
182
183
  }
183
184
 
185
+ /**
186
+ * quick step1 语义护栏进场注入拼装(task-05,FR-03,D-001@v1 模块四):开关 → 候选文件采集 →
187
+ * renderSemanticGuardBlock 反查。候选 = 会话声明文件(guard.allowedFiles,readQuickGuardField
188
+ * 同源读取:session guard 优先 / legacy 单文件回退)∪ git status --porcelain 脏文件
189
+ * (parsePorcelainPath 同款解析口径:引号剥离 / -> rename / 反斜杠归一),反斜杠归一去重封顶 20。
190
+ * 返回 ''(开关关 / 无候选 / 双零命中——prompt 与现状字节一致,零命中静默)或 advisory 段;
191
+ * 全链 fail-soft:异常返回单行降级说明,不阻断 quick 启动。导出供注入单测直测
192
+ * (test/semantic-guard-prompt-inject.test.mjs)。
193
+ */
194
+ export function buildQuickSemanticGuardInjection({ specBase, cwd, changeName } = {}) {
195
+ try {
196
+ // 开关首行(Grill X-012):false 直接零输出——不采候选文件、不跑反查(开关语义全停非半停)
197
+ if (!readSemanticGuardEnabled(specBase)) return ''
198
+ // 会话声明文件(--files 落 guard.allowedFiles;缺失 / 形态异常按空)
199
+ const declaredRaw = readQuickGuardField(changeName, specBase, 'allowedFiles')
200
+ const declared = Array.isArray(declaredRaw) ? declaredRaw : []
201
+ // git 脏文件:porcelain 解析同源 quick-audit(trim:false——首列前导空格是状态码,trim 会削掉
202
+ // 致 parsePorcelainPath 丢首字符;.sillyspec/ 运行时产物过滤同款——候选面是代码文件)
203
+ let dirty = []
204
+ const statusResult = safeGit(cwd, ['status', '--porcelain'], { trim: false })
205
+ if (!statusResult.error) {
206
+ dirty = (statusResult.value || '').split('\n').filter(Boolean)
207
+ .map(l => parsePorcelainPath(l))
208
+ .filter(Boolean)
209
+ .filter(f => !f.startsWith('.sillyspec/'))
210
+ }
211
+ // 去重(反斜杠归一后;renderSemanticGuardBlock 内侧亦有同款归一,此处为封顶前的口径)
212
+ // 封顶 20 —— 与 collectRecentForeignDelivery 内侧文件封顶同数
213
+ const candidateFiles = [...new Set(
214
+ [...declared, ...dirty].map(f => String(f || '').trim().replace(/\\/g, '/')).filter(Boolean)
215
+ )].slice(0, 20)
216
+ if (candidateFiles.length === 0) return ''
217
+ return renderSemanticGuardBlock({ specBase, cwd, candidateFiles, currentChange: changeName })
218
+ } catch (e) {
219
+ return `【语义护栏】注入失败(${e && e.message ? e.message : e})——跳过反查,不阻断本步骤;需要时自查 knowledge/INDEX.md 与 git log`
220
+ }
221
+ }
222
+
184
223
  // parseModuleMapSimple 复用 modules.js 的 canonical 实现(合并历史 copy-paste 副本,2026-08-07;
185
224
  // 无循环依赖:modules.js 仅 import fs/path/db.js,prompt.js → modules.js 单向)。
186
225
  /**
@@ -270,6 +309,14 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
270
309
  return false
271
310
  }
272
311
 
312
+ // X3 渲染侧接线(2026-08-29-change-delete-closure-and-spec-pull 遗留活跃坑收口;曾因并行
313
+ // 会话提交丢弃一次,2026-09-12 复检重接):步骤 prompt 渲染即步骤起点——补推一次 progress
314
+ // 刷新平台 last_pushed_at(停滞判定不再把长步骤误报为「停滞」)。不 await:prompt 渲染不被
315
+ // 网络阻塞;未连接平台静默 / quick 会话降级跳过 / 8s 熔断 / 失败只 warn,契约全在
316
+ // triggerStepStartSync 内(shared.js docstring 即本接线点的说明),Node 事件循环保证在飞
317
+ // POST 落地后进程才退出。
318
+ triggerStepStartSync(cwd, changeName, platformOpts)
319
+
273
320
  // ── 首个 agent 可见步索引(「首步一次建立」类注入的锚点)──
274
321
  // step0 为 noAI(brainstorm/execute/verify 的 step1「进度确认」,2026-09-07)时不渲染 prompt,
275
322
  // persona/护栏/完成契约/铁律/平台路径约束等 step0 专属注入须顺延到首个渲染步,否则整段丢失
@@ -1061,6 +1108,19 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
1061
1108
  }
1062
1109
  }
1063
1110
 
1111
+ // ── 语义护栏进场注入(task-05,FR-03,D-001@v1 模块四)──
1112
+ // 判定口径独立:与上方模块上下文注入同锚(quickFirstStep——quick step1「理解任务」),不嵌进
1113
+ // {QUICK_CONTEXT_DIGEST} 占位符守卫内——模板改版去占位符时护栏静默失效(plan 审查 N2)。
1114
+ // 非空追加到 step1 prompt 末尾;空串(开关关 / 零命中)prompt 与现状字节一致(零命中静默)。
1115
+ // 渲染层注入,src/stages/quick.js 模板零变更(design 明确不改清单)。全链 fail-soft 见 helper。
1116
+ if (quickFirstStep) {
1117
+ const sgSpecBase = resolvePromptSpecBase(platformOpts, cwd)
1118
+ const semanticGuardBlock = buildQuickSemanticGuardInjection({ specBase: sgSpecBase, cwd, changeName })
1119
+ if (semanticGuardBlock) {
1120
+ promptText = promptText + '\n\n' + semanticGuardBlock
1121
+ }
1122
+ }
1123
+
1064
1124
  // 平台模式 prompt 自检:确保没有裸相对输出路径
1065
1125
  // 只匹配正向写入指令中的裸路径,避免误杀「禁止写入 .sillyspec/」等安全说明
1066
1126
  if ((platformOpts?.specRoot || platformOpts?.runtimeRoot) && stageName === 'scan') {
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { join } from 'node:path'
15
15
  import { parsePorcelainPath, safeGit } from './shared.js'
16
+ import { collectRecentForeignDelivery, detectAssertionRewrites, readSemanticGuardEnabled } from '../semantic-guard.js'
16
17
 
17
18
  /**
18
19
  * 打印 quick 完成审计结论(按 review.status 输出 SAFE/WARNING/BLOCKED)。
@@ -136,6 +137,17 @@ export function printQuickAuditReview(review) {
136
137
  }
137
138
  console.warn(` quick 不强制文档同步,欠账已随本条 QUICKLOG「审计:」行落盘(可事后追溯)。若改动触及接口/契约,建议顺手同步模块文档。`)
138
139
  }
140
+ // spec 共享面他者噪声(坑 foreign-spec-churn-fail-closed):并行会话归档移动/文档收尾的
141
+ // 删除与 EOL-only 假 M——软警告点名归因,不再逼 --allow-delete/--allow-new 解锁。
142
+ if (review.foreignSpecChurn && review.foreignSpecChurn.length > 0) {
143
+ console.warn(`⚠️ spec 共享面 ${review.foreignSpecChurn.length} 个非本会话声明的删除(.sillyspec/ docs/,疑似并行会话归档移动/收尾——归其会话收口,本会话不阻断):`)
144
+ for (const f2 of review.foreignSpecChurn.slice(0, 6)) console.warn(` - ${f2}`)
145
+ if (review.foreignSpecChurn.length > 6) console.warn(` … 共 ${review.foreignSpecChurn.length} 个`)
146
+ }
147
+ if (review.eolOnlyFiles && review.eolOnlyFiles.length > 0) {
148
+ console.warn(`⚠️ ${review.eolOnlyFiles.length} 个文件仅行尾重写(内容零变化,gen:types/编辑器 CRLF 假 M——已剔出本会话审计):${review.eolOnlyFiles.slice(0, 5).join('、')}${review.eolOnlyFiles.length > 5 ? ' 等' : ''}`)
149
+ console.warn(` 根治:.gitattributes 声明 text=auto eol=lf(或按文件类型钉死),生成器重写不再制造假 M`)
150
+ }
139
151
  if (review.docsCheckHint && review.docsCheckHint.invalid > 0) {
140
152
  console.warn(`\n📎 文档引用失效(docs check):本次改动的文档含 ${review.docsCheckHint.invalid}/${review.docsCheckHint.total} 处失效 file:line 引用。`)
141
153
  // 逐条指名(2026-09-11 用户实证:只报计数不指名哪处,四轮复现才定位——门禁输出
@@ -242,6 +254,32 @@ export async function resolveQuickLinkedChanges({ pm, cwd, specDir, quickFiles,
242
254
 
243
255
  // ============ quick --done test+lint 硬门禁(2026-09-02 跨 agent 工单 P0-2)============
244
256
 
257
+ /**
258
+ * 语义护栏命中组装(change: 2026-09-11-cross-change-decision-guard,task-06,FR-04,
259
+ * D-001@v1):断言重写检测 ∩ 近因他者交付归因 → hits 清单,gate 内一行调用拼进返回对象。
260
+ * 单独抽出导出是为了可测性(runQuickTestLintGate 会真跑 test/lint 命令,检测组装层
261
+ * 独立测可避免测试里真跑 npm test——卡内可测性豁免)。
262
+ * - 步骤 0 总开关:readSemanticGuardEnabled false → 跳过检测,hits 恒空(fail-open 同 task-03)
263
+ * - 纯新增断言(detect 不命中)/ 无他者归因标记 / 非测试文件 → 落不到 hits
264
+ * - WARNING 级非阻断:本函数只组装数据,不参与 gate action/failed 判定
265
+ * @param {{ cwd?: string, specBase?: string, files?: string[], changeName?: string|null }} opts
266
+ * @returns {{ hits: Array<{ file: string, deliveredBy: string, sampleLines: string[] }> }}
267
+ */
268
+ export function buildSemanticGuardHits({ cwd, specBase, files, changeName } = {}) {
269
+ if (!readSemanticGuardEnabled(specBase)) return { hits: [] }
270
+ const rewrites = detectAssertionRewrites({ cwd, files: Array.isArray(files) ? files : [] })
271
+ if (Object.keys(rewrites).length === 0) return { hits: [] }
272
+ // 归因只查断言命中文件(省 git log 调用;collectRecentForeignDelivery 内部封顶 20 文件)
273
+ const delivery = collectRecentForeignDelivery({ cwd, files: Object.keys(rewrites), currentChange: changeName })
274
+ const hits = []
275
+ for (const file of Object.keys(rewrites)) {
276
+ // 无归因不点名:纯新增断言 / 无标记提交 / 交付者是本变更(currentChange 透传跳过)同落空
277
+ if (!delivery[file]) continue
278
+ hits.push({ file, deliveredBy: delivery[file], sampleLines: rewrites[file] })
279
+ }
280
+ return { hits }
281
+ }
282
+
245
283
  /**
246
284
  * quick --done 内置 test+lint 实测门禁:把 CLAUDE.md 规则 8「触及 src/test 的改动先
247
285
  * npm test + npm run lint」从 agent 自律下沉为 CLI 卡点。
@@ -265,8 +303,11 @@ export async function resolveQuickLinkedChanges({ pm, cwd, specDir, quickFiles,
265
303
  * @param {string} opts.specBase - .sillyspec 目录(local.yaml 读取源,信任边界见 runVerifyTestCheck SEC-02 注释)
266
304
  * @param {string[]} [opts.changedFiles] - 审计口径的本轮变更文件(仓库根相对 POSIX 路径;空/null → 回退 declaredFiles)
267
305
  * @param {string[]} [opts.declaredFiles] - 会话声明边界(guard.allowedFiles;changedFiles 为空时的兜底判定源)
268
- * @param {string} [opts.changeName] - quick 会话名(evidence-auto 策略解析用)
269
- * @returns {Promise<{action:'pass'|'fail'|'skip', failed:string[], reason:string, test:object|null, lint:object|null}>}
306
+ * @param {string} [opts.changeName] - quick 会话名(evidence-auto 策略解析用 + 语义护栏归因 currentChange 透传)
307
+ * @returns {Promise<{action:'pass'|'fail'|'skip', failed:string[], reason:string, test:object|null, lint:object|null,
308
+ * semanticGuard?:{hits:Array<{file:string,deliveredBy:string,sampleLines:string[]}>}}>}
309
+ * semanticGuard 仅检测跑过(三条早退 skip 路径不跑检测、无字段)时挂载;WARNING 级
310
+ * 非阻断——action/failed 判定不受它影响(调用方 complete-handlers.js 只读 action/failed,零改动)。
270
311
  */
271
312
  export async function runQuickTestLintGate({ cwd, specBase, changedFiles = [], declaredFiles = [], changeName = null }) {
272
313
  if (process.env.SILLYSPEC_QUICK_TEST_GATE === 'skip') {
@@ -284,6 +325,13 @@ export async function runQuickTestLintGate({ cwd, specBase, changedFiles = [], d
284
325
  return { action: 'skip', failed: [], reason: `纯 doc/配置改动(${files.length} 个文件均未触及 src/test,规则 8 语义跳过 test+lint)`, test: null, lint: null }
285
326
  }
286
327
 
328
+ // ── 语义护栏检测(task-06,FR-04 断言重写 WARNING,D-001@v1 非阻断)──
329
+ // 三条早退(env skip / 无文件 / 纯 doc)已在上文 return,检测不跑——语义一致。
330
+ // 检测对主仓 cwd 执行而非 gateCwd 隔离快照(下方才建快照):检测对象是本会话工作树
331
+ // 未提交改动(git diff HEAD 须见它),快照语义是 test/lint 实测隔离——两者不同层
332
+ // (Grill X-001 附注)。specBase 同理用主仓的(快照副本 local.yaml 不参与开关判定)。
333
+ const semanticGuard = buildSemanticGuardHits({ cwd, specBase, files, changeName })
334
+
287
335
  const { runVerifyTestCheck, runVerifyLintCheck } = await import('../verify-postcheck.js')
288
336
 
289
337
  // ── 隔离快照执行(2026-09-10 驾驭小结第六批②,用户实证「lint 实测对账在主仓跑被并行
@@ -301,16 +349,28 @@ export async function runQuickTestLintGate({ cwd, specBase, changedFiles = [], d
301
349
  if (snapshot) {
302
350
  gateCwd = snapshot.snapshotRoot
303
351
  gateSpecBase = join(snapshot.snapshotRoot, '.sillyspec')
304
- console.log(`🧪 门禁隔离快照:HEAD + 本会话 ${snapshot.overlaid} 个文件(并行会话脏文件不参与判定)`)
352
+ console.log(`🧪 门禁隔离快照(根:${snapshot.snapshotRoot}):HEAD + 本会话 ${snapshot.overlaid} 个文件(并行会话脏文件不参与判定)`)
305
353
  }
306
354
  } catch { /* 快照链路异常 → 主仓现行为 */ }
307
355
  }
308
356
  try {
309
357
  const test = runVerifyTestCheck({ cwd: gateCwd, specBase: gateSpecBase, changeName })
310
- const lint = runVerifyLintCheck({ cwd: gateCwd, specBase: gateSpecBase })
358
+ let lint = runVerifyLintCheck({ cwd: gateCwd, specBase: gateSpecBase, timeoutMs: snapshot ? 5 * 60 * 1000 : undefined })
359
+ // 快照 lint 超时回退主仓(2026-09-12 dogfood 两连实证:junction I/O 病态慢,3min/5min 均被
360
+ // 超时杀——主仓 60~110s 正常。按用户建议「自动回退」而非假败/advisory:主仓复跑保硬门,
361
+ // 并行噪声混入时失败输出带归属鉴定提示)
362
+ if (snapshot && lint.status === 'failed' && /超时/.test(String(lint.reason || ''))) {
363
+ console.warn('⚠️ 快照 lint 超时(node_modules junction I/O 慢)→ 主仓复跑 lint(并行噪声可能混入——失败先做归属鉴定)')
364
+ lint = runVerifyLintCheck({ cwd, specBase })
365
+ }
311
366
  const failed = []
312
367
  if (test.status === 'failed') failed.push('test')
313
368
  if (lint.status === 'failed') failed.push('lint')
369
+ if (failed.length > 0 && snapshot) {
370
+ try { const { printSnapshotFailureHint } = await import('./gate-snapshot.js')
371
+ printSnapshotFailureHint({ snapshotRoot: snapshot.snapshotRoot, changeFileCount: snapshot.overlaid, sourceRoot: snapshot.sourceRoot || null }, { offEnv: 'SILLYSPEC_QUICK_GATE_SNAPSHOT_OFF' })
372
+ } catch { /* 提示失败不影响门禁语义 */ }
373
+ }
314
374
  return {
315
375
  action: failed.length > 0 ? 'fail' : 'pass',
316
376
  failed,
@@ -319,6 +379,8 @@ export async function runQuickTestLintGate({ cwd, specBase, changedFiles = [], d
319
379
  : `触及 src/test 共 ${codeFiles.length} 个文件,test+lint 实测通过(或未配置命令自动跳过)${snapshot ? '(隔离快照口径:并行会话脏文件不计入)' : ''}`,
320
380
  test,
321
381
  lint,
382
+ // 语义护栏 WARNING(task-06):只增可选字段,action/failed 判定与调用方读取面均不受影响
383
+ semanticGuard,
322
384
  }
323
385
  } finally {
324
386
  if (snapshot) { try { snapshot.cleanup() } catch { /* 清理失败不连坐门禁结论 */ } }
@@ -350,4 +412,18 @@ export function printQuickTestLintGate(gate) {
350
412
  } else {
351
413
  console.log(`\n✅ quick test+lint 门禁 — PASS(${gate.reason})`)
352
414
  }
415
+
416
+ // 语义护栏 WARNING(task-06 / FR-04,非阻断——D-001@v1:误报校准数据为零先 advisory,
417
+ // 复潮条件:误报率实证后可升 block):他者近因交付的测试文件既有断言行被改 → 点名
418
+ // 文件 + 交付变更 + 被改样例行(上游 detectAssertionRewrites 已封顶 5/文件,渲染侧
419
+ // 再 slice(0,5) 兜底)。hits 空 / 字段缺失(三条早退 skip 路径不跑检测)→ 零输出。
420
+ const sgHits = gate.semanticGuard && Array.isArray(gate.semanticGuard.hits) ? gate.semanticGuard.hits : []
421
+ if (sgHits.length > 0) {
422
+ console.warn(`\n⚠️ 语义护栏 — 断言重写 WARNING(非阻断):他者近因交付的测试文件既有断言被改`)
423
+ for (const h of sgHits) {
424
+ console.warn(` - ${h.file} ← ${h.deliveredBy} 交付`)
425
+ for (const s of (h.sampleLines || []).slice(0, 5)) console.warn(` 被改: ${String(s).trim()}`)
426
+ }
427
+ console.warn(` ➜ 若确需重写:重写理由写进 quicklog --solution(--done 四参数收尾);并复查 ${[...new Set(sgHits.map(h => h.deliveredBy))].join('、')} 的关联决策——勿以改断言重定义绿灯。`)
428
+ }
353
429
  }
package/src/run/shared.js CHANGED
@@ -299,7 +299,7 @@ export function detectWorktreeSpecDrift(specBase) {
299
299
  const seg = resolve(specBase).split(sep)
300
300
  // specBase 恒以 .sillyspec 结尾(command.js specBase=join(cwd,'.sillyspec'))。worktree 副本形如
301
301
  // <mainRepo>/.sillyspec/.runtime/worktrees/<change>/.sillyspec —— 尾段也须是 .sillyspec,
302
- // 否则 worktree 根目录本身(<.../worktrees/<change>)会被误判(它不是 spec,不会作 specBase 传入)。
302
+ // 否则 worktree 根目录本身(<.../worktrees/<change>>)会被误判(它不是 spec,不会作 specBase 传入)。
303
303
  if (seg[seg.length - 1] !== '.sillyspec') return null
304
304
  for (let i = 0; i + 3 < seg.length; i++) {
305
305
  if (seg[i] === '.sillyspec' && seg[i + 1] === '.runtime' && seg[i + 2] === 'worktrees') {
@@ -317,6 +317,33 @@ export function detectWorktreeSpecDrift(specBase) {
317
317
  return null
318
318
  }
319
319
 
320
+ /**
321
+ * 判定「目录在 sillyspec 管理的隔离 worktree 内」(坑 worktree-cwd-silent-split,2026-08-29 用户实证:
322
+ * Bash cwd 残留两次误在 worktree 内跑 sillyspec——第一次走 status 类命令完全静默,进度险些写进
323
+ * worktree 副本 .sillyspec 与主仓进度库分裂)。
324
+ *
325
+ * 与 detectWorktreeSpecDrift 的分工:那边判 specBase(恒以 .sillyspec 结尾,runCommand 内自动
326
+ * 锚定用,只覆盖 plan/execute/verify/archive 四 stage);这边判任意目录(CLI 入口硬拦用,目录
327
+ * 可以是 worktree 根或其任意子目录,无尾段要求)。
328
+ *
329
+ * @param {string} dir 目标目录(通常为 process.cwd() 或 --dir 显式值)
330
+ * @returns {{ changeName: string, mainRepoRoot: string, worktreeRoot: string } | null} null=不在 worktree 内
331
+ */
332
+ export function detectCwdInsideWorktree(dir) {
333
+ if (!dir) return null
334
+ const seg = resolve(dir).split(sep)
335
+ for (let i = 0; i + 3 < seg.length; i++) {
336
+ if (seg[i] === '.sillyspec' && seg[i + 1] === '.runtime' && seg[i + 2] === 'worktrees' && seg[i + 3]) {
337
+ return {
338
+ changeName: seg[i + 3],
339
+ mainRepoRoot: seg.slice(0, i).join(sep),
340
+ worktreeRoot: seg.slice(0, i + 4).join(sep),
341
+ }
342
+ }
343
+ }
344
+ return null
345
+ }
346
+
320
347
  /**
321
348
  * 统一解析 .runtime 根目录(坑 execute-runs-isolation)。
322
349
  *
@@ -791,14 +818,15 @@ export async function triggerSync(cwd, changeName, platformOpts = {}, opts = {})
791
818
  * 与 triggerSync 同款契约:quick 会话降级跳过(无 progress 实体)、未连接平台在
792
819
  * sync() 内部静默跳过、8s 总熔断(raceWithAbort + abort 在飞请求)、失败只 warn 不抛。
793
820
  *
794
- * 接线说明(本变更 allowed_paths 约束):步骤 prompt 渲染点(run/stage.js outputStep
795
- * 前段 / run/prompt.js)不在本变更可改文件集内——本导出即「步骤开始钩子」本体,
796
- * 渲染侧一行 `triggerStepStartSync(cwd, changeName, platformOpts)` 的接线由后续
797
- * 变更补上;execute 阶段的任务粒度信号已由 X4(stages/execute.js Wave prompt
821
+ * 接线说明:已接线——run/prompt.js outputStep 步骤渲染前段 fire-and-forget 调用本钩子
822
+ * (task-13 当时 allowed_paths 不含渲染层,遗留活跃坑由 2026-08-29 docs/sillyspec
823
+ * 定期收口补上);execute 阶段的任务粒度信号另由 X4(stages/execute.js Wave prompt
798
824
  * 每任务上报指引)覆盖。
799
825
  */
800
826
  export async function triggerStepStartSync(cwd, changeName, platformOpts = {}, opts = {}) {
801
827
  try {
828
+ // 无 changeName(渲染层个别路径/测试 fixture)直接静默——sync(null) 只会打 warn 噪音
829
+ if (!changeName) return
802
830
  const tsSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
803
831
  if (changeName && QUICK_SID_RE.test(changeName) && !existsSync(join(tsSpecBase, 'changes', changeName))) {
804
832
  return // quick 会话无 progress 实体(与 triggerSync 同判),无步骤可报
@@ -948,16 +976,20 @@ export function warnApprovalUnknown(cwd, changeName, reason) {
948
976
  /**
949
977
  * 审批检查:execute 阶段启动前检查(W6 Step8a 从 run.js 搬入,runStage + runAutoMode 共用)。
950
978
  * 平台模式走自己的链路,跳过;否则 await import sync.js。
979
+ * 返回语义:null = 平台模式有意跳过;{status:'unknown'} = 检查链路意外失败——调用方走
980
+ * warnApprovalUnknown 横幅 + approval-unknown.log 留痕(HUB-07)。异常折叠成 null 会绕过
981
+ * unknown 留痕静默放行(2026-09-11 审查)。
951
982
  * @returns {{ status: string, reason?: string } | null}
952
- */export async function checkApproval(cwd, changeName, platformOpts = {}) {
983
+ */export async function checkApproval(cwd, changeName, platformOpts = {}, { loadSyncMod = null } = {}) {
953
984
  // 平台模式不需要 CLI 内置审批检查;自指回环(repo-native junction)按本地模式处理
954
985
  if (isPlatformMode(platformOpts, cwd)) return null
986
+ const load = loadSyncMod || (() => import('../sync.js'))
955
987
  try {
956
- // shared.js 在 src/run/,sync.js 在 src/ → 退一层
957
- const syncMod = await import('../sync.js')
988
+ // shared.js 在 src/run/,sync.js 在 src/ → 退一层;loadSyncMod 为测试缝(注入抛错验证 unknown 路由)
989
+ const syncMod = await load()
958
990
  return await syncMod.checkApproval(changeName, cwd)
959
991
  } catch (e) {
960
- return null
992
+ return { status: 'unknown', reason: `审批检查异常: ${e?.message || e}` }
961
993
  }
962
994
  }
963
995
 
@@ -1249,6 +1281,23 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
1249
1281
  // 不对整段 .trim():会削首行前导空格致首文件路径丢首字符(见 parsePorcelainPath 注释)。
1250
1282
  const currentEntries = gitStatus.split('\n').filter(Boolean)
1251
1283
 
1284
+ // EOL-only 噪声过滤(坑 eol-rewrite-fake-mtime-noise,2026-09-12 驾驭第十六批②,用户实证
1285
+ // gen:types 重写行尾制造假 M):git diff --ignore-cr-at-eol HEAD 的变更名单是「真实内容变更」
1286
+ // 集——tracked 修改出现在 porcelain 但不在此集 = 纯行尾重写。单次对照调用,剔出 changedFiles
1287
+ // 归 eolOnlyFiles 软警告(.gitattributes 指引),不再计入文件行/触发各门。
1288
+ // 注意:gitQuiet 不在本模块 import 面(safeGit 才是)——首版用 gitQuiet 触发
1289
+ // ReferenceError 被 catch 吞成 null,又被 new Set(null) 包装成空集 → 全部 tracked 修改
1290
+ // 被误判 EOL-only 剔除(audit-quick-completion 回归抓出)。改用 safeGit(已 import,
1291
+ // 返回 {value,error}),失败显式落 null 透传不过滤。
1292
+ const eolRawList = (() => {
1293
+ try {
1294
+ const res = safeGit(cwd, ['diff', '--name-only', '--ignore-cr-at-eol', 'HEAD'], { timeout: 30000 })
1295
+ if (!res || res.error) return null
1296
+ return String(res.value || '').split('\n').filter(Boolean).map(p => p.replace(/\\/g, '/'))
1297
+ } catch { return null }
1298
+ })()
1299
+ const eolRealChanged = eolRawList === null ? null : new Set(eolRawList)
1300
+
1252
1301
  const normalizeGitPath = (p) => p.replace(/\\/g, '/')
1253
1302
  // step1 启动时记录的全量脏文件 = 预存改动(非本次 quick 产生)。审计必须排除它们,
1254
1303
  // 否则脏工作区下预存文件持续留在 git status → 命中 baselineFiles → 误判「覆盖 baseline」
@@ -1321,6 +1370,14 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
1321
1370
  continue
1322
1371
  }
1323
1372
 
1373
+ // EOL-only(坑 eol-rewrite-fake-mtime-noise):tracked 修改但真实内容零变化(仅行尾重写)
1374
+ // → 剔出审计归属(changedFiles/各门),归 eolOnlyFiles 软警告。untracked(??)不经此径。
1375
+ if (eolRealChanged instanceof Set && status !== '??' && rawStatus[0] !== 'D' && rawStatus[1] !== 'D'
1376
+ && !eolRealChanged.has(file)) {
1377
+ result.eolOnlyFiles = result.eolOnlyFiles || []
1378
+ result.eolOnlyFiles.push(file)
1379
+ continue
1380
+ }
1324
1381
  result.changedFiles.push(file)
1325
1382
  if (rawStatus[0] === 'D' || rawStatus[1] === 'D') result.deletedFiles.push(file)
1326
1383
  if (status === '??') result.newFiles.push(file)
@@ -1357,8 +1414,28 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
1357
1414
  }
1358
1415
 
1359
1416
  // 检查 deleted files(--allow-delete 显式放行:删除是破坏性操作,默认 fail-closed,flag 即知情 opt-in)
1417
+ // spec 共享面降级(坑 foreign-spec-churn-fail-closed,2026-09-12 驾驭第十六批①,用户实证
1418
+ // 并行会话归档移动 docs/sillyspec + changes/ 被拦只能 --allow-delete):.sillyspec/ 与 docs/
1419
+ // 是多会话共享 spec 面——非本会话声明的删除在此面发生时,归因「并行他者 spec 收尾/归档移动」
1420
+ // 降级软警告不阻断(src/test 交付面删除仍 fail-closed——那是本会话可支配域)。
1421
+ const isSpecSharedPath = (p) => {
1422
+ const n = p.replace(/\\/g, '/')
1423
+ return n.startsWith('.sillyspec/') || n.startsWith('docs/')
1424
+ }
1360
1425
  if (!allowDelete) {
1426
+ const stillBlocked = []
1361
1427
  for (const f of result.deletedFiles) {
1428
+ if (isSpecSharedPath(f) && !ownDeclaredNorm.has(f.replace(/\\/g, '/'))) {
1429
+ result.foreignSpecChurn = result.foreignSpecChurn || []
1430
+ result.foreignSpecChurn.push(f)
1431
+ continue
1432
+ }
1433
+ stillBlocked.push(f)
1434
+ }
1435
+ // 整栈退(对齐 linkedChangeLeftovers 模式):归他者的 spec 面删除从 deletedFiles 移除——
1436
+ // 下方 status 判定与 --allow-delete 渲染均不再见它(否则 blocked-with-empty-reasons)
1437
+ result.deletedFiles = stillBlocked
1438
+ for (const f of stillBlocked) {
1362
1439
  result.reasons.push(`删除文件: ${f}`)
1363
1440
  }
1364
1441
  }
@@ -1374,7 +1451,18 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
1374
1451
  if (!allowNew) {
1375
1452
  for (const f of result.newFiles) {
1376
1453
  if (!isQuickMetadata(f, guard.linkedChanges)) {
1377
- result.reasons.push(`新增文件(需 --allow-new): ${f}`)
1454
+ // mtime 归因(坑 foreign-spec-churn-fail-closed 配套,用户建议「按 quick 启动后
1455
+ // mtime 软判定」):早于会话启动 = 预存残留(baseline 漏记或并行会话预存,非本
1456
+ // 会话窗口新建)——reason 标注供裁决,门语义不变(新增本就 warning 档)。
1457
+ let mtimeNote = ''
1458
+ try {
1459
+ const st = statSync(join(cwd, f))
1460
+ const startedAtMs = guard.startedAt ? new Date(guard.startedAt).getTime() : 0
1461
+ if (startedAtMs && st.mtimeMs < startedAtMs) {
1462
+ mtimeNote = '(mtime 早于会话启动——预存残留/并行预存,非本会话新建;--files 声明归属或忽略)'
1463
+ }
1464
+ } catch { /* stat 失败无注记 */ }
1465
+ result.reasons.push(`新增文件(需 --allow-new): ${f}${mtimeNote}`)
1378
1466
  }
1379
1467
  }
1380
1468
  }
@@ -276,7 +276,7 @@ export async function executeVerifyQualityScan({ cwd, specBase, changeName, plat
276
276
  const { createVerifyGateSnapshot } = await import('./gate-snapshot.js')
277
277
  snap = await createVerifyGateSnapshot({ cwd, changeName, specBase, platformOpts })
278
278
  if (snap) {
279
- console.log(`🧪 noAI 扫描隔离快照:HEAD + 本变更 ${snap.changeFileCount} 个文件${snap.sourceRoot ? '(overlay 自 worktree——本变更分支内容定向跑)' : ''}(并行会话脏文件不参与判定)`)
279
+ console.log(`🧪 noAI 扫描隔离快照(根:${snap.snapshotRoot}):HEAD + 本变更 ${snap.changeFileCount} 个文件${snap.sourceRoot ? '(overlay 自 worktree——本变更分支内容定向跑)' : ''}(并行会话脏文件不参与判定)`)
280
280
  }
281
281
  } catch { /* 快照链路异常 → 主仓现行为 */ }
282
282
  }
@@ -292,7 +292,14 @@ export async function executeVerifyQualityScan({ cwd, specBase, changeName, plat
292
292
  try {
293
293
  testCheck = runVerifyTestCheck({ cwd: gateCwd, specBase: gateSpecBase, changeName })
294
294
  printVerifyTestCheck(testCheck)
295
- lintCheck = runVerifyLintCheck({ cwd: gateCwd, specBase: gateSpecBase })
295
+ lintCheck = runVerifyLintCheck({ cwd: gateCwd, specBase: gateSpecBase, timeoutMs: snap ? 5 * 60 * 1000 : undefined })
296
+ // 快照 lint 超时回退主仓(2026-09-12 dogfood 两连实证:junction I/O 病态慢,3min/5min 均被
297
+ // 超时杀——主仓 60~110s 正常。按用户建议「自动回退」而非假败/advisory:主仓复跑保硬门,
298
+ // 并行噪声混入时失败输出带归属鉴定提示)
299
+ if (snap && lintCheck.status === 'failed' && /超时/.test(String(lintCheck.reason || ''))) {
300
+ console.warn('⚠️ 快照 lint 超时(node_modules junction I/O 慢)→ 主仓复跑 lint(并行噪声可能混入——失败先做归属鉴定)')
301
+ lintCheck = runVerifyLintCheck({ cwd, specBase })
302
+ }
296
303
  if (lintCheck.status !== 'skipped') {
297
304
  console.log(`\n⏳ noAI 质量扫描:CLI 亲自实测 commands.lint…`)
298
305
  }
@@ -315,9 +322,13 @@ export async function executeVerifyQualityScan({ cwd, specBase, changeName, plat
315
322
  // lint 失败(SILLYSPEC_VERIFY_LINT_GATE=advisory 时不 throw)同样是摩擦信号,计数不应随
316
323
  // 逃生阀漂移。recordFrictionEvent 自带静默降级,不影响本步推进/throw 语义。
317
324
  if (testFailed || lintCheck.status === 'failed') {
318
- recordFrictionEvent({ cwd, changeName, platformOpts, type: 'verify_run_failed', detail: testFailed ? 'test' : 'lint' })
325
+ await recordFrictionEvent({ cwd, changeName, platformOpts, type: 'verify_run_failed', detail: testFailed ? 'test' : 'lint' })
319
326
  }
320
327
  if (testFailed || lintBlocked) {
328
+ if (snap) {
329
+ try { const { printSnapshotFailureHint } = await import('./gate-snapshot.js')
330
+ printSnapshotFailureHint(snap) } catch { /* 提示失败不影响阻断 */ }
331
+ }
321
332
  const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
322
333
  await renderVerifyTestAttribution({ cwd, changeName, specBase, runtimeRoot })
323
334
  const which = testFailed ? '测试' : 'lint'