@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.
- package/.claude-plugin/plugin.json +12 -1
- package/CHANGELOG.md +93 -0
- package/README.md +5 -6
- package/bin/interlock +1655 -29
- package/hooks/mod.mjs +20 -23
- package/hooks/notify-option.mjs +69 -0
- package/hooks/preflight.mjs +6 -42
- package/lib/conformance.mjs +75 -0
- package/lib/coverage.mjs +387 -0
- package/lib/doctor.mjs +17 -13
- package/lib/drift.mjs +36 -12
- package/lib/findings.mjs +40 -3
- package/lib/graph/extract/openspec.mjs +6 -15
- package/lib/limits.mjs +78 -1
- package/lib/merge-confidence.mjs +1302 -0
- package/lib/merge-evidence.mjs +527 -0
- package/lib/notify.mjs +10 -20
- package/lib/outcomes.mjs +26 -5
- package/lib/pipeline.mjs +380 -0
- package/lib/prompts/dimensions.mjs +24 -2
- package/lib/prompts/domain-context.mjs +271 -0
- package/lib/prompts/handoff.mjs +32 -1
- package/lib/prompts/index.mjs +57 -0
- package/lib/prompts/remediate.mjs +29 -5
- package/lib/prompts/review.mjs +21 -7
- package/lib/prompts/schemas.mjs +2 -0
- package/lib/ready.mjs +13 -1
- package/lib/receipt.mjs +72 -2
- package/lib/review-core.mjs +224 -21
- package/lib/risk.mjs +8 -0
- package/lib/run-log.mjs +44 -0
- package/lib/run.mjs +859 -27
- package/lib/spec-audit.mjs +232 -0
- package/lib/spec-coverage.mjs +105 -0
- package/lib/spec-text.mjs +257 -0
- package/lib/verify.mjs +17 -4
- package/package.json +1 -1
- package/shared/SESSION-SHAPES.md +1 -1
- package/shared/TEST-PROFILE.md +39 -2
- package/skills/mr/SKILL.md +161 -11
- package/skills/review-code/REVIEW.template.md +24 -6
- package/skills/review-code/SKILL.md +16 -6
- 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 {
|
|
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
|
|
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({
|
|
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
|
-
*
|
|
1670
|
-
* and
|
|
1671
|
-
*
|
|
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 =
|
|
2111
|
+
const { policy, banners: policyBanners } = policyForAdjudication(ctx, manifest)
|
|
1679
2112
|
|
|
1680
|
-
const result = resolveReview(findings, verdicts, {
|
|
1681
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1735
|
-
|
|
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
|
-
|
|
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
|
}
|