@renzrollon/interlock 1.2.0 → 1.3.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.
@@ -0,0 +1,73 @@
1
+ // The ship meter's refusal words (speak-permission-prompts-and-guard-denials
2
+ // design D4-D6): the four guard names a settings guard's denial is read by,
3
+ // the slice of an errored result's text the meter repeats, and the pane lines.
4
+ //
5
+ // Pure and Node-free: `hooks/mod.mjs` imports it inside the engine, where there
6
+ // is no Node, and `test/spine/mod-pins.test.mjs` walks the import and pins the
7
+ // names against the four guards' own `GUARD` constants. Nothing here judges a
8
+ // denial: the words are the guard's, and the ruling is `lib/launch-rule.mjs`'s.
9
+
10
+ /** The settings guards whose denials the meter repeats, as each prints its name. */
11
+ export const GUARD_NAMES = Object.freeze(['guard-tests', 'guard-tasks', 'guard-commit', 'guard-relaunch'])
12
+
13
+ // Anywhere in the text, not only at its start: the engine puts
14
+ // `PreToolUse:<tool> hook error: ` in front of a settings hook's reason (probe 2).
15
+ const NAMED = new RegExp(`\\b(${GUARD_NAMES.join('|')}): `)
16
+
17
+ /**
18
+ * The text from the first guard name on, or `null` for a text that names no
19
+ * guard (another plugin's deny, a command that failed) or is not a string.
20
+ *
21
+ * @param {unknown} text an errored tool result's `text`
22
+ * @returns {string|null}
23
+ */
24
+ export function guardReason(text) {
25
+ if (typeof text !== 'string') return null
26
+ const m = NAMED.exec(text)
27
+ return m ? text.slice(m.index) : null
28
+ }
29
+
30
+ /**
31
+ * The guard that spoke `reason`, as `guardReason` sliced it; `null` for none.
32
+ *
33
+ * @param {unknown} reason
34
+ * @returns {string|null}
35
+ */
36
+ export function guardOf(reason) {
37
+ if (typeof reason !== 'string') return null
38
+ const m = NAMED.exec(reason)
39
+ return m && m.index === 0 ? m[1] : null
40
+ }
41
+
42
+ /**
43
+ * `guard denials: <n> (<guard> <n>, …)` in the order each guard was first
44
+ * seen, or `null` when none was: the pane draws no line for nothing.
45
+ *
46
+ * @param {Map<string, number>|undefined} counts
47
+ * @returns {string|null}
48
+ */
49
+ export function guardDenialLine(counts) {
50
+ if (!(counts instanceof Map) || counts.size === 0) return null
51
+ let total = 0
52
+ const parts = []
53
+ for (const [guard, n] of counts) {
54
+ total += n
55
+ parts.push(`${guard} ${n}`)
56
+ }
57
+ return `guard denials: ${total} (${parts.join(', ')})`
58
+ }
59
+
60
+ /**
61
+ * The launch guard's current ruling over the session's record, in the rule's
62
+ * own words. `now` is `null` only when the guard's facts could not be read,
63
+ * which allows the next launch (design D5).
64
+ *
65
+ * @param {{ decision: string, reason: string|null }} ruling what `decideLaunch` returned
66
+ * @param {number|null} now
67
+ * @returns {string}
68
+ */
69
+ export function launchGuardLine(ruling, now) {
70
+ if (now === null) return 'launch guard: facts unreadable; the next launch is allowed'
71
+ if (ruling && ruling.decision === 'deny' && ruling.reason) return `launch guard: next launch refused: ${ruling.reason}`
72
+ return 'launch guard: next launch allowed'
73
+ }
@@ -0,0 +1,6 @@
1
+ // The server used when an operator names a topic and no server.
2
+ // One spelling, imported by the transport, the doctor and the CLI help,
3
+ // so those three cannot drift.
4
+
5
+ /** The public server, used when no server was configured. */
6
+ export const DEFAULT_NTFY_URL = 'https://ntfy.sh'
package/lib/notify.mjs CHANGED
@@ -1,6 +1,6 @@
1
- // The one network call this CLI ever makes: a push to ntfy.sh (or a
2
- // self-hosted server) so a ship run that stops while nobody is watching can
3
- // still reach someone.
1
+ // The one network call this CLI ever makes: a push to the configured server
2
+ // (the public default, or a server the operator named) so a ship run that
3
+ // stops while nobody is watching can still reach someone.
4
4
  //
