sillyspec 3.28.10 → 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 (45) 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-risk-profile.js +51 -3
  7. package/src/config-schema.js +21 -0
  8. package/src/design-facts.js +46 -9
  9. package/src/docs-check.js +14 -10
  10. package/src/docs-debt.js +7 -2
  11. package/src/docs-gate.js +42 -15
  12. package/src/endpoint-extractor.js +50 -2
  13. package/src/foreign-declared.js +25 -16
  14. package/src/hooks/worktree-guard.js +75 -2
  15. package/src/index.js +129 -1
  16. package/src/machine-interface.js +26 -0
  17. package/src/probe7-anchor-check.js +20 -2
  18. package/src/progress/stage-machine.js +34 -5
  19. package/src/progress.js +1 -1
  20. package/src/quicklog.js +231 -1
  21. package/src/run/complete-handlers.js +172 -1
  22. package/src/run/complete.js +73 -3
  23. package/src/run/gate-snapshot.js +262 -0
  24. package/src/run/gates.js +41 -7
  25. package/src/run/prompt.js +79 -1
  26. package/src/run/shared.js +10 -0
  27. package/src/stage-contract-spec.js +14 -0
  28. package/src/stage-contract.js +314 -8
  29. package/src/stage-review-checklist.js +3 -1
  30. package/src/stage-review.js +137 -2
  31. package/src/stages/archive.js +9 -5
  32. package/src/stages/brainstorm.js +7 -1
  33. package/src/stages/execute.js +62 -8
  34. package/src/stages/plan-postcheck.js +42 -5
  35. package/src/stages/plan.js +8 -1
  36. package/src/stages/quick.js +5 -5
  37. package/src/stages/verify.js +3 -2
  38. package/src/sync.js +69 -0
  39. package/src/task-review.js +9 -3
  40. package/src/verify-facts-schema.js +78 -9
  41. package/src/verify-postcheck.js +260 -26
  42. package/src/verify-probes.js +780 -44
  43. package/src/worktree-apply.js +175 -3
  44. package/src/write-audit.js +55 -0
  45. package/.claude/full.json +0 -1
@@ -368,7 +368,7 @@ function collectPathTokens(text) {
368
368
 
369
369
  /**
370
370
  * 解析 module-impact.md 的「模块影响矩阵」+「未匹配文件」两节,按检查面归类影响证据。
371
- * 纯机械启发式(确定性:同输入同输出);矩阵行 <!--TODO--> 未回填 → 保守计行为类
371
+ * 纯机械启发式(确定性:同输入同输出);矩阵行 <!--TODO--> 未回填 → 保守计行为类 probe1-noqa
372
372
  * (影响面未知时多测不漏测)。矩阵节缺失 / 无数据行 → parseable=false(调用方降级)。
373
373
  *
374
374
  * @param {string|null} mdText
@@ -393,8 +393,8 @@ function classifyModuleImpactEvidence(mdText) {
393
393
  const tok = IMPACT_BEHAVIORAL_TOKENS.find((w) => rowText.includes(w))
394
394
  if (tok) { out.behavioral.push(`(影响类型:${tok})`); classified++ }
395
395
  }
396
- // 矩阵行 <!--TODO--> 未回填 → 影响面未知,保守计行为类
397
- if (String(rowText).includes('<!--TODO')) { out.behavioral.push('<!--TODO--> 未回填行'); classified++ }
396
+ // 矩阵行 <!--TODO--> 未回填 → 影响面未知,保守计行为类 /* probe1-noqa */
397
+ if (String(rowText).includes('<!--TODO')) { out.behavioral.push('<!--TODO--> 未回填行'); classified++ } /* probe1-noqa */
398
398
  return classified
399
399
  }
