@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.
- package/.claude-plugin/icon.png +0 -0
- package/.claude-plugin/plugin.json +16 -1
- package/CHANGELOG.md +68 -13
- package/bin/interlock +154 -18
- package/hooks/mod.mjs +835 -51
- package/hooks/preflight.mjs +209 -34
- package/lib/doctor.mjs +23 -18
- package/lib/draw-plan.mjs +496 -0
- package/lib/draw-run.mjs +466 -0
- package/lib/evals-triage.mjs +1 -1
- package/lib/lane.mjs +152 -0
- package/lib/limits.mjs +37 -2
- package/lib/meter-refusals.mjs +73 -0
- package/lib/notify-defaults.mjs +6 -0
- package/lib/notify.mjs +41 -25
- package/lib/plan-summary.mjs +119 -0
- package/lib/preflight-file.mjs +266 -0
- package/lib/resume-card.mjs +126 -8
- package/lib/run.mjs +43 -9
- package/lib/spec-meter.mjs +591 -0
- package/lib/waves.mjs +6 -107
- package/package.json +3 -2
- package/skills/evals/SKILL.md +3 -3
- package/skills/fix-tests/SKILL.md +2 -2
- package/skills/review-artifacts/SKILL.md +3 -3
- package/skills/spec/SKILL.md +6 -11
- package/skills/spec/scripts/compact-instructions.mjs +30 -0
- package/.claude-plugin/marketplace.json +0 -19
|
@@ -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
|
|
2
|
-
//
|
|
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
|
|
15
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
-
/**
|
|
40
|
-
|
|
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
|
|
65
|
-
*
|
|
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}
|
|
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(
|
|
74
|
-
const
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
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
|
|
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
|
+
}
|