@renzrollon/interlock 1.2.1 → 1.3.1
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/plugin.json +1 -2
- package/CHANGELOG.md +64 -5
- package/bin/interlock +142 -14
- package/hooks/mod.mjs +1013 -85
- package/hooks/preflight.mjs +168 -34
- package/lib/draw-plan.mjs +516 -0
- package/lib/draw-run.mjs +466 -0
- package/lib/lane.mjs +152 -0
- package/lib/limits.mjs +37 -2
- package/lib/meter-refusals.mjs +73 -0
- 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 +2 -2
- package/skills/review-artifacts/SKILL.md +3 -3
- package/skills/spec/SKILL.md +5 -5
- /package/{.claude-plugin → assets}/icon.png +0 -0
|
@@ -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,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
|
+
}
|
package/lib/resume-card.mjs
CHANGED
|
@@ -10,12 +10,18 @@
|
|
|
10
10
|
//
|
|
11
11
|
// Three rules it exists under, and all three are load-bearing:
|
|
12
12
|
//
|
|
13
|
-
// 1. **It is a record, not a trigger.** Nothing reads it back.
|
|
14
|
-
// still decides whether to skip the classifier from the
|
|
15
|
-
// from this file, so a stale or hand-edited card
|
|
16
|
-
// reuse a plan it should have rebuilt. The card says
|
|
17
|
-
// the file, because a markdown "resume" artifact that
|
|
18
|
-
// exactly the thing a reader would otherwise assume is
|
|
13
|
+
// 1. **It is a record, not a trigger.** Nothing reads it back to decide.
|
|
14
|
+
// `/interlock:ship` still decides whether to skip the classifier from the
|
|
15
|
+
// plan fingerprint, not from this file, so a stale or hand-edited card
|
|
16
|
+
// cannot make a later run reuse a plan it should have rebuilt. The card says
|
|
17
|
+
// that about itself, in the file, because a markdown "resume" artifact that
|
|
18
|
+
// nothing consumes is exactly the thing a reader would otherwise assume is
|
|
19
|
+
// wired in. Two readers show it and nothing more
|
|
20
|
+
// (show-preflight-and-interrupted-runs-at-session-start design D4): the
|
|
21
|
+
// SessionStart preflight lists the cards of open changes from the stamp on
|
|
22
|
+
// each card's first line (`listResumeCards`, below), and the hooks module's
|
|
23
|
+
// `/interlock-handoff` renders one verbatim on request. Neither marks,
|
|
24
|
+
// moves or deletes a card.
|
|
19
25
|
// 2. **It never throws.** A read-only checkout, a full disk, an unwritable
|
|
20
26
|
// `.claude/` — each degrades to a no-op that *reports* itself through the
|
|
21
27
|
// return value, and the close turns that into a degradation banner. The run
|
|
@@ -29,8 +35,8 @@
|
|
|
29
35
|
// Only on a halt. A clean run's summary already ends in `ARCHIVE PENDING` and
|
|
30
36
|
// there is nothing to resume.
|
|
31
37
|
|
|
32
|
-
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
33
|
-
import { join } from 'node:path'
|
|
38
|
+
import { closeSync, existsSync, mkdirSync, openSync, readSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs'
|
|
39
|
+
import { join, resolve } from 'node:path'
|
|
34
40
|
import { LIMITS } from './limits.mjs'
|
|
35
41
|
|
|
36
42
|
/** Where the card lands, relative to the repo root. Already gitignored. */
|
|
@@ -161,6 +167,7 @@ export function formatResumeCard({
|
|
|
161
167
|
const elsewhere = typeof stateHome === 'string' && stateHome.length > 0 && !samePath(stateHome, cwd)
|
|
162
168
|
const lines = []
|
|
163
169
|
|
|
170
|
+
// The stamp `parseResumeCardStamp` reads back: change it and the parser together.
|
|
164
171
|
lines.push(`<!-- ${RESUME_CARD_SCHEMA} change=${name} run=${hasRun ? runId : NO_RUN_ID} -->`)
|
|
165
172
|
lines.push(`# Ship halted — ${name}`)
|
|
166
173
|
lines.push('')
|
|
@@ -375,6 +382,117 @@ export function formatResumeCard({
|
|
|
375
382
|
return lines.join('\n')
|
|
376
383
|
}
|
|
377
384
|
|
|
385
|
+
// The writer's first line, read back. Anchored on the schema, so a card of
|
|
386
|
+
// another shape is no card; a change or run carrying a separator is refused.
|
|
387
|
+
const SCHEMA_PATTERN = RESUME_CARD_SCHEMA.replace(/[.\/]/g, '\\$&')
|
|
388
|
+
const STAMP = new RegExp(`^<!-- ${SCHEMA_PATTERN} change=([^\\s/\\\\]+) run=([^\\s/\\\\]+) -->$`)
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* The change and run id a card's first line names, or `null` when the line is
|
|
392
|
+
* not this schema's stamp. The inverse of the stamp `formatResumeCard` writes.
|
|
393
|
+
*
|
|
394
|
+
* @param {string} line
|
|
395
|
+
* @returns {{change: string, runId: string} | null}
|
|
396
|
+
*/
|
|
397
|
+
export function parseResumeCardStamp(line) {
|
|
398
|
+
if (typeof line !== 'string') return null
|
|
399
|
+
const m = STAMP.exec(line.replace(/\r$/, ''))
|
|
400
|
+
if (!m || m[1] === '.' || m[1] === '..') return null
|
|
401
|
+
return { change: m[1], runId: m[2] }
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** How much of a card the listing reads: the stamp line, never the body. */
|
|
405
|
+
const STAMP_BYTES = 512
|
|
406
|
+
|
|
407
|
+
function firstLine(path) {
|
|
408
|
+
const fd = openSync(path, 'r')
|
|
409
|
+
try {
|
|
410
|
+
const buffer = Buffer.alloc(STAMP_BYTES)
|
|
411
|
+
const n = readSync(fd, buffer, 0, STAMP_BYTES, 0)
|
|
412
|
+
return buffer.toString('utf8', 0, n).split('\n')[0]
|
|
413
|
+
} finally {
|
|
414
|
+
closeSync(fd)
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const messageOf = err => (err && err.message) || String(err)
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The halt resume cards under each root's handoff directory, as the
|
|
422
|
+
* SessionStart preflight lists them (design D4). A card whose change still has
|
|
423
|
+
* a directory under `changesDir` is listed with its absolute path, change, run
|
|
424
|
+
* id and modification time; the cards of other changes are counted under
|
|
425
|
+
* `archived`; a `ship-*.md` without a readable stamp is named under
|
|
426
|
+
* `unreadable`. Every handoff directory opened or found absent is recorded
|
|
427
|
+
* under `lookedIn`, and two roots naming one real directory are read once.
|
|
428
|
+
*
|
|
429
|
+
* Reads the first line of each card and nothing past it; marks, moves and
|
|
430
|
+
* deletes nothing. Never throws.
|
|
431
|
+
*
|
|
432
|
+
* @param {{roots?: string[], changesDir?: string}} [input]
|
|
433
|
+
* @returns {{listed: Array<{path: string, change: string, runId: string, writtenAt: string|null}>, archived: number, unreadable: Array<{path: string, reason: string}>, lookedIn: string[]}}
|
|
434
|
+
*/
|
|
435
|
+
export function listResumeCards({ roots = [], changesDir } = {}) {
|
|
436
|
+
const out = { listed: [], archived: 0, unreadable: [], lookedIn: [] }
|
|
437
|
+
try {
|
|
438
|
+
const seen = new Set()
|
|
439
|
+
for (const root of Array.isArray(roots) ? roots : []) {
|
|
440
|
+
if (typeof root !== 'string' || !root) continue
|
|
441
|
+
const dir = resolve(root, HANDOFF_DIR)
|
|
442
|
+
let real = dir
|
|
443
|
+
try {
|
|
444
|
+
real = realpathSync(dir)
|
|
445
|
+
} catch {
|
|
446
|
+
// An absent directory is still one directory, named as given.
|
|
447
|
+
}
|
|
448
|
+
if (seen.has(real)) continue
|
|
449
|
+
seen.add(real)
|
|
450
|
+
out.lookedIn.push(dir)
|
|
451
|
+
let names
|
|
452
|
+
try {
|
|
453
|
+
names = readdirSync(dir)
|
|
454
|
+
} catch (err) {
|
|
455
|
+
if (!(err && err.code === 'ENOENT')) out.unreadable.push({ path: dir, reason: messageOf(err) })
|
|
456
|
+
continue
|
|
457
|
+
}
|
|
458
|
+
for (const name of names.filter(n => n.startsWith('ship-') && n.endsWith('.md')).sort()) {
|
|
459
|
+
const path = join(dir, name)
|
|
460
|
+
let stamp
|
|
461
|
+
try {
|
|
462
|
+
stamp = parseResumeCardStamp(firstLine(path))
|
|
463
|
+
} catch (err) {
|
|
464
|
+
out.unreadable.push({ path, reason: messageOf(err) })
|
|
465
|
+
continue
|
|
466
|
+
}
|
|
467
|
+
if (!stamp) {
|
|
468
|
+
out.unreadable.push({ path, reason: `the first line is not an ${RESUME_CARD_SCHEMA} stamp` })
|
|
469
|
+
continue
|
|
470
|
+
}
|
|
471
|
+
let open = false
|
|
472
|
+
try {
|
|
473
|
+
open = typeof changesDir === 'string' && statSync(join(changesDir, stamp.change)).isDirectory()
|
|
474
|
+
} catch {
|
|
475
|
+
open = false
|
|
476
|
+
}
|
|
477
|
+
if (!open) {
|
|
478
|
+
out.archived++
|
|
479
|
+
continue
|
|
480
|
+
}
|
|
481
|
+
let writtenAt = null
|
|
482
|
+
try {
|
|
483
|
+
writtenAt = statSync(path).mtime.toISOString()
|
|
484
|
+
} catch {
|
|
485
|
+
writtenAt = null
|
|
486
|
+
}
|
|
487
|
+
out.listed.push({ path, change: stamp.change, runId: stamp.runId, writtenAt })
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
} catch (err) {
|
|
491
|
+
out.unreadable.push({ path: String(roots), reason: messageOf(err) })
|
|
492
|
+
}
|
|
493
|
+
return out
|
|
494
|
+
}
|
|
495
|
+
|
|
378
496
|
/**
|
|
379
497
|
* Write the card for a halted run.
|
|
380
498
|
*
|