400
400
  for (const line of lines) {
@@ -414,7 +414,7 @@ function classifyModuleImpactEvidence(mdText) {
414
414
  matrixRows++
415
415
  consumeRow(trimmed)
416
416
  } else if (section === 'unmatched') {
417
- // 骨架产出 `- \`path\` <!--TODO-->` 列表行;手写形态可能是 `| 文件 | 处置说明 |` 表行
417
+ // 骨架产出 `- \`path\` <!--TODO-->` 列表行;手写形态可能是 `| 文件 | 处置说明 |` 表行 /* probe1-noqa */
418
418
  const bullet = trimmed.match(/^-\s+(.+)$/)
419
419
  if (bullet) { consumeRow(bullet[1]); continue }
420
420
  if (trimmed.startsWith('|')) {
@@ -803,7 +803,21 @@ const PY_TEST_ID_RE = /^\S+::\S+$/
803
803
  // 通过行用例名恰含这些字样(如「✓ … 超时后 syncStatus=failed」)会被误判失败行——
804
804
  // 2710 用例套件 382 个"失败行"里 378 假阳性,known_failures 无法逐条枚举而实质失效。
805
805
  // 框架输出里通过行恒以通过标记开头、失败行恒以失败标记开头,行首判定即足以分离两类。
806
- const PASS_LINE_RE = /^\s*(?:[✓√✔]|PASS\b)/
806
+ // ✅ 补入标记集(2026-09-16-friction5-hardening verify 移交项②实证):本仓自定义 harness 用
807
+ // 「✅ PASS: <断言名>」打印通过行,断言名高频含 fail-closed/failed/×3(乘号)——✅ 不在集内时
808
+ // 整行落 \bFAIL\b/×/failed 子串命中成假失败行(一轮 verify 实测 20/27 未豁免行为此噪声)。
809
+ const PASS_LINE_RE = /^\s*(?:[✓√✔✅]|PASS\b)/
810
+ // CLI advisory 行(行首 emoji 前缀,剥 ANSI 后判定):⚠️ warn / ℹ️ info / 🔄 进度是工具自身的
811
+ // 提示通道(如 gate-snapshot 的「⚠️ 快照 overlay 冒烟:…import 失败」——warn 正文含 ModuleNotFoundError,
812
+ // 被子串命中成失败行)。测试框架的失败行恒以 ✕✗✘×/FAIL/--- FAIL 开头,不用这些 emoji 前缀——
813
+ // 行首判定零误伤;漏检风险由 judgeWithKnownFailures 的 fail-safe(检测不到失败行不判 pass)兜底。
814
+ const ADVISORY_LINE_RE = /^[ \t]*(?:⚠️?|ℹ️?|🔄)/
815
+ // 工具词汇 fail-<x> 复合词中和(判账前从行内剥除,2026-09-16 verify 移交项②):fail-closed/
816
+ // fail-open/fail-soft/fail-safe/fail-loud/fail-fast 是本工具的行为契约词汇,PER_TEST_FAIL_RE 的
817
+ // \bFAIL\b(i 标志)会把「(fail-closed)」「--- 1.3 fail-open 三态 ---」整行误收;剥除后再测,
818
+ // 行内其余真失败词(FAILED/assertionerror/error: 等)不受影响(如 AssertionError 断言消息里
819
+ // 出现 fail-closed 仍照常命中)。
820
+ const FAIL_COMPOUND_NEUTRALIZE_RE = /fail-(?:closed|open|soft|safe|loud|fast)/gi
807
821
  // ANSI 色码剥离(分类用):TTY 捕获的输出里 ✓/× 前缀可能被色码包裹,行首锚定会失配。
808
822
  const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g
809
823
 
@@ -848,8 +862,10 @@ export function partitionFailures(output, knownFailures) {
848
862
  else pyWarnCtx = false
849
863
  }
850
864
  if (PASS_LINE_RE.test(bare)) continue
865
+ if (ADVISORY_LINE_RE.test(bare)) continue
851
866
  if (ENV_NOISE_RE.test(bare)) continue
852
- if (PER_TEST_FAIL_RE.test(bare) && !SUMMARY_LINE_RE.test(bare)) failureLines.push(l)
867
+ // fail-<x> 工具词汇中和后再测(见 FAIL_COMPOUND_NEUTRALIZE_RE 注释):真失败词不被剥除
868
+ if (PER_TEST_FAIL_RE.test(bare.replace(FAIL_COMPOUND_NEUTRALIZE_RE, '')) && !SUMMARY_LINE_RE.test(bare)) failureLines.push(l)
853
869
  }
854
870
  // 豁免匹配同样在剥 ANSI 后的行上做(坑 verify-known-failures-ansi-exemption-split,2026-09-07
855
871
  // 工具复盘):TTY 捕获的失败行里色码把可见词拦腰拆开(`× \x1b[31mtests/foo.test.ts\x1b[0m > case`
@@ -1407,6 +1423,9 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
1407
1423
  // 跨仓仓不参与 module 子集策略,只跑 full npm test(design §6 + §5.4);
1408
1424
  // 跨仓仓无 package.json → 跳过 + warn 不阻断(design §9 兼容策略)。
1409
1425
  // 任一跨仓仓 fail → 整体 fail(合并语义)。
1426
+ // 主仓 skipped(test_strategy: skip / evidence-auto 推荐跳过 / module 0 命中)→ 进短路档:
1427
+ // 未自配 commands.test 的跨仓不再 fallback npm test 假败(task-05 / FR-07,见
1428
+ // runCrossRepoTestUnderMainSkip 逐仓三态)。
1410
1429
  return mergeCrossRepoResults(mainResult, ctx)
1411
1430
  }
1412
1431
 
@@ -1419,6 +1438,9 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
1419
1438
  * - 跨仓仓无 package.json → 跳过 + console.warn(不阻断 verify)
1420
1439
  * - 跨仓仓只跑 full npm test,不参与 module 子集策略(module 映射主仓强相关)
1421
1440
  * - 任一仓 fail → 整体 fail;主仓 skipped + 跨仓仓 passed → 整体 passed(跨仓仓有测试即有效)
1441
+ * - 主仓 skipped → 短路档(task-05 / FR-07):未自配 commands.test 的跨仓随主仓 skip 短路
1442
+ * (不跑 fallback npm test 假败),自配了 commands.test 的仍跑,own test_strategy: skip
1443
+ * 的单独 skip(runCrossRepoTestUnderMainSkip 逐仓三态)
1422
1444
  *
1423
1445
  * 单仓 ctx(无跨仓 entry)→ 直接返主仓结果,零行为变化(GOAL-2)。
1424
1446
  *
@@ -1434,9 +1456,15 @@ function mergeCrossRepoResults(mainResult, ctx) {
1434
1456
  }
1435
1457
  if (crossEntries.length === 0) return mainResult
1436
1458
 
1459
+ // 主仓 skip 短路档(task-05 / FR-07 / design §5):主仓 skipped(test_strategy: skip /
1460
+ // evidence-auto 推荐跳过 / module 0 命中)时,未自配 commands.test 的跨仓不再 fallback
1461
+ // `npm test`(无 test script 的仓必 exit 1 → npm missing-script 假败打回整批)——随主仓
1462
+ // skip 短路;自配了 commands.test 的跨仓仍跑(用户显式意图,主仓 skip 不连带豁免);own
1463
+ // local.yaml 配 test_strategy: skip 的跨仓单独 skip(逐仓生效)。主仓 passed/failed 零变化。
1464
+ const mainSkipped = !!(mainResult && mainResult.status === 'skipped')
1437
1465
  const crossResults = []
1438
1466
  for (const entry of crossEntries) {
1439
- const crossResult = runCrossRepoFullTest(entry)
1467
+ const crossResult = mainSkipped ? runCrossRepoTestUnderMainSkip(entry) : runCrossRepoFullTest(entry)
1440
1468
  crossResults.push({ repoKey: entry.repoKey, projectRoot: entry.projectRoot, result: crossResult })
1441
1469
  }
1442
1470
 
@@ -1454,10 +1482,66 @@ function mergeCrossRepoResults(mainResult, ctx) {
1454
1482
  return mergeResultInfo(mainResult, crossResults)
1455
1483
  }
1456
1484
 
1485
+ /**
1486
+ * 主仓 skip 短路档下跑单个跨仓仓测试(task-05 / FR-07,逐仓三态判定):
1487
+ * - own local.yaml(<projectRoot>/.sillyspec/local.yaml)配 test_strategy: skip → 该仓
1488
+ * 单独 skip(逐仓生效,独立于主仓策略)
1489
+ * - own local.yaml 配 commands.test → 仍走 runCrossRepoFullTest 执行(用户显式意图,
1490
+ * 主仓 skip 不连带豁免实测)
1491
+ * - 未自配 commands.test(无 local.yaml / 读取失败 / 无 commands.test)→ 随主仓 skip
1492
+ * 短路,不跑 fallback `npm test`(无 test script 的仓必 exit 1 → npm missing-script
1493
+ * 假败,与坑 cross-repo-no-test-script 同族的环境性假红——但那是主仓非 skip 时的豁免
1494
+ * 通道,语义不动)。出路注记:配 own local.yaml 的 commands.test 即可纳入实测。
1495
+ *
1496
+ * 合并语义(上游 mergeCrossRepoResults):跨仓全 skipped → 整体保持主仓 skipped(reason
1497
+ * 带短路与出路注记);自配 commands.test 的跨仓 failed → 整体 fail(真败非假败,合理打回)。
1498
+ *
1499
+ * @param {object} entry - RepoEntry(isMain=false)
1500
+ * @returns {object} 结果 shape 对齐 runCrossRepoFullTest 返回
1501
+ */
1502
+ function runCrossRepoTestUnderMainSkip(entry) {
1503
+ const crossLocalYaml = join(entry.projectRoot, '.sillyspec', 'local.yaml')
1504
+ let yamlText = null
1505
+ if (existsSync(crossLocalYaml)) {
1506
+ try { yamlText = readFileSync(crossLocalYaml, 'utf8') } catch { yamlText = null }
1507
+ }
1508
+ if (yamlText) {
1509
+ // own test_strategy: skip → 该仓单独 skip(显式声明优先于 commands.test)
1510
+ if (extractTestStrategy(yamlText) === 'skip') {
1511
+ return {
1512
+ status: 'skipped',
1513
+ command: null,
1514
+ exitCode: null,
1515
+ durationMs: null,
1516
+ outputTail: null,
1517
+ reason: `跨仓 repo "${entry.repoKey}" own local.yaml test_strategy: skip,该仓单独跳过测试(逐仓生效)`,
1518
+ resultPath: null,
1519
+ mode: 'cross-repo-skip-strategy',
1520
+ repoKey: entry.repoKey,
1521
+ }
1522
+ }
1523
+ // 自配 commands.test → 仍执行(原路径原语义)
1524
+ if (extractTestCommand(yamlText)) return runCrossRepoFullTest(entry)
1525
+ }
1526
+ // 未自配 → 随主仓 skip 短路(不再 fallback npm test 假败)
1527
+ console.warn(`⚠️ 主仓 test_strategy skip 短路档:跨仓 repo "${entry.repoKey}"(${entry.projectRoot})未自配 commands.test,不跑 fallback npm test(假败消除)。如需纳入实测:在 ${crossLocalYaml} 配 commands.test。`)
1528
+ return {
1529
+ status: 'skipped',
1530
+ command: null,
1531
+ exitCode: null,
1532
+ durationMs: null,
1533
+ outputTail: null,
1534
+ reason: `主仓 skip 短路:跨仓 repo "${entry.repoKey}" 未自配 commands.test,随主仓跳过(可配 own local.yaml commands.test 纳入实测)`,
1535
+ resultPath: null,
1536
+ mode: 'cross-repo-main-skip',
1537
+ repoKey: entry.repoKey,
1538
+ }
1539
+ }
1540
+
1457
1541
  /**
1458
1542
  * 在单个跨仓仓根跑 full npm test(跨仓仓不参与 module 子集,只跑 full)。
1459
1543
  * 跨仓仓 own local.yaml 若存在且配 commands.test → 用之;否则 fallback `npm test`。
1460
- * 跨仓仓无 package.json → 跳过 + warn。
1544
+ * 跨仓仓无 package.json,或有 package.json 但无 test 脚本(未被 own local.yaml 覆盖)→ 跳过 + warn。
1461
1545
  *
1462
1546
  * @param {object} entry - RepoEntry(isMain=false)
1463
1547
  * @returns {object} 结果 shape 对齐 runFullCommand 返回
@@ -1494,6 +1578,35 @@ function runCrossRepoFullTest(entry) {
1494
1578
  } catch { /* 读取失败 fallback npm test */ }
