sillyspec 3.28.9 → 3.28.11

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 (55) hide show
  1. package/.claude/skills/sillyspec-execute/SKILL.md +223 -222
  2. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  3. package/.claude/skills/sillyspec-quick/SKILL.md +8 -10
  4. package/.claude/skills/sillyspec-verify/SKILL.md +91 -87
  5. package/package.json +1 -1
  6. package/src/change-list.js +73 -20
  7. package/src/change-risk-profile.js +51 -3
  8. package/src/config-schema.js +51 -0
  9. package/src/contract-matrix.js +84 -5
  10. package/src/cross-repo-reconcile.js +134 -0
  11. package/src/design-facts.js +94 -18
  12. package/src/docs-check.js +14 -10
  13. package/src/docs-debt.js +7 -2
  14. package/src/docs-gate.js +42 -15
  15. package/src/endpoint-extractor.js +50 -2
  16. package/src/foreign-declared.js +25 -16
  17. package/src/hooks/worktree-guard.js +75 -2
  18. package/src/index.js +147 -2
  19. package/src/knowledge-classify.js +20 -1
  20. package/src/machine-interface.js +26 -0
  21. package/src/probe7-anchor-check.js +87 -0
  22. package/src/progress/stage-machine.js +34 -5
  23. package/src/progress.js +1 -1
  24. package/src/quicklog.js +240 -5
  25. package/src/run/command.js +25 -2
  26. package/src/run/complete-handlers.js +172 -1
  27. package/src/run/complete.js +143 -11
  28. package/src/run/gate-snapshot.js +372 -2
  29. package/src/run/gates.js +130 -12
  30. package/src/run/prompt.js +166 -7
  31. package/src/run/shared.js +81 -1
  32. package/src/scope-audit.js +101 -31
  33. package/src/stage-contract-spec.js +14 -0
  34. package/src/stage-contract.js +314 -8
  35. package/src/stage-review-checklist.js +3 -1
  36. package/src/stage-review.js +137 -2
  37. package/src/stages/archive.js +9 -5
  38. package/src/stages/brainstorm.js +7 -1
  39. package/src/stages/cmd-existence.js +9 -3
  40. package/src/stages/execute.js +62 -8
  41. package/src/stages/knowledge.js +24 -3
  42. package/src/stages/plan-postcheck.js +74 -10
  43. package/src/stages/plan.js +8 -1
  44. package/src/stages/quick.js +5 -5
  45. package/src/stages/verify.js +3 -2
  46. package/src/sync-noise.js +57 -1
  47. package/src/sync.js +77 -4
  48. package/src/task-review.js +84 -29
  49. package/src/verify-facts-schema.js +78 -9
  50. package/src/verify-postcheck.js +439 -68
  51. package/src/verify-probes.js +1156 -45
  52. package/src/worktree-apply.js +323 -8
  53. package/src/worktree-deps.js +24 -1
  54. package/src/worktree.js +274 -10
  55. package/src/write-audit.js +55 -0
