sillyspec 3.28.7 → 3.28.9

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 (79) hide show
  1. package/.claude/skills/sillyspec-quick/SKILL.md +2 -0
  2. package/.husky/pre-push +3 -1
  3. package/AGENTS.md +3 -2
  4. package/README.md +35 -11
  5. package/SKILL.md +24 -10
  6. package/SillySpec-/350/203/275/345/212/233/344/272/256/347/202/271/345/205/250/346/231/257-2026-09-14.pptx +0 -0
  7. package/package.json +1 -1
  8. package/src/agent-session-log.js +446 -35
  9. package/src/change-risk-profile.js +20 -0
  10. package/src/config-cat.js +6 -3
  11. package/src/config-schema.js +32 -0
  12. package/src/constants.js +3 -0
  13. package/src/db.js +23 -6
  14. package/src/decision-distill.js +15 -3
  15. package/src/design-facts.js +33 -3
  16. package/src/docs-check.js +137 -12
  17. package/src/docs-gate.js +108 -6
  18. package/src/doctor-diagnostics.js +243 -9
  19. package/src/friction-ledger.js +171 -0
  20. package/src/friction-tally.js +7 -2
  21. package/src/git-helper.js +6 -4
  22. package/src/hooks/worktree-guard.js +72 -30
  23. package/src/index.js +356 -81
  24. package/src/knowledge-classify.js +363 -0
  25. package/src/knowledge-hits.js +74 -0
  26. package/src/knowledge-stats.js +180 -0
  27. package/src/local-detect.js +30 -15
  28. package/src/local-register.js +13 -4
  29. package/src/mcp-server.js +38 -8
  30. package/src/progress/change-registry.js +224 -43
  31. package/src/progress/consistency-doctor.js +11 -4
  32. package/src/progress/shared.js +3 -2
  33. package/src/progress/stage-machine.js +3 -1
  34. package/src/progress/step-store.js +30 -17
  35. package/src/progress.js +123 -8
  36. package/src/quick-gate-profile.js +251 -0
  37. package/src/quicklog.js +199 -32
  38. package/src/review-dispatch.js +2 -1
  39. package/src/run/command.js +124 -15
  40. package/src/run/complete-handlers.js +514 -24
  41. package/src/run/complete.js +82 -25
  42. package/src/run/concurrent-detect.js +99 -1
  43. package/src/run/gate-snapshot.js +115 -5
  44. package/src/run/gates.js +85 -14
  45. package/src/run/prompt.js +143 -6
  46. package/src/run/quick-audit.js +136 -15
  47. package/src/run/shared.js +174 -20
  48. package/src/run/stage.js +6 -0
  49. package/src/run/verify-quality-scan.js +14 -3
  50. package/src/scan-diff.js +61 -30
  51. package/src/scan-postcheck.js +74 -0
  52. package/src/scan-refresh.js +477 -0
  53. package/src/scan-staleness.js +25 -13
  54. package/src/scope-audit.js +365 -35
  55. package/src/spec-sync.js +19 -14
  56. package/src/stage-contract-spec.js +4 -3
  57. package/src/stage-contract.js +185 -2
  58. package/src/stage-review.js +29 -12
  59. package/src/stages/brainstorm.js +7 -1
  60. package/src/stages/execute.js +91 -4
  61. package/src/stages/knowledge.js +52 -3
  62. package/src/stages/plan-postcheck.js +57 -12
  63. package/src/stages/quick.js +1 -1
  64. package/src/stages/verify.js +1 -0
  65. package/src/sync-noise.js +114 -6
  66. package/src/sync.js +201 -11
  67. package/src/task-review.js +333 -27
  68. package/src/verify-facts-schema.js +10 -3
  69. package/src/verify-postcheck.js +41 -6
  70. package/src/verify-probes.js +348 -7
  71. package/src/worktree-apply.js +594 -62
  72. package/src/worktree-cross.js +12 -18
  73. package/src/worktree-deps.js +100 -2
  74. package/src/worktree.js +255 -30
  75. package/src/wt-commit.js +128 -0
  76. package/templates/agents-instruction.md +2 -2
  77. package/templates/prompts/testcase-design.md +2 -1
  78. package/templates/prompts/verify-probes.md +4 -1
  79. package/sa-rename-probe/src/new.js +0 -4
package/src/run/shared.js CHANGED
@@ -20,6 +20,9 @@ import os from 'node:os'
20
20
  // unquoteGitPath 同理:parsePorcelainPath 内部消费 + re-export。
21
21
  import { safeGit, unquoteGitPath } from '../git-helper.js'
22
22
  import { createHash } from 'node:crypto'
23
+ // 分级门禁画像信号层(2026-09-14-quick-exit-tiered-gates task-01 / FR-02):纯函数零 IO,
24
+ // 仅依赖 change-risk-profile,无环——auditQuickCompletion 静态 import 安全(task-02 接线)。
25
+ import { computeGateProfile, resolveGateThresholds } from '../quick-gate-profile.js'
23
26
  export { safeGit, unquoteGitPath }
24
27
 