1495
1579
  }
1496
1580
 
1581
+ // 跨仓仓有 package.json 但无 test 脚本,且未被 own local.yaml 覆盖 → 降级跳过 + warn
1582
+ // (坑 cross-repo-no-test-script,2026-09-15 wp EHS 会话实证:spdemo 无 test script,
1583
+ // fallback `npm test` 必 exit 1(npm missing-script)→ verify-test 环境性假红,agent 被迫
1584
+ // 手工给该仓造 .sillyspec/local.yaml + 冒烟脚本。测试基建缺失是仓常态,非代码回归——
1585
+ // 与「无 package.json」同款 skip 处理,hint 指向 own local.yaml 配置通道)。
1586
+ // own local.yaml 显式配了命令 → 无条件执行(用户显式意图,即使无 test script 也跑)。
1587
+ // package.json 解析失败 → 按原行为跑 `npm test`(fail-visible,不静默吞)。
1588
+ if (command === 'npm test') {
1589
+ let hasTestScript = true
1590
+ try {
1591
+ const pkg = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8'))
1592
+ hasTestScript = !!(pkg && pkg.scripts && Object.prototype.hasOwnProperty.call(pkg.scripts, 'test'))
1593
+ } catch { /* 解析失败维持 npm test 原行为 */ }
1594
+ if (!hasTestScript) {
1595
+ console.warn(`⚠️ 跨仓 repo "${entry.repoKey}"(${projectRoot})package.json 无 test 脚本,跳过该仓测试(非代码回归,不阻断 verify)。如需纳入实测:在 ${join(projectRoot, '.sillyspec', 'local.yaml')} 配 commands.test(如轻量冒烟检查)。`)
1596
+ return {
1597
+ status: 'skipped',
1598
+ command: null,
1599
+ exitCode: null,
1600
+ durationMs: null,
1601
+ outputTail: null,
1602
+ reason: `跨仓 repo "${entry.repoKey}" package.json 无 test 脚本,跳过测试(可配 own local.yaml commands.test 纳入)`,
1603
+ resultPath: null,
1604
+ mode: 'cross-repo-no-test-script',
1605
+ repoKey: entry.repoKey,
1606
+ }
1607
+ }
1608
+ }
1609
+
1497
1610
  const startedAt = Date.now()