@@ -31,6 +31,9 @@ import { locateQuickSessionGuard, auditQuickCompletion, resolveRuntimeRoot, coll
31
31
  import { computeGateProfile, resolveGateThresholds } from './quick-gate-profile.js'
32
32
  import { safeGit } from './git-helper.js'
33
33
  import { parseFileChangeListDetailed, pathMatches } from './change-list.js'
34
+ import { classifyToolScaffold } from './worktree-apply.js'
35
+ import { parseRepoRegistry } from './stages/plan-postcheck.js'
36
+ import { _readWorktreeMeta } from './contract-matrix.js'
34
37
 
35
38
  /** quick 会话 id 形态(与 run/command.js :652 QUICK_SID_RE 同款——crypto.randomUUID 前 8 hex) */
36
39
  const QUICK_SID_RE = /^quick-[0-9a-f]{8}$/
@@ -211,10 +214,11 @@ function resolveChangeDir(sb, changeName) {
211
214
  function resolveDiffRoot(sb, changeName, form, cwd) {
212
215
  if (form === 'worktree') {
213
216
  try {
214
- const meta = JSON.parse(readFileSync(join(sb, '.runtime', 'worktrees', changeName, 'meta.json'), 'utf8'))
215
- if (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath)) {
216
- return meta.worktreePath
217
- }
217
+ // 双根读取(坑 platform-dual-root-form-misjudge):平台模式 sb=specRoot 时项目侧
218
+ // .sillyspec 的 meta 读不到 → worktree 根错落 cwd。_readWorktreeMeta 双候选(contract-matrix
219
+ // 既有样板,gitDir 已含 worktreePath/mode 判定)。
220
+ const found = _readWorktreeMeta(sb || null, cwd, changeName)
221
+ if (found) return found.gitDir
218
222
  } catch { /* 读失败 → cwd 兜底(advisory) */ }
219
223
  }
220
224
  return cwd
@@ -376,6 +380,22 @@ function findQuickPatchRecord(cwd, sessionId) {
376
380
  /** QUICKLOG 条目号形态(ql-20260914-001-abcd:日期-序号-短 hash,短 hash 4 hex) */
377
381
  const QL_ID_RE = /^ql-\d{8}-\d{3}-[0-9a-f]{4}$/
378
382
 
383
+ /**
384
+ * 注册仓 key 清单(scope-audit-cross-repo-blindness 改进点 2):读 <specBase>/local.yaml 的
385
+ * repos 段(parseRepoRegistry 同源,D-003 不另造解析)——design 清单跨仓子段 / `cross-repo:`
386
+ * 前缀只认注册 key(fail-closed,防普通路径误判)。local.yaml 缺失/坏 → 空清单(全部按主仓
387
+ * 解析,行为同改进前)。
388
+ * @param {string} specBase .sillyspec 根
389
+ * @returns {string[]} repoKey 清单
390
+ */
391
+ function loadRegisteredRepoKeys(specBase) {
392
+ try {
393
+ const p = join(specBase || '', 'local.yaml')
394
+ if (!specBase || !existsSync(p)) return []
395
+ return [...parseRepoRegistry(readFileSync(p, 'utf8')).keys()]
396
+ } catch { /* 读取/解析失败 → 空清单(主仓口径兜底) */ return [] }
397
+ }
398
+
379
399
  /**
380
400
  * ql-xxx → quick-xxx 反查(用户「为什么不直接用 ql-xxx 查」根治,ql-20260910-014 设计:
381
401
  * 映射只在本机 guard,会话结束即清;patches 记录按 qlId 命名且 json 冗余 sessionId——
@@ -704,7 +724,8 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
704
724
  // 审计 tag)全无 = 尚未进入 execute——实际侧不存在(B2 会吞整个工作区脏文件误归属本变更,
705
725
  // 2026-09-11-cross-change-decision-guard 实证 28 个「计划外」全是并行在途)。出计划清单
706
726
  // 视图:rows=design 清单 untouched(待实现),工作区改动归属各自会话不进表。
707
- const hasWorktreeMeta = existsSync(join(sb, '.runtime', 'worktrees', changeName, 'meta.json'))
727
+ // 双根(坑 platform-dual-root-form-misjudge):平台模式 sb=specRoot 时项目侧 meta 单路径读不到
728
+ const hasWorktreeMeta = !!_readWorktreeMeta(sb || null, cwd, changeName)
708
729
  const hasBranch = !!safeGit(cwd, ['rev-parse', '--verify', '--quiet', `sillyspec/${changeName}^{commit}`], { timeout: 15 * 1000 }).value
709
730
  const hasAuditTag = !!safeGit(cwd, ['rev-parse', '--verify', '--quiet', `sillyspec-audit/sillyspec/${changeName}^{commit}`], { timeout: 15 * 1000 }).value
710
731
  // 审查 C-F02:预执行判定必须排除归档(归档=流程走完,58/77 归档变更三信号全缺被误判
@@ -740,11 +761,12 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
740
761
  try {
741
762
  prePlanned = parseFileChangeListDetailed(
742
763
  join(changeDirInfo ? changeDirInfo.dir : join(sb, 'changes', changeName), 'design.md'),
743
- { keepSillyspecDocs: true })
764
+ { keepSillyspecDocs: true, repoKeys: loadRegisteredRepoKeys(sb) })
744
765
  } catch { /* 清单解析失败 → 空清单 + 说明 */ }
745
766
  const preRows = prePlanned.map(e => ({
746
767
  path: e.path, planned: e.operation || null, additions: 0, deletions: 0,
747
768
  kind: 'modified', verdict: 'untouched',
769
+ ...(e.repo ? { crossRepo: e.repo } : {}),
748
770
  }))
749
771
  return {
750
772
  ...emptyBase,
@@ -762,8 +784,9 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
762
784
  let planDegraded = null
763
785
  try {
764
786
  // keepSillyspecDocs=true:与 actual 侧 filterDeliverableFiles 保留 .sillyspec/docs/** 交付物
765
- // 的口径对齐(dogfood 模块文档=交付物)
766
- plannedEntries = parseFileChangeListDetailed(designMdPath, { keepSillyspecDocs: true })
787
+ // 的口径对齐(dogfood 模块文档=交付物);repoKeys=注册仓清单——跨仓子段/`cross-repo:` 前缀
788
+ // 条目带 repo 字段(改进点 2,标注「跨仓(本表不含)」的数据源)
789
+ plannedEntries = parseFileChangeListDetailed(designMdPath, { keepSillyspecDocs: true, repoKeys: loadRegisteredRepoKeys(sb) })
767
790
  } catch (e) {
768
791
  planDegraded = `design.md 清单解析异常: ${e && e.message ? String(e.message).split('\n')[0] : e}`
769
792
  }
@@ -783,13 +806,10 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
783
806
  let actual = null
784
807
  let actualFailure = null
785
808
  try {
786
- const metaPath = join(sb, '.runtime', 'worktrees', changeName, 'meta.json')
787
- if (existsSync(metaPath)) {
788
- const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
789
- const wtRoot = (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath))
790
- ? meta.worktreePath
791
- : cwd
792
- const anchor = meta.baselineCommit || meta.actualBaseHash || meta.baseHash || null
809
+ const found = _readWorktreeMeta(sb || null, cwd, changeName)
810
+ if (found) {
811
+ const wtRoot = found.gitDir
812
+ const anchor = found.meta.baselineCommit || found.meta.actualBaseHash || found.meta.baseHash || null
793
813
  if (anchor) {
794
814
  const diffOut = safeGit(wtRoot, ['diff', '--name-only', anchor], { timeout: 30 * 1000 })
795
815
  if (!diffOut.error && typeof diffOut.value === 'string') {
@@ -811,9 +831,9 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
811
831
  if (wtFilter(p) && !files.includes(p)) files.push(p)
812
832
  }
813
833
  }
814
- actual = { ok: true, form: 'worktree', files, sources: ['worktree:diff-anchor..worktree', 'worktree:status-untracked'], foreignExcluded: 0, degradedReason: null, baseAnchor: anchor }
834
+ actual = { ok: true, form: 'worktree', files, sources: ['worktree:diff-anchor..worktree', 'worktree:status-untracked'], foreignExcluded: 0, foreignExcludedFiles: [], degradedReason: null, baseAnchor: anchor }
815
835
  } else {
816
- actual = { ok: false, form: 'worktree', files: [], sources: [], foreignExcluded: 0, degradedReason: 'worktree 锚定 diff 失败', baseAnchor: null }
836
+ actual = { ok: false, form: 'worktree', files: [], sources: [], foreignExcluded: 0, foreignExcludedFiles: [], degradedReason: 'worktree 锚定 diff 失败', baseAnchor: null }
817
837
  }
818
838
  } else {
819
839
  // 无锚 meta(baseHash 等字段缺失):退 HEAD 未提交窗口口径——文件集=status 全量
@@ -829,7 +849,7 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
829
849
  && !p.startsWith('.sillyspec/quicklog/')) files.push(p)
830
850
  }
831
851
  }
832
- actual = { ok: true, form: 'worktree', files, sources: ['worktree:status-porcelain(HEAD-window)'], foreignExcluded: 0, degradedReason: null, baseAnchor: null }
852
+ actual = { ok: true, form: 'worktree', files, sources: ['worktree:status-porcelain(HEAD-window)'], foreignExcluded: 0, foreignExcludedFiles: [], degradedReason: null, baseAnchor: null }
833
853
  }