5
5
  // Three properties are load-bearing, and every one of them exists because
6
6
  // this module is the CLI's only exception to "no network":
@@ -11,8 +11,8 @@
11
11
  // so there is no settings file this module could misread in the first
12
12
  // place.
13
13
  //
14
- // 2. THE TOPIC NEVER LEAVES. The topic is the only auth ntfy.sh has on a
15
- // public server; it is a capability, not a label. `readNotifyConfig`
14
+ // 2. THE TOPIC NEVER LEAVES. The topic is the only auth the public server
15
+ // has; it is a capability, not a label. `readNotifyConfig`
16
16
  // never echoes it, `sanitizeReason` strips it (and the server URL) from
17
17
  // every failure reason before that reason reaches a summary line, a
18
18
  // receipt event or the trajectory, because a `fetch` failure's own
@@ -29,15 +29,23 @@
29
29
  // design D1, D2, D4, D5, D14 (add-harden-unattended-ship-runs).
30
30
 
31
31
  import { LIMITS } from './limits.mjs'
32
+ import { DEFAULT_NTFY_URL } from './notify-defaults.mjs'
32
33
 
33
- /** Where the topic is read from. Set by the operator, never by the repo. */
34
+ export { DEFAULT_NTFY_URL }
35
+
36
+ /** Where the topic is read from. Chosen by the operator, never by the repo. */
34
37
  export const NOTIFY_TOPIC_ENV = 'INTERLOCK_NTFY_TOPIC'
35
38
 
36
- /** Where a self-hosted server is read from. Unset means the public default. */
39
+ /** Where a self-hosted server is read from. Absent means the public default. */
37
40
  export const NOTIFY_URL_ENV = 'INTERLOCK_NTFY_URL'
38
41
 
39
- /** The public ntfy server, used when `NOTIFY_URL_ENV` is unset. */
40
- export const DEFAULT_NTFY_URL = 'https://ntfy.sh'
42
+ /**
43
+ * Plugin options the host injects into a hook process, from `userConfig`
44
+ * `ntfy_topic` and `ntfy_url`. Preferred over the shell variables when present,
45
+ * so a value the operator typed into the plugin is the one that is shared.
46
+ */
47
+ export const NOTIFY_TOPIC_OPTION = 'CLAUDE_PLUGIN_OPTION_NTFY_TOPIC'
48
+ export const NOTIFY_URL_OPTION = 'CLAUDE_PLUGIN_OPTION_NTFY_URL'
41
49
 
42
50
  function messageOf(err) {
43
51
  return (err && err.message) || String(err)
@@ -60,26 +68,34 @@ function urlShapeInvalid(url) {
60
68
  }
61
69
  }
62
70
 
71
+ function trimmed(source, name) {
72
+ if (!source || typeof source[name] !== 'string') return ''
73
+ return source[name].trim()
74
+ }
75
+
63
76
  /**
64
- * Read push configuration from the environment. Never the tree — `INTERLOCK_
65
- * NTFY_TOPIC` and `INTERLOCK_NTFY_URL` are the whole of it (design D2).
77
+ * Read push configuration. Never the tree. A plugin option the host injected
78
+ * wins over the shell variable of the same meaning, and an absent topic is
79
+ * unconfigured rather than an error (design D2). The shell variables remain
80
+ * the channel for a terminal, CI, and any host that does not inject options.
66
81
  *
67
- * @param {object} env environment to read (injected in tests; `process.env` at call sites)
82
+ * @param {object} source values to read (injected in tests; the process values at call sites)
68
83
  * @returns {{topic: string, url: string} | {topic: null} | {invalid: string}}
69
84
  * `{ topic: null }` when push is simply unconfigured — not an error, the
70
85
  * default and by far the common case. `{ invalid: '<VAR>' }` names the
71
86
  * offending variable and never its value.
72
87
  */
