@renzrollon/interlock 1.0.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/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +58 -0
- package/CHANGELOG.md +380 -0
- package/LICENSE +21 -0
- package/README.md +358 -0
- package/agents/ping.md +8 -0
- package/agents/worker.md +8 -0
- package/bin/interlock +2655 -0
- package/bin/interlock-graph +6 -0
- package/bin/interlock-run +508 -0
- package/bin/interlock-ship-acp +39 -0
- package/hooks/_shared.mjs +162 -0
- package/hooks/guard-commit.mjs +77 -0
- package/hooks/guard-tasks.mjs +107 -0
- package/hooks/guard-tests.mjs +63 -0
- package/hooks/preflight.mjs +107 -0
- package/lib/artifacts.mjs +316 -0
- package/lib/autonomy.mjs +149 -0
- package/lib/conformance.mjs +132 -0
- package/lib/doctor.mjs +1176 -0
- package/lib/drift.mjs +409 -0
- package/lib/eval-history.mjs +304 -0
- package/lib/evals-calibrate.mjs +317 -0
- package/lib/evals-capture.mjs +437 -0
- package/lib/evals-promote.mjs +257 -0
- package/lib/evals-triage.mjs +216 -0
- package/lib/findings.mjs +340 -0
- package/lib/graph/cli.mjs +266 -0
- package/lib/graph/extract/config-json.mjs +74 -0
- package/lib/graph/extract/create-extractor.mjs +35 -0
- package/lib/graph/extract/memory.mjs +99 -0
- package/lib/graph/extract/openspec.mjs +80 -0
- package/lib/graph/extract/python.mjs +137 -0
- package/lib/graph/extract/regex-fallback.mjs +187 -0
- package/lib/graph/extract/shell.mjs +71 -0
- package/lib/graph/extract/typescript.mjs +217 -0
- package/lib/graph/extract/walk.mjs +272 -0
- package/lib/graph/format.mjs +51 -0
- package/lib/graph/graph/build.mjs +374 -0
- package/lib/graph/graph/query.mjs +249 -0
- package/lib/graph/graph/report.mjs +122 -0
- package/lib/graph/graph/update.mjs +47 -0
- package/lib/graph/paths.mjs +66 -0
- package/lib/graph/retrieve/context.mjs +86 -0
- package/lib/graph/retrieve/docs-index.mjs +116 -0
- package/lib/graph/retrieve/markdown.mjs +246 -0
- package/lib/host/acp.mjs +551 -0
- package/lib/host/claude-cli.mjs +285 -0
- package/lib/host/codex.mjs +231 -0
- package/lib/host/model-map.mjs +129 -0
- package/lib/host/qwen.mjs +176 -0
- package/lib/host/registry.mjs +213 -0
- package/lib/host.mjs +393 -0
- package/lib/ledger.mjs +444 -0
- package/lib/limits.mjs +525 -0
- package/lib/locators.mjs +99 -0
- package/lib/merge-lanes.mjs +148 -0
- package/lib/metrics.mjs +99 -0
- package/lib/notify.mjs +229 -0
- package/lib/outcomes.mjs +560 -0
- package/lib/plan-fingerprint.mjs +570 -0
- package/lib/project-slug.mjs +18 -0
- package/lib/prompts/commit.mjs +26 -0
- package/lib/prompts/dimensions.mjs +70 -0
- package/lib/prompts/handoff.mjs +86 -0
- package/lib/prompts/implementer.mjs +150 -0
- package/lib/prompts/index.mjs +250 -0
- package/lib/prompts/planner.mjs +70 -0
- package/lib/prompts/remediate.mjs +137 -0
- package/lib/prompts/replan.mjs +23 -0
- package/lib/prompts/review.mjs +107 -0
- package/lib/prompts/schemas.mjs +206 -0
- package/lib/prompts/stage.mjs +48 -0
- package/lib/prompts/verify.mjs +88 -0
- package/lib/ready.mjs +812 -0
- package/lib/receipt.mjs +434 -0
- package/lib/remediate.mjs +275 -0
- package/lib/report-html.mjs +623 -0
- package/lib/report.mjs +1124 -0
- package/lib/review-core.mjs +713 -0
- package/lib/risk.mjs +370 -0
- package/lib/run-log.mjs +1004 -0
- package/lib/run-paths.mjs +239 -0
- package/lib/run.mjs +2921 -0
- package/lib/ship-stage.mjs +223 -0
- package/lib/spill.mjs +150 -0
- package/lib/surface.mjs +195 -0
- package/lib/verify.mjs +1074 -0
- package/lib/waves.mjs +2661 -0
- package/package.json +40 -0
- package/shared/CONTEXT-HYGIENE.md +84 -0
- package/shared/DECISION-LEDGER.md +141 -0
- package/shared/EXPLORE-BRIEF.md +96 -0
- package/shared/INVARIANT-SWEEP.md +57 -0
- package/shared/SESSION-SHAPES.md +261 -0
- package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
- package/shared/TEST-PROFILE.md +204 -0
- package/shared/TOOL-ECONOMY.md +155 -0
- package/skills/bootstrap/SKILL.md +226 -0
- package/skills/commit/SKILL.md +99 -0
- package/skills/dispatch/SKILL.md +78 -0
- package/skills/docs-digest/SKILL.md +109 -0
- package/skills/evals/SKILL.md +160 -0
- package/skills/explain-code/SKILL.md +42 -0
- package/skills/explain-code/commit-teachin.md +136 -0
- package/skills/explain-code/module-walkthrough.md +358 -0
- package/skills/explore/SKILL.md +159 -0
- package/skills/fix-tests/SKILL.md +120 -0
- package/skills/graph/SKILL.md +136 -0
- package/skills/manual-test-plan/SKILL.md +121 -0
- package/skills/mr/SKILL.md +170 -0
- package/skills/report/SKILL.md +121 -0
- package/skills/review-artifacts/SKILL.md +127 -0
- package/skills/review-code/REVIEW.template.md +49 -0
- package/skills/review-code/SKILL.md +156 -0
- package/skills/review-code/dimensions/architecture.md +10 -0
- package/skills/review-code/dimensions/devops.md +12 -0
- package/skills/review-code/dimensions/language.md +12 -0
- package/skills/review-code/dimensions/qa.md +12 -0
- package/skills/review-code/dimensions/security.md +12 -0
- package/skills/review-code/dimensions/technical-lead.md +19 -0
- package/skills/ship/SKILL.md +67 -0
- package/skills/spec/SKILL.md +264 -0
- package/skills/spec/continuity.md +66 -0
- package/workflows/ship.js +565 -0
package/lib/receipt.mjs
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
// The run receipt, and the summary both hosts print.
|
|
2
|
+
//
|
|
3
|
+
// `buildReceipt` was a marked block in `workflows/ship.js` that `bin/interlock-
|
|
4
|
+
// ship-acp` evaluated out of that file rather than carrying a second copy —
|
|
5
|
+
// because two hosts writing "the same" receipt from two implementations is
|
|
6
|
+
// precisely the drift a reader could never see: both trajectories would look
|
|
7
|
+
// fine. The smuggling is gone; the reason it existed is why this module is here.
|
|
8
|
+
//
|
|
9
|
+
// The summary text joins it for the same reason. Two drivers formatting the same
|
|
10
|
+
// facts would eventually format them differently, and the difference would show
|
|
11
|
+
// up as a support question rather than as a test failure.
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The wave-state skip reason meaning the per-run verification cap was already
|
|
15
|
+
* spent.
|
|
16
|
+
*
|
|
17
|
+
* A function rather than a constant so a prompt or a banner can name it too: two
|
|
18
|
+
* literals is how the printed banner and the recorded count would come to
|
|
19
|
+
* disagree about what "cap exhausted" means, and only one of them would be wrong
|
|
20
|
+
* in a way anyone noticed.
|
|
21
|
+
*/
|
|
22
|
+
export function capExhaustedSkipReason() {
|
|
23
|
+
return 'verify-cap-reached'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The three verification conditions a receipt reports, read out of a wave-state
|
|
28
|
+
* object.
|
|
29
|
+
*
|
|
30
|
+
* The Workflow host could not read that file and asked its closing agent for the
|
|
31
|
+
* same three facts. The CLI reads it directly — one fewer hop, and one statement
|
|
32
|
+
* of which skip reason means the cap was spent.
|
|
33
|
+
*/
|
|
34
|
+
export function closingFromWaveState(state) {
|
|
35
|
+
if (!state || !Array.isArray(state.skippedVerifications)) return null
|
|
36
|
+
const reasons = state.skippedVerifications.map(s => (s && s.reason) || '').filter(Boolean)
|
|
37
|
+
return {
|
|
38
|
+
skippedVerificationReasons: reasons,
|
|
39
|
+
capExhaustedVerifications: reasons.filter(r => r === capExhaustedSkipReason()).length,
|
|
40
|
+
unresolvedErrors: Array.isArray(state.unresolved) ? state.unresolved.length : undefined
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The strict tail's figures, read off the run manifest (design D7).
|
|
46
|
+
*
|
|
47
|
+
* They used to arrive through `--host-observed`, because the tail ran inside
|
|
48
|
+
* the Workflow host and the CLI never saw it. The tail is a CLI-emitted program
|
|
49
|
+
* now (`emit-strict-tail-from-cli`), so every one of these was adjudicated,
|
|
50
|
+
* counted or recorded by `interlock run` itself and is read back from where it
|
|
51
|
+
* was written rather than reported by the party it describes.
|
|
52
|
+
*
|
|
53
|
+
* An absent field stays absent. A lean run never reviewed, and `null` — "never
|
|
54
|
+
* found out" — is a different fact from a review that found nothing, which is
|
|
55
|
+
* the distinction the outcome corpus's control group depends on.
|
|
56
|
+
*
|
|
57
|
+
* @param {object|null} manifest the run manifest (`.claude/ship/run.json`)
|
|
58
|
+
* @returns {{review: object|null, remediation: object|null,
|
|
59
|
+
* remediationRounds: number|undefined, handoff: object|null,
|
|
60
|
+
* autonomy: object|null}}
|
|
61
|
+
*/
|
|
62
|
+
export function tailFromManifest(manifest) {
|
|
63
|
+
const m = manifest || {}
|
|
64
|
+
const counts = m.review && m.review.counts ? m.review.counts : null
|
|
65
|
+
return {
|
|
66
|
+
// Flattened to the field names `buildReceipt` and `formatRunSummary` read,
|
|
67
|
+
// so neither has to know that the manifest nests them under `counts`.
|
|
68
|
+
review: counts
|
|
69
|
+
? {
|
|
70
|
+
raised: counts.raised,
|
|
71
|
+
dismissed: counts.dismissed,
|
|
72
|
+
droppedByQuality: counts.droppedByQuality,
|
|
73
|
+
surviving: counts.surviving,
|
|
74
|
+
blockers: counts.blockers
|
|
75
|
+
}
|
|
76
|
+
: null,
|
|
77
|
+
remediation: m.remediation || null,
|
|
78
|
+
// What the run CONSUMED, never the published cap — and only when a review
|
|
79
|
+
// ran at all, so a lean run reports unobserved rather than zero rounds.
|
|
80
|
+
remediationRounds: m.review ? m.fixRoundsRun || 0 : undefined,
|
|
81
|
+
handoff: m.handoff || null,
|
|
82
|
+
// Storage only, and deliberately not printed: the ladder is experimental
|
|
83
|
+
// and a level means nothing to the reader (the retired autonomy-record
|
|
84
|
+
// prompt said so itself). Carried here so a reader of the manifest can see
|
|
85
|
+
// that the close recorded one.
|
|
86
|
+
autonomy: m.autonomy || null
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The run's receipt payload: what this run observed about itself, in the field
|
|
92
|
+
* names `lib/run-log.mjs`'s `run-receipt` whitelist copies by name.
|
|
93
|
+
*
|
|
94
|
+
* Pure. Every field is either observed or `undefined`. Nothing here substitutes
|
|
95
|
+
* a zero for a value the run never found out: the writer turns an absent field
|
|
96
|
+
* into `null`, and `null` reads as unknown. A receipt that reported an
|
|
97
|
+
* unobserved review as zero blockers would flatter exactly the halted runs this
|
|
98
|
+
* record exists to explain.
|
|
99
|
+
*
|
|
100
|
+
* @param {{
|
|
101
|
+
* change?: string,
|
|
102
|
+
* summary?: object,
|
|
103
|
+
* degradations?: string[],
|
|
104
|
+
* planFingerprint?: string,
|
|
105
|
+
* leftoverTaskIds?: string[],
|
|
106
|
+
* host?: {id?: string, billing?: string, hooks?: boolean, usage?: boolean}
|
|
107
|
+
* }} input
|
|
108
|
+
*/
|
|
109
|
+
export function buildReceipt(input) {
|
|
110
|
+
const source = input || {}
|
|
111
|
+
const summary = source.summary || {}
|
|
112
|
+
const closing = summary.closing || null
|
|
113
|
+
const plan = summary.plan || null
|
|
114
|
+
const review = summary.review || null
|
|
115
|
+
const commit = summary.commit || null
|
|
116
|
+
|
|
117
|
+
// The two path sets, in their own group rather than on `closing`: a host that
|
|
118
|
+
// could not read the wave state must still be able to report these, and a
|
|
119
|
+
// host that could not read these must not make `closing` look present.
|
|
120
|
+
//
|
|
121
|
+
// A set is carried only when the close actually read one. Anything else — no
|
|
122
|
+
// close at all, a read that failed, a host that could not run the readers —
|
|
123
|
+
// is `undefined`, which the writer records as unobserved.
|
|
124
|
+
const paths = summary.paths || null
|
|
125
|
+
const touched = paths && Array.isArray(paths.touchedPaths) ? paths.touchedPaths : undefined
|
|
126
|
+
const predicted = paths && Array.isArray(paths.predictedPaths) ? paths.predictedPaths : undefined
|
|
127
|
+
|
|
128
|
+
// surviving counts blockers and warnings together, so the warning count is
|
|
129
|
+
// the difference — and only when both halves were actually observed. A
|
|
130
|
+
// negative difference means the two counts disagree, which is reported as
|
|
131
|
+
// unknown rather than as a number nobody measured.
|
|
132
|
+
const surviving = review && Number.isFinite(review.surviving) ? review.surviving : undefined
|
|
133
|
+
const blockers = review && Number.isFinite(review.blockers) ? review.blockers : undefined
|
|
134
|
+
const warnings =
|
|
135
|
+
surviving !== undefined && blockers !== undefined && surviving >= blockers
|
|
136
|
+
? surviving - blockers
|
|
137
|
+
: undefined
|
|
138
|
+
|
|
139
|
+
// Which host ran this, and the three ways it may have been weaker than the
|
|
140
|
+
// default one (design D8). Recorded rather than inferred from the id: a
|
|
141
|
+
// reader comparing two receipts should not have to know that `acp` over the
|
|
142
|
+
// Claude binary has hooks and `acp` over anything else does not, and the
|
|
143
|
+
// outcome corpus's comparisons are only meaningful if the run said so itself.
|
|
144
|
+
const host = source.host && typeof source.host === 'object' ? source.host : null
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
type: 'run-receipt',
|
|
148
|
+
change: source.change,
|
|
149
|
+
host: host
|
|
150
|
+
? {
|
|
151
|
+
id: host.id,
|
|
152
|
+
billing: host.billing,
|
|
153
|
+
hooks: host.hooks,
|
|
154
|
+
usage: host.usage,
|
|
155
|
+
// Recorded per run so a reader can PARTITION the corpus rather than
|
|
156
|
+
// average across it: cache figures exist on some hosts and not others,
|
|
157
|
+
// and that asymmetry is stated rather than engineered away.
|
|
158
|
+
cacheAccounting: host.cacheAccounting
|
|
159
|
+
}
|
|
160
|
+
: undefined,
|
|
161
|
+
waves: (Array.isArray(summary.waves) ? summary.waves : []).map(wave => ({
|
|
162
|
+
wave: wave.wave,
|
|
163
|
+
ok: wave.ok,
|
|
164
|
+
failed: wave.failed,
|
|
165
|
+
notAttempted: wave.notAttempted
|
|
166
|
+
})),
|
|
167
|
+
// Measured from the runtime's own counter and carried, not computed by
|
|
168
|
+
// anyone downstream. Element by element, because these entries ride on the
|
|
169
|
+
// same summary the review findings and suite output do.
|
|
170
|
+
spend: (Array.isArray(summary.spend) ? summary.spend : []).map(entry => ({
|
|
171
|
+
wave: entry.wave,
|
|
172
|
+
outputTokens: entry.outputTokens,
|
|
173
|
+
// The input side, carried on exactly the terms the output side is: named
|
|
174
|
+
// element by element, never spread, and absent where the host reported
|
|
175
|
+
// nothing. `cacheCreationInputTokens` stays a per-tier object all the way
|
|
176
|
+
// through — a prefix written for five minutes and one written for an hour
|
|
177
|
+
// are different prices, and flattening them here could not be undone.
|
|
178
|
+
cacheReadInputTokens: entry.cacheReadInputTokens,
|
|
179
|
+
cacheCreationInputTokens: entry.cacheCreationInputTokens
|
|
180
|
+
})),
|
|
181
|
+
outputTokens: summary.outputTokens,
|
|
182
|
+
cacheReadInputTokens: summary.cacheReadInputTokens,
|
|
183
|
+
cacheCreationInputTokens: summary.cacheCreationInputTokens,
|
|
184
|
+
planReused: plan ? plan.reused === true : undefined,
|
|
185
|
+
planStatus: plan ? plan.status : undefined,
|
|
186
|
+
planReason: plan ? plan.reason : undefined,
|
|
187
|
+
planFingerprint: source.planFingerprint,
|
|
188
|
+
reviewRaised: review ? review.raised : undefined,
|
|
189
|
+
reviewSurviving: surviving,
|
|
190
|
+
reviewBlockers: blockers,
|
|
191
|
+
reviewWarnings: warnings,
|
|
192
|
+
remediationRounds: summary.remediationRounds,
|
|
193
|
+
// These three are what the degradation block reads, from the same place, so
|
|
194
|
+
// the receipt's counts and the printed banners cannot come apart.
|
|
195
|
+
skippedVerifications: closing ? (closing.skippedVerificationReasons || []).length : undefined,
|
|
196
|
+
capExhaustedVerifications: closing ? closing.capExhaustedVerifications : undefined,
|
|
197
|
+
unresolvedErrors: closing ? closing.unresolvedErrors : undefined,
|
|
198
|
+
leftoverTaskIds: source.leftoverTaskIds,
|
|
199
|
+
halted: Boolean(summary.halted),
|
|
200
|
+
haltReason: summary.halted || undefined,
|
|
201
|
+
// Tri-state, and the reason the field is not just the sha: a run told not to
|
|
202
|
+
// commit reports false, a run that halted before it ever reached a commit
|
|
203
|
+
// step reports unknown, and those are different facts about the same absent
|
|
204
|
+
// sha. `commitSkipped` is set only where the run chose to stop — never
|
|
205
|
+
// inferred from the sha being missing.
|
|
206
|
+
committed: commit ? commit.ok === true : summary.commitSkipped === true ? false : undefined,
|
|
207
|
+
commit: commit && commit.ok === true ? commit.sha : undefined,
|
|
208
|
+
// The two path sets, read at close and carried here — never the commit
|
|
209
|
+
// step's own account of what it committed. An absent set stays absent (the
|
|
210
|
+
// writer turns it into `null`) and takes a stated reason with it: `[]` would
|
|
211
|
+
// assert that a commit touched nothing, and this set is the denominator of
|
|
212
|
+
// an indicator, so that error would be silent and systematic.
|
|
213
|
+
touchedPaths: touched,
|
|
214
|
+
touchedPathsReason: touched
|
|
215
|
+
? undefined
|
|
216
|
+
: (paths && paths.touchedPathsReason) ||
|
|
217
|
+
(commit && commit.ok !== true
|
|
218
|
+
? 'the commit step reported failure, so no commit identifier was recorded'
|
|
219
|
+
: summary.commitSkipped === true
|
|
220
|
+
? 'the run was invoked so that it does not commit'
|
|
221
|
+
: paths
|
|
222
|
+
? 'the run recorded no commit identifier'
|
|
223
|
+
: 'the run did not reach its close, so no path set was read'),
|
|
224
|
+
predictedPaths: predicted,
|
|
225
|
+
predictedPathsComplete: predicted ? paths.predictedPathsComplete === true : undefined,
|
|
226
|
+
predictedPathsReason:
|
|
227
|
+
predicted && paths.predictedPathsComplete === true
|
|
228
|
+
? undefined
|
|
229
|
+
: (paths && paths.predictedPathsReason) ||
|
|
230
|
+
(paths
|
|
231
|
+
? 'the executed plan could not be read back at close'
|
|
232
|
+
: 'the run did not reach its close, so the executed plan was never read'),
|
|
233
|
+
degradations: source.degradations
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Every degradation this run accepted, derived from what was recorded.
|
|
239
|
+
*
|
|
240
|
+
* The block used to print whatever happened to be in a `banners` accumulator, so
|
|
241
|
+
* silence and cleanliness were indistinguishable: the push sites covered graph,
|
|
242
|
+
* test profile, model routing, three skip reasons and e2e, while cap exhaustion
|
|
243
|
+
* and unresolved errors pushed nothing at all. An accumulator can only report
|
|
244
|
+
* what someone remembered to push — which is the exact property this exists to
|
|
245
|
+
* remove. So the run conditions are read back from the wave state, and the
|
|
246
|
+
* absence of a close is itself reported.
|
|
247
|
+
*/
|
|
248
|
+
export function degradationLines({ banners = [], closing = null, hostBanners = [] } = {}) {
|
|
249
|
+
const out = [...banners, ...hostBanners]
|
|
250
|
+
|
|
251
|
+
if (!closing) {
|
|
252
|
+
out.push(
|
|
253
|
+
'CLOSING STEP OUTCOME UNKNOWN: the run ended before its close could read the wave state, so ' +
|
|
254
|
+
'the conditions it reads back — skipped verifications, cap exhaustion, unresolved errors — ' +
|
|
255
|
+
'were never observed'
|
|
256
|
+
)
|
|
257
|
+
} else {
|
|
258
|
+
for (const reason of closing.skippedVerificationReasons || []) {
|
|
259
|
+
out.push(`VERIFICATION SKIPPED: reason=${reason}`)
|
|
260
|
+
}
|
|
261
|
+
if (closing.capExhaustedVerifications > 0) {
|
|
262
|
+
out.push(
|
|
263
|
+
`VERIFY CAP EXHAUSTED: ${closing.capExhaustedVerifications} inter-wave checkpoint(s) were ` +
|
|
264
|
+
`skipped because the per-run verification cap was already spent`
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
if (closing.unresolvedErrors > 0) {
|
|
268
|
+
out.push(
|
|
269
|
+
`UNRESOLVED ERRORS CARRIED PAST A WAVE: ${closing.unresolvedErrors} — the fix budget was ` +
|
|
270
|
+
`spent and the run continued because the errors were reported as not blocking`
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Deduplicate: a skip reason can be both banner-pushed as it happened and
|
|
276
|
+
// read back from the state at the end, and printing it twice reads as two
|
|
277
|
+
// skips.
|
|
278
|
+
return [...new Set(out)]
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The summary's first line, exactly as `formatRunSummary` prints it.
|
|
283
|
+
*
|
|
284
|
+
* Exported so the close's push notification (design D4) can use the same
|
|
285
|
+
* headline the printed summary carries: two implementations of "what does
|
|
286
|
+
* this run's first line say" is exactly how the push and the print would
|
|
287
|
+
* come to disagree, and only the reader comparing them would notice.
|
|
288
|
+
*
|
|
289
|
+
* @param {{change?: string, halted?: string|false, leftoverTaskIds?: string[]}} input
|
|
290
|
+
*/
|
|
291
|
+
export function summaryHeadline({ change, halted, leftoverTaskIds } = {}) {
|
|
292
|
+
const leftover = Array.isArray(leftoverTaskIds) ? leftoverTaskIds : []
|
|
293
|
+
if (halted) return `SHIP HALTED — ${halted}`
|
|
294
|
+
if (leftover.length) return `SHIP COMPLETE WITH LEFTOVERS — ${change}`
|
|
295
|
+
return `SHIP COMPLETE — ${change}`
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The human summary both hosts print, verbatim.
|
|
300
|
+
*
|
|
301
|
+
* The banner block is always printed, on a halt and on a clean run alike.
|
|
302
|
+
* Silence is the failure mode it exists to remove: a summary with no banner
|
|
303
|
+
* section is indistinguishable from a run that degraded and hid it.
|
|
304
|
+
*
|
|
305
|
+
* `runId`, `root`, `projectSlug`, `push` and `unarchived` all default so this
|
|
306
|
+
* stays callable exactly as it was before design D7/D8/D9 added them — the
|
|
307
|
+
* receipt module stays import-free either way.
|
|
308
|
+
*/
|
|
309
|
+
export function formatRunSummary({
|
|
310
|
+
change,
|
|
311
|
+
summary = {},
|
|
312
|
+
flags = {},
|
|
313
|
+
leftoverTaskIds = [],
|
|
314
|
+
degradations = [],
|
|
315
|
+
runId = undefined,
|
|
316
|
+
root = undefined,
|
|
317
|
+
projectSlug = undefined,
|
|
318
|
+
push = null,
|
|
319
|
+
unarchived = null
|
|
320
|
+
} = {}) {
|
|
321
|
+
const leftover = Array.isArray(leftoverTaskIds) ? leftoverTaskIds : []
|
|
322
|
+
const lines = []
|
|
323
|
+
|
|
324
|
+
lines.push(summaryHeadline({ change, halted: summary.halted, leftoverTaskIds: leftover }))
|
|
325
|
+
|
|
326
|
+
// Printed on a halt too: the run that stopped early is the one whose
|
|
327
|
+
// unfinished work someone has to pick up, and the receipt carries this list
|
|
328
|
+
// either way.
|
|
329
|
+
if (leftover.length) {
|
|
330
|
+
lines.push(` leftover tasks (boxes still unchecked): ${leftover.join(', ')}`)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Which path the plan took, always, in both directions. A run that silently
|
|
334
|
+
// changed its own cost is the failure mode this block exists to remove, and
|
|
335
|
+
// "there was no prior plan" is a reason like any other.
|
|
336
|
+
if (summary.plan) {
|
|
337
|
+
lines.push(
|
|
338
|
+
summary.plan.reused
|
|
339
|
+
? ` PLAN REUSED (${summary.plan.status}): ${summary.plan.reason}`
|
|
340
|
+
: ` PLAN REBUILT (${summary.plan.status}): ${summary.plan.reason}`
|
|
341
|
+
)
|
|
342
|
+
} else {
|
|
343
|
+
lines.push(
|
|
344
|
+
' PLAN UNKNOWN: the run ended before the plan-reuse check reported, so whether the ' +
|
|
345
|
+
'classifier ran was never observed'
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
for (const wave of summary.waves || []) {
|
|
350
|
+
lines.push(` wave ${wave.wave ?? '?'} (${wave.kind}): ${wave.ok} ok, ${wave.failed} failed`)
|
|
351
|
+
}
|
|
352
|
+
if (summary.review) {
|
|
353
|
+
const r = summary.review
|
|
354
|
+
lines.push(
|
|
355
|
+
` review: ${r.raised ?? '?'} raised, ${r.dismissed ?? 0} dismissed by skeptics, ` +
|
|
356
|
+
`${r.droppedByQuality ?? 0} dropped as too weak to report, ${r.surviving ?? '?'} surviving`
|
|
357
|
+
)
|
|
358
|
+
}
|
|
359
|
+
if (summary.remediation) {
|
|
360
|
+
lines.push(
|
|
361
|
+
` remediation: ${summary.remediation.fixed ?? 0} fixed, ${summary.remediation.deferred ?? 0} deferred`
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
if (summary.handoff) {
|
|
365
|
+
if (flags.handoff) {
|
|
366
|
+
lines.push(
|
|
367
|
+
` handoff: manual test plan ${summary.handoff.manualTestPlan ? 'written' : `skipped (${summary.handoff.skipReason || 'not UI-testable'})`}`
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
if (summary.handoff.scenariosChecked) {
|
|
371
|
+
const unconfirmed = summary.handoff.scenariosUnconfirmed || 0
|
|
372
|
+
lines.push(
|
|
373
|
+
` conformance: ${summary.handoff.scenariosChecked - unconfirmed}/${summary.handoff.scenariosChecked} scenarios confirmed` +
|
|
374
|
+
(unconfirmed ? ` — ${unconfirmed} unconfirmed, see conformance.md` : '')
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (summary.commit && summary.commit.ok) lines.push(` commit: ${summary.commit.sha || 'created'}`)
|
|
379
|
+
for (const note of summary.notes || []) lines.push(` ${note}`)
|
|
380
|
+
|
|
381
|
+
// Identity rows (design D9): the join key a reader who was not watching uses
|
|
382
|
+
// to find this run afterwards. `run:` is always printed — a halt before any
|
|
383
|
+
// plan was adopted has no run id, and that absence is itself the fact.
|
|
384
|
+
lines.push(
|
|
385
|
+
typeof runId === 'string' && runId.length > 0
|
|
386
|
+
? ` run: ${runId}`
|
|
387
|
+
: ' run: none — the run halted before a plan was adopted'
|
|
388
|
+
)
|
|
389
|
+
if (projectSlug !== undefined && projectSlug !== null) lines.push(` project: ${projectSlug}`)
|
|
390
|
+
if (root !== undefined && root !== null) lines.push(` cwd: ${root}`)
|
|
391
|
+
|
|
392
|
+
// The push outcome (design D5): a row only when a push was actually
|
|
393
|
+
// attempted — an unconfigured relay is an optional feature left off, not a
|
|
394
|
+
// degraded run, so it prints nothing here.
|
|
395
|
+
if (push && typeof push === 'object' && push.sent !== undefined) {
|
|
396
|
+
lines.push(push.sent ? ' push: sent (ntfy)' : ` push: failed — ${push.reason}`)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// The archive reminder (design D7): only on a clean, leftover-free close of
|
|
400
|
+
// the change this run shipped. Never on a halt, never with leftovers — in
|
|
401
|
+
// both cases the change is not complete, and the reminder must not claim
|
|
402
|
+
// otherwise.
|
|
403
|
+
if (!summary.halted && leftover.length === 0 && unarchived && unarchived.thisChange) {
|
|
404
|
+
lines.push(`ARCHIVE PENDING — ${change}: after merge, run openspec archive ${change}`)
|
|
405
|
+
if (unarchived.others > 0) {
|
|
406
|
+
lines.push(` also unarchived: ${unarchived.others} completed change(s) — run interlock drift`)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
lines.push('Do not start another ship run unless the user asks.')
|
|
411
|
+
|
|
412
|
+
lines.push('')
|
|
413
|
+
// A lean run must not look like --strict. Silence here is the same failure
|
|
414
|
+
// mode the degradation block exists to remove.
|
|
415
|
+
const skipped = []
|
|
416
|
+
if (!flags.review) skipped.push('review')
|
|
417
|
+
if (!flags.handoff) skipped.push('handoff')
|
|
418
|
+
if (!flags.conformance) skipped.push('conformance')
|
|
419
|
+
if (skipped.length) {
|
|
420
|
+
lines.push(
|
|
421
|
+
`LEAN SHIP: skipped ${skipped.join(', ')} — pass --review / --handoff / --strict to enable`
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (degradations.length) {
|
|
426
|
+
for (const banner of degradations) lines.push(banner)
|
|
427
|
+
} else {
|
|
428
|
+
lines.push(
|
|
429
|
+
'No degradation banners — graph, test profile, model routing, verification and e2e were all clean.'
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return lines.join('\n')
|
|
434
|
+
}
|