834
854
  } else {
835
855
  const mod = await import('./verify-postcheck.js')
@@ -983,10 +1003,20 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
983
1003
  notes.push('baseAnchor 缺失——行数按 HEAD 未提交窗口采集(不含已提交改动,quick 模式同口径;numstat 采集失败时行列为 —)')
984
1004
  }
985
1005
 
986
- // 他者声明排除面可见(R-04 同精神):resolveReconcileActualFiles 只回计数不回名单,退栈的
987
- // 计划内文件会误显「计划未动」——note 点破归属重叠可能性,让 ⚠️ 行可解释。
1006
+ // 他者声明排除面可见(R-04 同精神)+ 退栈名单消费(scope-audit-cross-repo-blindness 改进点 3):
1007
+ // planned 文件被退栈排除但已写盘 → 该行标 suspectedForeignDone「疑似他者已实现·已退栈」,
1008
+ // 与真未动区分(名单来自 resolveReconcileActualFiles 新增的 foreignExcludedFiles)。
1009
+ const foreignExcludedSet = new Set((Array.isArray(actual.foreignExcludedFiles) ? actual.foreignExcludedFiles : []).map(toPosix))
988
1010
  if (actual.foreignExcluded > 0) {
989
- notes.push(`实际侧另有 ${actual.foreignExcluded} 个文件按他者会话声明退栈未进本表——「计划未动」行先怀疑归属重叠(声明即归属,git diff 核实)`)
1011
+ notes.push(`实际侧另有 ${actual.foreignExcluded} 个文件按他者会话声明退栈未进本表——「计划未动」行标注「疑似他者已实现·已退栈」者即此类(声明即归属,git diff 核实)`)
1012
+ }
1013
+
1014
+ // 计划侧跨仓段汇总(改进点 2):跨仓条目不进本仓对账(actual 只采主仓 git),行级标
1015
+ // crossRepo + note 交代去对应仓对账。
1016
+ const crossRepoKeys = [...new Set(plannedEntries.map(e => e.repo).filter(Boolean))]
1017
+ if (crossRepoKeys.length > 0) {
1018
+ const crossCount = plannedEntries.filter(e => e.repo).length
1019
+ notes.push(`计划侧含 ${crossCount} 个跨仓文件(repo:${crossRepoKeys.join('、')})——⊘ 行不在本仓对账面(实际侧只采主仓 git),请到对应仓跑 scope-audit 对账`)
990
1020
  }