25
28
  const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -299,7 +302,7 @@ export function detectWorktreeSpecDrift(specBase) {
299
302
  const seg = resolve(specBase).split(sep)
300
303
  // specBase 恒以 .sillyspec 结尾(command.js specBase=join(cwd,'.sillyspec'))。worktree 副本形如
301
304
  // <mainRepo>/.sillyspec/.runtime/worktrees/<change>/.sillyspec —— 尾段也须是 .sillyspec,
302
- // 否则 worktree 根目录本身(<.../worktrees/<change>)会被误判(它不是 spec,不会作 specBase 传入)。
305
+ // 否则 worktree 根目录本身(<.../worktrees/<change>>)会被误判(它不是 spec,不会作 specBase 传入)。
303
306
  if (seg[seg.length - 1] !== '.sillyspec') return null
304
307
  for (let i = 0; i + 3 < seg.length; i++) {
305
308
  if (seg[i] === '.sillyspec' && seg[i + 1] === '.runtime' && seg[i + 2] === 'worktrees') {
@@ -317,6 +320,33 @@ export function detectWorktreeSpecDrift(specBase) {
317
320
  return null
318
321
  }
319
322
 
323
+ /**
324
+ * 判定「目录在 sillyspec 管理的隔离 worktree 内」(坑 worktree-cwd-silent-split,2026-08-29 用户实证:
325
+ * Bash cwd 残留两次误在 worktree 内跑 sillyspec——第一次走 status 类命令完全静默,进度险些写进
326
+ * worktree 副本 .sillyspec 与主仓进度库分裂)。
327
+ *
328
+ * 与 detectWorktreeSpecDrift 的分工:那边判 specBase(恒以 .sillyspec 结尾,runCommand 内自动
329
+ * 锚定用,只覆盖 plan/execute/verify/archive 四 stage);这边判任意目录(CLI 入口硬拦用,目录
330
+ * 可以是 worktree 根或其任意子目录,无尾段要求)。
331
+ *
332
+ * @param {string} dir 目标目录(通常为 process.cwd() 或 --dir 显式值)
333
+ * @returns {{ changeName: string, mainRepoRoot: string, worktreeRoot: string } | null} null=不在 worktree 内
334
+ */
335
+ export function detectCwdInsideWorktree(dir) {
336
+ if (!dir) return null
337
+ const seg = resolve(dir).split(sep)
338
+ for (let i = 0; i + 3 < seg.length; i++) {
339
+ if (seg[i] === '.sillyspec' && seg[i + 1] === '.runtime' && seg[i + 2] === 'worktrees' && seg[i + 3]) {
340
+ return {
341
+ changeName: seg[i + 3],
342
+ mainRepoRoot: seg.slice(0, i).join(sep),
343
+ worktreeRoot: seg.slice(0, i + 4).join(sep),
344
+ }
345
+ }
346
+ }
347
+ return null
348
+ }
349
+
320
350
  /**
321
351
  * 统一解析 .runtime 根目录(坑 execute-runs-isolation)。
322
352
  *
@@ -791,14 +821,15 @@ export async function triggerSync(cwd, changeName, platformOpts = {}, opts = {})
791
821
  * 与 triggerSync 同款契约:quick 会话降级跳过(无 progress 实体)、未连接平台在
792
822
  * sync() 内部静默跳过、8s 总熔断(raceWithAbort + abort 在飞请求)、失败只 warn 不抛。
793
823
  *
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
824
+ * 接线说明:已接线——run/prompt.js outputStep 步骤渲染前段 fire-and-forget 调用本钩子
825
+ * (task-13 当时 allowed_paths 不含渲染层,遗留活跃坑由 2026-08-29 docs/sillyspec
826
+ * 定期收口补上);execute 阶段的任务粒度信号另由 X4(stages/execute.js Wave prompt
798
827
  * 每任务上报指引)覆盖。
799
828
  */
800
829
  export async function triggerStepStartSync(cwd, changeName, platformOpts = {}, opts = {}) {
801
830
  try {
831
+ // 无 changeName(渲染层个别路径/测试 fixture)直接静默——sync(null) 只会打 warn 噪音
832
+ if (!changeName) return
802
833
  const tsSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
803
834
  if (changeName && QUICK_SID_RE.test(changeName) && !existsSync(join(tsSpecBase, 'changes', changeName))) {
804
835
  return // quick 会话无 progress 实体(与 triggerSync 同判),无步骤可报
@@ -948,16 +979,20 @@ export function warnApprovalUnknown(cwd, changeName, reason) {
948
979
  /**
949
980
  * 审批检查:execute 阶段启动前检查(W6 Step8a 从 run.js 搬入,runStage + runAutoMode 共用)。
950
981
  * 平台模式走自己的链路,跳过;否则 await import sync.js。
982
+ * 返回语义:null = 平台模式有意跳过;{status:'unknown'} = 检查链路意外失败——调用方走
983
+ * warnApprovalUnknown 横幅 + approval-unknown.log 留痕(HUB-07)。异常折叠成 null 会绕过
984
+ * unknown 留痕静默放行(2026-09-11 审查)。
951
985
  * @returns {{ status: string, reason?: string } | null}
952
- */export async function checkApproval(cwd, changeName, platformOpts = {}) {
986
+ */export async function checkApproval(cwd, changeName, platformOpts = {}, { loadSyncMod = null } = {}) {
953
987
  // 平台模式不需要 CLI 内置审批检查;自指回环(repo-native junction)按本地模式处理
954
988
  if (isPlatformMode(platformOpts, cwd)) return null
989
+ const load = loadSyncMod || (() => import('../sync.js'))
955
990
  try {
956
- // shared.js 在 src/run/,sync.js 在 src/ → 退一层
957
- const syncMod = await import('../sync.js')
991
+ // shared.js 在 src/run/,sync.js 在 src/ → 退一层;loadSyncMod 为测试缝(注入抛错验证 unknown 路由)
992
+ const syncMod = await load()
958
993
  return await syncMod.checkApproval(changeName, cwd)
959
994
  } catch (e) {
960
- return null
995
+ return { status: 'unknown', reason: `审批检查异常: ${e?.message || e}` }
961
996
  }
962
997
  }
963
998
 
@@ -1119,7 +1154,7 @@ export function matchSameModuleTestFiles(declaredFiles, undeclaredFiles) {
1119
1154
 
1120
1155
  /**
1121
1156
  * quick 完成审计:对比 baseline 与实际变更。
1122
- * @returns {{ status: 'safe'|'warning'|'blocked', reasons: string[], changedFiles: string[], newFiles: string[], deletedFiles: string[], baselineHit: string[], stagedTotal: number, attributedFiles: string[], undeclaredFiles: string[], softTestFiles: string[], foreignSessionDeclared: Array<{file: string, sessions: string[]}> }}
1157
+ * @returns {{ status: 'safe'|'warning'|'blocked', reasons: string[], changedFiles: string[], newFiles: string[], deletedFiles: string[], baselineHit: string[], stagedTotal: number, attributedFiles: string[], undeclaredFiles: string[], softTestFiles: string[], foreignSessionDeclared: Array<{file: string, sessions: string[]}>, gateProfile: object|null }}
1123
1158
  */
1124
1159
  /**
1125
1160
  * D-8 O-1 模块归属(2026-08-15 docs-signals-o12):quick 欠账 hint 从"改了 N 文件"升级为
@@ -1131,13 +1166,8 @@ export function matchSameModuleTestFiles(declaredFiles, undeclaredFiles) {
1131
1166
  async function matchQuickModules(srcChanged, specBase, projectName) {
1132
1167
  if (!specBase || !projectName || srcChanged.length === 0) return []
1133
1168
  try {
1134
- const { join } = await import('node:path')
1135
- const { existsSync: ex, readFileSync: rf } = await import('node:fs')
1136
- const mapPath = join(specBase, 'docs', projectName, 'modules', '_module-map.yaml')
1137
- if (!ex(mapPath)) return []
1138
- const { parseModuleMapSimple } = await import('../modules.js')
1139
- const idx = parseModuleMapSimple(rf(mapPath, 'utf8'))
1140
- if (!idx || Object.keys(idx).length === 0) return []
1169
+ const idx = await loadQuickModuleIndex(specBase, projectName)
1170
+ if (!idx) return []
1141
1171
  const { matchFilesToModules } = await import('../docs-debt.js')
1142
1172
  const cardsDir = join(specBase, 'docs', projectName, 'modules')
1143
1173
  const { byModule } = matchFilesToModules(srcChanged, idx, { cardsDir })
@@ -1147,6 +1177,30 @@ async function matchQuickModules(srcChanged, specBase, projectName) {
1147
1177
  }
1148
1178
  }
1149
1179
 
1180
+ /**
1181
+ * quick 审计链的 _module-map.yaml 原始索引加载(2026-09-14-quick-exit-tiered-gates task-02 抽取):
1182
+ * D-8 模块归属(matchQuickModules)与门禁画像(auditQuickCompletion 挂 review.gateProfile)的
1183
+ * 同源单一加载点——路径 <specBase>/docs/<projectName>/modules/_module-map.yaml,解析
1184
+ * parseModuleMapSimple(canonical)。缺失/解析空/specBase 缺参/异常 → null(画像走 degraded
1185
+ * 降级档:span 退出判级,兼容策略第 1 条——未 scan/无 module-map 的存量项目零强制变化)。
1186
+ * join/existsSync/readFileSync 用本模块顶部静态 import(auditQuickCompletion 既有同款)。
1187
+ * @param {string} specBase .sillyspec 目录(guard 透传)
1188
+ * @param {string} projectName 项目名(progress.project)
1189
+ * @returns {Promise<object|null>} 模块索引(扁平 { id: {...} });不可得 → null
1190
+ */
1191
+ async function loadQuickModuleIndex(specBase, projectName) {
1192
+ if (!specBase || !projectName) return null
1193
+ try {
1194
+ const mapPath = join(specBase, 'docs', projectName, 'modules', '_module-map.yaml')
1195
+ if (!existsSync(mapPath)) return null
1196
+ const { parseModuleMapSimple } = await import('../modules.js')
1197
+ const idx = parseModuleMapSimple(readFileSync(mapPath, 'utf8'))
1198
+ return idx && Object.keys(idx).length > 0 ? idx : null
1199
+ } catch {
1200
+ return null
1201
+ }
1202
+ }
1203
+
1150
1204
  // ── task-03(2026-08-16-state-split-fixes): 活文档漂移提示辅助 ──
1151
1205
  // 背景:platform-interface-map.md 等活文档持续维护 src 主入口的 file:line 映射;并行会话改
1152
1206
  // src/ 后引用行号静默失效,docs gate 只能事后拦到别人的流程上。审计(auditQuickCompletion)
@@ -1225,11 +1279,14 @@ export function matchInvalidRefsToChanged(invalidRefs, srcChangedFiles) {
1225
1279
 
1226
1280
  export async function auditQuickCompletion(cwd, guard, options = {}) {
1227
1281
  const { baselineFiles, allowedFiles = [], allowNew = false, forceBaseline = false, allowDelete = false, specBase = null, projectName = null } = guard
1228
- const { isConfirm } = options
1282
+ // noDocs/fileNotes(task-02):--no-docs 显式豁免与 --file-notes 解析结果经 options 进审计链
1283
+ // (command.js knownFlags 登记 → complete.js → complete-handlers 透传;scope-audit 重放不传 →
1284
+ // noDocs/fileNotes 缺省的纯默认画像)。
1285
+ const { isConfirm, noDocs = false, fileNotes = null } = options
1229
1286
  // stagedTotal:当前所有非 quick 元数据的未提交条目(含前序 baseline 残留)。
1230
1287
  // 与 changedFiles(扣 baseline 后的本轮新增)区分,供审计文案同时展示「本轮新增 vs 累计暂存」,
1231
1288
  // 避免叠加 quick 会话时把前序会话未提交文件误读为「本会话只动了 N 个」。
1232
- const result = { status: 'safe', reasons: [], changedFiles: [], newFiles: [], deletedFiles: [], baselineHit: [], stagedTotal: 0, attributedFiles: [], undeclaredFiles: [], softTestFiles: [], foreignSessionDeclared: [] }
1289
+ const result = { status: 'safe', reasons: [], changedFiles: [], newFiles: [], deletedFiles: [], baselineHit: [], stagedTotal: 0, attributedFiles: [], undeclaredFiles: [], softTestFiles: [], foreignSessionDeclared: [], gateProfile: null }
1233
1290
 
1234
1291
  try {
1235
1292
  // safeGit 带 -c safe.directory,避免 linked worktree/容器异 uid/挂载点下裸 `git status` 抛错被
@@ -1249,6 +1306,23 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
1249
1306
  // 不对整段 .trim():会削首行前导空格致首文件路径丢首字符(见 parsePorcelainPath 注释)。
1250
1307
  const currentEntries = gitStatus.split('\n').filter(Boolean)
1251
1308
 
1309
+ // EOL-only 噪声过滤(坑 eol-rewrite-fake-mtime-noise,2026-09-12 驾驭第十六批②,用户实证
1310
+ // gen:types 重写行尾制造假 M):git diff --ignore-cr-at-eol HEAD 的变更名单是「真实内容变更」
1311
+ // 集——tracked 修改出现在 porcelain 但不在此集 = 纯行尾重写。单次对照调用,剔出 changedFiles
1312
+ // 归 eolOnlyFiles 软警告(.gitattributes 指引),不再计入文件行/触发各门。
1313
+ // 注意:gitQuiet 不在本模块 import 面(safeGit 才是)——首版用 gitQuiet 触发
1314
+ // ReferenceError 被 catch 吞成 null,又被 new Set(null) 包装成空集 → 全部 tracked 修改
1315
+ // 被误判 EOL-only 剔除(audit-quick-completion 回归抓出)。改用 safeGit(已 import,
1316
+ // 返回 {value,error}),失败显式落 null 透传不过滤。
1317
+ const eolRawList = (() => {
1318
+ try {
1319
+ const res = safeGit(cwd, ['diff', '--name-only', '--ignore-cr-at-eol', 'HEAD'], { timeout: 30000 })
1320
+ if (!res || res.error) return null
1321
+ return String(res.value || '').split('\n').filter(Boolean).map(p => p.replace(/\\/g, '/'))
1322
+ } catch { return null }
1323
+ })()
1324
+ const eolRealChanged = eolRawList === null ? null : new Set(eolRawList)
1325
+
1252
1326
  const normalizeGitPath = (p) => p.replace(/\\/g, '/')
1253
1327
  // step1 启动时记录的全量脏文件 = 预存改动(非本次 quick 产生)。审计必须排除它们,
1254
1328
  // 否则脏工作区下预存文件持续留在 git status → 命中 baselineFiles → 误判「覆盖 baseline」
@@ -1321,6 +1395,14 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
1321
1395
  continue
1322
1396
  }
1323
1397
 
1398
+ // EOL-only(坑 eol-rewrite-fake-mtime-noise):tracked 修改但真实内容零变化(仅行尾重写)
1399
+ // → 剔出审计归属(changedFiles/各门),归 eolOnlyFiles 软警告。untracked(??)不经此径。
1400
+ if (eolRealChanged instanceof Set && status !== '??' && rawStatus[0] !== 'D' && rawStatus[1] !== 'D'
1401
+ && !eolRealChanged.has(file)) {
1402
+ result.eolOnlyFiles = result.eolOnlyFiles || []
1403
+ result.eolOnlyFiles.push(file)
1404
+ continue
1405
+ }
1324
1406
  result.changedFiles.push(file)
1325
1407
  if (rawStatus[0] === 'D' || rawStatus[1] === 'D') result.deletedFiles.push(file)
1326
1408
  if (status === '??') result.newFiles.push(file)
@@ -1357,8 +1439,28 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
1357
1439
  }
1358
1440
 
1359
1441
  // 检查 deleted files(--allow-delete 显式放行:删除是破坏性操作,默认 fail-closed,flag 即知情 opt-in)
1442
+ // spec 共享面降级(坑 foreign-spec-churn-fail-closed,2026-09-12 驾驭第十六批①,用户实证
1443
+ // 并行会话归档移动 docs/sillyspec + changes/ 被拦只能 --allow-delete):.sillyspec/ 与 docs/
1444
+ // 是多会话共享 spec 面——非本会话声明的删除在此面发生时,归因「并行他者 spec 收尾/归档移动」
1445
+ // 降级软警告不阻断(src/test 交付面删除仍 fail-closed——那是本会话可支配域)。
1446
+ const isSpecSharedPath = (p) => {
1447
+ const n = p.replace(/\\/g, '/')
1448
+ return n.startsWith('.sillyspec/') || n.startsWith('docs/')
1449
+ }
1360
1450
  if (!allowDelete) {
1451
+ const stillBlocked = []
1361
1452
  for (const f of result.deletedFiles) {
1453
+ if (isSpecSharedPath(f) && !ownDeclaredNorm.has(f.replace(/\\/g, '/'))) {
1454
+ result.foreignSpecChurn = result.foreignSpecChurn || []
1455
+ result.foreignSpecChurn.push(f)
1456
+ continue
1457
+ }
1458
+ stillBlocked.push(f)
1459
+ }
1460
+ // 整栈退(对齐 linkedChangeLeftovers 模式):归他者的 spec 面删除从 deletedFiles 移除——
1461
+ // 下方 status 判定与 --allow-delete 渲染均不再见它(否则 blocked-with-empty-reasons)
1462
+ result.deletedFiles = stillBlocked
1463
+ for (const f of stillBlocked) {
1362
1464
  result.reasons.push(`删除文件: ${f}`)
1363
1465
  }
1364
1466
  }
@@ -1374,7 +1476,18 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
1374
1476
  if (!allowNew) {
1375
1477
  for (const f of result.newFiles) {
1376
1478
  if (!isQuickMetadata(f, guard.linkedChanges)) {
1377
- result.reasons.push(`新增文件(需 --allow-new): ${f}`)
1479
+ // mtime 归因(坑 foreign-spec-churn-fail-closed 配套,用户建议「按 quick 启动后
1480
+ // mtime 软判定」):早于会话启动 = 预存残留(baseline 漏记或并行会话预存,非本
1481
+ // 会话窗口新建)——reason 标注供裁决,门语义不变(新增本就 warning 档)。
1482
+ let mtimeNote = ''
1483
+ try {
1484
+ const st = statSync(join(cwd, f))
1485
+ const startedAtMs = guard.startedAt ? new Date(guard.startedAt).getTime() : 0
1486
+ if (startedAtMs && st.mtimeMs < startedAtMs) {
1487
+ mtimeNote = '(mtime 早于会话启动——预存残留/并行预存,非本会话新建;--files 声明归属或忽略)'
1488
+ }
1489
+ } catch { /* stat 失败无注记 */ }
1490
+ result.reasons.push(`新增文件(需 --allow-new): ${f}${mtimeNote}`)
1378
1491
  }
1379
1492
  }
1380
1493
  }
@@ -1551,6 +1664,47 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
1551
1664
  result.softTestFiles = []
1552
1665
  }
1553
1666
 
1667
+ // ── 分级门禁画像(FR-03,2026-09-14-quick-exit-tiered-gates task-02):照 docSyncHint 先例
1668
+ // 挂 review.gateProfile(audit result 即 printQuickAuditReview 的 review / progress.lastQuickReview
1669
+ // / scope-audit 重放的 audit),全部 advisory——不改 status 三态 / reasons / exit code(D-003),
1670
+ // fail-open:任何异常只跳过(内层 try/catch,绝不落外层 catch 的 warning 语义)。
1671
+ // 输入纪律:
1672
+ // - changedFiles = 本函数已算的 git 事实窗口(非 --files 自声明);先经 isQuicklogFileLineNoise
1673
+ // 滤会话簿记(quicklog/.runtime/_module-map 等)但保留模块卡与 changelog sidecar——模块卡恰是
1674
+ // L2 docClaim 的认领对象,用 isQuickMetadata 会把 claimed 判死成 missing(记录面谓词分叉);
1675
+ // - moduleIndex 复用 matchQuickModules 同源加载(loadQuickModuleIndex,单点);
1676
+ // - 阈值经 resolveGateThresholds 合并 local.yaml quick-gate 段覆写(读取走 readLocalYamlRaw +
1677
+ // js-yaml 动态 import,与 resolveLivingDocs 同款链路;未配置/坏 YAML → 纯默认,D-009);
1678
+ // - noDocs/fileNotes 由 options 透传(--no-docs 豁免 / --file-notes 覆盖率)。
1679
+ // 不加 git 子进程(约束:画像纯字符串运算,R-04);未声明脏文件维持上方归属分流不并入
1680
+ // docClaim 判定(D-005)——级判(span/files/risk)仍用 git 事实全窗口(防 37.1% 未声明面
1681
+ // 漏判级),docClaim 单独用归属口径重算覆盖(他者窗口内改模块卡不得伪造成「已认领」)。
1682
+ try {
1683
+ let thresholds = null
1684
+ const rawLocalYaml = readLocalYamlRaw(cwd)
1685
+ if (rawLocalYaml) {
1686
+ try {
1687
+ const mod = await import('js-yaml')
1688
+ const yamlLoad = mod.load || mod.default?.load
1689
+ const cfg = yamlLoad(rawLocalYaml)
1690
+ if (cfg && typeof cfg === 'object') thresholds = resolveGateThresholds(cfg)
1691
+ } catch { /* 坏 YAML → 纯默认阈值 */ }
1692
+ }
1693
+ const gateFiles = result.changedFiles.filter(f => !isQuicklogFileLineNoise(f, guard.linkedChanges))
1694
+ const gateOpts = {
1695
+ ...(thresholds ? { thresholds } : {}),
1696
+ ...(noDocs ? { noDocs: true } : {}),
1697
+ ...(Array.isArray(fileNotes) && fileNotes.length > 0 ? { fileNotes } : {}),
1698
+ }
1699
+ const moduleIndex = await loadQuickModuleIndex(specBase, projectName)
1700
+ result.gateProfile = computeGateProfile(gateFiles, moduleIndex, gateOpts)
1701
+ if (result.undeclaredFiles.length > 0) {
1702
+ const undeclaredNorm = new Set(result.undeclaredFiles.map(f => String(f).replace(/\\/g, '/')))
1703
+ const attrGateFiles = gateFiles.filter(f => !undeclaredNorm.has(String(f).replace(/\\/g, '/')))
1704
+ result.gateProfile.checks.docClaim = computeGateProfile(attrGateFiles, moduleIndex, gateOpts).checks.docClaim
1705
+ }
1706
+ } catch { /* 画像 fail-open:异常只跳过(gateProfile 保持 null,D-003 不碰 status) */ }
1707
+
1554
1708
  // --confirm 模式:展示 diff 并等待确认
1555
1709
  if (isConfirm && (result.status === 'warning' || result.status === 'blocked')) {
1556
1710
  console.log(`\n📋 quick 变更概览:`)
package/src/run/stage.js CHANGED
@@ -440,6 +440,9 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
440
440
  description: quickDesc,
441
441
  linkedChanges,
442
442
  allowedFiles,
443
+ // 他者会话 guard 预留让位(坑 ql-id-double-occupancy):与 guard 落盘同源目录
444
+ // (Q4 单一解析,平台模式 runtimeRoot 分裂时也对齐)
445
+ sessionsDir: resolveQuickSessionsDir(platformOpts, specBase),
443
446
  })
444
447
  progress.quickGuard = {
445
448
  sessionId: changeName,
@@ -461,6 +464,9 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
461
464
  allowDelete,
462
465
  forceBaseline,
463
466
  linkedChanges,
467
+ // 机器自动关联溯源(坑 quick-single-change-auto-link):--done 归档闸对仅被自动
468
+ // 关联(信号命中猜测,非 --linked-changes 显式协作声明)的变更不触发轻量归档
469
+ linkedChangesAuto: Array.isArray(quickOpts?.linkedChangesAuto) ? quickOpts.linkedChangesAuto : [],
464
470
  quicklogId: qlId,
465
471
  // 刀①(2026-09-08):启动 --input 的任务描述落 guard——prompt.js 渲染 step1 时据此做
466
472
  // 模块上下文关键词匹配(changeName 是 quick-<hash> 无语义,step.prompt 全文噪音大)
@@ -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'
package/src/scan-diff.js CHANGED
@@ -60,7 +60,7 @@ export function computeScanDiff({ projectRoot, specBase, projectName, base } = {
60
60
 
61
61
  // ── 基线:缺省读 specBase/docs/<projectName>/scan/*.md frontmatter 的 source_commit ──
62
62
  if (!base) {
63
- const sourceCommit = readSourceCommit(specBase, projectName)
63
+ const sourceCommit = readSourceCommit(specBase, projectName, projectRoot)
64
64
  if (!sourceCommit) {
65
65
  return {
66
66
  ok: false, error:
@@ -169,34 +169,20 @@ export function computeScanDiff({ projectRoot, specBase, projectName, base } = {
169
169
  // 变更集取 parseNameStatus 全量——**不经 module-map scope 过滤**(引用自带范围:指向「已变更
170
170
  // 但范围外」文件的引用同样过时,advisory 信号不该被 scope 吞掉);rename 的旧路径也入集
171
171
  //(文档写在 scan 时点,引用的是旧路径,重命名后引用必失效——最强失效信号)。
172
+ // 命中逻辑抽为导出 collectStaleRefs(2026-09-14-scan-incremental-refresh D-003@v2:scan-refresh
173
+ // 复用同一单源,防 A/D/M/R 标注与 src/ 前缀归一规则双源漂移)。
172
174
  // best-effort(读不到文档/异常 → 空清单不阻断文件级结果),不计入 driftCount / 退出码
173
175
  //(advisory:引用指向已变更文件 ≠ 引用失效,提示人工核对)。──
174
176
  const staleRefs = []
175
177
  try {
176
178
  const scanDir = join(specBase, 'docs', projectName, 'scan')
177
- if (existsSync(scanDir)) {
178
- const fullChanged = new Map() // POSIX path → A/D/M/R
179
- for (const item of parseNameStatus(diffRes.value)) {
180
- if (item.status === 'A' || item.status === 'D') fullChanged.set(item.path, item.status)
181
- else if (item.status === 'M' || item.status === 'C') fullChanged.set(item.path, 'M')
182
- else if (item.status === 'R') { fullChanged.set(item.path, 'R'); fullChanged.set(item.oldPath, 'R') }
183
- }
184
- const hitChange = (p) => {
185
- if (fullChanged.has(p)) return { file: p, change: fullChanged.get(p) }
186
- // src/ 前缀双向归一:文档 src 相对写法 ↔ git 仓库根路径
187
- const alt = p.startsWith('src/') ? p.slice(4) : `src/${p}`
188
- return fullChanged.has(alt) ? { file: alt, change: fullChanged.get(alt) } : null
189
- }
190
- for (const f of readdirSync(scanDir)) {
191
- if (!f.endsWith('.md')) continue
192
- const content = readFileSync(join(scanDir, f), 'utf8')
193
- for (const ref of collectDocRefs(content)) {
194
- if (ref.repo !== null) continue
195
- const hit = hitChange(ref.file.replace(/\\/g, '/'))
196
- if (hit) staleRefs.push({ doc: f, ref: ref.ref, change: hit.change, file: hit.file })
197
- }
198
- }
179
+ const fullChanged = new Map() // POSIX path → A/D/M/R
180
+ for (const item of parseNameStatus(diffRes.value)) {
181
+ if (item.status === 'A' || item.status === 'D') fullChanged.set(item.path, item.status)
182
+ else if (item.status === 'M' || item.status === 'C') fullChanged.set(item.path, 'M')
183
+ else if (item.status === 'R') { fullChanged.set(item.path, 'R'); fullChanged.set(item.oldPath, 'R') }
199
184
  }
185
+ staleRefs.push(...collectStaleRefs(scanDir, fullChanged))
200
186
  } catch { /* 引用级漂移为 advisory,异常静默降级 */ }
201
187
 
202
188
  return {
@@ -380,18 +366,62 @@ function writeReport(r, { specBase, projectName }) {
380
366
  }
381
367
  }
382
368
 
383
- /** 读 scan 目录任一 .md 的 source_commit(同 modules.js 写入同值,取首个命中;全失败 → null) */
384
- function readSourceCommit(specBase, projectName) {
369
+ /**
370
+ * 读 scan 目录全部 .md 的 source_commit,多文档异基线保守聚合(D-003@v2/D-009@v1)。
371
+ * 聚合=落后最多:去重基线逐个 `rev-list --count <c>..HEAD` 取计数最大者——拓扑序免疫
372
+ * rebase/amend 日期倒挂,且落后最多=漂移窗最大=保守目标本体。git 全失败 fail-soft 回退
373
+ * 首个命中(与旧版批次同值场景输出一致)。
374
+ * @param {string} specBase @param {string} projectName @param {string} [projectRoot] git 锚(计数用;缺省不聚合)
375
+ * @returns {string|null}
376
+ */
377
+ function readSourceCommit(specBase, projectName, projectRoot) {
385
378
  const scanDir = join(specBase, 'docs', projectName, 'scan')
386
379
  if (!existsSync(scanDir)) return null
380
+ const commits = []
387
381
  try {
388
382
  for (const f of readdirSync(scanDir)) {
389
383
  if (!f.endsWith('.md')) continue
390
384
  const c = parseSourceCommit(readFileSync(join(scanDir, f), 'utf8'))
391
- if (c) return c
385
+ if (c) commits.push(c)
386
+ }
387
+ } catch { /* 读失败 → 按空清单处理 */ }
388
+ if (commits.length === 0) return null
389
+ const distinct = [...new Set(commits)]
390
+ if (distinct.length === 1 || !projectRoot) return distinct[0]
391
+ let best = null, bestCount = -1
392
+ for (const c of distinct) {
393
+ const r = safeGit(projectRoot, ['rev-list', '--count', `${c}..HEAD`], { timeout: GIT_TIMEOUT_MS })
394
+ const n = r.error ? null : parseInt(r.value, 10)
395
+ if (n !== null && !Number.isNaN(n) && n > bestCount) { best = c; bestCount = n }
396
+ }
397
+ return best !== null ? best : distinct[0]
398
+ }
399
+
400
+ /**
401
+ * 引用级漂移命中(scan-refresh 与 computeScanDiff 共用单源,2026-09-14-scan-incremental-refresh)。
402
+ * @param {string} scanDir scan 文档目录
403
+ * @param {Map<string,string>} fullChanged POSIX 路径 → A/D/M/R(R 的旧路径须已入集)
404
+ * @returns {{doc:string, ref:string, change:string, file:string}[]} 命中清单(目录不存在/异常 → 空数组)
405
+ */
406
+ export function collectStaleRefs(scanDir, fullChanged) {
407
+ const staleRefs = []
408
+ if (!existsSync(scanDir)) return staleRefs
409
+ const hitChange = (p) => {
410
+ if (fullChanged.has(p)) return { file: p, change: fullChanged.get(p) }
411
+ // src/ 前缀双向归一:文档 src 相对写法 ↔ git 仓库根路径
412
+ const alt = p.startsWith('src/') ? p.slice(4) : `src/${p}`
413
+ return fullChanged.has(alt) ? { file: alt, change: fullChanged.get(alt) } : null
414
+ }
415
+ for (const f of readdirSync(scanDir)) {
416
+ if (!f.endsWith('.md')) continue
417
+ const content = readFileSync(join(scanDir, f), 'utf8')
418
+ for (const ref of collectDocRefs(content)) {
419
+ if (ref.repo !== null) continue
420
+ const hit = hitChange(ref.file.replace(/\\/g, '/'))
421
+ if (hit) staleRefs.push({ doc: f, ref: ref.ref, change: hit.change, file: hit.file })
392
422
  }
393
- } catch { /* 读失败 → null */ }
394
- return null
423
+ }
424
+ return staleRefs
395
425
  }
396
426
 
397
427
  /** 收集 module-map 全部 paths 去重(归一 Windows 反斜杠 + 去尾部斜杠;无 paths → 空数组) */
@@ -412,8 +442,9 @@ function inScope(filePath, scope) {
412
442
  return scope.some((p) => p && (filePath === p || filePath.startsWith(p + '/')))
413
443
  }
414
444
 
415
- /** 解析 `git diff --name-status --find-renames` 输出 → [{ status, path, oldPath? }](R/C 双路径) */
416
- function parseNameStatus(out) {
445
+ /** 解析 `git diff --name-status --find-renames` 输出 → [{ status, path, oldPath? }](R/C 双路径)。
446
+ * 导出供 scan-refresh 复用(同单源,2026-09-14-scan-incremental-refresh)。 */
447
+ export function parseNameStatus(out) {
417
448
  const items = []
418
449
  if (!out) return items
419
450
  for (const line of out.split('\n')) {
@@ -561,6 +561,80 @@ export function stampScanDocHeaders({ cwd, specDir = null, project = null, mode
561
561
  return { fixed, skipped, stampedKeys }
562
562
  }
563
563
 
564
+ /**
565
+ * per-doc 基线推进盖章(2026-09-14-scan-incremental-refresh,D-002@v1/D-003@v2)。
566
+ * 与 stampScanDocHeaders 的「只补缺不覆盖」契约相互独立:本函数**只改点名文档**的
567
+ * source_commit / updated_at / generator 三键(已有则替换、缺失则补),其余 frontmatter 键
568
+ * 与其余文档逐字节不动——增量刷新(scan refresh --done)专用。幂等:同值重跑零 diff。
569
+ *
570
+ * @param {{ cwd: string, specDir?: string|null, project?: string|null, docs?: string[],
571
+ * headShort?: string|null, generator?: string }} opts
572
+ * docs:scan 目录内文档文件名列表(如 ['ARCHITECTURE.md']);project 缺省时遍历全部项目
573
+ * @returns {{ bumped: string[], skipped: { file: string, reason: string }[] }}
574
+ */
575
+ export function bumpScanDocBaselines({ cwd, specDir = null, project = null, docs = [], headShort = null, generator = 'sillyspec-scan-refresh' }) {
576
+ const specBase = specDir ? specDir : join(cwd, '.sillyspec')
577
+ const docsRoot = join(specBase, 'docs')
578
+ const projects = project
579
+ ? [project]
580
+ : (existsSync(docsRoot) ? readdirSync(docsRoot, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name) : [])
581
+ if (!headShort) {
582
+ try { headShort = String(git(cwd, ['rev-parse', '--short', 'HEAD']) || '').trim() || null } catch { headShort = null }
583
+ }
584
+ const updatedIso = new Date().toISOString()
585
+ const bumped = []
586
+ const skipped = []
587
+ for (const p of projects) {
588
+ const scanDir = join(docsRoot, p, 'scan')
589
+ if (!existsSync(scanDir)) {
590
+ for (const name of docs) skipped.push({ file: name, reason: `无 scan 目录:${scanDir}` })
591
+ continue
592
+ }
593
+ for (const name of docs) {
594
+ const filePath = join(scanDir, name)
595
+ let content
596
+ try { content = readFileSync(filePath, 'utf8') } catch (e) {
597
+ skipped.push({ file: name, reason: `读取失败:${String((e && e.message) || e).split('\n')[0]}` })
598
+ continue
599
+ }
600
+ // headShort 拿不到(非 git 仓)→ source_commit 键跳过不写坏值;updated_at/generator 恒写
601
+ const out = bumpFrontmatterKeys(content, {
602
+ source_commit: headShort || undefined,
603
+ updated_at: updatedIso,
604
+ generator,
605
+ })
606
+ if (out === null) { skipped.push({ file: name, reason: 'frontmatter 写入失败(内部错误)' }); continue }
607
+ try { writeFileSync(filePath, out); bumped.push(filePath) } catch (e) {
608
+ skipped.push({ file: name, reason: `写入失败:${String((e && e.message) || e).split('\n')[0]}` })
609
+ }
610
+ }
611
+ }
612
+ return { bumped, skipped }
613
+ }
614
+
615
+ /** frontmatter 键替换内核:已有行替换、缺失行在 frontmatter 内补(无 frontmatter 则新建头部)。
616
+ * 值为 undefined 的键跳过;键名限安全标识符(内部调用方固定三键,不做正则转义)。
617
+ * 尾部 `---` 无换行(agent 手编删尾换行的中间态)也认既有 frontmatter——否则会走「无
618
+ * frontmatter」分支头部新建一段、旧块残留正文(双 frontmatter,重审 P3 实证)。 */
619
+ function bumpFrontmatterKeys(content, bumpKeys) {
620
+ const normalized = content.replace(/\r\n?/g, '\n')
621
+ const entries = Object.entries(bumpKeys).filter(([, v] ) => v !== undefined)
622
+ if (entries.length === 0) return null
623
+ const fmMatch = normalized.match(/^---\n([\s\S]*?)\n---(?:\n|$)/)
624
+ if (!fmMatch) {
625
+ return `---\n${entries.map(([k, v]) => `${k}: ${v}`).join('\n')}\n---\n\n` + normalized
626
+ }
627
+ let fm = fmMatch[1]
628
+ for (const [k, v] of entries) {
629
+ const re = new RegExp(`^${k}:[^\\n]*$`, 'm')
630
+ if (re.test(fm)) fm = fm.replace(re, `${k}: ${v}`)
631
+ else fm += `\n${k}: ${v}`
632
+ }
633
+ // 重建 frontmatter 块(fmMatch[0] 可能无尾换行——重建统一补 \n,余下正文原样接回)
634
+ const rest = normalized.slice(fmMatch[0].length)
635
+ return `---\n${fm}\n---\n${rest}`
636
+ }
637
+
564
638
  /**
565
639
  * source_root 污染一键修复(2026-08-21 agent-手工产出审计第四批 C-3)。
566
640
  *