1498
1611
  let exitCode = 0
1499
1612
  let output = ''
@@ -1910,7 +2023,7 @@ export function printVerifyParityCheck(result) {
1910
2023
  console.warn(' ⚠️ 本次 scope 为 full-repo(diff 与 apply-pathspec 均不可得时的兜底)——missing 大概率是口径错配噪音而非真实 contract gap。')
1911
2024
  console.warn(' 先收窄复跑:sillyspec verify-probes --change <变更名>(change-diff/apply-pathspec 口径)再判定。')
1912
2025
  }
1913
- console.warn(' 提示:检查是否后端漏实现,或前端调用了尚未实现的端点。确认无误可在 design.md 标注豁免。')
2026
+ console.warn(' 提示:检查是否后端漏实现,或前端调用了尚未实现的端点。确认无误可在 design.md 标注豁免。') /* probe1-noqa */
1914
2027
  }
1915
2028
 
1916
2029
  /**
@@ -2088,6 +2201,34 @@ export function trackVerifyResultRegression(specBase, changeName, absPath) {
2088
2201
  * @param {{cwd:string, specBase:string, changeName?:string}} args
2089
2202
  * @returns {{status:'skipped'|'passed'|'warning', items:Array, unacknowledged:Array, summary:string, reason:string|null}}
2090
2203
  */