73
- export function readNotifyConfig(env) {
74
- const rawTopic = env && typeof env[NOTIFY_TOPIC_ENV] === 'string' ? env[NOTIFY_TOPIC_ENV].trim() : ''
88
+ export function readNotifyConfig(source) {
89
+ const optionTopic = trimmed(source, NOTIFY_TOPIC_OPTION)
90
+ const shellTopic = trimmed(source, NOTIFY_TOPIC_ENV)
91
+ const rawTopic = optionTopic || shellTopic
75
92
  if (!rawTopic) return { topic: null }
76
- if (topicShapeInvalid(rawTopic)) return { invalid: NOTIFY_TOPIC_ENV }
93
+ if (topicShapeInvalid(rawTopic)) return { invalid: optionTopic ? NOTIFY_TOPIC_OPTION : NOTIFY_TOPIC_ENV }
77
94
 
78
- const rawUrl =
79
- env && typeof env[NOTIFY_URL_ENV] === 'string' && env[NOTIFY_URL_ENV].trim()
80
- ? env[NOTIFY_URL_ENV].trim()
81
- : DEFAULT_NTFY_URL
82
- if (urlShapeInvalid(rawUrl)) return { invalid: NOTIFY_URL_ENV }
95
+ const optionUrl = trimmed(source, NOTIFY_URL_OPTION)
96
+ const shellUrl = trimmed(source, NOTIFY_URL_ENV)
97
+ const rawUrl = optionUrl || shellUrl || DEFAULT_NTFY_URL
98
+ if (urlShapeInvalid(rawUrl)) return { invalid: optionUrl ? NOTIFY_URL_OPTION : NOTIFY_URL_ENV }
83
99
 
84
100
  return { topic: rawTopic, url: rawUrl }
85
101
  }
@@ -156,10 +172,10 @@ export function sanitizeReason(reason, { topic, url } = {}) {
156
172
  * all: EVERY headline this module sends contains an em dash (`SHIP COMPLETE —
157
173
  * <change>`), HTTP header values are ByteStrings, and Node's `fetch` throws
158
174
  * `Cannot convert argument to a ByteString` rather than transmitting one. So
159
- * a non-ASCII value is sent as an RFC 2047 base64 encoded-word, which ntfy
160
- * documents as the supported encoding for exactly this case (docs.ntfy.sh
161
- * §Publish → "ntfy supports UTF-8 in HTTP headers, but not every library or
162
- * programming language does", checked 2026-09-07).
175
+ * a non-ASCII value is sent as an RFC 2047 base64 encoded-word, which the
176
+ * server's publish documentation names as the supported encoding for exactly
177
+ * this case ("UTF-8 in HTTP headers" is not accepted by every client,
178
+ * checked 2026-09-07).
163
179
  *
164
180
  * A pure-ASCII value is passed through untouched, so the common `interlock
165
181
  * notify --title` case stays readable on the wire.
@@ -175,7 +191,7 @@ export function encodeHeaderValue(value) {
175
191
  }
176
192
 
177
193
  /**
178
- * Post one ntfy message. Never throws: every failure mode — invalid
194
+ * Post one message. Never throws: every failure mode — invalid
179
195
  * configuration, a non-2xx status, a network error, the timeout — resolves
180
196
  * to `{ sent: false, reason }`, sanitized, so the caller can always print
181
197
  * something and never has to catch anything.
@@ -0,0 +1,119 @@
1
+ // The plan summary (draw-the-wave-board-in-the-meter-pane design D3, spec
2
+ // ship/diagrams): the `plan` field the adoption and replan steps carry, so a
3
+ // second reader of the step stream — the ship meter — can draw the wave board
4
+ // without reading a file.
5
+ //
6
+ // The summary is the plan's shape cut to what the board reads: per wave its
7
+ // position, group, kind, red marker and batches; per task its id, tier, model
8
+ // and the words the lane title is built from; and the ordered-after edges as
9
+ // the plan's deferral records. Nothing else rides, because every byte of it is
10
+ // retyped by the Workflow host's relay. `test/spine/plan-summary.test.mjs`
11
+ // draws a plan and its summary through the renderer and compares the lines.
12
+ //
13
+ // Pure and Node-free, so the CLI builds the field with it and the hooks module
14
+ // could read it; its closure is `lib/lane.mjs` and `lib/limits.mjs`.
15
+
16
+ import { dependsOnIds, titleGist } from './lane.mjs'
17
+
18
+ const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value)
19
+ const list = value => (Array.isArray(value) ? value : [])
20
+ const isText = value => typeof value === 'string' && value !== ''
21
+
22
+ /** A task as the board reads it; a field the task lacks is left out, never filled. */
23
+ function summarizeTask(task) {
24
+ const out = { id: task.id }
25
+ if (Number.isInteger(task.tier)) out.tier = task.tier
26
+ if (isText(task.model)) out.model = task.model
27
+ const gist = titleGist(task.description)
28
+ if (gist) out.description = gist
29
+ return out
30
+ }
31
+
32
+ /** A wave's batches as the board draws them: lanes of tasks, empty lanes and batches dropped. */
33
+ function summarizeBatches(wave) {
34
+ return list(wave.batches)
35
+ .map(batch =>
36
+ list(batch)
37
+ .map(lane => list(lane).filter(task => isObject(task) && isText(task.id)))
38
+ .filter(lane => lane.length > 0)
39
+ )
40
+ .filter(batch => batch.length > 0)
41
+ }
42
+
43
+ /** A deferral record reduced to the three fields the board reads. */
44
+ const reduceRecord = record => ({
45
+ id: record.id,
46
+ group: record.group ?? null,
47
+ after: list(record.after).filter(isText)
48
+ })
49
+
50
+ /**
51
+ * The planner's deferral records, derived from the tasks' own dependency
52
+ * fields: each id a task depends on that belongs to its own group and is
53
+ * placed at an earlier position or batch. A run state keeps no deferral list,
54
+ * and over the real halted run's state this yields its plan's four records.
55
+ */
56
+ function deriveDeferred(positions) {
57
+ const placed = new Map()
58
+ positions.forEach(({ batches }, index) =>
59
+ batches.forEach((batch, b) => {
60
+ for (const task of batch.flat()) if (!placed.has(task.id)) placed.set(task.id, { index, b, task })
61
+ })
62
+ )
63
+ const out = []
64
+ for (const [id, at] of placed) {
65
+ const group = at.task.group ?? null
66
+ const after = dependsOnIds(at.task).filter(dep => {
67
+ const before = placed.get(dep)
68
+ return (
69
+ before !== undefined &&
70
+ (before.task.group ?? null) === group &&
71
+ (before.index < at.index || (before.index === at.index && before.b < at.b))
72
+ )
73
+ })
74
+ if (after.length) out.push({ id, group, after })
75
+ }
76
+ return out
77
+ }
78
+
79
+ /**
80
+ * The summary of a plan (`waves` plus `testWave`) or of a run state (`waves`,
81
+ * the test wave last among them with `kind: 'test'`), in the plan's shape so
82
+ * the renderer reads either the same way.
83
+ *
84
+ * @param {unknown} source a plan or a run state; anything else summarises to no waves
85
+ * @param {{deferred?: Array}} [opts] the planner's own deferral records, when the caller has them
86
+ * @returns {{waves: Array, testWave: object|null, deferred: Array}}
87
+ */
88
+ export function summarizePlan(source, { deferred } = {}) {
89
+ const from = isObject(source) ? source : {}
90
+ const waves = list(from.waves).filter(isObject)
91
+ let impl = waves
92
+ let test = isObject(from.testWave) ? from.testWave : null
93
+ if (!test && waves.length && waves.at(-1).kind === 'test') {
94
+ impl = waves.slice(0, -1)
95
+ test = waves.at(-1)
96
+ }
97
+
98
+ const positions = impl
99
+ .map(wave => ({ wave, batches: summarizeBatches(wave) }))
100
+ .filter(position => position.batches.length > 0)
101
+ const testBatches = test ? summarizeBatches(test) : []
102
+ const all = testBatches.length ? [...positions, { wave: test, batches: testBatches }] : positions
103
+
104
+ const records = Array.isArray(deferred) ? deferred : Array.isArray(from.deferred) ? from.deferred : null
105
+ const summarize = batches => batches.map(batch => batch.map(lane => lane.map(summarizeTask)))
106
+ return {
107
+ waves: positions.map(({ wave, batches }, index) => ({
108
+ index,
109
+ group: wave.group ?? null,
110
+ kind: wave.kind === 'test' ? 'test' : 'impl',
111
+ ...(wave.red === true ? { red: true } : {}),
112
+ batches: summarize(batches)
113
+ })),
114
+ testWave: testBatches.length ? { index: positions.length, batches: summarize(testBatches) } : null,
115
+ deferred: records
116
+ ? records.filter(record => isObject(record) && isText(record.id)).map(reduceRecord)
117
+ : deriveDeferred(all)
118
+ }
119
+ }
@@ -0,0 +1,266 @@
1
+ // The session start's report file: what `hooks/preflight.mjs` leaves in
2
+ // `.claude/ship/preflight.json`, and every line the hooks module draws from it
3
+ // (show-preflight-and-interrupted-runs-at-session-start design D1, D5-D8).
4
+ //
5
+ // Two readers of one shape. The hook builds the report from what it already
6
+ // holds: the doctor's verdict and checks, the interrupted-run notes it spoke
7
+ // and marked, the halt resume cards it listed. The hooks module reads it at a
8
+ // prompt the person submits and draws the `AbovePrompt` band and two panes.
9
+ // Neither side derives a fact the other did not say: every word drawn is the
10
+ // doctor's, the hook's or the card's, and the time is a date, never a
11
+ // staleness verdict.
12
+ //
13
+ // Node-free, and importing only `./limits.mjs`, because the hooks module runs
14
+ // in the engine with no Node (test/spine/mod-pins.test.mjs walks it). The
15
+ // handoff pane's cap is read here and restated nowhere in the module.
16
+
17
+ import { LIMITS } from './limits.mjs'
18
+
19
+ /** Bump when the report's shape changes, so a reader can tell an older one. */
20
+ export const PREFLIGHT_SCHEMA = 'interlock.preflight/1'
21
+
22
+ /** Where the report lands, relative to the working root. Already gitignored with `.claude/ship/`. */
23
+ export const PREFLIGHT_FILE = '.claude/ship/preflight.json'
24
+
25
+ /** The most characters of a card the handoff pane renders: the `Markdown` element's own bound (design D7). */
26
+ export const HANDOFF_PANE_CHARS = LIMITS.handoffPaneChars
27
+
28
+ /** The SessionStart sources the host names; anything else is recorded as null. */
29
+ const SOURCES = new Set(['startup', 'resume', 'clear', 'compact'])
30
+
31
+ const str = v => (typeof v === 'string' && v ? v : null)
32
+ const text = v => (typeof v === 'string' ? v : v === null || v === undefined ? '' : String(v))
33
+ const list = v => (Array.isArray(v) ? v.filter(x => x && typeof x === 'object') : [])
34
+ const count = v => (Number.isInteger(v) && v >= 0 ? v : 0)
35
+
36
+ /** The report file's path under `cwd`, or the relative path the engine resolves under the session's directory. */
37
+ export function preflightFilePath(cwd) {
38
+ if (typeof cwd !== 'string' || !cwd) return PREFLIGHT_FILE
39
+ return `${cwd.endsWith('/') ? cwd.slice(0, -1) : cwd}/${PREFLIGHT_FILE}`
40
+ }
41
+
42
+ function doctorOf(d) {
43
+ const doctor = d && typeof d === 'object' ? d : {}
44
+ const counts =
45
+ doctor.counts && typeof doctor.counts === 'object'
46
+ ? Object.fromEntries(['ok', 'warn', 'fail', 'skip'].map(k => [k, count(doctor.counts[k])]))
47
+ : null
48
+ return {
49
+ ran: doctor.ran === true,
50
+ parsed: doctor.parsed === true,
51
+ ok: typeof doctor.ok === 'boolean' ? doctor.ok : null,
52
+ counts,
53
+ // `evidence` is not copied: no reader draws it, and the module reads the file whole.
54
+ checks: list(doctor.checks).map(c => ({ id: text(c.id), status: text(c.status), detail: text(c.detail), fix: str(c.fix) }))
55
+ }
56
+ }
57
+
58
+ function notesOf(n) {
59
+ const notes = n && typeof n === 'object' ? n : {}
60
+ return {
61
+ spoken: list(notes.spoken).map(s => ({
62
+ runId: text(s.runId),
63
+ change: str(s.change),
64
+ stage: str(s.stage),
65
+ banner: text(s.banner),
66
+ marked: s.marked === true,
67
+ marks: list(s.marks).map(m => ({ root: text(m.root), marked: m.marked === true, reason: str(m.reason) }))
68
+ })),
69
+ unreadable: list(notes.unreadable).map(u => ({ file: text(u.file), reason: text(u.reason) }))
70
+ }
71
+ }
72
+
73
+ function cardsOf(c) {
74
+ const cards = c && typeof c === 'object' ? c : {}
75
+ return {
76
+ listed: list(cards.listed).map(k => ({ path: text(k.path), change: text(k.change), runId: text(k.runId), writtenAt: str(k.writtenAt) })),
77
+ archived: count(cards.archived),
78
+ unreadable: list(cards.unreadable).map(u => ({ path: text(u.path), reason: text(u.reason) })),
79
+ lookedIn: Array.isArray(cards.lookedIn) ? cards.lookedIn.filter(d => typeof d === 'string' && d) : []
80
+ }
81
+ }
82
+
83
+ /**
84
+ * The report, every field present: an absent fact is `null` or `[]`, never
85
+ * omitted (design D1). Pure: the caller passes the time it wrote at.
86
+ */
87
+ export function buildPreflightReport(input) {
88
+ const i = input && typeof input === 'object' ? input : {}
89
+ return {
90
+ schema: PREFLIGHT_SCHEMA,
91
+ writtenAt: str(i.writtenAt),
92
+ source: SOURCES.has(i.source) ? i.source : null,
93
+ root: str(i.root),
94
+ stateHome: str(i.stateHome),
95
+ surface: str(i.surface),
96
+ message: typeof i.message === 'string' ? i.message : null,
97
+ doctor: doctorOf(i.doctor),
98
+ notes: notesOf(i.notes),
99
+ cards: cardsOf(i.cards)
100
+ }
101
+ }
102
+
103
+ /** `{ report }` from the file's text, or `{ problem }` naming why there is none. */
104
+ export function readPreflightReport(raw) {
105
+ if (typeof raw !== 'string') return { problem: `not text: the read returned ${raw === null ? 'null' : typeof raw}` }
106
+ let value
107
+ try {
108
+ value = JSON.parse(raw)
109
+ } catch (err) {
110
+ return { problem: `not JSON: ${(err && err.message) || err}` }
111
+ }
112
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return { problem: 'not an object' }
113
+ if (value.schema !== PREFLIGHT_SCHEMA) return { problem: `stamped ${JSON.stringify(value.schema)}, not ${PREFLIGHT_SCHEMA}` }
114
+ return { report: buildPreflightReport(value) }
115
+ }
116
+
117
+ const flagged = report => report.doctor.checks.filter(c => c.status === 'fail' || c.status === 'warn')
118
+
119
+ /** Whether the band has anything to say: a fail or warn check, a doctor that did not run or parse, a spoken note, a card. */
120
+ export function shouldDraw(report) {
121
+ return (
122
+ flagged(report).length > 0 ||
123
+ !report.doctor.ran ||
124
+ !report.doctor.parsed ||
125
+ report.notes.spoken.length > 0 ||
126
+ report.cards.listed.length > 0
127
+ )
128
+ }
129
+
130
+ /** One listed card's line: where it is, which change and run, when it was written. */
131
+ export function cardLine(card) {
132
+ return `${card.path} · ${card.change} · run ${card.runId} · written ${card.writtenAt || 'at an unknown time'}`
133
+ }
134
+
135
+ /** The listed cards, newest written first; equal or unknown times keep the listing's order. */
136
+ export function orderCards(report) {
137
+ const at = c => {
138
+ const t = Date.parse(c.writtenAt)
139
+ return Number.isNaN(t) ? -Infinity : t
140
+ }
141
+ return report.cards.listed
142
+ .map((card, i) => ({ card, i }))
143
+ .sort((a, b) => at(b.card) - at(a.card) || a.i - b.i)
144
+ .map(x => x.card)
145
+ }
146
+
147
+ const writtenLine = report => `preflight written ${report.writtenAt || 'at an unknown time'}`
148
+
149
+ /**
150
+ * The band's lines, in order (design D6): the message's first line, each
151
+ * `fail` or `warn` check in the doctor's words, each spoken note's banner,
152
+ * each card with the command that shows it, and when the report was written.
153
+ * Empty when there is nothing to say.
154
+ */
155
+ export function bandLines(report) {
156
+ if (!shouldDraw(report)) return []
157
+ const lines = []
158
+ const first = (report.message || '').split('\n')[0]
159
+ if (first) lines.push(first)
160
+ for (const c of flagged(report)) lines.push(`${c.status} ${c.id}: ${c.detail}`)
161
+ for (const n of report.notes.spoken) lines.push(n.banner)
162
+ for (const card of orderCards(report)) lines.push(`${cardLine(card)} — /interlock-handoff`)
163
+ lines.push(`${writtenLine(report)} · /interlock-preflight`)
164
+ return lines
165
+ }
166
+
167
+ /**
168
+ * The `/interlock-preflight` pane's lines: the message verbatim, every check
169
+ * with its fix lines indented as the hook prints them, the notes spoken at
170
+ * this session start with each root's mark, the cards and what was not
171
+ * listed, and every note and card the hook could not read. No empty line.
172
+ */
173
+ export function preflightPaneLines(report) {
174
+ const lines = []
175
+ for (const line of (report.message || '').split('\n')) if (line) lines.push(line)
176
+ lines.push(writtenLine(report))
177
+
178
+ lines.push(report.doctor.ran ? (report.doctor.parsed ? 'checks' : 'checks: the doctor ran and its output could not be parsed') : 'checks: the doctor did not run')
179
+ for (const c of report.doctor.checks) {
180
+ lines.push(`${c.status} ${c.id}: ${c.detail}`)
181
+ if (c.fix) {
182
+ const [first, ...rest] = c.fix.split('\n')
183
+ lines.push(` fix: ${first}`)
184
+ for (const more of rest) if (more.trim()) lines.push(` ${more.trim()}`)
185
+ }
186
+ }
187
+
188
+ lines.push(report.notes.spoken.length ? 'interrupted runs spoken at this session start' : 'no interrupted run was spoken at this session start')
189
+ for (const n of report.notes.spoken) {
190
+ lines.push(n.banner)
191
+ for (const m of n.marks) lines.push(m.marked ? ` marked · ${m.root}` : ` not marked: ${m.reason || 'no reason given'}`)
192
+ }
193
+ for (const u of report.notes.unreadable) lines.push(`note unreadable: ${u.file}: ${u.reason}`)
194
+
195
+ const ordered = orderCards(report)
196
+ lines.push(ordered.length ? 'halt resume cards — /interlock-handoff' : 'no halt resume card is on disk for an open change')
197
+ for (const card of ordered) lines.push(cardLine(card))
198
+ if (report.cards.archived) lines.push(`${report.cards.archived} card(s) of archived changes not listed`)
199
+ for (const u of report.cards.unreadable) lines.push(`card unreadable: ${u.path}: ${u.reason}`)
200
+ for (const dir of report.cards.lookedIn) lines.push(`looked in: ${dir}`)
201
+ return lines
202
+ }
203
+
204
+ /** What a pane says with no report held: the file's path and why, or that it has not been read yet. */
205
+ export function noReportText(path, problem) {
206
+ return `no preflight report is held for this session start: ${path}: ${problem || 'not read yet'}`
207
+ }
208
+
209
+ /** What the handoff pane says with a report and no card: that, and every directory looked in. */
210
+ export function noCardText(report) {
211
+ return ['no halt resume card is on disk for an open change', ...report.cards.lookedIn.map(dir => `looked in: ${dir}`)]
212
+ }
213
+
214
+ // Every control character but tab and newline: the `Markdown` element refuses them.
215
+ const CONTROL = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g
216
+
217
+ /**
218
+ * A card's text as the handoff pane renders it: control characters made
219
+ * spaces, and when longer than `cap`, cut so that the text and a note naming
220
+ * the characters left out and the card's path fit within `cap` (design D7).
221
+ */
222
+ export function clampCard(raw, path, cap = LIMITS.handoffPaneChars) {
223
+ const clean = text(raw).replace(CONTROL, ' ')
224
+ if (clean.length <= cap) return { text: clean, cut: 0 }
225
+ const note = n => `\n\n_… ${n} characters left out; the whole card is ${path}_`
226
+ // The note for the whole text is the longest the note can be, so the kept head plus any shorter note still fits.
227
+ let keep = Math.max(0, cap - note(clean.length).length)
228
+ const code = clean.charCodeAt(keep - 1)
229
+ if (keep > 0 && code >= 0xd800 && code <= 0xdbff) keep -= 1
230
+ const cut = clean.length - keep
231
+ return { text: (clean.slice(0, keep) + note(cut)).slice(0, cap), cut }
232
+ }
233
+
234
+ /** What the module holds between reads (design D8): the report or the problem, the hide flag, whether the problem was named. */
235
+ export function freshStart() {
236
+ return { report: null, path: null, problem: null, hidden: false, named: false }
237
+ }
238
+
239
+ /**
240
+ * The module's next `start` after a read (design D5, D8), whether the band
241
+ * must be redrawn, and the one debug line to name, if any. A re-read of the
242
+ * report already held changes nothing, hide flag and all. A new report is
243
+ * held, and stays hidden only when it continues a report already held across
244
+ * a compaction. A problem drops the held report, since the file is no longer
245
+ * this session's, and is named once until a report is held again.
246
+ *
247
+ * @param {object} start what `freshStart` returns, as last adopted
248
+ * @param {{report?: object, problem?: string}} outcome `readPreflightReport`'s result, or a read's rejection as a problem
249
+ * @param {string} path the file's path, named on the debug line and the panes
250
+ * @returns {{start: object, changed: boolean, log: string|null}}
251
+ */
252
+ export function adoptPreflight(start, outcome, path) {
253
+ const held = start && typeof start === 'object' ? start : freshStart()
254
+ const report = outcome && outcome.report
255
+ if (report) {
256
+ if (held.report && held.report.writtenAt === report.writtenAt && held.report.root === report.root) {
257
+ return { start: held, changed: false, log: null }
258
+ }
259
+ const hidden = held.report !== null && held.hidden && report.source === 'compact'
260
+ return { start: { report, path, problem: null, hidden, named: false }, changed: true, log: null }
261
+ }
262
+ const problem = (outcome && outcome.problem) || 'no report was read'
263
+ const log = held.named ? null : `interlock meter: no preflight report at ${path}: ${problem}`
264
+ const changed = held.report !== null || held.problem !== problem || held.path !== path
265
+ return { start: { report: null, path, problem, hidden: held.hidden, named: true }, changed, log }
266
+ }