991
1021
 
992
1022
  // —— 三态判定 ——
@@ -1006,6 +1036,12 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
1006
1036
  } else {
1007
1037
  row.planned = null
1008
1038
  row.verdict = 'unplanned'
1039
+ // 工具/平台脚手架(改进点 1 软桶):CLI/平台自装文件不占「计划外」——硬排档
1040
+ // (.worktrees//.sillyspec-platform*/knowledge/local.yaml)已被 filterDeliverableFiles
1041
+ // 滤出不进 actual;此处打标的是可能正当交付的软档(.claude/skills/、CLAUDE.md、
1042
+ // attachments/),范围视图归入「工具/平台设施」桶。
1043
+ const facility = classifyToolScaffold(path)
1044
+ if (facility) row.facility = facility
1009
1045
  }
1010
1046
  }
1011
1047
  rows.push(row)
@@ -1013,7 +1049,16 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
1013
1049
  // 计划未动:清单文件无实际改动 → 补行(行数 0/0);计划侧降级时不产(plannedEntries 为空天然不跑)
1014
1050
  for (const e of plannedEntries) {
1015
1051
  if (matchedPlanned.has(e.path)) continue
1016
- rows.push({ path: e.path, planned: e.operation || null, additions: 0, deletions: 0, kind: 'modified', verdict: 'untouched' })
1052
+ const row = { path: e.path, planned: e.operation || null, additions: 0, deletions: 0, kind: 'modified', verdict: 'untouched' }
1053
+ if (e.repo) {
1054
+ // 跨仓条目(改进点 2):不是「未动」是「本表不含」
1055
+ row.crossRepo = e.repo
1056
+ } else {
1057
+ // 退栈排除 + 盘面已存在 → 他者会话已写盘(改进点 3);NEW: 前缀剥除后比对(pathMatches 同款口径)
1058
+ const norm = toPosix(e.path).replace(/^NEW:/, '')
1059
+ if (foreignExcludedSet.has(norm) && existsSync(join(numstatRoot, norm))) row.suspectedForeignDone = true
1060
+ }
1061
+ rows.push(row)
1017
1062
  }