2204
+ /**
2205
+ * evidence 核验双根解析(坑⑤ / D-005@v1 消费侧双根 + 坑 platform-dual-root-fourth-consumer
2206
+ * 2026-09-16 hunt):apply 前新文件只在 worktree,逐文件核验单查主仓必误报「文件不存在」。
2207
+ * meta 双候选定位(平台模式 specBase=specRoot 时主仓 worktree meta 在项目侧 <cwd>/.sillyspec,
2208
+ * 单查 specBase 必落空——对齐 contract-matrix._readWorktreeMeta 双候选样板);meta 在场 &&
2209
+ * 非 in-place-fallback && worktreePath 目录存在 → [cwd, worktreePath],否则 [cwd]。
2210
+ * 解析异常 fail-open 退单根 + warning(不新增阻断路径)。导出供统一双根夹具测试。
2211
+ */
2212
+ export function resolveEvidenceDualRoots({ cwd, specBase, changeName, warnings = [] }) {
2213
+ let roots = [cwd]
2214
+ try {
2215
+ const metaCandidates = [...new Set([specBase, join(cwd, '.sillyspec')].filter(Boolean))]
2216
+ const metaPath = metaCandidates
2217
+ .map(b => join(b, '.runtime', 'worktrees', changeName, 'meta.json'))
2218
+ .find(p => existsSync(p))
2219
+ if (metaPath) {
2220
+ const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
2221
+ if (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath)) {
2222
+ roots = [cwd, meta.worktreePath]
2223
+ }
2224
+ }
2225
+ } catch (e) {
2226
+ roots = [cwd]
2227
+ warnings.push(`worktree meta 解析失败(${e.message}),evidence 双根核验退主仓单根`)
2228
+ }
2229
+ return roots
2230
+ }
2231
+
2091
2232
  export function runVerifyRequiredEvidenceCheck({ cwd, specBase, changeName = null, verifyStartAt = null }) {
2092
2233
  if (!changeName) {
2093
2234
  return { status: 'skipped', items: [], unacknowledged: [], summary: '', reason: '无 changeName(quick 等无关联变更场景),evidence 对账跳过' }
@@ -2179,19 +2320,7 @@ function runRequiredEvidenceCheckV2({ items, slots, cwd, specBase, changeName, v
2179
2320
  // && worktreePath 目录存在 → roots = [cwd, worktreePath];任一不满足退 [cwd] 单根
2180
2321
  // (零回归)。解析异常 fail-open 退单根 + warning(不新增阻断路径)。静默双根——对齐
2181
2322
  // buildAcceptanceHints(verify-probes.js:223)双根先例,核验明细 root 字段已可辨。──
2182
- let roots = [cwd]
2183
- try {
2184
- const metaPath = join(specBase || join(cwd, '.sillyspec'), '.runtime', 'worktrees', changeName, 'meta.json')
2185
- if (existsSync(metaPath)) {
2186
- const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
2187
- if (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath)) {
2188
- roots = [cwd, meta.worktreePath]
2189
- }
2190
- }
2191
- } catch (e) {
2192
- roots = [cwd]
2193
- warnings.push(`worktree meta 解析失败(${e.message}),evidence 双根核验退主仓单根`)
2194
- }
2323
+ let roots = resolveEvidenceDualRoots({ cwd, specBase, changeName, warnings })
2195
2324
 
2196
2325
  const detailed = []
2197
2326
  let blockedCount = 0
@@ -2750,6 +2879,21 @@ export const PROBE5_MISSING_ROW_RE = /^\| ❌ missing \|/
2750
2879
  * unavailable 行 / 无删除行 / ℹ️ note 行天然区分——后三者均无「反引号路径+状态尾缀」形态) */
2751
2880
  export const PROBE6_DELETION_LINE_RE = /^- (?:✅ 合规|❌ 高风险|⚠️ 未声明删除)[^`]* `[^`]+`(git 状态/
2752
2881
 
2882
+ /** probe8 契约外载荷键汇总行:`- ⚠️ 契约外载荷键 N 条(design 契约面未见,…):…`
2883
+ * (renderProbe8Lines,verify-probes.js;N 条计数段与段内其它 ⚠️ 行(疑似字段错位配对/
2884
+ * NOT NULL 列前端未见)区分——契约行是汇总行非逐条行,锚点取行内 N 而非行计数) */
2885
+ export const PROBE8_CONTRACT_ORPHANS_LINE_RE = /^- ⚠️ 契约外载荷键 (\d+) 条/
2886
+
2887
+ /** probe8 契约必填漏发汇总行:`- ⚠️ 契约必填漏发 N 条(契约 required 前端载荷未见,…):…`
2888
+ * (同上汇总行口径,行内 N 为锚) */
2889
+ export const PROBE8_MISSING_REQUIRED_LINE_RE = /^- ⚠️ 契约必填漏发 (\d+) 条/
2890
+
2891
+ /** probe9 守卫不一致汇总行:`- ⚠️ 守卫不一致实体组 N 个(同实体有守卫/无守卫并存——…)`
2892
+ * (renderProbe9Lines,verify-probes.js;同 probe8 契约行汇总行口径——锚点取行内 N 而非
2893
+ * 逐条实体组行计数;`实体组 N 个` 计数段与段内逐条行 `- ⚠️ 实体 X:有守卫 […] / 无守卫 […]`
2894
+ * 及其它 ⚠️ 行(NOT NULL 列等)形态区分) */
2895
+ export const PROBE9_INCONSISTENT_LINE_RE = /^- ⚠️ 守卫不一致实体组 (\d+) 个/
2896
+
2753
2897
  /**
2754
2898
  * `#### 探针 N` 子节定界(G8/R-05):子节 = 标题行起、下一任意 markdown 标题(下一探针
2755
2899
  * 子节 / `##` 章 / `#` 题)前止。探针 2/4 的 agent 补写内容天然落在本子节定界之外,其
@@ -2780,27 +2924,40 @@ function extractProbeSubsections(text) {
2780
2924
  * 导出供渲染→解析 round-trip 测试直接消费(R-01)。
2781
2925
  * @param {string} text verify-result.md 全文
2782
2926
  * @returns {{
2783
- * subsections: { probe1: boolean, probe3: boolean, probe5: boolean, probe6: boolean, any: boolean },
2927
+ * subsections: { probe1: boolean, probe3: boolean, probe5: boolean, probe6: boolean, probe8: boolean, probe9: boolean, any: boolean },
2784
2928
  * probe1Hits: number, // probe1 命中行计数(对比 probe1.matches.length)
2785
2929
  * probe3HasTest: number, // probe3 hasTest 行计数(对比 tasks.filter(hasTest).length)
2786
2930
  * probe5SummaryPresent: boolean, // probe5 summary 锚行存在性(pass 形态锚)
2787
2931
  * probe5Missing: number, // `| ❌ missing |` 行计数(fail 形态锚,对比 missingBackend.length)
2788
2932
  * probe6Deletions: number, // probe6 删除条目行计数(对比 deletions.length)
2933
+ * probe8ContractOrphans: number, // probe8 契约外载荷键汇总行内 N 求和(无行=0;对比 contractOrphans.length)
2934
+ * probe8MissingRequired: number, // probe8 契约必填漏发汇总行内 N 求和(无行=0;对比 missingRequired.length)
2935
+ * probe9InconsistentGroups: number, // probe9 守卫不一致汇总行内 N 求和(无行=0;对比 inconsistentGroups.length)
2789
2936
  * }}
2790
2937
  */
2791
2938
  export function parseProbePrefillAnchors(text) {
2792
2939
  const sections = extractProbeSubsections(text)
2793
2940
  const count = (lines, re) => lines.reduce((n, l) => n + (re.test(l) ? 1 : 0), 0)
2941
+ // 汇总行锚点(probe8 契约行/probe9 守卫行是「一行含总数」非逐条行):提取行内 N 求和,无行 → 0
2942
+ // (渲染面恒只输出一行,重复撞形行累加——重复本身就会撞出 mismatch,同子节合并口径)
2943
+ const sumGroupCount = (lines, re) => lines.reduce((n, l) => {
2944
+ const m = l.match(re)
2945
+ return n + (m ? Number(m[1]) : 0)
2946
+ }, 0)
2794
2947
  const s1 = sections['1'] || []
2795
2948
  const s3 = sections['3'] || []
2796
2949
  const s5 = sections['5'] || []
2797
2950
  const s6 = sections['6'] || []
2951
+ const s8 = sections['8'] || []
2952
+ const s9 = sections['9'] || []
2798
2953
  return {
2799
2954
  subsections: {
2800
2955
  probe1: '1' in sections,
2801
2956
  probe3: '3' in sections,
2802
2957
  probe5: '5' in sections,
2803
2958
  probe6: '6' in sections,
2959
+ probe8: '8' in sections,
2960
+ probe9: '9' in sections,
2804
2961
  any: Object.keys(sections).length > 0,
2805
2962
  },
2806
2963
  probe1Hits: count(s1, PROBE1_HIT_LINE_RE),
@@ -2808,6 +2965,9 @@ export function parseProbePrefillAnchors(text) {
2808
2965
  probe5SummaryPresent: count(s5, PROBE5_SUMMARY_LINE_RE) > 0,
2809
2966
  probe5Missing: count(s5, PROBE5_MISSING_ROW_RE),
2810
2967
  probe6Deletions: count(s6, PROBE6_DELETION_LINE_RE),
2968
+ probe8ContractOrphans: sumGroupCount(s8, PROBE8_CONTRACT_ORPHANS_LINE_RE),
2969
+ probe8MissingRequired: sumGroupCount(s8, PROBE8_MISSING_REQUIRED_LINE_RE),
2970
+ probe9InconsistentGroups: sumGroupCount(s9, PROBE9_INCONSISTENT_LINE_RE),
2811
2971
  }
2812
2972
  }
2813
2973
 
@@ -2856,7 +3016,13 @@ function detectHeadAdvanceSinceFacts(cwd, facts) {
2856
3016
  * - HEAD 前进子案(R-06):facts.generatedAt 之后有新 commit 时 probe6 的 `git diff HEAD`
2857
3017
  * 口径整体漂移 → 该 mismatch 降 WARNING 提示重跑 --init;
2858
3018
  * - probe3 hasTest 数 / probe5 锚(summary 行存在性 + missing 行数)不符 = WARNING
2859
- * (测试文件布局与 parity 扫描根环境敏感)。
3019
+ * (测试文件布局与 parity 扫描根环境敏感);
3020
+ * - probe8 契约锚(契约外载荷键/契约必填漏发汇总行内 N)不符 = WARNING(2026-09-16
3021
+ * task-02:契约面解析随 design 形态与清单文件读取环境敏感;重跑降级(fail-soft catch
3022
+ * 兜底无契约键)时跳过该维度不误报);
3023
+ * - probe9 守卫锚(守卫不一致实体组汇总行内 N)不符 = WARNING(2026-09-16
3024
+ * guard-consistency-probe task-02:聚类/信号比对随 design 清单与 Java 文件读取环境
3025
+ * 敏感;重跑降级时跳过该维度不误报)。
2860
3026
  *
2861
3027
  * 接线约定(task-03,gates.js):status='mismatch' 且 severity='error' → 阻断回滚;
2862
3028
  * 'mismatch'+'warning' → 放行告警(envelope probe_consistency_drift);'skipped'/'degraded' →
@@ -2877,7 +3043,7 @@ function detectHeadAdvanceSinceFacts(cwd, facts) {
2877
3043
  * mismatches: Array<{probe: string, expected: *, actual: *, severity: 'error'|'warning', note: string}>,
2878
3044
  * // expected = 当前重跑指标(应然),actual = 正文锚点解析值(agent 可篡改侧)
2879
3045
  * skipReason: string|null,
2880
- * subsections: {probe1: boolean, probe3: boolean, probe5: boolean, probe6: boolean, any: boolean}|null,
3046
+ * subsections: {probe1: boolean, probe3: boolean, probe5: boolean, probe6: boolean, probe8: boolean, probe9: boolean, any: boolean}|null,
2881
3047
  * // 诊断(additive):探针子节在场性;报告未读到的早期 skip 为 null
2882
3048
  * }}
2883
3049
  */
@@ -2993,6 +3159,37 @@ export function checkProbeConsistency({ cwd, specBase = null, changeName = null,
2993
3159
  note: 'contract gap(missing backend)行数与重跑不符(探针5 扫描根/缓存口径环境敏感,WARNING 不阻断)' })
2994
3160
  }
2995
3161
 
3162
+ // probe8 契约锚(WARNING——2026-09-16 task-02:契约外载荷键/契约必填漏发汇总行内 N 对比
3163
+ // 重跑数组长度;契约面解析随 design 形态与清单文件读取环境敏感,不阻断。fail-soft:重跑
3164
+ // 降级(runVerifyProbes 的 catch 兜底对象无契约键)时 curP8*=null 跳过对比不误报)
3165
+ const curProbe8 = current.probe8 || {}
3166
+ const curP8Orphans = Array.isArray(curProbe8.contractOrphans) ? curProbe8.contractOrphans.length : null
3167
+ const curP8MissingRequired = Array.isArray(curProbe8.missingRequired) ? curProbe8.missingRequired.length : null
3168
+ if (curP8Orphans !== null && anchors.probe8ContractOrphans !== curP8Orphans) {
3169
+ mismatches.push({ probe: 'probe8', expected: curP8Orphans, actual: anchors.probe8ContractOrphans, severity: 'warning',
3170
+ note: '契约外载荷键条数与重跑不符(探针8 契约面解析对 design/清单文件形态环境敏感,WARNING 不阻断)——建议重跑 sillyspec verify-probes --change <变更名> --init 刷新预填段' })
3171
+ }
3172
+ if (curP8MissingRequired !== null && anchors.probe8MissingRequired !== curP8MissingRequired) {
3173
+ mismatches.push({ probe: 'probe8', expected: curP8MissingRequired, actual: anchors.probe8MissingRequired, severity: 'warning',
3174
+ note: '契约必填漏发条数与重跑不符(探针8 契约面解析对 design/清单文件形态环境敏感,WARNING 不阻断)——建议重跑 sillyspec verify-probes --change <变更名> --init 刷新预填段' })
3175
+ }
3176
+
3177
+ // probe9 守卫锚(WARNING——2026-09-16 guard-consistency-probe task-02:守卫不一致实体组
3178
+ // 汇总行内 N 对比重跑数组长度;聚类启发式与信号比对随 design 清单及 Java 文件读取环境
3179
+ // 敏感,不阻断。fail-soft:重跑降级(runVerifyProbes 的 catch 兜底)时 probe9 兜底对象与
3180
+ // probe8 不同——inconsistentGroups 恒为数组([]),Array.isArray 判别失效,改以「探针 9
3181
+ // 执行失败」注记判降级跳过对比,不误报)
3182
+ const curProbe9 = current.probe9 || {}
3183
+ const curP9Degraded = (curProbe9.notes || []).some(n => typeof n === 'string' && n.includes('探针 9 执行失败'))
3184
+ const curP9Inconsistent = Array.isArray(curProbe9.inconsistentGroups) && !curP9Degraded
3185
+ ? curProbe9.inconsistentGroups.length : null
3186
+ const curP9GroupCount = typeof curProbe9.groupCount === 'number' && !curP9Degraded
3187
+ ? curProbe9.groupCount : null
3188
+ if (curP9Inconsistent !== null && anchors.probe9InconsistentGroups !== curP9Inconsistent) {
3189
+ mismatches.push({ probe: 'probe9', expected: curP9Inconsistent, actual: anchors.probe9InconsistentGroups, severity: 'warning',
3190
+ note: '守卫不一致实体组数与重跑不符(探针9 聚类/信号比对对 design 清单与 Java 文件形态环境敏感,WARNING 不阻断)——建议重跑 sillyspec verify-probes --change <变更名> --init 刷新预填段' })
3191
+ }
3192
+
2996
3193
  // —— facts 基线对比(2026-09-08-ir-verify-facts FR-05 / task-05):重跑指标 vs
2997
3194
  // verify-facts.json probes 快照(init 时点)。md 锚点对账查「正文没被改」,本维度查
2998
3195
  // 「md 被手改对齐新代码后 facts 底稿过期」(P3d 数据源保鲜)。分级沿用现实现口径:
@@ -3025,8 +3222,45 @@ export function checkProbeConsistency({ cwd, specBase = null, changeName = null,
3025
3222
  fm.push({ probe: 'probe5', snapshot: snap5, rerun: cur5, severity: 'warning',
3026
3223
  note: 'facts 快照 backend 端点数与重跑不符(扫描根口径环境敏感,WARNING)' })
3027
3224
  }
3225
+ // probe8 契约指标(2026-09-16 task-02,WARNING 同 md 锚点维度口径;metrics fail-soft
3226
+ // 少列键(旧 facts 无 probe8)或重跑降级时不对账不列 checked,不误报)
3227
+ const snap8Metrics = (((factsSnapshot.probes.probe8 || {}).metrics) || {})
3228
+ const p8MetricsAvailable = typeof snap8Metrics.contractOrphans === 'number' || typeof snap8Metrics.missingRequired === 'number'
3229
+ const p8RerunAvailable = curP8Orphans !== null || curP8MissingRequired !== null
3230
+ if (typeof snap8Metrics.contractOrphans === 'number' && curP8Orphans !== null
3231
+ && snap8Metrics.contractOrphans !== curP8Orphans) {
3232
+ fm.push({ probe: 'probe8', snapshot: snap8Metrics.contractOrphans, rerun: curP8Orphans, severity: 'warning',
3233
+ note: 'facts 快照契约外载荷键数与重跑不符(契约面解析环境敏感,WARNING)——建议 verify-probes --init 刷新 facts + 同步 md 探针段' })
3234
+ }
3235
+ if (typeof snap8Metrics.missingRequired === 'number' && curP8MissingRequired !== null
3236
+ && snap8Metrics.missingRequired !== curP8MissingRequired) {
3237
+ fm.push({ probe: 'probe8', snapshot: snap8Metrics.missingRequired, rerun: curP8MissingRequired, severity: 'warning',
3238
+ note: 'facts 快照契约必填漏发数与重跑不符(契约面解析环境敏感,WARNING)——建议 verify-probes --init 刷新 facts + 同步 md 探针段' })
3239
+ }
3240
+ // probe9 守卫指标(2026-09-16 guard-consistency-probe task-02,WARNING 同 md 锚点维度口径;
3241
+ // 双指标 = inconsistentGroups(命中面,同 md 锚点维度)+ groupCount(聚类组面,design 清单
3242
+ // Java 文件内容变更的漂移信号)。javaFileCount 不入对账——文件读取受 worktree 存活态等
3243
+ // 环境维度影响(R-02 同款排除口径,probe1 排除 worktreeHits 先例);metrics fail-soft 少列
3244
+ // 键(旧 facts 无 probe9)或重跑降级(curP9*=null)时不对账不列 checked,不误报)
3245
+ const snap9Metrics = (((factsSnapshot.probes.probe9 || {}).metrics) || {})
3246
+ const p9MetricsAvailable = typeof snap9Metrics.inconsistentGroups === 'number' || typeof snap9Metrics.groupCount === 'number'
3247
+ const p9RerunAvailable = curP9Inconsistent !== null || curP9GroupCount !== null
3248
+ if (typeof snap9Metrics.inconsistentGroups === 'number' && curP9Inconsistent !== null
3249
+ && snap9Metrics.inconsistentGroups !== curP9Inconsistent) {
3250
+ fm.push({ probe: 'probe9', snapshot: snap9Metrics.inconsistentGroups, rerun: curP9Inconsistent, severity: 'warning',
3251
+ note: 'facts 快照守卫不一致组数与重跑不符(聚类/信号比对环境敏感,WARNING)——建议 verify-probes --init 刷新 facts + 同步 md 探针段' })
3252
+ }
3253
+ if (typeof snap9Metrics.groupCount === 'number' && curP9GroupCount !== null
3254
+ && snap9Metrics.groupCount !== curP9GroupCount) {
3255
+ fm.push({ probe: 'probe9', snapshot: snap9Metrics.groupCount, rerun: curP9GroupCount, severity: 'warning',
3256
+ note: 'facts 快照同实体变更方法组数与重跑不符(聚类面随 design 清单 Java 文件内容漂移,WARNING)——建议 verify-probes --init 刷新 facts + 同步 md 探针段' })
3257
+ }
3028
3258
  factsConsistency = {
3029
- checked: fm.length === 0 ? ['probe1', 'probe3', 'probe5', 'probe6'] : [...new Set(fm.map(x => x.probe))],
3259
+ checked: fm.length === 0
3260
+ ? ['probe1', 'probe3', 'probe5', 'probe6',
3261
+ ...((p8MetricsAvailable && p8RerunAvailable) ? ['probe8'] : []),
3262
+ ...((p9MetricsAvailable && p9RerunAvailable) ? ['probe9'] : [])]
3263
+ : [...new Set(fm.map(x => x.probe))],
3030
3264
  verdict: fm.length === 0 ? 'match' : 'mismatch',
3031
3265
  detail: fm.length === 0 ? null : fm,
3032
3266
  }