@renzrollon/interlock 1.0.0 → 1.1.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 +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +23 -1
- package/README.md +70 -301
- package/bin/interlock +16 -3
- package/lib/evals-triage.mjs +38 -2
- package/lib/limits.mjs +13 -2
- package/lib/merge-lanes.mjs +45 -2
- package/lib/receipt.mjs +14 -4
- package/lib/resume-card.mjs +319 -0
- package/lib/run.mjs +193 -23
- package/package.json +5 -5
- package/shared/TOOL-ECONOMY.md +8 -1
package/lib/evals-triage.mjs
CHANGED
|
@@ -49,6 +49,42 @@ function graderName(grader, i) {
|
|
|
49
49
|
return grader && typeof grader.name === 'string' && grader.name ? grader.name : `grader-${i + 1}`
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// Two results shapes reach triage: the one `claude plugin eval` writes at
|
|
53
|
+
// schemaVersion 1 (`name`, `arms.with` / `arms['with-only']`) and the legacy
|
|
54
|
+
// `id` / `runs` shape the offline unit tests construct. Normalizing both at the
|
|
55
|
+
// case boundary keeps `classifyCase` the single classification rule — a second
|
|
56
|
+
// copy of that rule, one per shape, is how the next schema tweak forks the
|
|
57
|
+
// verdict.
|
|
58
|
+
|
|
59
|
+
function nonEmptyString(value) {
|
|
60
|
+
return typeof value === 'string' && value ? value : null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Case identity: `name` when it is a non-empty string, else `id`. */
|
|
64
|
+
function caseIdentity(kase) {
|
|
65
|
+
if (!kase) return '(unnamed)'
|
|
66
|
+
return nonEmptyString(kase.name) || nonEmptyString(kase.id) || '(unnamed)'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function nonEmptyList(value) {
|
|
70
|
+
return Array.isArray(value) && value.length > 0 ? value : null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// First non-empty of `runs`, `arms.with`, `arms['with-only']`. `arms.without` is
|
|
74
|
+
// never selected: it is the no-plugin baseline, and scoring it would mix
|
|
75
|
+
// comparison trials into the plugin verdict.
|
|
76
|
+
function caseTrials(kase) {
|
|
77
|
+
if (!kase) return []
|
|
78
|
+
const arms = kase.arms && typeof kase.arms === 'object' ? kase.arms : {}
|
|
79
|
+
const trials = nonEmptyList(kase.runs) || nonEmptyList(arms.with) || nonEmptyList(arms['with-only']) || []
|
|
80
|
+
return trials.map(trial => ({ graders: trial && trial.graders }))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Map one raw case, in either shape, onto the `{ id, runs }` classifyCase reads. */
|
|
84
|
+
function normalizeCase(kase) {
|
|
85
|
+
return { id: caseIdentity(kase), runs: caseTrials(kase) }
|
|
86
|
+
}
|
|
87
|
+
|
|
52
88
|
/**
|
|
53
89
|
* Classify one case from its per-run graders.
|
|
54
90
|
* @returns {{id, classification: 'regression'|'variance'|'pass', graders: string[], confirm: string|null}}
|
|
@@ -141,7 +177,7 @@ export function triage(results) {
|
|
|
141
177
|
const allCases = Array.isArray(r.cases) ? r.cases : []
|
|
142
178
|
const unloadable = allCases
|
|
143
179
|
.filter(c => c && c.loaded === false)
|
|
144
|
-
.map(
|
|
180
|
+
.map(caseIdentity)
|
|
145
181
|
const scorable = allCases.filter(c => c && c.loaded !== false)
|
|
146
182
|
|
|
147
183
|
// Zero cases, or a suite where nothing loaded, is a configuration problem —
|
|
@@ -161,7 +197,7 @@ export function triage(results) {
|
|
|
161
197
|
}
|
|
162
198
|
}
|
|
163
199
|
|
|
164
|
-
const cases = scorable.map(classifyCase)
|
|
200
|
+
const cases = scorable.map(kase => classifyCase(normalizeCase(kase)))
|
|
165
201
|
const counts = {
|
|
166
202
|
regressions: cases.filter(c => c.classification === 'regression').length,
|
|
167
203
|
variance: cases.filter(c => c.classification === 'variance').length,
|
package/lib/limits.mjs
CHANGED
|
@@ -139,7 +139,17 @@ export const LIMITS = {
|
|
|
139
139
|
* close itself (design D5, add-harden-unattended-ship-runs). Read by
|
|
140
140
|
* `postNtfy` in `lib/notify.mjs` via `AbortController`.
|
|
141
141
|
*/
|
|
142
|
-
notifyTimeoutMs: 5000
|
|
142
|
+
notifyTimeoutMs: 5000,
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Rows of any one list — leftover task ids, wave tallies, degradation banners
|
|
146
|
+
* — the halt resume card prints before it says how many it left out and names
|
|
147
|
+
* the command that prints them all. The card is a pointer a person reads in
|
|
148
|
+
* one sitting, not a second copy of the trajectory, so it is bounded; but a
|
|
149
|
+
* silent head would make it lie, so the truncation is always stated. Read by
|
|
150
|
+
* `lib/resume-card.mjs`.
|
|
151
|
+
*/
|
|
152
|
+
resumeCardListRows: 40
|
|
143
153
|
}
|
|
144
154
|
|
|
145
155
|
/**
|
|
@@ -444,7 +454,8 @@ export function formatLimits() {
|
|
|
444
454
|
['verify preview budget (chars)', LIMITS.verifyPreviewChars],
|
|
445
455
|
['wave handoff budget (chars, per task)', LIMITS.maxHandoffChars],
|
|
446
456
|
['review policy scan cap (bytes)', LIMITS.maxReviewPolicyBytes],
|
|
447
|
-
['push timeout (ms)', LIMITS.notifyTimeoutMs]
|
|
457
|
+
['push timeout (ms)', LIMITS.notifyTimeoutMs],
|
|
458
|
+
['resume card list rows (per list)', LIMITS.resumeCardListRows]
|
|
448
459
|
]
|
|
449
460
|
const evalRows = [
|
|
450
461
|
['eval smoke cost ceiling (per PR)', `$${EVAL_CAPS.smokeCostUsd}`],
|
package/lib/merge-lanes.mjs
CHANGED
|
@@ -31,14 +31,57 @@ import { canonicalizePath } from './risk.mjs'
|
|
|
31
31
|
* workflows/ship.js) — this module never sees the task objects themselves.
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Every path a lane's fold will create, modify or delete, read off the git
|
|
36
|
+
* `name-status` rows the caller already collected.
|
|
37
|
+
*
|
|
38
|
+
* Contention is about what the fold WRITES, and a rename writes twice: it
|
|
39
|
+
* creates the destination and deletes the source (`applyLaneDiff` does exactly
|
|
40
|
+
* that). Keyed on the destination alone, one lane renaming `lib/a.mjs` and
|
|
41
|
+
* another editing `lib/a.mjs` look disjoint, and the fold then resurrects or
|
|
42
|
+
* deletes that source depending on which lane happened to apply last — the
|
|
43
|
+
* last-writer-wins outcome `lane-merge` exists to refuse.
|
|
44
|
+
*
|
|
45
|
+
* A copy's source is not a mutation: `applyLaneDiff` copies the destination in
|
|
46
|
+
* and leaves the source alone, so entering it as a contention key would halt a
|
|
47
|
+
* batch that is genuinely disjoint.
|
|
48
|
+
*
|
|
49
|
+
* Spellings are passed through untouched — `canonicalizePath` inside
|
|
50
|
+
* `mergeDecision` stays the single normalization site.
|
|
51
|
+
*
|
|
52
|
+
* @param {Array<{status?: string, path?: string, oldPath?: string}>} diffEntries
|
|
53
|
+
* @returns {string[]} de-duplicated, in the order the entries reported them
|
|
54
|
+
*/
|
|
55
|
+
export function foldMutationPaths(diffEntries) {
|
|
56
|
+
const entries = Array.isArray(diffEntries) ? diffEntries : []
|
|
57
|
+
const seen = new Set()
|
|
58
|
+
const out = []
|
|
59
|
+
const add = p => {
|
|
60
|
+
if (typeof p !== 'string' || !p.trim()) return
|
|
61
|
+
if (seen.has(p)) return
|
|
62
|
+
seen.add(p)
|
|
63
|
+
out.push(p)
|
|
64
|
+
}
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
if (!entry || typeof entry !== 'object') continue
|
|
67
|
+
add(entry.path)
|
|
68
|
+
if (entry.status === 'R' && entry.oldPath !== entry.path) add(entry.oldPath)
|
|
69
|
+
}
|
|
70
|
+
return out
|
|
71
|
+
}
|
|
72
|
+
|
|
34
73
|
/**
|
|
35
74
|
* Decide how a completed batch's lane worktrees fold into the shared tree.
|
|
36
75
|
*
|
|
37
76
|
* @param {object} input
|
|
38
77
|
* @param {LaneRef[]} input.lanes the batch's lanes, in dispatch order
|
|
39
78
|
* @param {Record<string, string[]|null|undefined>} input.changedByLane maps a
|
|
40
|
-
* lane label to
|
|
41
|
-
*
|
|
79
|
+
* lane label to every path that lane's fold will MUTATE vs `base` — create,
|
|
80
|
+
* modify or delete. Raw spellings, not yet canonicalized. The production
|
|
81
|
+
* caller derives it with `foldMutationPaths`, so a rename contributes both
|
|
82
|
+
* its destination and the source the fold deletes; it is not a verbatim git
|
|
83
|
+
* `name-status` path list, and a reader of the reported `folds[].files`
|
|
84
|
+
* should expect a rename source among them. `null`/`undefined`/absent means
|
|
42
85
|
* the lane's changed-file set could not be observed (its worktree could not
|
|
43
86
|
* be located), which is a fold-time error, not an empty write.
|
|
44
87
|
* @param {string} [input.base] the shared-tree commit every lane's diff was
|
package/lib/receipt.mjs
CHANGED
|
@@ -302,9 +302,10 @@ export function summaryHeadline({ change, halted, leftoverTaskIds } = {}) {
|
|
|
302
302
|
* Silence is the failure mode it exists to remove: a summary with no banner
|
|
303
303
|
* section is indistinguishable from a run that degraded and hid it.
|
|
304
304
|
*
|
|
305
|
-
* `runId`, `root`, `projectSlug`, `push` and `
|
|
306
|
-
* stays callable exactly as it was before design D7/D8/D9
|
|
307
|
-
* receipt module stays import-free either
|
|
305
|
+
* `runId`, `root`, `projectSlug`, `push`, `unarchived` and `resumeCard` all
|
|
306
|
+
* default so this stays callable exactly as it was before design D7/D8/D9 and
|
|
307
|
+
* the halt resume card added them — the receipt module stays import-free either
|
|
308
|
+
* way.
|
|
308
309
|
*/
|
|
309
310
|
export function formatRunSummary({
|
|
310
311
|
change,
|
|
@@ -316,7 +317,8 @@ export function formatRunSummary({
|
|
|
316
317
|
root = undefined,
|
|
317
318
|
projectSlug = undefined,
|
|
318
319
|
push = null,
|
|
319
|
-
unarchived = null
|
|
320
|
+
unarchived = null,
|
|
321
|
+
resumeCard = null
|
|
320
322
|
} = {}) {
|
|
321
323
|
const leftover = Array.isArray(leftoverTaskIds) ? leftoverTaskIds : []
|
|
322
324
|
const lines = []
|
|
@@ -396,6 +398,14 @@ export function formatRunSummary({
|
|
|
396
398
|
lines.push(push.sent ? ' push: sent (ntfy)' : ` push: failed — ${push.reason}`)
|
|
397
399
|
}
|
|
398
400
|
|
|
401
|
+
// The resume card: the one file a halted run leaves for a reader who was not
|
|
402
|
+
// watching. Printed only when one was actually written — a close that could
|
|
403
|
+
// not write it says so in the degradation block instead, and a clean run has
|
|
404
|
+
// nothing to resume.
|
|
405
|
+
if (typeof resumeCard === 'string' && resumeCard.length > 0) {
|
|
406
|
+
lines.push(` resume card: ${resumeCard}`)
|
|
407
|
+
}
|
|
408
|
+
|
|
399
409
|
// The archive reminder (design D7): only on a clean, leftover-free close of
|
|
400
410
|
// the change this run shipped. Never on a halt, never with leftovers — in
|
|
401
411
|
// both cases the change is not complete, and the reminder must not claim
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// The halt resume card: the one file a halted ship run leaves behind for the
|
|
2
|
+
// person who has to pick it up later.
|
|
3
|
+
//
|
|
4
|
+
// Everything a halt already records is machine-shaped and scattered — the
|
|
5
|
+
// trajectory under `.claude/ship/runs/`, the plan and its fingerprint under
|
|
6
|
+
// `.claude/ship/`, the leftover boxes in `tasks.md`, the reason in a summary
|
|
7
|
+
// printed to a terminal nobody was watching. A reader coming back in a new
|
|
8
|
+
// session, with none of that conversation in context, had to reassemble it. The
|
|
9
|
+
// card is that reassembly, written once at the close, in one place, in prose.
|
|
10
|
+
//
|
|
11
|
+
// Three rules it exists under, and all three are load-bearing:
|
|
12
|
+
//
|
|
13
|
+
// 1. **It is a record, not a trigger.** Nothing reads it back. `/interlock:ship`
|
|
14
|
+
// still decides whether to skip the classifier from the plan fingerprint, not
|
|
15
|
+
// from this file, so a stale or hand-edited card cannot make a later run
|
|
16
|
+
// reuse a plan it should have rebuilt. The card says that about itself, in
|
|
17
|
+
// the file, because a markdown "resume" artifact that nothing consumes is
|
|
18
|
+
// exactly the thing a reader would otherwise assume is wired in.
|
|
19
|
+
// 2. **It never throws.** A read-only checkout, a full disk, an unwritable
|
|
20
|
+
// `.claude/` — each degrades to a no-op that *reports* itself through the
|
|
21
|
+
// return value, and the close turns that into a degradation banner. The run
|
|
22
|
+
// already halted; losing its card must not also change how it halted. This
|
|
23
|
+
// is the outcome-corpus class of the repository's deliberately-different
|
|
24
|
+
// corpus-loss semantics, not the trajectory's.
|
|
25
|
+
// 3. **It is bounded, and says where the unbounded copy is.** Every list it
|
|
26
|
+
// prints is capped by `LIMITS.resumeCardListRows` and names the command that
|
|
27
|
+
// prints the whole thing. Truncation is stated, never silent.
|
|
28
|
+
//
|
|
29
|
+
// Only on a halt. A clean run's summary already ends in `ARCHIVE PENDING` and
|
|
30
|
+
// there is nothing to resume.
|
|
31
|
+
|
|
32
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
33
|
+
import { join } from 'node:path'
|
|
34
|
+
import { LIMITS } from './limits.mjs'
|
|
35
|
+
|
|
36
|
+
/** Where the card lands, relative to the repo root. Already gitignored. */
|
|
37
|
+
export const HANDOFF_DIR = join('.claude', 'handoff')
|
|
38
|
+
|
|
39
|
+
/** Bump when the card's shape changes, so an older one stays identifiable. */
|
|
40
|
+
export const RESUME_CARD_SCHEMA = 'interlock.resume-card/1'
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What the filename says when the run halted before a plan was adopted and so
|
|
44
|
+
* never got a run id. A literal rather than a timestamp: a card keyed to the
|
|
45
|
+
* clock would accumulate one file per failed invocation, and the thing a reader
|
|
46
|
+
* wants is the latest state of this change, not a pile of them.
|
|
47
|
+
*/
|
|
48
|
+
export const NO_RUN_ID = 'no-run-id'
|
|
49
|
+
|
|
50
|
+
/** Halt reasons are already bounded upstream; this is the backstop. */
|
|
51
|
+
const MAX_REASON_CHARS = 500
|
|
52
|
+
|
|
53
|
+
// A change name and a run id both reach us from a branch, a directory or a
|
|
54
|
+
// model, so both are untrusted as path components: `../` or a slash would put
|
|
55
|
+
// the card somewhere other than the handoff directory.
|
|
56
|
+
// Dropping the separators alone would leave `a/b/../c` as `a-b-..-c`: safe,
|
|
57
|
+
// because nothing can traverse without a separator, but a filename carrying a
|
|
58
|
+
// literal `..` invites the next reader to decide it is fine somewhere else. The
|
|
59
|
+
// run of dots collapses too, so no segment ever contains one.
|
|
60
|
+
function segment(value, fallback) {
|
|
61
|
+
const s = typeof value === 'string' ? value.trim() : ''
|
|
62
|
+
const safe = s
|
|
63
|
+
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
64
|
+
.replace(/\.{2,}/g, '-')
|
|
65
|
+
.replace(/^[-.]+|[-.]+$/g, '')
|
|
66
|
+
return safe.slice(0, 80) || fallback
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function clamp(value, max) {
|
|
70
|
+
const s = typeof value === 'string' ? value.trim() : ''
|
|
71
|
+
if (s.length <= max) return s
|
|
72
|
+
return `${s.slice(0, max)}… (truncated)`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The first `LIMITS.resumeCardListRows` of a list, plus a line saying how many
|
|
77
|
+
* were left out and where the whole list is. Never a silent head.
|
|
78
|
+
*/
|
|
79
|
+
function rows(list, { whole }) {
|
|
80
|
+
const all = Array.isArray(list) ? list.filter(Boolean) : []
|
|
81
|
+
const cap = LIMITS.resumeCardListRows
|
|
82
|
+
if (all.length <= cap) return { shown: all, note: null }
|
|
83
|
+
return {
|
|
84
|
+
shown: all.slice(0, cap),
|
|
85
|
+
note: `_… and ${all.length - cap} more — the full list is in ${whole}._`
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Where this change's card lives, relative to the repo root.
|
|
91
|
+
*
|
|
92
|
+
* One card per change per run. A second halt of the same run overwrites its own
|
|
93
|
+
* card, which is correct: the card describes the run's final state, and two
|
|
94
|
+
* cards for one run would only ask a reader to work out which is current.
|
|
95
|
+
*
|
|
96
|
+
* @param {{change?: string, runId?: string}} [input]
|
|
97
|
+
* @returns {string} repo-relative path
|
|
98
|
+
*/
|
|
99
|
+
export function resumeCardPath({ change, runId } = {}) {
|
|
100
|
+
return join(HANDOFF_DIR, `ship-${segment(change, 'unnamed')}-${segment(runId, NO_RUN_ID)}.md`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Render the card. Pure: every fact is passed in, nothing is read from disk and
|
|
105
|
+
* nothing is inferred about a future run.
|
|
106
|
+
*
|
|
107
|
+
* In particular this never predicts whether the next ship will reuse the plan.
|
|
108
|
+
* It reports what is stored now and what the reuse rule is; the recomputation
|
|
109
|
+
* happens at the next `run start`, against artifacts that may change in
|
|
110
|
+
* between, and a card that guessed would be wrong exactly when it mattered.
|
|
111
|
+
*
|
|
112
|
+
* @param {object} input
|
|
113
|
+
* @returns {string} the markdown body
|
|
114
|
+
*/
|
|
115
|
+
export function formatResumeCard({
|
|
116
|
+
change,
|
|
117
|
+
runId = null,
|
|
118
|
+
halted = null,
|
|
119
|
+
leftoverTaskIds = [],
|
|
120
|
+
plan = null,
|
|
121
|
+
planStored = false,
|
|
122
|
+
waves = [],
|
|
123
|
+
degradations = [],
|
|
124
|
+
projectSlug = null,
|
|
125
|
+
cwd = null
|
|
126
|
+
} = {}) {
|
|
127
|
+
const name = typeof change === 'string' && change.trim() ? change.trim() : 'unnamed'
|
|
128
|
+
const reason = clamp(halted, MAX_REASON_CHARS) || 'the run halted without recording a reason'
|
|
129
|
+
const hasRun = typeof runId === 'string' && runId.length > 0
|
|
130
|
+
const lines = []
|
|
131
|
+
|
|
132
|
+
lines.push(`<!-- ${RESUME_CARD_SCHEMA} change=${name} run=${hasRun ? runId : NO_RUN_ID} -->`)
|
|
133
|
+
lines.push(`# Ship halted — ${name}`)
|
|
134
|
+
lines.push('')
|
|
135
|
+
lines.push(reason)
|
|
136
|
+
lines.push('')
|
|
137
|
+
lines.push(
|
|
138
|
+
'This file is a record, not a trigger. Nothing reads it back: the next ship decides ' +
|
|
139
|
+
'what to skip from the stored plan fingerprint, never from this card. ' +
|
|
140
|
+
'**Do not start another ship run unless the user asks.**'
|
|
141
|
+
)
|
|
142
|
+
lines.push('')
|
|
143
|
+
|
|
144
|
+
lines.push('## Where this run stopped')
|
|
145
|
+
lines.push('')
|
|
146
|
+
lines.push(`- change: \`${name}\``)
|
|
147
|
+
lines.push(
|
|
148
|
+
hasRun
|
|
149
|
+
? `- run: \`${runId}\``
|
|
150
|
+
: '- run: none — the run halted before a plan was adopted, so no trajectory was opened'
|
|
151
|
+
)
|
|
152
|
+
if (projectSlug) lines.push(`- project: \`${projectSlug}\``)
|
|
153
|
+
if (cwd) lines.push(`- cwd: \`${cwd}\``)
|
|
154
|
+
if (hasRun) {
|
|
155
|
+
lines.push(`- trajectory: \`.claude/ship/runs/${runId}.jsonl\``)
|
|
156
|
+
lines.push('')
|
|
157
|
+
lines.push('Read the whole walk that led here rather than re-deriving it:')
|
|
158
|
+
lines.push('')
|
|
159
|
+
lines.push('```bash')
|
|
160
|
+
lines.push(`interlock run-log show ${runId}`)
|
|
161
|
+
lines.push(`interlock run-log query --run ${runId} --halted`)
|
|
162
|
+
lines.push('```')
|
|
163
|
+
}
|
|
164
|
+
lines.push('')
|
|
165
|
+
|
|
166
|
+
lines.push('## Leftover tasks')
|
|
167
|
+
lines.push('')
|
|
168
|
+
const leftover = rows(leftoverTaskIds, { whole: `\`openspec/changes/${name}/tasks.md\`` })
|
|
169
|
+
if (!leftover.shown.length) {
|
|
170
|
+
lines.push(
|
|
171
|
+
`Every box in \`openspec/changes/${name}/tasks.md\` is ticked. The halt was not about ` +
|
|
172
|
+
'unfinished tasks — read the reason above.'
|
|
173
|
+
)
|
|
174
|
+
} else {
|
|
175
|
+
lines.push('Boxes still unchecked in `openspec/changes/' + name + '/tasks.md`:')
|
|
176
|
+
lines.push('')
|
|
177
|
+
for (const id of leftover.shown) lines.push(`- \`${id}\``)
|
|
178
|
+
if (leftover.note) {
|
|
179
|
+
lines.push('')
|
|
180
|
+
lines.push(leftover.note)
|
|
181
|
+
}
|
|
182
|
+
lines.push('')
|
|
183
|
+
// Deliberately NOT paste-ready over the whole list. Most of these ids were
|
|
184
|
+
// never dispatched — the run halted — and `interlock tasks tick` is
|
|
185
|
+
// mechanical: it flips a marker by id and verifies nothing. A command that
|
|
186
|
+
// ticked all of them would delete the only on-disk record of what is left to
|
|
187
|
+
// do, which is the outcome the halt that wrote this card exists to prevent.
|
|
188
|
+
lines.push(
|
|
189
|
+
'Some of these are done on disk and only unticked; the rest were never attempted, ' +
|
|
190
|
+
'because the run stopped. Tick **only** the ones you have checked are implemented — ' +
|
|
191
|
+
'ticking the rest is how unimplemented work ships behind a `[x]`:'
|
|
192
|
+
)
|
|
193
|
+
lines.push('')
|
|
194
|
+
lines.push('```bash')
|
|
195
|
+
lines.push(`interlock tasks tick ${name} --ids <the ids above you verified are done>`)
|
|
196
|
+
lines.push('```')
|
|
197
|
+
}
|
|
198
|
+
lines.push('')
|
|
199
|
+
|
|
200
|
+
lines.push('## The plan')
|
|
201
|
+
lines.push('')
|
|
202
|
+
if (plan && typeof plan === 'object') {
|
|
203
|
+
lines.push(
|
|
204
|
+
plan.reused
|
|
205
|
+
? `This run **reused** the stored plan (${plan.status}): ${plan.reason}`
|
|
206
|
+
: `This run **rebuilt** the plan (${plan.status}): ${plan.reason}`
|
|
207
|
+
)
|
|
208
|
+
} else {
|
|
209
|
+
lines.push(
|
|
210
|
+
'This run ended before the plan-reuse check reported, so whether the classifier ran ' +
|
|
211
|
+
'was never observed.'
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
lines.push('')
|
|
215
|
+
if (planStored) {
|
|
216
|
+
lines.push(
|
|
217
|
+
'A plan and its fingerprint are on disk at `.claude/ship/plan.json` and ' +
|
|
218
|
+
'`.claude/ship/plan-fingerprint.json`, and they name this change. The next ship ' +
|
|
219
|
+
'skips the wave classifier only if a fingerprint recomputed from the current ' +
|
|
220
|
+
'artifacts still matches — ticking a checkbox does not break it; editing an ' +
|
|
221
|
+
'artifact, or adding, removing, reordering or rewording a task does, and so does ' +
|
|
222
|
+
'passing a different `--solo` / `--waves` / `--tdd` shape.'
|
|
223
|
+
)
|
|
224
|
+
} else {
|
|
225
|
+
lines.push(
|
|
226
|
+
'No stored plan on disk names this change, so the next ship classifies the waves ' +
|
|
227
|
+
'again. That is the normal cost of a first run, not a fault.'
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
lines.push('')
|
|
231
|
+
|
|
232
|
+
lines.push('## Waves, as far as they got')
|
|
233
|
+
lines.push('')
|
|
234
|
+
const waveRows = rows(
|
|
235
|
+
(Array.isArray(waves) ? waves : []).map(w =>
|
|
236
|
+
w && typeof w === 'object'
|
|
237
|
+
? `- wave ${w.wave ?? '?'} (${w.kind ?? 'unknown'}): ${w.ok ?? '?'} ok, ${w.failed ?? '?'} failed`
|
|
238
|
+
: null
|
|
239
|
+
),
|
|
240
|
+
{ whole: hasRun ? `\`interlock run-log show ${runId}\`` : 'the run receipt' }
|
|
241
|
+
)
|
|
242
|
+
if (!waveRows.shown.length) lines.push('No wave was recorded — the run halted before or during the first one.')
|
|
243
|
+
else {
|
|
244
|
+
for (const row of waveRows.shown) lines.push(row)
|
|
245
|
+
if (waveRows.note) {
|
|
246
|
+
lines.push('')
|
|
247
|
+
lines.push(waveRows.note)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
lines.push('')
|
|
251
|
+
|
|
252
|
+
lines.push('## Degraded before the halt')
|
|
253
|
+
lines.push('')
|
|
254
|
+
const bannerRows = rows(
|
|
255
|
+
(Array.isArray(degradations) ? degradations : []).map(b => (typeof b === 'string' ? `- ${b}` : null)),
|
|
256
|
+
{ whole: 'the printed summary' }
|
|
257
|
+
)
|
|
258
|
+
if (!bannerRows.shown.length) lines.push('No degradation banners were raised.')
|
|
259
|
+
else {
|
|
260
|
+
for (const row of bannerRows.shown) lines.push(row)
|
|
261
|
+
if (bannerRows.note) {
|
|
262
|
+
lines.push('')
|
|
263
|
+
lines.push(bannerRows.note)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
lines.push('')
|
|
267
|
+
|
|
268
|
+
lines.push('## Picking it up')
|
|
269
|
+
lines.push('')
|
|
270
|
+
lines.push('1. Fix what the halt reason names. A halt is a gate that failed closed, not a flake.')
|
|
271
|
+
lines.push('2. Tick anything already done but unticked, with the command above.')
|
|
272
|
+
lines.push(
|
|
273
|
+
`3. In a new session, when a person asks for it: \`/interlock:ship ${name}\`. A stored ` +
|
|
274
|
+
'plan whose fingerprint still matches is reused, so the wave classifier is not paid ' +
|
|
275
|
+
'for twice. Add `--apply-only` if you want the waves alone and will review and commit ' +
|
|
276
|
+
'yourself.'
|
|
277
|
+
)
|
|
278
|
+
lines.push('')
|
|
279
|
+
lines.push(
|
|
280
|
+
'The wave cursor and the previous wave\'s handoff packets are **not** resumed: ' +
|
|
281
|
+
'`.claude/ship/state.json` is replaced at wave 0 by the next run. Work that was ' +
|
|
282
|
+
'written but never ticked can therefore be dispatched again, which is why step 2 is ' +
|
|
283
|
+
'not optional.'
|
|
284
|
+
)
|
|
285
|
+
lines.push('')
|
|
286
|
+
|
|
287
|
+
return lines.join('\n')
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Write the card for a halted run.
|
|
292
|
+
*
|
|
293
|
+
* Never throws — see the module header. The caller gets a reason, never an
|
|
294
|
+
* exception, and turns it into a degradation banner.
|
|
295
|
+
*
|
|
296
|
+
* @param {string} root repo root; `.claude/handoff` is created beneath it
|
|
297
|
+
* @param {object} [input] everything `formatResumeCard` takes
|
|
298
|
+
* @returns {{written: boolean, path: string|null, reason: string|null}}
|
|
299
|
+
*/
|
|
300
|
+
export function writeResumeCard(root, input = {}) {
|
|
301
|
+
try {
|
|
302
|
+
if (typeof root !== 'string' || !root.trim()) {
|
|
303
|
+
return { written: false, path: null, reason: 'no root directory given' }
|
|
304
|
+
}
|
|
305
|
+
if (!existsSync(root)) {
|
|
306
|
+
return { written: false, path: null, reason: `root does not exist: ${root}` }
|
|
307
|
+
}
|
|
308
|
+
const rel = resumeCardPath(input)
|
|
309
|
+
const dest = join(root, rel)
|
|
310
|
+
mkdirSync(join(root, HANDOFF_DIR), { recursive: true })
|
|
311
|
+
writeFileSync(dest, formatResumeCard(input))
|
|
312
|
+
// The repo-relative path, not the absolute one: it is printed in a summary
|
|
313
|
+
// a person reads next to a `cwd:` row, and it is what they type.
|
|
314
|
+
return { written: true, path: rel, reason: null }
|
|
315
|
+
} catch (err) {
|
|
316
|
+
// Deliberately swallowed: see the module header.
|
|
317
|
+
return { written: false, path: null, reason: (err && err.message) || String(err) }
|
|
318
|
+
}
|
|
319
|
+
}
|