1018
1063
 
1019
1064
  // 冻结 patch(collectPatch,execute --done 落盘方消费):与行数同根同锚(numstatRoot +
@@ -1054,7 +1099,11 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
1054
1099
  * rows: Array<{ path: string, planned?: string|null, declared?: boolean,
1055
1100
  * additions: number|null, deletions: number|null,
1056
1101
  * kind: 'binary'|'new'|'modified'|'deleted',
1057
- * verdict?: 'planned'|'unplanned'|'untouched', attribution?: 'declared'|'soft'|'undeclared' }>,
1102
+ * verdict?: 'planned'|'unplanned'|'untouched', attribution?: 'declared'|'soft'|'undeclared',
1103
+ * crossRepo?: string, suspectedForeignDone?: boolean, facility?: 'tool'|'platform' }>,
1104
+ * (crossRepo/suspectedForeignDone/facility 为 scope-audit-cross-repo-blindness 改进点 1/2/3
1105
+ * 增量字段:跨仓条目标 repoKey / 退栈排除但已写盘的 planned 文件 / 计划外行里的工具平台
1106
+ * 脚手架桶——均为按存在性读取,缺省无字段零回归)
1058
1107
  * excluded: { foreignDeclared: Array<{ file: string, sessions: string[] }> },
1059
1108
  * note?: string|null,
1060
1109
  * gateProfile?: object|null }>}
