catherd-cli 1.0.0 → 1.1.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.
Files changed (83) hide show
  1. package/README.md +66 -42
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin/.mcp.json +2 -2
  5. package/plugin/bin/catherd-mcp +30 -0
  6. package/plugin/commands/catherd.md +1 -1
  7. package/plugin/skills/catherd/SKILL.md +112 -95
  8. package/plugin/skills/catherd-setup/SKILL.md +3 -3
  9. package/src/adapters/access.ts +118 -0
  10. package/src/adapters/backend.ts +19 -4
  11. package/src/adapters/claude-code/index.ts +95 -1
  12. package/src/adapters/codex/index.ts +78 -23
  13. package/src/adapters/opencode/index.ts +17 -0
  14. package/src/domain/errors.ts +2 -0
  15. package/src/domain/failover.ts +119 -0
  16. package/src/domain/lane.ts +23 -0
  17. package/src/domain/notice.ts +95 -0
  18. package/src/domain/profile-rules.ts +43 -9
  19. package/src/domain/profile.ts +12 -6
  20. package/src/domain/record.ts +2 -0
  21. package/src/domain/role-prompts.ts +33 -1
  22. package/src/domain/state.ts +18 -2
  23. package/src/entry/deps.ts +2 -1
  24. package/src/entry/doctor-command.ts +7 -1
  25. package/src/entry/glyphs.ts +2 -1
  26. package/src/entry/init-command.ts +41 -1
  27. package/src/entry/mcp/command.ts +5 -1
  28. package/src/entry/mcp/dispatch-tools.ts +10 -40
  29. package/src/entry/mcp/handshake.ts +19 -9
  30. package/src/entry/mcp/lane-tools.ts +7 -6
  31. package/src/entry/mcp/protocol-tools.ts +57 -0
  32. package/src/entry/mcp/run-tools.ts +4 -3
  33. package/src/entry/mcp/server.ts +17 -1
  34. package/src/entry/mcp/setup-tools.ts +1 -1
  35. package/src/entry/profile-command.ts +3 -5
  36. package/src/entry/runs-command.ts +64 -21
  37. package/src/entry/tui/commands.ts +1 -1
  38. package/src/entry/tui/effects.ts +98 -56
  39. package/src/entry/tui/fixtures.ts +352 -85
  40. package/src/entry/tui/profile-edits.ts +2 -2
  41. package/src/entry/tui/profile-tree.ts +2 -1
  42. package/src/entry/tui/providers/data.tsx +35 -8
  43. package/src/entry/tui/run.tsx +11 -1
  44. package/src/entry/tui/state.ts +26 -6
  45. package/src/entry/tui/stories.ts +36 -4
  46. package/src/entry/tui/text.ts +12 -0
  47. package/src/entry/tui/theme.ts +1 -0
  48. package/src/entry/tui/views/runs.tsx +409 -117
  49. package/src/entry/tui/views/status.tsx +5 -2
  50. package/src/entry/tui/widgets/list.tsx +4 -3
  51. package/src/infra/claude-session.ts +98 -0
  52. package/src/infra/dispatch-dir.ts +14 -6
  53. package/src/infra/env.ts +26 -2
  54. package/src/infra/peer-inbox.ts +92 -0
  55. package/src/infra/supervisor.ts +33 -3
  56. package/src/services/admission.ts +38 -12
  57. package/src/services/dispatch-service.ts +338 -197
  58. package/src/services/dispatches.ts +30 -1
  59. package/src/services/doctor-access.ts +231 -0
  60. package/src/services/doctor-backends.ts +3 -18
  61. package/src/services/doctor-checks.ts +46 -1
  62. package/src/services/doctor-push.ts +109 -0
  63. package/src/services/doctor.ts +59 -79
  64. package/src/services/finalize.ts +43 -25
  65. package/src/services/gate-service.ts +288 -0
  66. package/src/services/global-install.ts +99 -0
  67. package/src/services/lane-service.ts +150 -8
  68. package/src/services/milestones.ts +208 -0
  69. package/src/services/notifier.ts +296 -0
  70. package/src/services/peek.ts +103 -0
  71. package/src/services/ports.ts +7 -4
  72. package/src/services/preflight.ts +17 -3
  73. package/src/services/protocol.ts +212 -0
  74. package/src/services/questions.ts +102 -0
  75. package/src/services/reconcile.ts +40 -11
  76. package/src/services/reentry.ts +22 -0
  77. package/src/services/run-service.ts +42 -9
  78. package/src/services/run-store.ts +18 -0
  79. package/src/services/runs-page.ts +254 -0
  80. package/src/services/session-view.ts +152 -0
  81. package/src/services/sessions.ts +92 -0
  82. package/src/services/state.ts +10 -3
  83. package/src/services/summary.ts +14 -0
