@renzrollon/interlock 1.5.1 → 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 +93 -0
  3. package/README.md +5 -6
  4. package/bin/interlock +1655 -29
  5. package/hooks/mod.mjs +20 -23
  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/run.mjs CHANGED
@@ -22,7 +22,7 @@
22
22
  // through `ctx.deps` rather than being written a second time here.
23
23
 
24
24
  import { createHash } from 'node:crypto'
25
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
25
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
26
26
  import { dirname, join, resolve } from 'node:path'
27
27
 
28
28
  import { LIMITS, EFFORT, RUNTIME } from './limits.mjs'
@@ -53,7 +53,7 @@ import {
53
53
  summaryHeadline,
54
54
  tailFromManifest
55
55
  } from './receipt.mjs'
56
- import { detectUnarchived } from './drift.mjs'
56
+ import { detectUnarchived, livingSpecEdges, readGraphAt } from './drift.mjs'
57
57
  import { projectSlug } from './project-slug.mjs'
58
58
  import { composeCloseMessage, postNtfy, readNotifyConfig } from './notify.mjs'
59
59
  import {
@@ -64,11 +64,22 @@ import {
64
64
  planVerification,
65
65
  judgeVerification
66
66
  } from './verify.mjs'
67
- import { resolveReview, readReviewPolicy } from './review-core.mjs'
67
+ import {
68
+ REVIEW_POLICY_FILE,
69
+ composePolicy,
70
+ parseReviewPolicy,
71
+ readReviewPolicy,
72
+ resolveReview
73
+ } from './review-core.mjs'
74
+ import { applyToleranceBand, carriesFindingsList } from './findings.mjs'
75
+ import { canonicalizePath } from './risk.mjs'
76
+ import { capabilityOf, parseSpec } from './spec-text.mjs'
68
77
  import { writeReviewMetrics } from './metrics.mjs'
69
78
  import { planRemediation } from './remediate.mjs'
70
79
  import { classifySurface } from './surface.mjs'
71
- import { buildChecklist } from './conformance.mjs'
80
+ import { buildChecklist, buildLivingChecklist } from './conformance.mjs'
81
+ import { assessMergeConfidence, combineSignals, mergeConfidenceHeadline } from './merge-confidence.mjs'
82
+ import { buildRunEvidence, gatherMergeEvidence } from './merge-evidence.mjs'
72
83
  import { record as recordAutonomy } from './autonomy.mjs'
73
84
  import { writeResumeCard } from './resume-card.mjs'
74
85
  import { summarizePlan } from './plan-summary.mjs'
@@ -88,6 +99,7 @@ import {
88
99
  } from './waves.mjs'
89
100
  import {
90
101
  assembleCommitPrompt,
102
+ assembleDomainContext,
91
103
  assembleHandoffPrompt,
92
104
  assembleImplementerPrompt,
93
105
  assemblePlannerPrompt,
@@ -97,7 +109,8 @@ import {
97
109
  assembleVerdictPrompt,
98
110
  assembleVerifyPrompt,
99
111
  publishStageLine,
100
- selectDimensions
112
+ selectDimensions,
113
+ selectDomainCapabilities
101
114
  } from './prompts/index.mjs'
102
115
  import {
103
116
  COMMIT_SCHEMA,
@@ -299,6 +312,13 @@ export function newManifest({ change, flags, host, hostCapabilities }) {
299
312
  // this is where the CLI reads it back, because the driver returns only
300
313
  // results and the wave state has no such field.
301
314
  mergeBase: null,
315
+ // The commit the run started on, recorded once at `run start` and never
316
+ // moved. The enforced `REVIEW.md` lists and the living requirements the
317
+ // `domain` review judges against are read from it, so a change cannot edit
318
+ // the copy that is applied to it. Null in a repository with no commit, and
319
+ // absent on a manifest written before this field existed: both read the
320
+ // working tree and say so (`REVIEW BASE NOT PINNED`).
321
+ startSha: null,
302
322
  // The inter-wave verification budget's own clock (`interWaveVerifyBudgetMs`).
303
323
  // Every `run` subcommand is a fresh process running BETWEEN agent turns, so
304
324
  // the interval a verify agent spends is only measurable across two
@@ -991,7 +1011,7 @@ export const GRAPH_UNAVAILABLE_BANNER =
991
1011
  'GRAPH UNAVAILABLE: never built — implementer and reviewer agents fall back to grep and will be slower'
992
1012
 
993
1013
  /** Root first, then the home, never written: the inputs a worktree run reads through. */
994
- function locateInput(root, home, relative) {
1014
+ export function locateInput(root, home, relative) {
995
1015
  const atRoot = resolve(root, relative)
996
1016
  if (existsSync(atRoot)) return { path: atRoot, source: 'root' }
997
1017
  const atHome = resolve(home, relative)
@@ -1519,6 +1539,248 @@ function rejectedReportsOfRun(root) {
1519
1539
  }
1520
1540
  }
1521
1541
 
1542
+ // --- the base a review is judged against (judge-against-base-and-living-specs, D3, D4) ---
1543
+ //
1544
+ // Two inputs a review reads are enforced, not advisory: the `REVIEW.md` path
1545
+ // lists and the living requirements the `domain` dimension judges against. A
1546
+ // change must not be able to edit the copy that is applied to it, so a run reads
1547
+ // both from the commit it started on (`manifest.startSha`). The policy PROSE
1548
+ // stays the working tree's: it is advice that cannot relax the gate, and an
1549
+ // uncommitted edit to it reaching a reviewer is a feature. A run with no start
1550
+ // commit reads everything from the working tree and says so.
1551
+
1552
+ /** The short form of a commit, as banners name it. */
1553
+ const shortSha = sha => String(sha).slice(0, 7)
1554
+
1555
+ /** HEAD when the run starts, or null — never a guess, and never a crash. */
1556
+ function readStartSha(ctx) {
1557
+ try {
1558
+ const sha = ctx.deps && typeof ctx.deps.headCommit === 'function' ? ctx.deps.headCommit(ctx.root) : null
1559
+ return typeof sha === 'string' && sha ? sha : null
1560
+ } catch {
1561
+ return null
1562
+ }
1563
+ }
1564
+
1565
+ /** The start commit this run pinned, or null (none recorded, or an older manifest). */
1566
+ function pinnedSha(manifest) {
1567
+ return manifest && typeof manifest.startSha === 'string' && manifest.startSha ? manifest.startSha : null
1568
+ }
1569
+
1570
+ /**
1571
+ * One repository file, from `ref` or — when `ref` is null — the working tree.
1572
+ * `{ text }`, `{ absent: true }`, `{ tooLarge: true, size }` or `{ error }`; it
1573
+ * never throws, because the caller decides how each outcome degrades.
1574
+ */
1575
+ function readFileAt(ctx, ref, relative, { maxBytes } = {}) {
1576
+ if (ref === null) {
1577
+ const path = join(ctx.root, relative)
1578
+ try {
1579
+ if (!existsSync(path)) return { absent: true }
1580
+ const size = statSync(path).size
1581
+ if (Number.isFinite(maxBytes) && size > maxBytes) return { tooLarge: true, size }
1582
+ return { text: readFileSync(path, 'utf8') }
1583
+ } catch (err) {
1584
+ return { error: (err && err.message) || String(err) }
1585
+ }
1586
+ }
1587
+ if (!ctx.deps || typeof ctx.deps.readAtRef !== 'function') {
1588
+ return { error: 'this host supplies no reader for a commit' }
1589
+ }
1590
+ try {
1591
+ const read = ctx.deps.readAtRef(ctx.root, ref, relative, { maxBytes })
1592
+ return read && typeof read === 'object' ? read : { error: 'the reader returned nothing' }
1593
+ } catch (err) {
1594
+ return { error: (err && err.message) || String(err) }
1595
+ }
1596
+ }
1597
+
1598
+ /** Push a banner onto the manifest unless one with the same label is already there. */
1599
+ function raiseOnce(manifest, banner) {
1600
+ if (!Array.isArray(manifest.banners)) manifest.banners = []
1601
+ const label = banner.slice(0, banner.indexOf(':') + 1)
1602
+ if (!manifest.banners.some(b => typeof b === 'string' && b.startsWith(label))) manifest.banners.push(banner)
1603
+ }
1604
+
1605
+ /** The working tree's `REVIEW.md` as text plus its parsed policy, or null when absent. */
1606
+ function readWorkingPolicy(root) {
1607
+ const path = join(root, REVIEW_POLICY_FILE)
1608
+ if (!existsSync(path)) return null
1609
+ let text = ''
1610
+ try {
1611
+ if (statSync(path).size <= LIMITS.maxReviewPolicyBytes) text = readFileSync(path, 'utf8')
1612
+ } catch {
1613
+ // The policy reader below reports an unreadable file as a problem.
1614
+ }
1615
+ return { text, policy: readReviewPolicy(root) }
1616
+ }
1617
+
1618
+ /**
1619
+ * The policy a review is adjudicated by: prose and owner from the working tree,
1620
+ * both enforced path lists from the start commit (`composePolicy`).
1621
+ *
1622
+ * With no start commit the working tree supplies everything, unchanged from
1623
+ * before; `reviewStep` has already said so (`REVIEW BASE NOT PINNED`). A start
1624
+ * commit that cannot be read degrades the same way and says so here. A working
1625
+ * copy that differs from the start commit's raises `POLICY DIFFERS FROM BASE`.
1626
+ *
1627
+ * @returns {{ policy: object, banners: string[] }}
1628
+ */
1629
+ function policyForAdjudication(ctx, manifest) {
1630
+ const sha = pinnedSha(manifest)
1631
+ if (!sha) return { policy: readReviewPolicy(ctx.root), banners: [] }
1632
+
1633
+ const short = shortSha(sha)
1634
+ const read = readFileAt(ctx, sha, REVIEW_POLICY_FILE, { maxBytes: LIMITS.maxReviewPolicyBytes })
1635
+ if (read.error !== undefined) {
1636
+ return {
1637
+ policy: readReviewPolicy(ctx.root),
1638
+ banners: [
1639
+ `REVIEW BASE NOT PINNED: start commit ${short} could not be read (${read.error}); ` +
1640
+ `read from the working tree: ${REVIEW_POLICY_FILE} lists`
1641
+ ]
1642
+ }
1643
+ }
1644
+
1645
+ let base = null
1646
+ if (typeof read.text === 'string') {
1647
+ base = { text: read.text, policy: parseReviewPolicy(read.text) }
1648
+ } else if (read.tooLarge) {
1649
+ base = {
1650
+ text: '',
1651
+ policy: {
1652
+ prose: '',
1653
+ excludePaths: [],
1654
+ humanPaths: [],
1655
+ owner: null,
1656
+ problems: [
1657
+ `${REVIEW_POLICY_FILE} is ${read.size} bytes, over the ${LIMITS.maxReviewPolicyBytes}-byte scan ` +
1658
+ `cap — not parsed; review proceeds under default policy`
1659
+ ]
1660
+ }
1661
+ }
1662
+ }
1663
+
1664
+ const policy = composePolicy({ working: readWorkingPolicy(ctx.root), base, ref: short })
1665
+ const banners = []
1666
+ if (policy.differsFromBase) {
1667
+ banners.push(
1668
+ `POLICY DIFFERS FROM BASE: ${REVIEW_POLICY_FILE} differs from ${short}; ` +
1669
+ `its Do Not Report paths were read from ${short}`
1670
+ )
1671
+ }
1672
+ return { policy, banners }
1673
+ }
1674
+
1675
+ const SPEC_CITATION_PATH = /^openspec\/specs\/.+\/spec\.md$/
1676
+
1677
+ /**
1678
+ * What `resolveReview` needs to check requirement citations: for every spec a
1679
+ * finding cites, that spec's requirement keys at the base (the start commit, or
1680
+ * the working tree for an unpinned run), or `{ unreadable }` when it could not
1681
+ * be read. Only canonical `openspec/specs/**\/spec.md` paths are read, so a
1682
+ * citation cannot make the run read an arbitrary file.
1683
+ */
1684
+ function requirementHeadingsFor(ctx, manifest, findings) {
1685
+ const ref = pinnedSha(manifest)
1686
+ const where = ref ? `at ${shortSha(ref)}` : 'in the working tree'
1687
+ const headings = new Map()
1688
+ for (const finding of applyToleranceBand(findings, null).reportable) {
1689
+ const cited = finding && typeof finding.requirement === 'string' ? finding.requirement : ''
1690
+ const hash = cited.indexOf('#')
1691
+ if (hash === -1) continue
1692
+ const path = canonicalizePath(cited.slice(0, hash).trim())
1693
+ if (!path || !SPEC_CITATION_PATH.test(path) || headings.has(path)) continue
1694
+ const read = readFileAt(ctx, ref, path)
1695
+ if (typeof read.text === 'string') {
1696
+ headings.set(path, parseSpec(read.text).requirements.map(r => r.key))
1697
+ } else if (read.absent) {
1698
+ headings.set(path, { unreadable: `${path} does not exist ${where}` })
1699
+ } else {
1700
+ headings.set(path, { unreadable: read.error || 'unreadable' })
1701
+ }
1702
+ }
1703
+ return headings
1704
+ }
1705
+
1706
+ /** Where a capability's living spec and a change's delta spec live. */
1707
+ const livingSpecPathOf = capability => `openspec/specs/${capability}/spec.md`
1708
+ const deltaSpecPathOf = (change, capability) => `openspec/changes/${change}/specs/${capability}/spec.md`
1709
+
1710
+ /**
1711
+ * The text of the given capabilities' requirements, read from `ref` (null = the
1712
+ * working tree), and the change's own delta specs, which are always the working
1713
+ * tree's: they ARE the change. `readErrors` names every file that could not be
1714
+ * read, so a degraded assembly says why.
1715
+ */
1716
+ function assembleDomainFor(ctx, manifest, capabilities, ref, living = {}) {
1717
+ const deltas = {}
1718
+ const readErrors = []
1719
+ for (const { capability } of capabilities) {
1720
+ if (!(capability in living)) {
1721
+ const read = readFileAt(ctx, ref, livingSpecPathOf(capability))
1722
+ if (typeof read.text === 'string') living[capability] = read.text
1723
+ else if (read.error) readErrors.push(`${livingSpecPathOf(capability)}: ${read.error}`)
1724
+ }
1725
+ const delta = readFileAt(ctx, null, deltaSpecPathOf(manifest.change, capability))
1726
+ if (typeof delta.text === 'string') deltas[capability] = delta.text
1727
+ }
1728
+ const assembled = assembleDomainContext({ capabilities, living, deltas, ref })
1729
+ return { assembled, readErrors }
1730
+ }
1731
+
1732
+ /**
1733
+ * Decide whether `domain` runs and what it is given (design D4).
1734
+ *
1735
+ * Capabilities are selected from the run's graph (`manifest.graphPath`) and from
1736
+ * the capabilities the change's delta specs declare it modifies, the latter only
1737
+ * where a living spec exists at the base. The text is assembled from the start
1738
+ * commit. Nothing here is silent: a missing graph is `partial` or `skipped`,
1739
+ * a graph that links no changed path is recorded as `notSelected` and nothing
1740
+ * else (an unlinked file is a coverage gap, not a degraded review).
1741
+ */
1742
+ function gatherDomain(ctx, manifest, changedPaths) {
1743
+ const ref = pinnedSha(manifest)
1744
+ const graph = manifest.graphPath
1745
+ ? readGraphAt(manifest.graphPath)
1746
+ : { links: null, reason: 'no graph has been built — run /interlock:bootstrap once' }
1747
+ const edges = Array.isArray(graph.links) ? livingSpecEdges(graph) : null
1748
+
1749
+ const living = {}
1750
+ let specFiles = []
1751
+ try {
1752
+ specFiles = inspectChange(ctx.root, manifest.change).specFiles
1753
+ } catch {
1754
+ // A change that cannot be inspected declares nothing; the run's own checks say why.
1755
+ }
1756
+ const declared = []
1757
+ for (const rel of specFiles) {
1758
+ const capability = capabilityOf(rel)
1759
+ if (!capability || declared.includes(capability)) continue
1760
+ const read = readFileAt(ctx, ref, livingSpecPathOf(capability))
1761
+ if (typeof read.text === 'string') {
1762
+ living[capability] = read.text
1763
+ declared.push(capability)
1764
+ }
1765
+ }
1766
+
1767
+ const selection = selectDomainCapabilities({ changedPaths, edges, graphReason: graph.reason, declared })
1768
+ let assembled = null
1769
+ let skipped = selection.skipped
1770
+ let readErrors = []
1771
+ if (selection.capabilities.length) {
1772
+ const result = assembleDomainFor(ctx, manifest, selection.capabilities, ref, living)
1773
+ assembled = result.assembled
1774
+ readErrors = result.readErrors
1775
+ if (assembled.skipped) {
1776
+ skipped = result.readErrors.length
1777
+ ? `${assembled.skipped} (${result.readErrors.join('; ')})`
1778
+ : assembled.skipped
1779
+ }
1780
+ }
1781
+ return { selection, assembled, skipped, readErrors, runs: Boolean(assembled) && !assembled.skipped }
1782
+ }
1783
+
1522
1784
  /**
1523
1785
  * The review step: one worker, every selected dimension fanned out inside it,
1524
1786
  * two skeptics per finding (design D1, D3, D4, D8).
@@ -1532,7 +1794,10 @@ function reviewStep(ctx, manifest, carried = {}) {
1532
1794
  const { root } = ctx
1533
1795
  const change = manifest.change
1534
1796
  const changedPaths = ctx.deps.observedChangedPaths(root)
1535
- const { dimensions: names, reasons } = selectDimensions(changedPaths)
1797
+ const domain = gatherDomain(ctx, manifest, changedPaths)
1798
+ const { dimensions: names, reasons } = selectDimensions(changedPaths, {
1799
+ domain: domain.runs ? domain.selection : null
1800
+ })
1536
1801
  const dimensions = resolveDimensionRubrics(names)
1537
1802
  const policy = readReviewPolicy(root)
1538
1803
 
@@ -1543,10 +1808,83 @@ function reviewStep(ctx, manifest, carried = {}) {
1543
1808
  }
1544
1809
  }
1545
1810
 
1811
+ // What the review reads from the working tree because there is no start
1812
+ // commit to read it from, named once (design D3). The lists are always read;
1813
+ // the living requirements only when `domain` runs.
1814
+ const raised = []
1815
+ if (!pinnedSha(manifest)) {
1816
+ const unpinned = [`${REVIEW_POLICY_FILE} lists`, ...(domain.runs ? ['living requirements'] : [])]
1817
+ raised.push(
1818
+ `REVIEW BASE NOT PINNED: this run recorded no start commit; read from the working tree: ${unpinned.join(', ')}`
1819
+ )
1820
+ }
1821
+ if (domain.skipped) {
1822
+ raised.push(`DOMAIN REVIEW SKIPPED: ${domain.skipped}`)
1823
+ } else if (domain.runs) {
1824
+ // Two ways the reviewer is given less than the whole: a selection made
1825
+ // without a graph, and a text the size bound cut. `raiseOnce` keeps one
1826
+ // banner per label, so when both hold one line states both (design D20 of
1827
+ // label-and-harden-the-merge-verdict). The limit is cited by its key, never
1828
+ // restated.
1829
+ const partial = []
1830
+ if (domain.selection.partial) partial.push(domain.selection.partial)
1831
+ const omitted = Array.isArray(domain.assembled.omitted) ? domain.assembled.omitted.length : 0
1832
+ if (omitted > 0) {
1833
+ partial.push(
1834
+ `${omitted} requirement(s) were left out of the domain reviewer's text by the size bound ` +
1835
+ `(LIMITS.maxDomainSpecChars, printed by \`interlock limits\`)`
1836
+ )
1837
+ }
1838
+ if (partial.length) raised.push(`DOMAIN REVIEW PARTIAL: ${partial.join('; ')}`)
1839
+ }
1840
+
1841
+ // A run adjudicates only what THIS review wrote (design D24). The work files
1842
+ // sit in a directory no run clears, so a review that writes nothing would
1843
+ // otherwise be adjudicated from whatever an earlier run left there. Removed
1844
+ // once, when the step is first emitted: `manifest.reviewDimensions` is set a
1845
+ // few lines below, and a step derived again by `run next` finds it set and
1846
+ // removes nothing, since by then the reviewer may already be writing.
1847
+ if (!manifest.reviewDimensions) {
1848
+ for (const path of [FINDINGS_PATH, VERDICTS_PATH]) {
1849
+ try {
1850
+ rmSync(join(root, path), { force: true })
1851
+ } catch (err) {
1852
+ raised.push(
1853
+ `REVIEW WORK FILE NOT CLEARED: ${path} could not be removed (${(err && err.message) || err}) — ` +
1854
+ `what an earlier run left there may be adjudicated as this review's`
1855
+ )
1856
+ }
1857
+ }
1858
+ }
1859
+ for (const banner of raised) {
1860
+ raiseOnce(manifest, banner)
1861
+ banners.push(banner)
1862
+ }
1863
+
1546
1864
  // Carried across every later `run reviewed`/`run remediated` call in this
1547
1865
  // run, each its own process: what the CLI selected, and why, so an agent-
1548
1866
  // added dimension can be told apart from one the CLI asked for (design D4).
1867
+ // `skipped`, `notSelected` and `domain` are recorded only when they apply, so
1868
+ // a record without them says `domain` was neither refused nor unlinked.
1549
1869
  manifest.reviewDimensions = { selected: names, reasons }
1870
+ // A selected dimension whose criteria could not be read was dispatched on its
1871
+ // name alone. The banner above says so once; the record keeps it, because the
1872
+ // merge verdict asks which dimensions ran WITH their rubric (design D4 of
1873
+ // complete-and-record-the-merge-verdict).
1874
+ const unavailable = dimensions.filter(d => d.rubric === null).map(d => d.name)
1875
+ if (unavailable.length) manifest.reviewDimensions.unavailable = unavailable
1876
+ if (domain.skipped) manifest.reviewDimensions.skipped = [{ name: 'domain', reason: domain.skipped }]
1877
+ if (domain.selection.notSelected) manifest.reviewDimensions.notSelected = domain.selection.notSelected
1878
+ if (domain.runs) {
1879
+ const { superseded, omitted, specifiedByChange, ref } = domain.assembled
1880
+ manifest.reviewDimensions.domain = {
1881
+ capabilities: domain.selection.capabilities,
1882
+ ref,
1883
+ superseded,
1884
+ omitted,
1885
+ specifiedByChange
1886
+ }
1887
+ }
1550
1888
 
1551
1889
  // Tasks recorded as done whose handoff packet was rejected. A later wave was
1552
1890
  // told about its predecessor's; the last wave's had nobody to tell. The review
@@ -1574,6 +1912,7 @@ function reviewStep(ctx, manifest, carried = {}) {
1574
1912
  change,
1575
1913
  dimensions,
1576
1914
  policyProse: policy.prose,
1915
+ domainContext: domain.runs ? domain.assembled.text : undefined,
1577
1916
  rejectedReports,
1578
1917
  findingsPath: FINDINGS_PATH,
1579
1918
  verdictsPath: VERDICTS_PATH,
@@ -1615,6 +1954,22 @@ function remediateStep(ctx, manifest, plan, extra = {}) {
1615
1954
 
1616
1955
  const dimensions = resolveDimensionRubrics(plan.reReviewDimensions)
1617
1956
  const policyProse = readReviewPolicy(root).prose
1957
+ // A re-review of `domain` is handed the text the first pass was, re-assembled
1958
+ // from the capabilities and the ref it recorded — not re-selected, so the
1959
+ // reviewer judges the fix against the same requirements.
1960
+ const recordedDomain = manifest.reviewDimensions && manifest.reviewDimensions.domain
1961
+ const domainContext =
1962
+ plan.reReviewDimensions.includes('domain') &&
1963
+ recordedDomain &&
1964
+ Array.isArray(recordedDomain.capabilities) &&
1965
+ recordedDomain.capabilities.length
1966
+ ? assembleDomainFor(
1967
+ ctx,
1968
+ manifest,
1969
+ recordedDomain.capabilities,
1970
+ typeof recordedDomain.ref === 'string' && recordedDomain.ref ? recordedDomain.ref : null
1971
+ ).assembled.text
1972
+ : undefined
1618
1973
  const stageLine = publishStageLine('remediation', change, ++manifest.stageIndex)
1619
1974
  return makeStep({
1620
1975
  action: 'remediate',
@@ -1628,7 +1983,17 @@ function remediateStep(ctx, manifest, plan, extra = {}) {
1628
1983
  model: null,
1629
1984
  effort: EFFORT.skeptic,
1630
1985
  schema: REMEDIATE_RESULT_SCHEMA,
1631
- prompt: assembleRemediatePrompt({ change, round: plan.round, plan, dimensions, policyProse, stageLine })
1986
+ prompt: assembleRemediatePrompt({
1987
+ change,
1988
+ round: plan.round,
1989
+ plan,
1990
+ dimensions,
1991
+ policyProse,
1992
+ domainContext,
1993
+ findingsPath: FINDINGS_PATH,
1994
+ verdictsPath: VERDICTS_PATH,
1995
+ stageLine
1996
+ })
1632
1997
  })
1633
1998
  ]
1634
1999
  })
@@ -1666,21 +2031,98 @@ function stageMarkerBanners(results) {
1666
2031
  }
1667
2032
 
1668
2033
  /**
1669
- * Re-adjudicate the work files a review or fixer just wrote, write `review.json`
1670
- * and the metrics file exactly as `interlock review --metrics <change>` would,
1671
- * and record the counts on the manifest (design D2, D7).
2034
+ * The dimension record `review.json` carries (design D4 of
2035
+ * complete-and-record-the-merge-verdict), read off the manifest so every
2036
+ * adjudication of one run writes the same one:
2037
+ *
2038
+ * selected what the CLI selected
2039
+ * unavailable selected dimensions whose rubric could not be read
2040
+ * completed whether the review worker's result was usable and `ok`
2041
+ * ran selected minus unavailable, plus any dimension the reviewer
2042
+ * added, when the review completed; nothing otherwise
2043
+ * skipped dimensions refused with a reason (`domain`, for want of a graph)
2044
+ *
2045
+ * The CLI never watches one dimension run: the worker fans them out in its own
2046
+ * context and reports `ok`. So `ran` means "dispatched with its rubric in a
2047
+ * review the worker reported complete", and nothing stronger.
2048
+ */
2049
+ function dimensionRecord(manifest) {
2050
+ const recorded =
2051
+ manifest.reviewDimensions && typeof manifest.reviewDimensions === 'object' ? manifest.reviewDimensions : {}
2052
+ const names = value => (Array.isArray(value) ? value.filter(name => typeof name === 'string' && name) : [])
2053
+ const selected = names(recorded.selected)
2054
+ const unavailable = names(recorded.unavailable).filter(name => selected.includes(name))
2055
+ const completed = recorded.completed === true
2056
+ const added = names((Array.isArray(recorded.added) ? recorded.added : []).map(d => d && d.name)).filter(
2057
+ name => !selected.includes(name)
2058
+ )
2059
+ return {
2060
+ selected,
2061
+ ran: completed ? [...selected.filter(name => !unavailable.includes(name)), ...new Set(added)] : [],
2062
+ unavailable,
2063
+ completed,
2064
+ skipped: Array.isArray(recorded.skipped) ? recorded.skipped : []
2065
+ }
2066
+ }
2067
+
2068
+ /** Where every adjudication of a run writes its result, and a later round reads the last one back. */
2069
+ const REVIEW_RECORD = 'review.json'
2070
+ const REVIEW_RECORD_PATH = join(WORK_DIR, REVIEW_RECORD)
2071
+
2072
+ /**
2073
+ * The findings work file as a review or a fixer left it: `{ findings }`, or
2074
+ * `{ missing }` saying why it carries no findings list. An absent file, one that
2075
+ * is not JSON and one holding none of the accepted shapes are the same fact to
2076
+ * an adjudication: there is no list this agent wrote (design D10 of
2077
+ * label-and-harden-the-merge-verdict). An empty list is a list.
2078
+ */
2079
+ function readFindingsWork(root) {
2080
+ let raw
2081
+ try {
2082
+ raw = readFileSync(join(root, FINDINGS_PATH), 'utf8')
2083
+ } catch (err) {
2084
+ return { missing: err && err.code === 'ENOENT' ? 'the file is absent' : `the file could not be read (${(err && err.code) || err})` }
2085
+ }
2086
+ let parsed
2087
+ try {
2088
+ parsed = JSON.parse(raw)
2089
+ } catch {
2090
+ return { missing: 'the file is not valid JSON' }
2091
+ }
2092
+ if (!carriesFindingsList(parsed)) return { missing: 'it holds none of the shapes a findings list takes' }
2093
+ return { findings: parsed }
2094
+ }
2095
+
2096
+ /** The reason a findings work file could not be adjudicated, as a note or a halt states it. */
2097
+ const noFindingsList = missing => `${FINDINGS_PATH} carries no findings list (${missing})`
2098
+
2099
+ /**
2100
+ * Adjudicate the findings a review or fixer just wrote against the verdicts
2101
+ * work file, write `review.json` and the metrics file exactly as `interlock
2102
+ * review --metrics <change>` would, and record the counts on the manifest
2103
+ * (design D2, D7). Every adjudication rewrites `review.json`, so every one
2104
+ * writes the dimension record with it. The caller reads the findings work file
2105
+ * and decides what a missing list means; this is handed a list.
1672
2106
  */
1673
- function adjudicateReview(ctx, manifest) {
2107
+ function adjudicateReview(ctx, manifest, findings) {
1674
2108
  const { root } = ctx
1675
- const findings = readJson(join(root, FINDINGS_PATH))
1676
2109
  const verdicts = readJson(join(root, VERDICTS_PATH))
1677
2110
  const changedPaths = ctx.deps.observedChangedPaths(root)
1678
- const policy = readReviewPolicy(root)
2111
+ const { policy, banners: policyBanners } = policyForAdjudication(ctx, manifest)
1679
2112
 
1680
- const result = resolveReview(findings, verdicts, { changedPaths, excludePaths: policy.excludePaths })
1681
- writeWork(root, 'review.json', result)
2113
+ const result = resolveReview(findings, verdicts, {
2114
+ changedPaths,
2115
+ excludePaths: policy.excludePaths,
2116
+ requirementHeadings: requirementHeadingsFor(ctx, manifest, findings)
2117
+ })
2118
+ writeWork(root, REVIEW_RECORD, { ...result, dimensions: dimensionRecord(manifest) })
1682
2119
 
1683
- const banners = []
2120
+ // Once per run: a later round adjudicates the same policy against the same
2121
+ // base, and `manifest.banners` already holds what the first said.
2122
+ const recorded = Array.isArray(manifest.banners) ? manifest.banners : []
2123
+ const banners = policyBanners.filter(
2124
+ b => !recorded.some(r => typeof r === 'string' && r.startsWith(b.slice(0, b.indexOf(':') + 1)))
2125
+ )
1684
2126
  const metrics = writeReviewMetrics(stateHomeOf(ctx, manifest), { change: manifest.change, counts: result.counts })
1685
2127
  if (!metrics.written) {
1686
2128
  banners.push(`REVIEW METRICS NOT WRITTEN: ${metrics.reason}`)
@@ -1710,19 +2152,30 @@ export function runReviewed(ctx, { results, hostRecords }) {
1710
2152
  return finishStep(ctx, manifest, trajectoryHaltStep('agent-result', observed.failure))
1711
2153
  }
1712
2154
 
1713
- const { result, banners } = adjudicateReview(ctx, manifest)
1714
- banners.push(...stageMarkerBanners(results))
1715
-
1716
2155
  // A review step that reported nothing usable still adjudicates — the files
1717
2156
  // are read either way — but "the review did not complete" is a different
1718
2157
  // fact from "the review found nothing", and the summary must not blur them
1719
2158
  // (workflow-host spec, edge case: an agent that returns no findings file).
1720
2159
  const report = results && results[0] && typeof results[0] === 'object' ? results[0] : null
1721
- if (!report || report.ok !== true) {
2160
+ const reported = Boolean(report) && report.ok === true
2161
+ if (!reported) {
1722
2162
  manifest.notes.push(
1723
2163
  `review did not complete: ${(report && report.detail) || 'the step returned no usable result'}`
1724
2164
  )
1725
2165
  }
2166
+ // Whatever the step reported about itself, a review that left no findings
2167
+ // list did not complete: the work files were removed when the step was
2168
+ // emitted, so an absent, unparseable or list-less file is this review's own
2169
+ // silence, never a clean result (design D10, D24 of
2170
+ // label-and-harden-the-merge-verdict). The run goes on, as it does for a
2171
+ // step that returned nothing usable, and adjudicates as with no findings.
2172
+ const work = readFindingsWork(root)
2173
+ if (work.missing) {
2174
+ manifest.notes.push(
2175
+ `review did not complete: ${noFindingsList(work.missing)}, so there is nothing this review wrote to adjudicate`
2176
+ )
2177
+ }
2178
+ const reviewCompleted = reported && !work.missing
1726
2179
 
1727
2180
  // A dimension the reviewer added beyond the CLI's selection (design D4).
1728
2181
  // The CLI cannot know why; the one-line reason is the agent's own report,
@@ -1731,10 +2184,19 @@ export function runReviewed(ctx, { results, hostRecords }) {
1731
2184
  const added = Array.isArray(report && report.addedDimensions)
1732
2185
  ? report.addedDimensions.filter(d => d && typeof d.name === 'string' && !selected.has(d.name))
1733
2186
  : []
1734
- if (added.length) {
1735
- manifest.reviewDimensions = { ...(manifest.reviewDimensions || { selected: [], reasons: {} }), added }
2187
+ // Whether the worker reported that it completed, recorded for every later
2188
+ // adjudication of this run: the dimension record on `review.json` reads it,
2189
+ // and a fixing round must not turn "did not complete" into "ran".
2190
+ manifest.reviewDimensions = {
2191
+ ...(manifest.reviewDimensions || { selected: [], reasons: {} }),
2192
+ ...(added.length ? { added } : {}),
2193
+ completed: reviewCompleted
1736
2194
  }
1737
2195
 
2196
+ // After the two facts above, because the adjudication writes them on `review.json`.
2197
+ const { result, banners } = adjudicateReview(ctx, manifest, work.missing ? [] : work.findings)
2198
+ banners.push(...stageMarkerBanners(results))
2199
+
1738
2200
  manifest.banners.push(...banners)
1739
2201
 
1740
2202
  const { plan, error } = planRemediationRound(result.surviving, 1)
@@ -1797,7 +2259,36 @@ export function runRemediated(ctx, { round, results, hostRecords }) {
1797
2259
  ))
1798
2260
  }
1799
2261
 
1800
- const { result, banners } = adjudicateReview(ctx, manifest)
2262
+ // A round that leaves no findings list has cleared nothing (design D10 of
2263
+ // label-and-harden-the-merge-verdict). Read as an empty review it would
2264
+ // clear the very blockers it was asked to fix, and at the verdict round the
2265
+ // run would commit. So the round is adjudicated from the surviving findings
2266
+ // of the last adjudication, unchanged: `review.json`, which every
2267
+ // adjudication of this run writes, read here before anything rewrites it and
2268
+ // left as it is. When that cannot be read back either there is nothing to
2269
+ // stand on, and the run halts: the one outcome that cannot clear a blocker.
2270
+ const work = readFindingsWork(root)
2271
+ let result
2272
+ let banners
2273
+ if (work.missing) {
2274
+ const last = readJson(join(root, REVIEW_RECORD_PATH))
2275
+ if (!last || typeof last !== 'object' || !Array.isArray(last.surviving)) {
2276
+ return finishStep(ctx, manifest, haltStep(
2277
+ `round ${completedRound} left no findings to adjudicate and the last adjudication cannot be read back: ` +
2278
+ `${noFindingsList(work.missing)}, and ${REVIEW_RECORD_PATH} is absent, unparseable or without its ` +
2279
+ `surviving list — nothing here can say a blocker was cleared`,
2280
+ { banners: stageMarkerBanners(results) }
2281
+ ))
2282
+ }
2283
+ manifest.notes.push(
2284
+ `round ${completedRound} was not re-reviewed: ${noFindingsList(work.missing)}; the ${last.surviving.length} ` +
2285
+ `finding(s) that survived the last adjudication stand unchanged`
2286
+ )
2287
+ result = last
2288
+ banners = []
2289
+ } else {
2290
+ ;({ result, banners } = adjudicateReview(ctx, manifest, work.findings))
2291
+ }
1801
2292
  banners.push(...stageMarkerBanners(results))
1802
2293
 
1803
2294
  if (completedRound === roundCap + 1) {
@@ -2487,6 +2978,7 @@ function speakInterruptedNotes(root, manifest) {
2487
2978
  export function runStart(ctx, { change, flags, host, hostCapabilities, hostEnv, stateHome }) {
2488
2979
  const { root } = ctx
2489
2980
  const manifest = newManifest({ change, flags, host, hostCapabilities })
2981
+ manifest.startSha = readStartSha(ctx)
2490
2982
  // Where this run's corpora go, decided once and recorded (ship/state-home):
2491
2983
  // every later subcommand reads it back off the manifest and never resolves
2492
2984
  // again, so one run uses one home even if git state changes under it.
@@ -3366,6 +3858,7 @@ export function runJudge(ctx, { context, results, hostRecords }) {
3366
3858
  }
3367
3859
  verdict = judgeVerification(plan, reportedSteps, { context })
3368
3860
  for (const banner of verdict.banners || []) banners.push(banner)
3861
+ if (context === 'final') recordFinalVerdict(root, manifest, verdict, banners)
3369
3862
  const exitFailure = logCliExit(ctx, manifest, 'verify judge', verdict.halt ? 1 : 0)
3370
3863
  if (exitFailure) {
3371
3864
  // The judgement happened; the record of it did not. Halting here is what
@@ -3388,6 +3881,25 @@ export function runJudge(ctx, { context, results, hostRecords }) {
3388
3881
  }
3389
3882
  } else {
3390
3883
  banners.push(`VERIFICATION SKIPPED: reason=${reason}`)
3884
+ // Nothing ran, so there is no verdict; what the close reads instead is a
3885
+ // verdict-shaped record of WHY, one band per skip reason (design D3 of
3886
+ // complete-and-record-the-merge-verdict). Each kind is null, not absent:
3887
+ // it did not run.
3888
+ if (context === 'final') {
3889
+ recordFinalVerdict(
3890
+ root,
3891
+ manifest,
3892
+ {
3893
+ context: 'final',
3894
+ skipped: plan && Array.isArray(plan.skipped) ? plan.skipped : [],
3895
+ unit: null,
3896
+ e2e: null,
3897
+ coverage: null,
3898
+ typecheck: null
3899
+ },
3900
+ banners
3901
+ )
3902
+ }
3391
3903
  }
3392
3904
 
3393
3905
  if (context === 'inter-wave') {
@@ -3449,6 +3961,30 @@ export function runJudge(ctx, { context, results, hostRecords }) {
3449
3961
  return finishStep(ctx, manifest, commitStep(ctx, manifest, banners))
3450
3962
  }
3451
3963
 
3964
+ /** The final verdict the run acted on, where the close reads it back. */
3965
+ const VERIFY_FINAL_FILE = 'verify-final.json'
3966
+
3967
+ /**
3968
+ * Persist the final verification verdict, stamped with this run's id (design
3969
+ * D3, D18 of complete-and-record-the-merge-verdict). It used to be a local of
3970
+ * `runJudge`; the close assesses merge confidence from it, in another process.
3971
+ * The id is what makes it THIS run's: the working directory is not cleared
3972
+ * between runs, and a verdict an earlier run left must not be read as ours.
3973
+ *
3974
+ * A write that fails loses evidence, not the run: it is spoken and the judge
3975
+ * goes on, and the close then reads the verification signals as unobserved.
3976
+ */
3977
+ function recordFinalVerdict(root, manifest, verdict, banners) {
3978
+ try {
3979
+ writeWork(root, VERIFY_FINAL_FILE, { ...verdict, runId: manifest.runId ?? null })
3980
+ } catch (err) {
3981
+ banners.push(
3982
+ `MERGE EVIDENCE UNAVAILABLE: the final verification verdict — it could not be recorded ` +
3983
+ `(${(err && err.message) || String(err)})`
3984
+ )
3985
+ }
3986
+ }
3987
+
3452
3988
  /**
3453
3989
  * The handoff step: the manual-test-plan decision and the conformance
3454
3990
  * checklist, computed here and inlined (design D5).
@@ -3480,6 +4016,11 @@ function handoffStep(ctx, manifest, banners = []) {
3480
4016
  }
3481
4017
 
3482
4018
  let scenarios = null
4019
+ // The living requirements the change is asked to confirm (design D5 of
4020
+ // complete-and-record-the-merge-verdict), and how many: `null` when
4021
+ // conformance is off, or when no list could be built at all.
4022
+ let living = null
4023
+ let livingCount = null
3483
4024
  const extra = [...banners]
3484
4025
  if (manifest.flags.conformance === true) {
3485
4026
  const checklist = buildChecklist(root, change, { changed: changedPaths })
@@ -3492,6 +4033,30 @@ function handoffStep(ctx, manifest, banners = []) {
3492
4033
  } else {
3493
4034
  scenarios = checklist.scenarios
3494
4035
  }
4036
+
4037
+ // The same selection, base and bound the review step gives the `domain`
4038
+ // reviewer, so the two can never disagree about which requirements are in
4039
+ // force. Skipped and partial are each said once per run, as the two
4040
+ // `DOMAIN REVIEW` banners are; a graph that links nothing is an empty list
4041
+ // and says nothing. A living spec the base could not be read for is a list
4042
+ // nobody could build, and is said as a skip rather than counted as zero.
4043
+ const domain = gatherDomain(ctx, manifest, changedPaths)
4044
+ const list = buildLivingChecklist({ selection: domain.selection, assembled: domain.assembled })
4045
+ const unread = domain.readErrors.length ? `a living spec could not be read (${domain.readErrors.join('; ')})` : null
4046
+ const skipReason = list.skipped || unread
4047
+ if (skipReason) {
4048
+ const banner = `LIVING CONFORMANCE SKIPPED: ${skipReason}`
4049
+ raiseOnce(manifest, banner)
4050
+ extra.push(banner)
4051
+ } else {
4052
+ living = list.requirements
4053
+ livingCount = list.requirementsRequired
4054
+ if (list.partial) {
4055
+ const banner = `LIVING CONFORMANCE PARTIAL: ${list.partial}`
4056
+ raiseOnce(manifest, banner)
4057
+ extra.push(banner)
4058
+ }
4059
+ }
3495
4060
  }
3496
4061
 
3497
4062
  return makeStep({
@@ -3499,6 +4064,7 @@ function handoffStep(ctx, manifest, banners = []) {
3499
4064
  banners: extra,
3500
4065
  needsManualTestPlan,
3501
4066
  scenarios: scenarios ? scenarios.length : null,
4067
+ living: livingCount,
3502
4068
  then: { argv: ['run', 'commit'] },
3503
4069
  spawns: [
3504
4070
  spawn(root, {
@@ -3513,7 +4079,8 @@ function handoffStep(ctx, manifest, banners = []) {
3513
4079
  needsManualTestPlan,
3514
4080
  testPlanReason,
3515
4081
  conformance: scenarios,
3516
- learnings: manifest.flags.handoff === true
4082
+ learnings: manifest.flags.handoff === true,
4083
+ ...(living ? { living } : {})
3517
4084
  })
3518
4085
  })
3519
4086
  ]
@@ -3581,7 +4148,12 @@ export function runCommit(ctx, { results, hostRecords }) {
3581
4148
  skipReason: typeof reported.skipReason === 'string' ? reported.skipReason : undefined,
3582
4149
  learnings: count(reported.learnings),
3583
4150
  scenariosChecked: count(reported.scenariosChecked),
3584
- scenariosUnconfirmed: count(reported.scenariosUnconfirmed)
4151
+ scenariosUnconfirmed: count(reported.scenariosUnconfirmed),
4152
+ // The living requirements, beside the scenarios (design D5 of
4153
+ // complete-and-record-the-merge-verdict). Absent stays absent: a count
4154
+ // nobody reported is not a count of zero.
4155
+ requirementsChecked: count(reported.requirementsChecked),
4156
+ requirementsUnconfirmed: count(reported.requirementsUnconfirmed)
3585
4157
  }
3586
4158
  : null
3587
4159
  if (!manifest.handoff) {
@@ -3803,6 +4375,182 @@ function recordDispatched(manifest, step) {
3803
4375
  }
3804
4376
  }
3805
4377
 
4378
+ // --- merge confidence at close (complete-and-record-the-merge-verdict) -------
4379
+ //
4380
+ // A completed run states its merge verdict once, from facts it observed: the
4381
+ // input is gathered by the function the `merge-confidence` command calls
4382
+ // (`lib/merge-evidence.mjs`), so the two cannot assemble it differently. The
4383
+ // verdict is reported — in the summary, the receipt, the outcome record and an
4384
+ // evidence file — and never enforced: nothing here touches the exit code.
4385
+
4386
+ /** The run's evidence file, beside `review.json` in the working root (design D9). */
4387
+ const MERGE_EVIDENCE_FILE = 'merge-confidence.json'
4388
+
4389
+ const isRecord = value => Boolean(value) && typeof value === 'object' && !Array.isArray(value)
4390
+
4391
+ /** One thing the gather could not read, as the degradation block says it (design D1). */
4392
+ function mergeEvidenceBanner(note) {
4393
+ if (typeof note === 'string') return `MERGE EVIDENCE UNAVAILABLE: ${note}`
4394
+ const what = note && typeof note.what === 'string' && note.what ? note.what : 'a fact'
4395
+ const reason = note && typeof note.reason === 'string' && note.reason ? note.reason : 'no reason was given'
4396
+ return `MERGE EVIDENCE UNAVAILABLE: ${what} — ${reason}`
4397
+ }
4398
+
4399
+ /**
4400
+ * Assess one gathered input. With no start commit the run has no base, and the
4401
+ * module's readers say so in the command's words: the `--base` input was not
4402
+ * given. A run has no flags, so the close adds what that means here — the base
4403
+ * a ship run reads is its start commit, and this one recorded none (design D2).
4404
+ * Evidence only: recombining the same bands and flags yields the same verdict
4405
+ * and score.
4406
+ */
4407
+ function assessForRun(input, base) {
4408
+ const assessed = assessMergeConfidence(input)
4409
+ if (base) return assessed
4410
+ const line = 'a ship run reads its base from its start commit, and this run recorded no start commit'
4411
+ return combineSignals(
4412
+ assessed.signals.map(signal =>
4413
+ /--base\b/.test(signal.reason) ? { ...signal, evidence: [...signal.evidence, line] } : signal
4414
+ )
4415
+ )
4416
+ }
4417
+
4418
+ /**
4419
+ * Gather and assess at close (design D2). Throws when the gather or the module
4420
+ * does; the caller speaks that as `MERGE CONFIDENCE NOT ASSESSED`.
4421
+ *
4422
+ * What the run hands in is only what THIS run observed (design D18): the
4423
+ * working directory is not cleared between runs, so `review.json` is read only
4424
+ * when this run adjudicated a review, and `verify-final.json` only when it
4425
+ * carries this run's id. No pipeline input, ever: the run is offline and no
4426
+ * platform has seen the commit it just made.
4427
+ *
4428
+ * @returns {{ input: object, assessment: object, notes: Array, base: string|null, headSha: string|null,
4429
+ * committed: boolean, evidence: { review: any, verify: any, handoff: any, coverage: any, run: object } }}
4430
+ */
4431
+ function assessAtClose(ctx, manifest, { touchedPaths, run }) {
4432
+ const { root } = ctx
4433
+ const deps = ctx.deps && typeof ctx.deps === 'object' ? ctx.deps : {}
4434
+ const has = name => typeof deps[name] === 'function'
4435
+ const notes = []
4436
+ const base = pinnedSha(manifest)
4437
+
4438
+ // The commit the run made. The step reports the sha; HEAD, where this host
4439
+ // can read it, supplies the full form of the same commit. A run told not to
4440
+ // commit, and one whose commit step failed, are still on their start commit.
4441
+ const commit = manifest.commit && manifest.commit.ok === true ? manifest.commit : null
4442
+ const committed = Boolean(commit)
4443
+ const reported = commit && typeof commit.sha === 'string' && commit.sha ? commit.sha : null
4444
+ let head = null
4445
+ try {
4446
+ head = has('headCommit') ? deps.headCommit(root) : null
4447
+ } catch {
4448
+ head = null
4449
+ }
4450
+ if (typeof head !== 'string' || !head) head = null
4451
+ const headSha = committed ? (reported && head && head.startsWith(reported) ? head : reported || head) : head || base
4452
+
4453
+ // The commit's own touched paths; the working tree's changed paths when the
4454
+ // run made no commit or its commit could not be read (design D14).
4455
+ let changedPaths = Array.isArray(touchedPaths) ? touchedPaths : null
4456
+ if (!changedPaths) {
4457
+ try {
4458
+ const observed = has('observedChangedPaths') ? deps.observedChangedPaths(root) : []
4459
+ changedPaths = Array.isArray(observed) ? observed : []
4460
+ } catch {
4461
+ changedPaths = []
4462
+ }
4463
+ }
4464
+
4465
+ let review = null
4466
+ if (manifest.review) {
4467
+ review = readJson(join(root, WORK_DIR, 'review.json'))
4468
+ if (!isRecord(review)) {
4469
+ review = null
4470
+ notes.push({
4471
+ what: 'the review record',
4472
+ reason: `this run adjudicated a review and ${join(WORK_DIR, 'review.json')} could not be read back`
4473
+ })
4474
+ }
4475
+ }
4476
+
4477
+ let verify = null
4478
+ const recorded = readJson(join(root, WORK_DIR, VERIFY_FINAL_FILE))
4479
+ if (isRecord(recorded) && typeof manifest.runId === 'string' && manifest.runId && recorded.runId === manifest.runId) {
4480
+ const { runId: _stamp, ...verdict } = recorded
4481
+ verify = verdict
4482
+ }
4483
+
4484
+ // Coverage figures, computed where the verdict says the coverage command ran,
4485
+ // so the report read is this run's (design D3). A reason is a note; the
4486
+ // signal's own fallbacks then apply.
4487
+ let coverage = null
4488
+ if (verify && isRecord(verify.coverage)) {
4489
+ const unavailable = reason => notes.push({ what: 'the coverage figures', reason })
4490
+ if (!base) {
4491
+ unavailable('this run recorded no start commit to compute them against')
4492
+ } else if (!has('coverageInput')) {
4493
+ unavailable('no coverageInput function was supplied, so this host has no means of computing them')
4494
+ } else {
4495
+ try {
4496
+ const answer = deps.coverageInput(root, { base })
4497
+ if (isRecord(answer) && isRecord(answer.input)) coverage = answer.input
4498
+ else {
4499
+ unavailable(
4500
+ isRecord(answer) && typeof answer.reason === 'string' && answer.reason
4501
+ ? answer.reason
4502
+ : 'the computation returned neither figures nor a reason'
4503
+ )
4504
+ }
4505
+ } catch (err) {
4506
+ unavailable((err && err.message) || String(err))
4507
+ }
4508
+ }
4509
+ }
4510
+
4511
+ // Git, as this host supplies it. A missing function is the gather's to note.
4512
+ const git = {}
4513
+ for (const name of ['diffShape', 'nameStatus', 'listFiles', 'readAtRef']) {
4514
+ if (has(name)) git[name] = deps[name]
4515
+ }
4516
+ if (!committed && base) {
4517
+ // The head is still the start commit, so the diff is empty by construction
4518
+ // and says nothing about the change (design D14). Said in the run's terms.
4519
+ git.diffShape = () => ({
4520
+ base,
4521
+ paths: [],
4522
+ nothingToDiff:
4523
+ `the run made no commit, so ${shortSha(base)}...HEAD is empty by construction and says nothing about the change`
4524
+ })
4525
+ }
4526
+
4527
+ const evidence = { review, verify, handoff: isRecord(manifest.handoff) ? manifest.handoff : null, coverage, run }
4528
+ const gather = has('gatherMergeEvidence') ? deps.gatherMergeEvidence : gatherMergeEvidence
4529
+ const gathered = gather(root, {
4530
+ change: manifest.change,
4531
+ changedPaths,
4532
+ base,
4533
+ headSha,
4534
+ supplied: { ...evidence, pipeline: null },
4535
+ deps: git,
4536
+ // The graph the run selected living requirements from at the handoff, so
4537
+ // the close recounts the same denominator (design D16).
4538
+ ...(typeof manifest.graphPath === 'string' && manifest.graphPath ? { graphPath: manifest.graphPath } : {})
4539
+ })
4540
+ if (!isRecord(gathered) || !isRecord(gathered.input)) throw new Error('the gather returned no input to assess')
4541
+ if (Array.isArray(gathered.notes)) notes.push(...gathered.notes)
4542
+
4543
+ return {
4544
+ input: gathered.input,
4545
+ assessment: assessForRun(gathered.input, base),
4546
+ notes,
4547
+ base,
4548
+ headSha,
4549
+ committed,
4550
+ evidence
4551
+ }
4552
+ }
4553
+
3806
4554
  // --- run close (design D10) -------------------------------------------------
3807
4555
 
3808
4556
  /**
@@ -4068,12 +4816,42 @@ export async function runClose(
4068
4816
  const runnerCounts = manifest.agentCounts && typeof manifest.agentCounts === 'object' ? manifest.agentCounts : null
4069
4817
  const observedAny = Boolean(runnerCounts) || joined.present
4070
4818
  const count = (value = 0) => (observedAny ? value : undefined)
4819
+
4820
+ // Merge confidence, once, on a completed run only (design D2): a diff that
4821
+ // stopped part-way is not a candidate for merging, and "never assessed" must
4822
+ // stay distinguishable from a low score. Before the receipt, which carries
4823
+ // the verdict. A failed assessment is spoken and changes no exit code
4824
+ // (design D17): it is the outcome-corpus class of failure.
4825
+ let merge = null
4826
+ if (!haltReason) {
4827
+ try {
4828
+ merge = assessAtClose(ctx, manifest, {
4829
+ touchedPaths: summary.paths.touchedPaths,
4830
+ run: {
4831
+ halted: false,
4832
+ trajectoryComplete: !committerObserved.failure,
4833
+ stageMarkerGaps: degradations.filter(b => typeof b === 'string' && b.startsWith('STAGE MARKER NOT PUBLISHED'))
4834
+ .length,
4835
+ rejectedReports: rejectedReportsOfRun(root).length,
4836
+ permissionPrompts: count(closeObserved.permissionPrompts) ?? null,
4837
+ autoModeDenials: count(closeObserved.autoModeDenials) ?? null,
4838
+ leftoverTasks: leftoverTaskIds.length
4839
+ }
4840
+ })
4841
+ for (const note of merge.notes) degradations.push(mergeEvidenceBanner(note))
4842
+ } catch (err) {
4843
+ merge = null
4844
+ degradations.push(`MERGE CONFIDENCE NOT ASSESSED: ${(err && err.message) || String(err)}`)
4845
+ }
4846
+ }
4847
+
4071
4848
  const receipt = buildReceipt({
4072
4849
  change: manifest.change,
4073
4850
  summary,
4074
4851
  degradations,
4075
4852
  planFingerprint,
4076
4853
  leftoverTaskIds,
4854
+ mergeConfidence: merge ? merge.assessment : undefined,
4077
4855
  lanesStoppedByHost: count(runnerCounts ? runnerCounts.lanesStoppedByHost : 0),
4078
4856
  schemaResultsMissing: count(runnerCounts ? runnerCounts.schemaResultsMissing : 0),
4079
4857
  toolsDeniedInLanes: count(runnerCounts ? runnerCounts.toolsDeniedInLanes : 0),
@@ -4168,6 +4946,59 @@ export async function runClose(
4168
4946
  }
4169
4947
  }
4170
4948
 
4949
+ // The assessment's last word (design D15). The receipt was built before its
4950
+ // own append, so it could not know whether that append, the terminal event or
4951
+ // the check above would fail. When one did, the run record's
4952
+ // `trajectoryComplete` is no longer true: the close reassesses from the SAME
4953
+ // gathered input with that one field changed, and the evidence file and the
4954
+ // summary carry the result. The receipt keeps what it was built with. In the
4955
+ // ordinary case nothing is computed twice.
4956
+ let mergeSummary = null
4957
+ if (merge) {
4958
+ let assessed = merge.assessment
4959
+ let runRecord = merge.evidence.run
4960
+ const complete = trajectoryComplete && reconstructable
4961
+ if (runRecord.trajectoryComplete !== complete) {
4962
+ runRecord = { ...runRecord, trajectoryComplete: complete }
4963
+ try {
4964
+ assessed = assessForRun({ ...merge.input, run: runRecord }, merge.base)
4965
+ } catch (err) {
4966
+ assessed = null
4967
+ degradations.push(`MERGE CONFIDENCE NOT ASSESSED: ${(err && err.message) || String(err)}`)
4968
+ }
4969
+ }
4970
+ if (assessed) {
4971
+ // Per-run working state, so it goes to the working root and never the
4972
+ // state home. A file that cannot be written is spoken and is not named.
4973
+ let assessmentPath = null
4974
+ try {
4975
+ writeWork(
4976
+ root,
4977
+ MERGE_EVIDENCE_FILE,
4978
+ buildRunEvidence({
4979
+ change: manifest.change,
4980
+ runId: manifest.runId,
4981
+ base: merge.base,
4982
+ headSha: merge.headSha,
4983
+ committed: merge.committed,
4984
+ evidence: { ...merge.evidence, run: runRecord },
4985
+ result: assessed
4986
+ })
4987
+ )
4988
+ assessmentPath = [...WORK_DIR.split(/[\\/]/), MERGE_EVIDENCE_FILE].join('/')
4989
+ } catch (err) {
4990
+ degradations.push(`MERGE CONFIDENCE NOT WRITTEN: ${(err && err.message) || String(err)}`)
4991
+ }
4992
+ const driven = kind => assessed.driving.filter(d => d.kind === kind).map(d => d.id)
4993
+ mergeSummary = {
4994
+ headline: mergeConfidenceHeadline(assessed),
4995
+ groups: { fix: driven('finding'), person: driven('person'), evidence: driven('evidence') },
4996
+ flags: assessed.flags,
4997
+ assessmentPath
4998
+ }
4999
+ }
5000
+ }
5001
+
4171
5002
  // The autonomy ladder's record for this run's review (design D6). It used to
4172
5003
  // ride on the commit prompt, which asked the party being assessed to write
4173
5004
  // the assessment's input — the exact provenance rule
@@ -4245,7 +5076,8 @@ export async function runClose(
4245
5076
  projectSlug: slug,
4246
5077
  push,
4247
5078
  unarchived,
4248
- resumeCard
5079
+ resumeCard,
5080
+ mergeConfidence: mergeSummary
4249
5081
  })
4250
5082
  }
4251
5083
  }