@renzrollon/interlock 1.5.0 → 1.6.0

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 (43) hide show
  1. package/.claude-plugin/plugin.json +12 -1
  2. package/CHANGELOG.md +104 -0
  3. package/README.md +7 -8
  4. package/bin/interlock +1655 -29
  5. package/hooks/mod.mjs +21 -20
  6. package/hooks/notify-option.mjs +69 -0
  7. package/hooks/preflight.mjs +6 -42
  8. package/lib/conformance.mjs +75 -0
  9. package/lib/coverage.mjs +387 -0
  10. package/lib/doctor.mjs +17 -13
  11. package/lib/drift.mjs +36 -12
  12. package/lib/findings.mjs +40 -3
  13. package/lib/graph/extract/openspec.mjs +6 -15
  14. package/lib/limits.mjs +78 -1
  15. package/lib/merge-confidence.mjs +1302 -0
  16. package/lib/merge-evidence.mjs +527 -0
  17. package/lib/notify.mjs +10 -20
  18. package/lib/outcomes.mjs +26 -5
  19. package/lib/pipeline.mjs +380 -0
  20. package/lib/prompts/dimensions.mjs +24 -2
  21. package/lib/prompts/domain-context.mjs +271 -0
  22. package/lib/prompts/handoff.mjs +32 -1
  23. package/lib/prompts/index.mjs +57 -0
  24. package/lib/prompts/remediate.mjs +29 -5
  25. package/lib/prompts/review.mjs +21 -7
  26. package/lib/prompts/schemas.mjs +2 -0
  27. package/lib/ready.mjs +13 -1
  28. package/lib/receipt.mjs +72 -2
  29. package/lib/review-core.mjs +224 -21
  30. package/lib/risk.mjs +8 -0
  31. package/lib/run-log.mjs +44 -0
  32. package/lib/run.mjs +859 -27
  33. package/lib/spec-audit.mjs +232 -0
  34. package/lib/spec-coverage.mjs +105 -0
  35. package/lib/spec-text.mjs +257 -0
  36. package/lib/verify.mjs +17 -4
  37. package/package.json +1 -1
  38. package/shared/SESSION-SHAPES.md +1 -1
  39. package/shared/TEST-PROFILE.md +39 -2
  40. package/skills/mr/SKILL.md +161 -11
  41. package/skills/review-code/REVIEW.template.md +24 -6
  42. package/skills/review-code/SKILL.md +16 -6
  43. package/skills/review-code/dimensions/domain.md +21 -0
package/lib/receipt.mjs CHANGED
@@ -87,6 +87,35 @@ export function tailFromManifest(manifest) {
87
87
  }
88
88
  }
89
89
 
90
+ /**
91
+ * The merge-confidence block a receipt carries (design D11): the verdict and its
92
+ * shape, and no text. The close hands over a whole assessment whose reasons and
93
+ * evidence lines can quote a finding's title and the file it names, so this
94
+ * reduces it to the verdict, the score, the worst band, the counts, the ids of
95
+ * the signals that drove it and the flag names. `undefined` when the run did not
96
+ * assess — never a zero score and never a default verdict.
97
+ *
98
+ * Import-free like the rest of this module; `lib/run-log.mjs`'s whitelist bounds
99
+ * and validates the same fields again on the way to disk.
100
+ */
101
+ function mergeConfidenceBlock(assessment) {
102
+ if (!assessment || typeof assessment !== 'object' || Array.isArray(assessment)) return undefined
103
+ const ids = list =>
104
+ (Array.isArray(list) ? list : [])
105
+ .map(entry => (entry && typeof entry === 'object' ? entry.id : entry))
106
+ .filter(id => typeof id === 'string' && id.length > 0)
107
+ return {
108
+ verdict: assessment.verdict,
109
+ score: assessment.score,
110
+ worstBand: assessment.worstBand,
111
+ counts: assessment.counts && typeof assessment.counts === 'object' ? { ...assessment.counts } : undefined,
112
+ driving: ids(assessment.driving),
113
+ flags: (Array.isArray(assessment.flags) ? assessment.flags : [])
114
+ .filter(f => f && typeof f === 'object' && typeof f.id === 'string' && typeof f.flag === 'string')
115
+ .map(f => ({ id: f.id, flag: f.flag }))
116
+ }
117
+ }
118
+
90
119
  /**
91
120
  * The run's receipt payload: what this run observed about itself, in the field
92
121
  * names `lib/run-log.mjs`'s `run-receipt` whitelist copies by name.
@@ -108,7 +137,7 @@ export function tailFromManifest(manifest) {
108
137
  * lanesStoppedByHost?: number, schemaResultsMissing?: number,
109
138
  * toolsDeniedInLanes?: number, modelSubstitutions?: number,
110
139
  * permissionPrompts?: number, autoModeDenials?: number,
111
- * surface?: string, stateHome?: string
140
+ * surface?: string, stateHome?: string, mergeConfidence?: object
112
141
  * }} input the six counts are passed only when the run observed agents at all
113
142
  * (a host record or a sidecar); leaving one out records it as unobserved
114
143
  */