@@ -0,0 +1,296 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { errorMessage } from "../domain/errors.ts";
4
+ import { envelope, formatNotices, type Notice, type NoticePriority, priorityOf } from "../domain/notice.ts";
5
+ import type { RunRecord } from "../domain/record.ts";
6
+ import { awaitsCollect, dispatchPaths } from "../infra/dispatch-dir.ts";
7
+ import { tryLock } from "../infra/filelock.ts";
8
+ import { log } from "../infra/log.ts";
9
+ import { type SendResult, sendToInbox } from "../infra/peer-inbox.ts";
10
+ import { writeJsonAtomic } from "../infra/store.ts";
11
+ import { type Settled, settledHooks, type Stalled, stallHooks } from "./dispatch-service.ts";
12
+ import { type Dispatch, listDispatches, readFailover } from "./dispatches.ts";
13
+ import type { Deps } from "./ports.ts";
14
+ import { listRuns, readRecords, type Run } from "./run-store.ts";
15
+ import { currentSession, ownsRun } from "./sessions.ts";
16
+
17
+ /**
18
+ * Spec §3.4: runs only inside the MCP server, the session's child and so its only sender (§3.2). For every run this
19
+ * session owns, a dispatch that is settled with its record still unread is announced to the session's peer inbox;
20
+ * notices that arrive within the window of each other go as one message; a message that went out is written down
21
+ * (`notified.json`), so a restart never sends it twice, and claimed while it goes, so two servers of one session never
22
+ * both send it. Ownership is asked again under the claim and once more after the message went: a run another session
23
+ * claimed meanwhile is left unmarked, so its new owner announces it too (a copy to the session that gave the run up is
24
+ * acceptable, a notice lost to the owner is not); a notice whose claim another server holds is tried again after the
25
+ * window until it is sent, read, marked or no longer this session's. Disk stays the truth: a notice that cannot be
26
+ * sent is dropped (its claim freed), and the record waits, unread, for `result` or `peek`.
27
+ */
28
+
29
+ export interface NotifierOptions {
30
+ /** notices this close to each other go as one message (spec: 3 s); tests shorten it */
31
+ coalesceMs?: number;
32
+ /** the sender; tests may replace it */
33
+ send?: (
34
+ target: { socketPath: string | null; token: string | null },
35
+ content: string,
36
+ p: NoticePriority,
37
+ ) => Promise<SendResult>;
38
+ }
39
+
40
+ export interface Notifier {
41
+ /** the settled hook: queues the dispatch's notice when this session should hear of it */
42
+ onSettled(s: Settled): void;
43
+ /** the stall hook: queues a stalled role's notice, once per dispatch */
44
+ onStall(s: Stalled): void;
45
+ /** spec §3.4 "a scan on start": every unread, un-notified record of a run this session owns */
46
+ scan(): Promise<void>;
47
+ /** resolves once nothing is queued and no message is on its way */
48
+ idle(): Promise<void>;
49
+ /** removes the hook and drops what is queued */
50
+ stop(): void;
51
+ }
52
+
53
+ const notified = (dir: string): boolean => existsSync(dispatchPaths(dir).notified);
54
+
55
+ function replyOf(run: Run, r: RunRecord): string {
56
+ try {
57
+ return readFileSync(join(run.dir, r.replyPath), "utf8");
58
+ } catch {
59
+ return "";
60
+ }
61
+ }
62
+
63
+ /** Spec §3.5/§3.6: one finished role's notice, with what failover did in place of a limit's status. */
64
+ export function finishedNotice(run: Run, d: Dispatch, r: RunRecord): Notice {
65
+ const fo = readFailover(d.dir);
66
+ let status: string = r.status;
67
+ if (r.status === "limit") {
68
+ const why = fo?.standIn
69
+ ? `failed over to ${fo.standIn.rung}`
70
+ : fo?.hints.some((h) => h.startsWith("failover: run "))
71
+ ? "its stand-in is a Claude agent: result(run, name) has the hint"
72
+ : fo?.hints.some((h) => h.startsWith("failover: "))
73
+ ? "paused: the stand-in was refused"
74
+ : "paused: no stand-in";
75
+ status = `limit on ${r.rung}; ${why}`;
76
+ }
77
+ const urgent = r.status === "limit" || r.replyStatus === "blocked" || r.replyStatus === "refused";
78
+ return {
79
+ kind: "finished",
80
+ runId: run.id,
81
+ runTitle: run.meta.title,
82
+ dispatchId: r.dispatchId,
83
+ name: r.name,
84
+ role: r.role,
85
+ lane: r.lane,
86
+ rung: r.rung,
87
+ status,
88
+ replyStatus: r.replyStatus,
89
+ secs: r.secs,
90
+ changedOwned: r.changedOwned.length,
91
+ reply: replyOf(run, r),
92
+ priority: urgent ? "next" : "later",
93
+ };
94
+ }
95
+
96
+ /** Spec §3.6: a live role that went quiet, once, at `next`. */
97
+ export function stalledNotice(run: Run, d: Dispatch, quietMs: number, now: number): Notice {
98
+ return {
99
+ kind: "stalled",
100
+ runId: run.id,
101
+ runTitle: run.meta.title,
102
+ dispatchId: d.admit.dispatchId,
103
+ name: d.admit.name,
104
+ role: d.admit.role,
105
+ lane: d.admit.lane,
106
+ rung: d.admit.rung,
107
+ status: `stalled: no output for ${Math.max(1, Math.round(quietMs / 60_000))} min`,
108
+ replyStatus: null,
109
+ secs: Math.max(0, Math.round((now - Date.parse(d.admit.admittedAt)) / 1000)),
110
+ changedOwned: 0,
111
+ reply: "",
112
+ priority: "next",
113
+ };
114
+ }
115
+
116
+ interface Queued {
117
+ /** its key in the queue */
118
+ key: string;
119
+ /** the run it is about: its owner may change before the message goes */
120
+ run: Run;
121
+ notice: Notice;
122
+ /** the mark a message that went out leaves */
123
+ mark: string;
124
+ /** whether it is still news when the message goes */
125
+ due: () => boolean;
126
+ }
127
+
128
+ /** Starts the notifier for this process's session and hooks it to every settled dispatch. */
129
+ export function startNotifier(deps: Deps, o: NotifierOptions = {}): Notifier {
130
+ const window = o.coalesceMs ?? 3_000;
131
+ const send = o.send ?? sendToInbox;
132
+ const queue = new Map<string, Queued>();
133
+ let timer: ReturnType<typeof setTimeout> | null = null;
134
+ let firstAt = 0;
135
+ let flight: Promise<void> = Promise.resolve();
136
+ let stopped = false;
137
+
138
+ /** This session's id, when there is one and it owns the run. */
139
+ const owned = (run: Run): boolean => ownsRun(deps, run);
140
+
141
+ const flush = (): void => {
142
+ timer = null;
143
+ const batch = [...queue.values()];
144
+ queue.clear();
145
+ // a delivery that throws (the notified.json write, say) is logged and never stops the ones after it
146
+ flight = flight
147
+ .then(() => deliver(batch))
148
+ .catch((e: unknown) =>
149
+ log("warn", "notify", { error: errorMessage(e), dispatches: batch.map((q) => q.notice.dispatchId) }),
150
+ );
151
+ };
152
+
153
+ const schedule = (): void => {
154
+ const now = Date.now();
155
+ if (timer === null) firstAt = now;
156
+ else clearTimeout(timer);
157
+ // quiet for `window`, but never held past five windows: a steady trickle still goes out
158
+ timer = setTimeout(flush, Math.max(0, Math.min(window, firstAt + 5 * window - now)));
159
+ };
160
+
161
+ async function deliver(batch: Queued[]): Promise<void> {
162
+ if (!deps.session) return;
163
+ // Each notice is claimed before it goes (an exclusive lock on its mark, freed if its holder dies): two servers
164
+ // of one session, a replacement starting while the old one still watches, never both send it. Whether it is
165
+ // still news is asked under the claim: the other may have announced it just before letting go.
166
+ const claims: (() => void)[] = [];
167
+ const due: Queued[] = [];
168
+ const busy: Queued[] = [];
169
+ try {
170
+ for (const q of batch) {
171
+ // read (or announced) meanwhile, or the run moved to another session (whose server tells it)
172
+ if (!q.due() || !owned(q.run)) continue;
173
+ const release = tryLock(q.mark);
174
+ // another server is announcing it: asked again after the window, since that server may leave it unmarked
175
+ // (the run moved to this session while its message was on its way)
176
+ if (!release) {
177
+ busy.push(q);
178
+ continue;
179
+ }
180
+ claims.push(release);
181
+ // asked again under the claim: the run may have moved, or the notice gone out, while it was taken
182
+ if (q.due() && owned(q.run)) due.push(q);
183
+ }
184
+ if (due.length === 0) return;
185
+ const notices = due.map((q) => q.notice);
186
+ const r = await send(
187
+ { socketPath: deps.session.socketPath, token: deps.session.token },
188
+ envelope(formatNotices(notices)),
189
+ priorityOf(notices),
190
+ ).catch((e: unknown): SendResult => ({ outcome: "error", reason: errorMessage(e) }));
191
+ if (r.outcome !== "sent") {
192
+ log("warn", "notify", {
193
+ outcome: r.outcome,
194
+ reason: r.reason,
195
+ dispatches: notices.map((n) => n.dispatchId),
196
+ });
197
+ return;
198
+ }
199
+ const at = new Date(deps.now()).toISOString();
200
+ // a run that moved while the message was on its way is left unmarked: its new owner announces it too
201
+ const marked = due.filter((q) => owned(q.run));
202
+ for (const q of marked) writeJsonAtomic(q.mark, { schema: 1, msgId: r.msgId, at });
203
+ const unmarked = due.filter((q) => !marked.includes(q)).map((q) => q.notice.dispatchId);
204
+ log("info", "notify", { msgId: r.msgId, dispatches: notices.map((n) => n.dispatchId), unmarked });
205
+ } finally {
206
+ for (const release of claims) release();
207
+ requeue(busy);
208
+ }
209
+ }
210
+
211
+ /** Queues again the notices another server held the claim on; the next pass drops those no longer due or owned. */
212
+ function requeue(busy: Queued[]): void {
213
+ if (stopped || busy.length === 0) return;
214
+ for (const q of busy) if (!queue.has(q.key)) queue.set(q.key, q);
215
+ schedule();
216
+ }
217
+
218
+ const enqueue = (run: Run, d: Dispatch, record: RunRecord): void => {
219
+ if (queue.has(record.dispatchId) || notified(d.dir) || !awaitsCollect(d.dir) || !owned(run)) return;
220
+ queue.set(record.dispatchId, {
221
+ key: record.dispatchId,
222
+ run,
223
+ notice: finishedNotice(run, d, record),
224
+ mark: dispatchPaths(d.dir).notified,
225
+ due: () => awaitsCollect(d.dir) && !notified(d.dir),
226
+ });
227
+ schedule();
228
+ };
229
+
230
+ const onStall = (s: Stalled): void => {
231
+ try {
232
+ const p = dispatchPaths(s.d.dir);
233
+ const key = `${s.d.admit.dispatchId} stall`;
234
+ if (queue.has(key) || existsSync(p.stallNotified) || existsSync(p.exit) || !owned(s.run)) return;
235
+ queue.set(key, {
236
+ key,
237
+ run: s.run,
238
+ notice: stalledNotice(s.run, s.d, s.quietMs, deps.now()),
239
+ mark: p.stallNotified,
240
+ // it finished meanwhile: its record is the news now
241
+ due: () => !existsSync(p.stallNotified) && !existsSync(p.exit),
242
+ });
243
+ schedule();
244
+ } catch (e) {
245
+ log("warn", "notify", { run: s.run.id, name: s.d.admit.name, error: errorMessage(e) });
246
+ }
247
+ };
248
+
249
+ const onSettled = (s: Settled): void => {
250
+ try {
251
+ enqueue(s.run, s.d, s.record);
252
+ } catch (e) {
253
+ log("warn", "notify", { run: s.run.id, name: s.d.admit.name, error: errorMessage(e) });
254
+ }
255
+ };
256
+
257
+ const n: Notifier = {
258
+ onSettled,
259
+ onStall,
260
+ async scan() {
261
+ if (!currentSession(deps)) return;
262
+ for (const run of listRuns().runs) {
263
+ try {
264
+ if (!owned(run)) continue;
265
+ const records = new Map(readRecords(run).records.map((r) => [r.dispatchId, r]));
266
+ for (const d of listDispatches(run)) {
267
+ const r = records.get(d.admit.dispatchId);
268
+ // a limit not yet failed over is reconcile's to settle first: its hook announces it then
269
+ if (r && !(r.status === "limit" && !readFailover(d.dir))) enqueue(run, d, r);
270
+ }
271
+ } catch (e) {
272
+ log("warn", "notify", { run: run.id, error: errorMessage(e) });
273
+ }
274
+ }
275
+ },
276
+ async idle() {
277
+ for (;;) {
278
+ if (timer === null && queue.size === 0) {
279
+ await flight;
280
+ if (timer === null && queue.size === 0) return;
281
+ } else await Bun.sleep(5);
282
+ }
283
+ },
284
+ stop() {
285
+ stopped = true;
286
+ settledHooks.delete(onSettled);
287
+ stallHooks.delete(onStall);
288
+ if (timer !== null) clearTimeout(timer);
289
+ timer = null;
290
+ queue.clear();
291
+ },
292
+ };
293
+ settledHooks.add(onSettled);
294
+ stallHooks.add(onStall);
295
+ return n;
296
+ }
@@ -0,0 +1,103 @@
1
+ import { assertId } from "../domain/ids.ts";
2
+ import { noticeHeader } from "../domain/notice.ts";
3
+ import { awaitsCollect } from "../infra/dispatch-dir.ts";
4
+ import { claim } from "./dispatch-service.ts";
5
+ import { type Dispatch, type DispatchState, listDispatches, liveDispatches } from "./dispatches.ts";
6
+ import { lastActivity } from "./finalize.ts";
7
+ import { finishedNotice } from "./notifier.ts";
8
+ import type { Deps } from "./ports.ts";
9
+ import { type Reentry, reentry } from "./reentry.ts";
10
+ import { findRun, listRuns, readAgentRuns, readRecords, type Run } from "./run-store.ts";
11
+ import { currentSession, runOwner } from "./sessions.ts";
12
+ import { readNotes } from "./state.ts";
13
+
14
+ export interface PeekRole {
15
+ name: string;
16
+ role: string;
17
+ rung: string;
18
+ state: DispatchState;
19
+ /** since admission */
20
+ secs: number;
21
+ /** the last command, file edit or message line, else the last event's name; null before any event */
22
+ lastEvent: string | null;
23
+ }
24
+
25
+ /** Spec 1.1 §8/§10: the open owner questions come first, then the run, its protocol step and the verifier's step. */
26
+ export interface PeekRun extends Reentry {
27
+ run: string;
28
+ title: string;
29
+ /** the session that owns the run now, null when none has */
30
+ owner: string | null;
31
+ live: PeekRole[];
32
+ /** each finished record not yet read, as the first line of its message */
33
+ unread: { name: string; dispatchId: string; header: string }[];
34
+ /** the latest native Claude run recorded with record_agent_run */
35
+ native: { name: string; role: string; rung: string; status: string; at: string } | null;
36
+ /** the run's next step (state.md's last line) */
37
+ next: string;
38
+ }
39
+
40
+ function peekRun(deps: Deps, run: Run, name: string | undefined): PeekRun {
41
+ const now = deps.now();
42
+ const mine = (d: Dispatch) => name === undefined || d.admit.name === name;
43
+ const records = new Map(readRecords(run).records.map((r) => [r.dispatchId, r]));
44
+ const agents = readAgentRuns(run).filter((a) => name === undefined || a.name === name);
45
+ const native = agents.at(-1);
46
+ const r = reentry(run, now);
47
+ return {
48
+ questions: r.questions,
49
+ run: run.id,
50
+ title: run.meta.title,
51
+ owner: runOwner(run)?.sessionId ?? null,
52
+ live: liveDispatches(run, now)
53
+ .filter(mine)
54
+ .map((d) => ({
55
+ name: d.admit.name,
56
+ role: d.admit.role,
57
+ rung: d.admit.rung,
58
+ state: d.state,
59
+ secs: Math.max(0, Math.round((now - Date.parse(d.admit.admittedAt)) / 1000)),
60
+ lastEvent: lastActivity(d),
61
+ })),
62
+ unread: listDispatches(run)
63
+ .filter(mine)
64
+ .flatMap((d) => {
65
+ const r = records.get(d.admit.dispatchId);
66
+ return r && awaitsCollect(d.dir)
67
+ ? [{ name: r.name, dispatchId: r.dispatchId, header: noticeHeader(finishedNotice(run, d, r)) }]
68
+ : [];
69
+ }),
70
+ native: native
71
+ ? { name: native.name, role: native.role, rung: native.rung, status: native.status, at: native.at }
72
+ : null,
73
+ next: readNotes(run).next,
74
+ protocol: r.protocol,
75
+ verifier: r.verifier,
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Spec §3.7 `peek(run?, name?)`: never waits, never marks a record read. With a run it makes this session the
81
+ * run's owner (spec §3.3) and watches the roles another session's server launched; without one it shows every run
82
+ * this session owns, else the newest run.
83
+ */
84
+ export async function peek(
85
+ deps: Deps,
86
+ i: { run?: string; name?: string },
87
+ ): Promise<{ runs: PeekRun[]; hints: string[] }> {
88
+ if (i.name !== undefined) assertId("role name", i.name);
89
+ let runs: Run[];
90
+ if (i.run) {
91
+ const run = findRun(i.run);
92
+ // ownership now; what the run's earlier owner left is taken care of after the call returns
93
+ await claim(deps, run, { background: true });
94
+ runs = [run];
95
+ } else {
96
+ const all = listRuns().runs;
97
+ const me = currentSession(deps);
98
+ const owned = me ? all.filter((r) => runOwner(r)?.sessionId === me.sessionId) : [];
99
+ runs = owned.length ? owned : all.slice(0, 1);
100
+ }
101
+ const hints = runs.length === 0 ? ["no runs yet: run_start(repo, title, a_lines) starts one"] : [];
102
+ return { runs: runs.map((r) => peekRun(deps, r, i.name)), hints };
103
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Budget } from "../domain/budget.ts";
2
+ import type { SessionEnv } from "../infra/claude-session.ts";
2
3
  import type { BillingMode } from "../domain/cost.ts";
3
4
  import type { Verdict } from "../domain/jev.ts";
4
5
  import type { Difficulty, Kind } from "../domain/lane.ts";
@@ -13,7 +14,9 @@ export interface ProfileView {
13
14
  name: string;
14
15
  objective: "cost" | "speed";
15
16
  /** `defaultRung`, when set, is where a lane starts without a kind and difficulty (spec §5.4) */
16
- roles: Partial<Record<Role, { enabled: boolean; access: Access; rungs: string[]; defaultRung?: string }>>;
17
+ roles: Partial<
18
+ Record<Role, { enabled: boolean; access: Access; rungs: string[]; defaultRung?: string; network?: false }>
19
+ >;
17
20
  /** per billing key (spec §7.1); a missing key bills as DEFAULT_BILLING */
18
21
  billing: Partial<Record<string, BillingMode>>;
19
22
  jev: { use: "auto" | "off" };
@@ -124,9 +127,9 @@ export interface Deps {
124
127
  profiles: ProfilePort;
125
128
  routing: RoutingPort;
126
129
  version: string;
127
- /** how often the supervisor and the dispatch wait poll, in ms */
130
+ /** how often the supervisor and the dispatch watchers poll, in ms */
128
131
  pollMs: number;
129
- /** how often a running dispatch reports progress, in ms (spec §4.4: 30 s) */
130
- tickMs: number;
132
+ /** the Claude Code session this process serves (spec §3.3), from its environment; null outside one */
133
+ session: SessionEnv | null;
131
134
  now: () => number;
132
135
  }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { parseLaneHeader } from "../domain/lane.ts";
3
+ import { CatherdError, errorMessage } from "../domain/errors.ts";
4
+ import { assertLaneHeader, LANE_HEADER_FIX, parseLaneHeader } from "../domain/lane.ts";
4
5
  import { checkEnv } from "../infra/env.ts";
5
6
  import { heavySlots, withHeavySlot } from "../infra/heavy-lock.ts";
6
7
  import { killGroup } from "../infra/proc.ts";
@@ -47,6 +48,8 @@ interface LaneCheck {
47
48
  check: string | null;
48
49
  owns: string[];
49
50
  problem: string | null;
51
+ /** spec 1.1 §6: why its Kind: or Difficulty: line is refused */
52
+ invalid: string | null;
50
53
  }
51
54
 
52
55
  function laneChecks(run: Run): LaneCheck[] {
@@ -57,16 +60,24 @@ function laneChecks(run: Run): LaneCheck[] {
57
60
  .sort()
58
61
  .map((f) => {
59
62
  const lane = f.slice(0, -".md".length);
63
+ let invalid: string | null = null;
60
64
  try {
61
- const h = parseLaneHeader(readFileSync(join(dir, f), "utf8"));
65
+ const text = readFileSync(join(dir, f), "utf8");
66
+ try {
67
+ assertLaneHeader(text, `lanes/${f}`);
68
+ } catch (e) {
69
+ invalid = errorMessage(e);
70
+ }
71
+ const h = parseLaneHeader(text);
62
72
  return {
63
73
  lane,
64
74
  check: h.fastCheck,
65
75
  owns: h.owns,
66
76
  problem: h.fastCheck ? null : `lanes/${f} has no Fast check: line`,
77
+ invalid,
67
78
  };
68
79
  } catch (e) {
69
- return { lane, check: null, owns: [], problem: (e as Error).message };
80
+ return { lane, check: null, owns: [], problem: (e as Error).message, invalid };
70
81
  }
71
82
  });
72
83
  }
@@ -159,6 +170,9 @@ export async function preflight(
159
170
  const run = findRun(i.run);
160
171
  const profile = deps.profiles.forRepo(run.meta.repo);
161
172
  const lanes = laneChecks(run);
173
+ // spec 1.1 §6: refuse before running anything, naming every lane whose header the catalog cannot route
174
+ const invalid = lanes.flatMap((l) => (l.invalid ? [l.invalid] : []));
175
+ if (invalid.length) throw new CatherdError("E_LANE_INVALID", invalid.join("; "), { fix: LANE_HEADER_FIX });
162
176
  const commands = lanes.map((l) => ({ lane: l.lane, check: l.check }));
163
177
  if (profile.preflight.confirm && !i.confirmed) return { needsConfirmation: true, commands };
164
178
  const timeoutMs = i.timeoutMs ?? CHECK_TIMEOUT_MS;