@@ -1187,7 +1236,7 @@ export async function getFileDiff({ cwd, specBase, changeName, platformOpts, fil
1187
1236
  const located = locateQuickSessionGuard(cwd, changeName)
1188
1237
  if (located) root = dirname(located.specBase)
1189
1238
  } else {
1190
- const form = existsSync(join(sb, '.runtime', 'worktrees', changeName, 'meta.json')) ? 'worktree' : 'post-apply'
1239
+ const form = _readWorktreeMeta(sb || null, cwd, changeName) ? 'worktree' : 'post-apply'
1191
1240
  root = resolveDiffRoot(sb, changeName, form, cwd)
1192
1241
  }
1193
1242
 
@@ -1210,12 +1259,16 @@ export async function getFileDiff({ cwd, specBase, changeName, platformOpts, fil
1210
1259
  }
1211
1260
  }
1212
1261
 
1213
- /** verdict / attribution → 展示标记(人类可读面;BIN 为 binary 行数占位,— 为降级占位) */
1262
+ /** verdict / attribution → 展示标记(人类可读面;BIN 为 binary 行数占位,— 为降级占位)。
1263
+ * 行级标注优先(scope-audit-cross-repo-blindness 改进点 1/2/3):跨仓 > 疑似他者已实现 > 设施桶 */
1214
1264
  const VERDICT_LABEL = {
1215
1265
  planned: '✓ 计划内',
1216
1266
  unplanned: '⚠️ 计划外',
1217
1267
  untouched: '⚠️ 计划未动',
1218
1268
  }
1269
+ const CROSS_REPO_LABEL = '⊘ 跨仓(本表不含)'
1270
+ const SUSPECTED_FOREIGN_LABEL = '⚠️ 计划未动(疑似他者已实现·已退栈)'
1271
+ const FACILITY_LABEL = '⚠️ 计划外(工具/平台设施)'
1219
1272
  const ATTRIBUTION_LABEL = {
1220
1273
  declared: '✓ 已声明',
1221
1274
  soft: '🔍 软归属',
@@ -1278,7 +1331,10 @@ export function renderScopeAuditTable(result, opts = {}) {
1278
1331
  const stat = row.kind === 'binary'
1279
1332
  ? { a: 'BIN', d: 'BIN' }
1280
1333
  : { a: fmtCount(row.additions) ?? '—', d: fmtCount(row.deletions) ?? '—' }
1281
- const label = VERDICT_LABEL[row.verdict] || ATTRIBUTION_LABEL[row.attribution] || ''
1334
+ const label = row.crossRepo ? CROSS_REPO_LABEL
1335
+ : row.suspectedForeignDone ? SUSPECTED_FOREIGN_LABEL
1336
+ : row.facility ? FACILITY_LABEL
1337
+ : VERDICT_LABEL[row.verdict] || ATTRIBUTION_LABEL[row.attribution] || ''
1282
1338
  lines.push(` ${row.path || '(未知路径)'}${' '.repeat(Math.max(1, 40 - String(row.path || '').length))}${label}${' '.repeat(Math.max(1, 12 - label.length))}${String(stat.a).padStart(5)} ${String(stat.d).padStart(5)} ${row.kind || ''}`)
1283
1339
  }
1284
1340
  if (rows.length > shown.length) {
@@ -1287,15 +1343,29 @@ export function renderScopeAuditTable(result, opts = {}) {
1287
1343
 
1288
1344
  lines.push(` 合计:${totals.files ?? rows.length} 文件 +${totals.additions ?? 0} / -${totals.deletions ?? 0}`)
1289
1345
 
1290
- // ⚠️ 出口指引(full-flow 三态面):计划外补声明、计划未动确认遗漏
1291
- const unplanned = rows.filter(x => x && x.verdict === 'unplanned').length
1292
- const untouched = rows.filter(x => x && x.verdict === 'untouched').length
1293
- if (unplanned > 0 || untouched > 0) {
1346
+ // ⚠️ 出口指引(full-flow 三态面):计划外补声明、计划未动确认遗漏。
1347
+ // 行级标注三分(scope-audit-cross-repo-blindness 改进点 1/2/3):跨仓行/疑似他者已实现行/
1348
+ // 工具设施桶各有去向说明,不混入笼统的「计划外/计划未动」计数逼用户排查噪音。
1349
+ const unplanned = rows.filter(x => x && x.verdict === 'unplanned' && !x.facility).length
1350
+ const facility = rows.filter(x => x && x.verdict === 'unplanned' && x.facility).length
1351
+ const untouchedReal = rows.filter(x => x && x.verdict === 'untouched' && !x.suspectedForeignDone && !x.crossRepo).length
1352
+ const suspectedForeign = rows.filter(x => x && x.suspectedForeignDone).length
1353
+ const crossRepo = rows.filter(x => x && x.crossRepo).length
1354
+ if (unplanned > 0 || untouchedReal > 0) {
1294
1355
  const parts = []
1295
1356
  if (unplanned > 0) parts.push(`计划外 ${unplanned} 文件`)
1296
- if (untouched > 0) parts.push(`计划未动 ${untouched} 文件`)
1357
+ if (untouchedReal > 0) parts.push(`计划未动 ${untouchedReal} 文件`)
1297
1358
  lines.push(` ⚠️ ${parts.join('、')}——计划外请补 design.md 声明或 --output 注明原因;计划未动请确认是否遗漏`)
1298
1359
  }
1360
+ if (suspectedForeign > 0) {
1361
+ lines.push(` ℹ️ 疑似他者已实现 ${suspectedForeign} 文件(已写盘但被退栈——他者会话声明归属,本表不计未动;git diff 核实归属)`)
1362
+ }
1363
+ if (facility > 0) {
1364
+ lines.push(` ℹ️ 工具/平台设施 ${facility} 文件(CLI/平台自装脚手架,非本变更改动,不占计划外)`)
1365
+ }
1366
+ if (crossRepo > 0) {
1367
+ lines.push(` ℹ️ 跨仓 ${crossRepo} 文件(⊘ 行——本表不含,请到对应仓跑 scope-audit 对账)`)
1368
+ }
1299
1369
  const undeclared = rows.filter(x => x && x.attribution === 'undeclared').length
1300
1370
  if (undeclared > 0) lines.push(` ⚠️ 未声明 ${undeclared} 文件——超出 allowedFiles 声明面,补 --files 声明或注明原因`)
1301
1371
 
@@ -274,6 +274,20 @@ const PLAN_RULES = [
274
274
  spec: 'large 变更产出 `module-impact.md` 首版(plan review_plan 步生成;scale=small 豁免)',
275
275
  failMessage: 'module-impact.md 缺失: ${path}(large 变更应在 plan 审查计划步生成模块影响分析首版)',
276
276
  },
277
+ // 全局硬约束段存在性(ql-20260917-003,warning——ql-20260917-002 落地段的事后校验):plan_level=full
278
+ // 的 plan.md 应含「全局硬约束」段(design.md 跨 task 硬约束逐字抄录,执行期子代理只读自己的
279
+ // 任务卡+本段)。condition 生效依赖 validatePlanOutputs 把 plan.md frontmatter plan_level 传入
280
+ // ctx;读不到(无 frontmatter/旧格式/light)→ eq:'full' 不成立跳过(存量零误报 fail-safe)。
281
+ // 确无跨 task 硬约束时写一段「无」占位即可消警(段在场即过,不校验段体非空——空体由 execute
282
+ // 注入端容忍+审查目检)。
283
+ {
284
+ id: 'plan.global-constraints', stage: 'plan', source: 'validatePlanOutputs', severity: 'warning', kind: 'literal-any',
285
+ target: { root: 'change', path: 'plan.md', scope: 'full' },
286
+ data: { literals: ['全局硬约束'] },
287
+ condition: { ctxField: 'planLevel', eq: 'full' },
288
+ spec: 'plan_level=full 的 plan.md 含「全局硬约束」段(design.md 跨 task 硬约束逐字抄录;无跨 task 硬约束时写「无」占位消警)',
289
+ failMessage: 'plan.md 缺少「## 全局硬约束」段(plan_level=full:从 design.md 逐字抄录绑定所有 task 的硬约束,执行期子代理只读自己的任务卡+本段;确无跨 task 硬约束时写一段「无」占位消警)',
290
+ },
277
291
  // entry-point-wiring(custom):design.md 命中入口实例化/启动路径模式时,被提到的入口文件
278
292
  // (cli.ts/main.ts/server.(js|ts)/index.(js|ts))必须出现在某 task allowed_paths 或 plan.md
279
293
  // 文件变更清单中,否则需 design.md 紧邻写明豁免。判定算法(多源 allowed_paths 收集 + 逐文件对账 +