@@ -225,6 +254,7 @@ export function buildReceipt(input) {
225
254
  capExhaustedVerifications: closing ? closing.capExhaustedVerifications : undefined,
226
255
  unresolvedErrors: closing ? closing.unresolvedErrors : undefined,
227
256
  leftoverTaskIds: source.leftoverTaskIds,
257
+ mergeConfidence: mergeConfidenceBlock(source.mergeConfidence),
228
258
  halted: Boolean(summary.halted),
229
259
  haltReason: summary.halted || undefined,
230
260
  // Tri-state, and the reason the field is not just the sha: a run told not to
@@ -373,7 +403,8 @@ export function formatRunSummary({
373
403
  stateHome = undefined,
374
404
  push = null,
375
405
  unarchived = null,
376
- resumeCard = null
406
+ resumeCard = null,
407
+ mergeConfidence = null
377
408
  } = {}) {
378
409
  const leftover = Array.isArray(leftoverTaskIds) ? leftoverTaskIds : []
379
410
  const lines = []
@@ -439,6 +470,19 @@ export function formatRunSummary({
439
470
  (unconfirmed ? ` — ${unconfirmed} unconfirmed, see conformance.md` : '')
440
471
  )
441
472
  }
473
+ // The living requirements, counted on their own terms beside the scenarios
474
+ // (design D5). Absent when the count is: a run that was not asked about
475
+ // them, or whose handoff did not report, prints nothing rather than 0/0.
476
+ const requirementsChecked = summary.handoff.requirementsChecked
477
+ if (Number.isFinite(requirementsChecked) && requirementsChecked > 0) {
478
+ const unconfirmedReqs = Number.isFinite(summary.handoff.requirementsUnconfirmed)
479
+ ? summary.handoff.requirementsUnconfirmed
480
+ : 0
481
+ lines.push(
482
+ ` living requirements: ${requirementsChecked - unconfirmedReqs}/${requirementsChecked} confirmed` +
483
+ (unconfirmedReqs ? ` — ${unconfirmedReqs} unconfirmed, see conformance.md` : '')
484
+ )
485
+ }
442
486
  }
443
487
  if (summary.commit && summary.commit.ok) lines.push(` commit: ${summary.commit.sha || 'created'}`)
444
488
  for (const note of summary.notes || []) lines.push(` ${note}`)
@@ -462,6 +506,32 @@ export function formatRunSummary({
462
506
  lines.push(` state home: ${stateHome}`)
463
507
  }
464
508
 
509
+ // The merge-confidence block (design D10), on a completed run only: a halt
510
+ // never assessed, and a line here would read as if it had. The close hands
511
+ // over the headline already rendered and the ids already grouped, so this
512
+ // module derives neither. A group prints only when it has an entry; reasons
513
+ // and evidence stay in the assessment file.
514
+ if (!summary.halted && mergeConfidence && typeof mergeConfidence === 'object') {
515
+ const mc = mergeConfidence
516
+ if (typeof mc.headline === 'string' && mc.headline.length > 0) {
517
+ lines.push(mc.headline)
518
+ const groups = mc.groups && typeof mc.groups === 'object' ? mc.groups : {}
519
+ const rows = [
520
+ ['needs a fix', groups.fix],
521
+ ['needs a person', groups.person],
522
+ ['needs more evidence', groups.evidence]
523
+ ]
524
+ for (const [label, ids] of rows) {
525
+ if (Array.isArray(ids) && ids.length) lines.push(` ${label}: ${ids.join(', ')}`)
526
+ }
527
+ const flagged = (Array.isArray(mc.flags) ? mc.flags : []).filter(f => f && f.id && f.flag)
528
+ if (flagged.length) lines.push(` flags: ${flagged.map(f => `${f.id} ${f.flag}`).join(', ')}`)
529
+ if (typeof mc.assessmentPath === 'string' && mc.assessmentPath.length > 0) {
530
+ lines.push(` assessment: ${mc.assessmentPath}`)
531
+ }
532
+ }
533
+ }
534
+
465
535
  // The push outcome (design D5): a row only when a push was actually
466
536
  // attempted — an unconfigured relay is an optional feature left off, not a
467
537
  // degraded run, so it prints nothing here.
@@ -32,12 +32,15 @@ import { existsSync, readFileSync, statSync } from 'node:fs'
32
32
  import { join } from 'node:path'
33
33
 
34
34
  import { LIMITS } from './limits.mjs'
35
- import { SEVERITIES, TOLERANCE_BAND, applyToleranceBand } from './findings.mjs'
35
+ import { SEVERITIES, TOLERANCE_BAND, applyToleranceBand, normalizeSeverity } from './findings.mjs'
36
36
  // The locator vocabulary lives in one module because a second surface now
37
37
  // audits citations the same way (`auditHandoffEvidence` in lib/waves.mjs). Two
38
38
  // definitions of "what a locator looks like" drift silently, each still passing
39
39
  // its own tests. See lib/locators.mjs for what is shared and what is not.
40
40
  import { CITATION_TOKEN, canonicalizePath, citesLineIn, diffIndex } from './locators.mjs'
41
+ // The one canonical requirement-heading form (design D1): a citation and the
42
+ // spec text it names are compared on `requirementKey`, never on raw spelling.
43
+ import { requirementKey } from './spec-text.mjs'
41
44
 
42
45
  // Accept the same three input shapes `evaluateGate` accepts (bare array, one
43
46
  // `{dimension, findings}` object, or an array of those). `applyToleranceBand`
@@ -205,11 +208,16 @@ function numericScore(value) {
205
208
  * `REVIEW.md`); a finding whose canonical file lies at or under one is dropped
206
209
  * BEFORE the band is applied, so exclusions shrink the input but never alter
207
210
  * the arithmetic on what remains (add-interlock-review-policy spec §5).
211
+ * `requirementHeadings` maps each cited spec path (canonical) to that file's
212
+ * requirement keys, or to `{ unreadable: reason }`; with it, a finding's
213
+ * `requirement` citation is shape-checked (see `checkRequirementCitation`).
214
+ * A failed check is reported under `anomalies.requirementCitations` and
215
+ * changes nothing about how the finding is adjudicated.
208
216
  * @returns {{
209
217
  * surviving: Array, dismissed: Array, droppedByQuality: Array, droppedByPolicy: Array,
210
218
  * counts: {raised: number, dismissed: number, droppedByQuality: number, droppedByPolicy: number, surviving: number},
211
219
  * dimensionStats: Object<string, {raised: number, surviving: number}>,
212
- * anomalies: {orphanVerdicts: Array, malformedVerdicts: Array}
220
+ * anomalies: {orphanVerdicts: Array, malformedVerdicts: Array, requirementCitations: Array}
213
221
  * }}
214
222
  */
215
223
  export function resolveReview(findings, verdicts, opts = {}) {
@@ -233,6 +241,24 @@ export function resolveReview(findings, verdicts, opts = {}) {
233
241
  else all.push(finding)
234
242
  }
235
243
 
244
+ // Requirement citations are checked on every finding that reaches
245
+ // adjudication, before survival is decided and without feeding into it: the
246
+ // check is a SHAPE check, a broken citation must never delete a real defect,
247
+ // and whether the cited requirement forbids the diff is the skeptics' question.
248
+ const requirementCitations = []
249
+ for (const finding of all) {
250
+ const problem = checkRequirementCitation(finding, opts.requirementHeadings)
251
+ if (problem) {
252
+ requirementCitations.push({
253
+ dimension: finding.dimension || 'unknown',
254
+ file: typeof finding.file === 'string' ? finding.file : null,
255
+ title: titleOf(finding),
256
+ requirement: typeof finding.requirement === 'string' ? finding.requirement : null,
257
+ problem
258
+ })
259
+ }
260
+ }
261
+
236
262
  // Index verdicts by what they claim to adjudicate: title, plus file when the
237
263
  // skeptic named one.
238
264
  const byKey = new Map()
@@ -354,7 +380,7 @@ export function resolveReview(findings, verdicts, opts = {}) {
354
380
  droppedByPolicy,
355
381
  counts,
356
382
  dimensionStats,
357
- anomalies: { orphanVerdicts, malformedVerdicts }
383
+ anomalies: { orphanVerdicts, malformedVerdicts, requirementCitations }
358
384
  }
359
385
  }
360
386
 
@@ -375,12 +401,82 @@ export function resolveReview(findings, verdicts, opts = {}) {
375
401
  * root) is not excluded: an exclusion is an affirmative scope decision, and a
376
402
  * path we cannot place cannot be affirmatively matched.
377
403
  */
378
- function pathExcludedBy(file, canonicalPrefix) {
404
+ export function pathExcludedBy(file, canonicalPrefix) {
379
405
  const canonicalFile = canonicalizePath(file)
380
406
  if (!canonicalFile) return false
381
407
  return canonicalFile === canonicalPrefix || canonicalFile.startsWith(canonicalPrefix + '/')
382
408
  }
383
409
 
410
+ const SPEC_CITATION_PATH = /^openspec\/specs\/.+\/spec\.md$/
411
+
412
+ // A cited file's entry in `requirementHeadings`: a Map or a plain object.
413
+ function headingsEntry(headings, path) {
414
+ if (headings instanceof Map) return headings.get(path)
415
+ if (headings && typeof headings === 'object') {
416
+ return Object.prototype.hasOwnProperty.call(headings, path) ? headings[path] : undefined
417
+ }
418
+ return undefined
419
+ }
420
+
421
+ /**
422
+ * The shape check on one finding's requirement citation, or null when it passes
423
+ * (or there is nothing to check).
424
+ *
425
+ * A citation is `openspec/specs/<capability>/spec.md#<heading>`. Three checks,
426
+ * in order: the path is canonical, under `openspec/specs/` and names a
427
+ * `spec.md`; the file was read at the base (`requirementHeadings` has it, and it
428
+ * is not `{ unreadable }`); the heading, on `requirementKey`, names a
429
+ * requirement in it. Case is part of the heading: a different case is a
430
+ * different requirement, as OpenSpec itself reads it.
431
+ *
432
+ * A `domain` blocker with no citation is also a problem, because contradicting a
433
+ * requirement in force is the only blocker that dimension raises. A `domain`
434
+ * warning without one is not: behaviour no requirement describes has nothing to
435
+ * cite.
436
+ */
437
+ function checkRequirementCitation(finding, headings) {
438
+ const cited = finding.requirement
439
+ const present = typeof cited === 'string' ? cited.trim() !== '' : cited !== undefined && cited !== null
440
+ if (!present) {
441
+ // On the canonical severity, as the gate and the merge verdict read it:
442
+ // "BLOCKER" blocks there, so it is a blocker here.
443
+ return finding.dimension === 'domain' && normalizeSeverity(finding.severity) === 'blocker'
444
+ ? 'domain blocker cites no requirement'
445
+ : null
446
+ }
447
+ if (typeof cited !== 'string') return 'requirement citation is not a string'
448
+
449
+ const hash = cited.indexOf('#')
450
+ if (hash === -1) {
451
+ return `requirement citation "${cited}" has no #heading naming a requirement`
452
+ }
453
+ const path = canonicalizePath(cited.slice(0, hash).trim())
454
+ if (!path || !SPEC_CITATION_PATH.test(path)) {
455
+ return (
456
+ `requirement citation "${cited.slice(0, hash).trim()}" is not a canonical ` +
457
+ `openspec/specs/<capability>/spec.md path`
458
+ )
459
+ }
460
+
461
+ const entry = headingsEntry(headings, path)
462
+ if (headings === undefined || headings === null) {
463
+ return `unchecked: ${path} was not read, so the cited requirement could not be checked`
464
+ }
465
+ if (entry === undefined || entry === null) {
466
+ return `unchecked: ${path} was not among the spec files read at the base`
467
+ }
468
+ if (!Array.isArray(entry) && !(entry instanceof Set)) {
469
+ const reason = typeof entry.unreadable === 'string' && entry.unreadable ? entry.unreadable : 'unreadable'
470
+ return `unchecked: ${path} could not be read at the base: ${reason}`
471
+ }
472
+ const wanted = requirementKey(cited.slice(hash + 1))
473
+ const keys = [...entry].map(requirementKey)
474
+ if (!wanted || !keys.includes(wanted)) {
475
+ return `names no requirement in ${path}: "${cited.slice(hash + 1).trim()}"`
476
+ }
477
+ return null
478
+ }
479
+
384
480
  /** Human-readable review summary. */
385
481
  export function formatReview(result) {
386
482
  const { raised, dismissed, droppedByQuality, droppedByPolicy, surviving, dismissalsRejected } =
@@ -416,6 +512,11 @@ export function formatReview(result) {
416
512
  }
417
513
 
418
514
  const { orphanVerdicts, malformedVerdicts } = result.anomalies
515
+ for (const c of result.anomalies.requirementCitations || []) {
516
+ const where = c.file ? `${c.file} — ` : ''
517
+ const cite = c.requirement ? ` [${c.requirement}]` : ''
518
+ lines.push(` ANOMALY: requirement citation on ${where}${c.title || '(untitled)'}: ${c.problem}${cite}`)
519
+ }
419
520
  if (orphanVerdicts.length) {
420
521
  const titles = [...new Set(orphanVerdicts.map(v => v.findingTitle))]
421
522
  lines.push(` ANOMALY: ${orphanVerdicts.length} verdict(s) matched no finding: ${titles.join(', ')}`)
@@ -439,7 +540,7 @@ export function formatReview(result) {
439
540
  /** The one location that is read as policy: the repository root. */
440
541
  export const REVIEW_POLICY_FILE = 'REVIEW.md'
441
542
 
442
- const EMPTY_POLICY = () => ({ prose: '', excludePaths: [], owner: null, problems: [] })
543
+ const EMPTY_POLICY = () => ({ prose: '', excludePaths: [], humanPaths: [], owner: null, problems: [] })
443
544
 
444
545
  // The only recognized front-matter key. The band and nit cap deliberately are
445
546
  // NOT here — they live in the CLI so they are not re-argued (spec §5). Any other
@@ -465,6 +566,28 @@ function normalizeHeading(text) {
465
566
 
466
567
  const PROSE_HEADINGS = new Set(['owner', 'what important means', 'exclusions rationale'])
467
568
  const EXCLUSIONS_HEADING = 'do not report'
569
+ const HUMAN_REVIEW_HEADING = 'always human review'
570
+
571
+ // The two path-list sections share one grammar, one canonical form and one set
572
+ // of problem wordings; only the nouns differ. `humanPaths` is scope, read by the
573
+ // merge verdict alone — it never reaches `resolveReview` (design D3).
574
+ const EXCLUSIONS_SECTION = {
575
+ label: 'Do Not Report',
576
+ noPaths: 'no exclusions applied',
577
+ entry: 'exclusion',
578
+ nothing: 'excludes nothing'
579
+ }
580
+ const HUMAN_REVIEW_SECTION = {
581
+ label: 'Always Human Review',
582
+ noPaths: 'no paths declared',
583
+ entry: 'entry',
584
+ nothing: 'declares nothing'
585
+ }
586
+
587
+ // What marks a list item as a pattern rather than a path (design D11 of
588
+ // label-and-harden-the-merge-verdict). Only these two: brackets and braces
589
+ // occur in real paths, such as a route directory.
590
+ const PATTERN_ITEM = /[*?]/
468
591
 
469
592
  /**
470
593
  * Read the repo-root `REVIEW.md` and parse it into a policy object.
@@ -475,7 +598,7 @@ const EXCLUSIONS_HEADING = 'do not report'
475
598
  * run proceeds under default policy rather than parsing unbounded input.
476
599
  *
477
600
  * @param {string} repoRoot repository root to look under (defaults to cwd)
478
- * @returns {{prose: string, excludePaths: string[], owner: string|null, problems: string[]}}
601
+ * @returns {{prose: string, excludePaths: string[], humanPaths: string[], owner: string|null, problems: string[]}}
479
602
  */
480
603
  export function readReviewPolicy(repoRoot) {
481
604
  const root = typeof repoRoot === 'string' && repoRoot.trim() ? repoRoot : '.'
@@ -509,7 +632,7 @@ export function readReviewPolicy(repoRoot) {
509
632
  }
510
633
 
511
634
  /**
512
- * Parse `REVIEW.md` text into `{ prose, excludePaths, owner, problems }`.
635
+ * Parse `REVIEW.md` text into `{ prose, excludePaths, humanPaths, owner, problems }`.
513
636
  *
514
637
  * Each half is validated independently (design.md D3): a broken exclusions block
515
638
  * does not stop valid prose from being returned, and a broken prose section does
@@ -561,11 +684,17 @@ export function parseReviewPolicy(text) {
561
684
  for (const section of sections) {
562
685
  const norm = normalizeHeading(section.heading)
563
686
  if (norm === EXCLUSIONS_HEADING) {
564
- const { paths, problems } = parseExclusions(section.content)
687
+ const { paths, problems } = parsePathList(section.content, EXCLUSIONS_SECTION)
565
688
  policy.excludePaths.push(...paths)
566
689
  policy.problems.push(...problems)
567
690
  continue
568
691
  }
692
+ if (norm === HUMAN_REVIEW_HEADING) {
693
+ const { paths, problems } = parsePathList(section.content, HUMAN_REVIEW_SECTION)
694
+ policy.humanPaths.push(...paths)
695
+ policy.problems.push(...problems)
696
+ continue
697
+ }
569
698
  if (PROSE_HEADINGS.has(norm)) {
570
699
  const content = section.content.trim()
571
700
  if (norm === 'owner') {
@@ -589,6 +718,7 @@ export function parseReviewPolicy(text) {
589
718
  policy.owner = owner
590
719
  // `./dist` and `dist/` canonicalize to one prefix; keep it once.
591
720
  policy.excludePaths = [...new Set(policy.excludePaths)]
721
+ policy.humanPaths = [...new Set(policy.humanPaths)]
592
722
  return policy
593
723
  }
594
724
 
@@ -644,22 +774,32 @@ function splitSections(body) {
644
774
  }
645
775
 
646
776
  /**
647
- * Parse a `## Do Not Report` block into canonical excluded prefixes.
777
+ * Parse a path-list section (`## Do Not Report`, `## Always Human Review`) into
778
+ * canonical path prefixes.
648
779
  *
649
780
  * Entries are markdown list items (`- path` / `* path`). Each is canonicalized
650
781
  * with the same `canonicalizePath` findings go through, so a finding cannot slip
651
- * an exclusion on an alternate spelling; an entry that cannot be canonicalized
652
- * (absolute, escaping the root) excludes nothing and is reported (spec §3, task
653
- * 1.3). A structurally broken block — a fenced code block, or content with no
654
- * list entries at all — is reported unusable as a whole and yields no
655
- * exclusions, rather than being half-guessed (spec §4, the partially-valid file).
782
+ * past a listed prefix on an alternate spelling; an entry that cannot be
783
+ * canonicalized (absolute, escaping the root) declares nothing and is reported
784
+ * (spec §3, task 1.3). An entry shaped like a survival threshold (`- nitCap: 0`)
785
+ * is reported and never read as a path, in either section. So is an entry that
786
+ * holds `*` or `?`: the lists hold prefixes, not patterns, and a pattern read as
787
+ * a literal path matches no file, so `- src/**` would list nothing and say
788
+ * nothing. Square brackets and braces are characters a path may hold
789
+ * (`app/[id]/`) and are read as part of it. A structurally broken
790
+ * block — a fenced code block, or content with no list entries at all — is
791
+ * reported unusable as a whole and yields no paths, rather than being
792
+ * half-guessed (spec §4, the partially-valid file).
793
+ *
794
+ * @param {string} content the section body
795
+ * @param {{label: string, noPaths: string, entry: string, nothing: string}} section
656
796
  */
657
- function parseExclusions(content) {
797
+ function parsePathList(content, section) {
658
798
  const problems = []
659
799
  if (/^\s*```/m.test(content)) {
660
800
  problems.push(
661
- `the "Do Not Report" block contains a code fence and could not be parsed as a path list; ` +
662
- `no exclusions applied`
801
+ `the "${section.label}" block contains a code fence and could not be parsed as a path list; ` +
802
+ `${section.noPaths}`
663
803
  )
664
804
  return { paths: [], problems }
665
805
  }
@@ -681,32 +821,95 @@ function parseExclusions(content) {
681
821
  // Non-blank content but not a single list item: the block is not a path list.
682
822
  if (!entries.length) {
683
823
  problems.push(
684
- `the "Do Not Report" block has content but no path list items (\`- path\`); ` +
685
- `no exclusions applied`
824
+ `the "${section.label}" block has content but no path list items (\`- path\`); ` +
825
+ `${section.noPaths}`
686
826
  )
687
827
  return { paths: [], problems }
688
828
  }
689
829
  if (sawNonEntry) {
690
830
  problems.push(
691
- `the "Do Not Report" block mixes list items with non-list text; only the \`- path\` items ` +
831
+ `the "${section.label}" block mixes list items with non-list text; only the \`- path\` items ` +
692
832
  `were read`
693
833
  )
694
834
  }
695
835
 
696
836
  const paths = []
697
837
  for (const entry of entries) {
838
+ if (BAND_KEY_LINE.test(entry)) {
839
+ problems.push(
840
+ `"${section.label}" item "${entry}" looks like a survival threshold — the band and nit cap ` +
841
+ `live in the CLI, not REVIEW.md; ignored, not read as a path`
842
+ )
843
+ continue
844
+ }
845
+ if (PATTERN_ITEM.test(entry)) {
846
+ // The problem names its section in the form the threshold-shaped one
847
+ // does: the merge verdict tells the two sections' problems apart by it.
848
+ problems.push(
849
+ `"${section.label}" item "${entry}" is a pattern, and only a path or a directory prefix is read ` +
850
+ `here; ignored, not read as a path (list the directory instead, such as \`src/\`)`
851
+ )
852
+ continue
853
+ }
698
854
  const canonical = canonicalizePath(stripQuotes(entry.replace(/\/+$/, '')))
699
855
  if (canonical) paths.push(canonical)
700
- else problems.push(`exclusion "${entry}" is not a repo-relative path and excludes nothing`)
856
+ else problems.push(`${section.entry} "${entry}" is not a repo-relative path and ${section.nothing}`)
701
857
  }
702
858
  return { paths, problems }
703
859
  }
704
860
 
861
+ // Line endings and trailing newlines are not a policy difference.
862
+ function normalizePolicyText(text) {
863
+ if (typeof text !== 'string') return ''
864
+ const lf = text.replace(/\r\n?/g, '\n')
865
+ return lf.trim() ? lf.replace(/\s+$/, '') + '\n' : ''
866
+ }
867
+
868
+ /**
869
+ * Compose the policy a review is judged by from two copies of `REVIEW.md`.
870
+ *
871
+ * The trust split `docs/12` draws decides where each part comes from. The two
872
+ * path lists are enforced, so a change must not edit the copy applied to it:
873
+ * they come from `base`. The prose is advice that cannot relax the gate, so it
874
+ * (and the owner it names) comes from `working` — an uncommitted edit still
875
+ * reaches the reviewer. `differsFromBase` says the two copies differ, so a
876
+ * caller can speak it; it is the only thing that makes an edited policy visible.
877
+ *
878
+ * Pure: each copy is `{ text, policy }` (`policy` as `parseReviewPolicy`
879
+ * returns it, `text` carried so line endings can be compared) or `null` for an
880
+ * absent file, which is the empty policy with no problem — absence is the off
881
+ * switch. Reading a file at a ref is I/O and lives with the caller.
882
+ *
883
+ * @param {{working: ?{text: string, policy: object}, base: ?{text: string, policy: object}, ref: ?string}} input
884
+ * @returns {{prose: string, owner: ?string, excludePaths: string[], humanPaths: string[],
885
+ * problems: string[], listsFrom: ?string, differsFromBase: boolean}}
886
+ */
887
+ export function composePolicy({ working = null, base = null, ref = null } = {}) {
888
+ const workingPolicy = working?.policy ?? EMPTY_POLICY()
889
+ const basePolicy = base?.policy ?? EMPTY_POLICY()
890
+ const baseLabel = ref ? `(${ref})` : '(base)'
891
+ const problems = [
892
+ ...(workingPolicy.problems || []).map(p => `${p} (working tree)`),
893
+ ...(basePolicy.problems || []).map(p => `${p} ${baseLabel}`)
894
+ ]
895
+ return {
896
+ prose: workingPolicy.prose || '',
897
+ owner: workingPolicy.owner ?? null,
898
+ excludePaths: [...(basePolicy.excludePaths || [])],
899
+ humanPaths: [...(basePolicy.humanPaths || [])],
900
+ problems,
901
+ listsFrom: ref ?? null,
902
+ differsFromBase: normalizePolicyText(working?.text) !== normalizePolicyText(base?.text)
903
+ }
904
+ }
905
+
705
906
  /** Human-readable dump of a parsed policy, for `interlock review-policy`. */
706
907
  export function formatReviewPolicy(policy) {
707
908
  const lines = []
708
909
  lines.push(`REVIEW POLICY — ${policy.owner ? `owner: ${policy.owner}` : 'no owner declared'}`)
709
910
  lines.push(` exclusions: ${policy.excludePaths.length ? policy.excludePaths.join(', ') : '(none)'}`)
911
+ const humanPaths = policy.humanPaths || []
912
+ lines.push(` human-review: ${humanPaths.length ? humanPaths.join(', ') : '(none)'}`)
710
913
  lines.push(` prose: ${policy.prose ? `${policy.prose.length} char(s)` : '(none)'}`)
711
914
  for (const problem of policy.problems) lines.push(` PROBLEM: ${problem}`)
712
915
  return lines.join('\n') + '\n'
package/lib/risk.mjs CHANGED
@@ -45,6 +45,14 @@ const RANK = { low: 0, medium: 1, high: 2, critical: 3 }
45
45
  // to strict (['low']) or loose (['low','medium','high']) is this one edit.
46
46
  export const CONTINUITY_ALLOWED_CLASSES = Object.freeze(['low', 'medium'])
47
47
 
48
+ // The classes the merge verdict (`lib/merge-confidence.mjs`, signal
49
+ // `risk.class`) reads green; anything above them is amber, marked as needing a
50
+ // person, and never red. The same two classes as continuity today, but a
51
+ // separate constant on purpose: skipping the spec checkpoint and merging a pull
52
+ // request are different decisions, and re-tuning one must not silently move the
53
+ // other. The two policies may diverge.
54
+ export const MERGE_ALLOWED_CLASSES = Object.freeze(['low', 'medium'])
55
+
48
56
  // Where an input with no usable signal at all lands. Deliberately outside
49
57
  // CONTINUITY_ALLOWED_CLASSES: "we could not tell" must stop the machine.
50
58
  export const UNCLASSIFIABLE_CLASS = 'high'
package/lib/run-log.mjs CHANGED
@@ -92,6 +92,13 @@ export const MODEL_SCOPES = Object.freeze(['turns', 'session'])
92
92
  */
93
93
  export const RUN_SURFACES = Object.freeze(['main', 'linked-worktree', 'lane-worktree', 'unknown'])
94
94
 
95
+ /**
96
+ * The three words a merge verdict may be recorded as on a receipt (design D11).
97
+ * Mirrors `lib/merge-confidence.mjs`'s published verdicts; restated here because
98
+ * this module's whitelist is the place a stray word is refused.
99
+ */
100
+ export const MERGE_VERDICTS = Object.freeze(['MERGE', 'HUMAN', 'BLOCK'])
101
+
95
102
  /** The two modes a run starts in — mirrors `lib/outcomes.mjs`'s `MODES`. */
96
103
  export const RUN_MODES = Object.freeze(['checkpoint', 'continue'])
97
104
 
@@ -390,6 +397,39 @@ function agentKind(value) {
390
397
  return enumOf(AGENT_KINDS, value, 'other')
391
398
  }
392
399
 
400
+ // The merge-confidence block (design D11), built field by field. The source at
401
+ // the close is a whole assessment whose reasons and evidence lines can quote a
402
+ // finding's title and the file it names, so nothing is spread and the only
403
+ // strings kept are the verdict (against the three published words), the band,
404
+ // signal ids and flag names, each bounded. `null` when the receipt carries no
405
+ // block: a run that never assessed is not a run with a zero score.
406
+ const MERGE_COUNT_KEYS = Object.freeze(['gating', 'clean', 'green', 'amber', 'red', 'unobserved', 'flagged'])
407
+
408
+ function mergeCounts(value) {
409
+ const source = isObject(value) ? value : {}
410
+ const out = {}
411
+ for (const key of MERGE_COUNT_KEYS) out[key] = nullableCount(source[key])
412
+ return out
413
+ }
414
+
415
+ function mergeConfidenceBlock(value) {
416
+ if (!isObject(value)) return null
417
+ return {
418
+ verdict: enumOf(MERGE_VERDICTS, value.verdict, null),
419
+ score: nullableCount(value.score),
420
+ worstBand: nullableText(value.worstBand, MAX_NAME),
421
+ counts: mergeCounts(value.counts),
422
+ driving: Array.isArray(value.driving)
423
+ ? value.driving.filter(v => typeof v === 'string' && v.trim()).map(v => text(v, MAX_NAME))
424
+ : [],
425
+ flags: Array.isArray(value.flags)
426
+ ? value.flags
427
+ .filter(f => isObject(f) && typeof f.id === 'string' && f.id.trim() && typeof f.flag === 'string' && f.flag.trim())
428
+ .map(f => ({ id: text(f.id, MAX_NAME), flag: text(f.flag, MAX_NAME) }))
429
+ : []
430
+ }
431
+ }
432
+
393
433
  function stringArray(value, max = MAX_TEXT) {
394
434
  if (!Array.isArray(value)) return []
395
435
  return value.filter(v => typeof v === 'string').map(v => text(v, max))
@@ -587,6 +627,10 @@ const TYPE_FIELDS = {
587
627
  capExhaustedVerifications: source => nullableCount(source.capExhaustedVerifications),
588
628
  unresolvedErrors: source => nullableCount(source.unresolvedErrors),
589
629
  leftoverTaskIds: source => stringArray(source.leftoverTaskIds, MAX_NAME),
630
+ // The merge verdict and its shape, no text (design D11): ids, counts and
631
+ // flag names only. The receipt module hands over a block already reduced;
632
+ // this whitelist is what holds if a caller hands a whole assessment.
633
+ mergeConfidence: source => mergeConfidenceBlock(source.mergeConfidence),
590
634
  // Output-token spend, per wave and for the run. The figure is a cumulative
591
635
  // process-wide delta over the wave's span, so it includes the
592
636
  // orchestrator's own turns as well as the implementers' — it is the wave's