@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.
Files changed (125) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +58 -0
  3. package/CHANGELOG.md +380 -0
  4. package/LICENSE +21 -0
  5. package/README.md +358 -0
  6. package/agents/ping.md +8 -0
  7. package/agents/worker.md +8 -0
  8. package/bin/interlock +2655 -0
  9. package/bin/interlock-graph +6 -0
  10. package/bin/interlock-run +508 -0
  11. package/bin/interlock-ship-acp +39 -0
  12. package/hooks/_shared.mjs +162 -0
  13. package/hooks/guard-commit.mjs +77 -0
  14. package/hooks/guard-tasks.mjs +107 -0
  15. package/hooks/guard-tests.mjs +63 -0
  16. package/hooks/preflight.mjs +107 -0
  17. package/lib/artifacts.mjs +316 -0
  18. package/lib/autonomy.mjs +149 -0
  19. package/lib/conformance.mjs +132 -0
  20. package/lib/doctor.mjs +1176 -0
  21. package/lib/drift.mjs +409 -0
  22. package/lib/eval-history.mjs +304 -0
  23. package/lib/evals-calibrate.mjs +317 -0
  24. package/lib/evals-capture.mjs +437 -0
  25. package/lib/evals-promote.mjs +257 -0
  26. package/lib/evals-triage.mjs +216 -0
  27. package/lib/findings.mjs +340 -0
  28. package/lib/graph/cli.mjs +266 -0
  29. package/lib/graph/extract/config-json.mjs +74 -0
  30. package/lib/graph/extract/create-extractor.mjs +35 -0
  31. package/lib/graph/extract/memory.mjs +99 -0
  32. package/lib/graph/extract/openspec.mjs +80 -0
  33. package/lib/graph/extract/python.mjs +137 -0
  34. package/lib/graph/extract/regex-fallback.mjs +187 -0
  35. package/lib/graph/extract/shell.mjs +71 -0
  36. package/lib/graph/extract/typescript.mjs +217 -0
  37. package/lib/graph/extract/walk.mjs +272 -0
  38. package/lib/graph/format.mjs +51 -0
  39. package/lib/graph/graph/build.mjs +374 -0
  40. package/lib/graph/graph/query.mjs +249 -0
  41. package/lib/graph/graph/report.mjs +122 -0
  42. package/lib/graph/graph/update.mjs +47 -0
  43. package/lib/graph/paths.mjs +66 -0
  44. package/lib/graph/retrieve/context.mjs +86 -0
  45. package/lib/graph/retrieve/docs-index.mjs +116 -0
  46. package/lib/graph/retrieve/markdown.mjs +246 -0
  47. package/lib/host/acp.mjs +551 -0
  48. package/lib/host/claude-cli.mjs +285 -0
  49. package/lib/host/codex.mjs +231 -0
  50. package/lib/host/model-map.mjs +129 -0
  51. package/lib/host/qwen.mjs +176 -0
  52. package/lib/host/registry.mjs +213 -0
  53. package/lib/host.mjs +393 -0
  54. package/lib/ledger.mjs +444 -0
  55. package/lib/limits.mjs +525 -0
  56. package/lib/locators.mjs +99 -0
  57. package/lib/merge-lanes.mjs +148 -0
  58. package/lib/metrics.mjs +99 -0
  59. package/lib/notify.mjs +229 -0
  60. package/lib/outcomes.mjs +560 -0
  61. package/lib/plan-fingerprint.mjs +570 -0
  62. package/lib/project-slug.mjs +18 -0
  63. package/lib/prompts/commit.mjs +26 -0
  64. package/lib/prompts/dimensions.mjs +70 -0
  65. package/lib/prompts/handoff.mjs +86 -0
  66. package/lib/prompts/implementer.mjs +150 -0
  67. package/lib/prompts/index.mjs +250 -0
  68. package/lib/prompts/planner.mjs +70 -0
  69. package/lib/prompts/remediate.mjs +137 -0
  70. package/lib/prompts/replan.mjs +23 -0
  71. package/lib/prompts/review.mjs +107 -0
  72. package/lib/prompts/schemas.mjs +206 -0
  73. package/lib/prompts/stage.mjs +48 -0
  74. package/lib/prompts/verify.mjs +88 -0
  75. package/lib/ready.mjs +812 -0
  76. package/lib/receipt.mjs +434 -0
  77. package/lib/remediate.mjs +275 -0
  78. package/lib/report-html.mjs +623 -0
  79. package/lib/report.mjs +1124 -0
  80. package/lib/review-core.mjs +713 -0
  81. package/lib/risk.mjs +370 -0
  82. package/lib/run-log.mjs +1004 -0
  83. package/lib/run-paths.mjs +239 -0
  84. package/lib/run.mjs +2921 -0
  85. package/lib/ship-stage.mjs +223 -0
  86. package/lib/spill.mjs +150 -0
  87. package/lib/surface.mjs +195 -0
  88. package/lib/verify.mjs +1074 -0
  89. package/lib/waves.mjs +2661 -0
  90. package/package.json +40 -0
  91. package/shared/CONTEXT-HYGIENE.md +84 -0
  92. package/shared/DECISION-LEDGER.md +141 -0
  93. package/shared/EXPLORE-BRIEF.md +96 -0
  94. package/shared/INVARIANT-SWEEP.md +57 -0
  95. package/shared/SESSION-SHAPES.md +261 -0
  96. package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
  97. package/shared/TEST-PROFILE.md +204 -0
  98. package/shared/TOOL-ECONOMY.md +155 -0
  99. package/skills/bootstrap/SKILL.md +226 -0
  100. package/skills/commit/SKILL.md +99 -0
  101. package/skills/dispatch/SKILL.md +78 -0
  102. package/skills/docs-digest/SKILL.md +109 -0
  103. package/skills/evals/SKILL.md +160 -0
  104. package/skills/explain-code/SKILL.md +42 -0
  105. package/skills/explain-code/commit-teachin.md +136 -0
  106. package/skills/explain-code/module-walkthrough.md +358 -0
  107. package/skills/explore/SKILL.md +159 -0
  108. package/skills/fix-tests/SKILL.md +120 -0
  109. package/skills/graph/SKILL.md +136 -0
  110. package/skills/manual-test-plan/SKILL.md +121 -0
  111. package/skills/mr/SKILL.md +170 -0
  112. package/skills/report/SKILL.md +121 -0
  113. package/skills/review-artifacts/SKILL.md +127 -0
  114. package/skills/review-code/REVIEW.template.md +49 -0
  115. package/skills/review-code/SKILL.md +156 -0
  116. package/skills/review-code/dimensions/architecture.md +10 -0
  117. package/skills/review-code/dimensions/devops.md +12 -0
  118. package/skills/review-code/dimensions/language.md +12 -0
  119. package/skills/review-code/dimensions/qa.md +12 -0
  120. package/skills/review-code/dimensions/security.md +12 -0
  121. package/skills/review-code/dimensions/technical-lead.md +19 -0
  122. package/skills/ship/SKILL.md +67 -0
  123. package/skills/spec/SKILL.md +264 -0
  124. package/skills/spec/continuity.md +66 -0
  125. package/workflows/ship.js +565 -0
@@ -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
+ }