catherd-cli 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -42
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +2 -2
- package/plugin/bin/catherd-mcp +30 -0
- package/plugin/commands/catherd.md +1 -1
- package/plugin/skills/catherd/SKILL.md +112 -95
- package/plugin/skills/catherd-setup/SKILL.md +3 -3
- package/src/adapters/access.ts +63 -0
- package/src/adapters/backend.ts +19 -4
- package/src/adapters/claude-code/index.ts +95 -1
- package/src/adapters/codex/index.ts +78 -23
- package/src/adapters/opencode/index.ts +17 -0
- package/src/domain/errors.ts +2 -0
- package/src/domain/failover.ts +119 -0
- package/src/domain/lane.ts +23 -0
- package/src/domain/notice.ts +95 -0
- package/src/domain/profile-rules.ts +43 -9
- package/src/domain/profile.ts +12 -6
- package/src/domain/record.ts +2 -0
- package/src/domain/role-prompts.ts +33 -1
- package/src/domain/state.ts +18 -2
- package/src/entry/deps.ts +2 -1
- package/src/entry/doctor-command.ts +7 -1
- package/src/entry/glyphs.ts +2 -1
- package/src/entry/init-command.ts +41 -1
- package/src/entry/mcp/command.ts +5 -1
- package/src/entry/mcp/dispatch-tools.ts +10 -40
- package/src/entry/mcp/handshake.ts +19 -9
- package/src/entry/mcp/lane-tools.ts +7 -6
- package/src/entry/mcp/protocol-tools.ts +57 -0
- package/src/entry/mcp/run-tools.ts +4 -3
- package/src/entry/mcp/server.ts +17 -1
- package/src/entry/mcp/setup-tools.ts +1 -1
- package/src/entry/profile-command.ts +3 -5
- package/src/entry/runs-command.ts +64 -21
- package/src/entry/tui/commands.ts +1 -1
- package/src/entry/tui/effects.ts +98 -56
- package/src/entry/tui/fixtures.ts +352 -85
- package/src/entry/tui/profile-edits.ts +2 -2
- package/src/entry/tui/profile-tree.ts +2 -1
- package/src/entry/tui/providers/data.tsx +35 -8
- package/src/entry/tui/state.ts +26 -6
- package/src/entry/tui/stories.ts +36 -4
- package/src/entry/tui/text.ts +12 -0
- package/src/entry/tui/theme.ts +1 -0
- package/src/entry/tui/views/runs.tsx +409 -117
- package/src/entry/tui/views/status.tsx +5 -2
- package/src/entry/tui/widgets/list.tsx +4 -3
- package/src/infra/claude-session.ts +98 -0
- package/src/infra/dispatch-dir.ts +14 -6
- package/src/infra/env.ts +26 -2
- package/src/infra/peer-inbox.ts +92 -0
- package/src/infra/supervisor.ts +33 -3
- package/src/services/admission.ts +38 -12
- package/src/services/dispatch-service.ts +338 -197
- package/src/services/dispatches.ts +30 -1
- package/src/services/doctor-access.ts +231 -0
- package/src/services/doctor-backends.ts +3 -18
- package/src/services/doctor-checks.ts +46 -1
- package/src/services/doctor-push.ts +109 -0
- package/src/services/doctor.ts +59 -79
- package/src/services/finalize.ts +43 -25
- package/src/services/gate-service.ts +288 -0
- package/src/services/global-install.ts +99 -0
- package/src/services/lane-service.ts +150 -8
- package/src/services/milestones.ts +208 -0
- package/src/services/notifier.ts +296 -0
- package/src/services/peek.ts +103 -0
- package/src/services/ports.ts +7 -4
- package/src/services/preflight.ts +17 -3
- package/src/services/protocol.ts +212 -0
- package/src/services/questions.ts +102 -0
- package/src/services/reconcile.ts +40 -11
- package/src/services/reentry.ts +22 -0
- package/src/services/run-service.ts +42 -9
- package/src/services/run-store.ts +18 -0
- package/src/services/runs-page.ts +254 -0
- package/src/services/session-view.ts +152 -0
- package/src/services/sessions.ts +92 -0
- package/src/services/state.ts +10 -3
- package/src/services/summary.ts +14 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { budgetStatus } from "../domain/budget.ts";
|
|
4
|
+
import { ID_PATTERN } from "../domain/ids.ts";
|
|
5
|
+
import type { RunRecord } from "../domain/record.ts";
|
|
6
|
+
import { dispatchPaths } from "../infra/dispatch-dir.ts";
|
|
7
|
+
import { nonBlankLines } from "../infra/store.ts";
|
|
8
|
+
import { spendOf } from "./budget.ts";
|
|
9
|
+
import { dispatchState, listDispatches, liveDispatches } from "./dispatches.ts";
|
|
10
|
+
import { lastActivity } from "./finalize.ts";
|
|
11
|
+
import type { Deps } from "./ports.ts";
|
|
12
|
+
import { findRun, listRuns, readRecords, type Run, runPaths } from "./run-store.ts";
|
|
13
|
+
import { groupRuns, type SessionGroup } from "./session-view.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Spec §4: what the Runs tab shows. The top level is the sessions; a session's screen holds its runs, each with its
|
|
17
|
+
* milestones and every role (live ones first); a role's screen holds its brief, reply and record.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface SessionRow {
|
|
21
|
+
/** the session id; null for "earlier runs" */
|
|
22
|
+
key: string | null;
|
|
23
|
+
name: string;
|
|
24
|
+
live: boolean;
|
|
25
|
+
runs: number;
|
|
26
|
+
liveRoles: number;
|
|
27
|
+
landed: number;
|
|
28
|
+
/** ISO time of its newest activity */
|
|
29
|
+
lastActivity: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RoleRow {
|
|
33
|
+
run: string;
|
|
34
|
+
dispatchId: string;
|
|
35
|
+
name: string;
|
|
36
|
+
role: string;
|
|
37
|
+
rung: string;
|
|
38
|
+
/** running | starting while live; once finished, the record's status (`finished` until it has one) */
|
|
39
|
+
status: string;
|
|
40
|
+
live: boolean;
|
|
41
|
+
/** a live role's admission time: the page ticks its elapsed time from it */
|
|
42
|
+
since: string;
|
|
43
|
+
/** a finished role's seconds, from its record */
|
|
44
|
+
secs: number | null;
|
|
45
|
+
/** a live role's last command, file edit or message line */
|
|
46
|
+
lastEvent: string | null;
|
|
47
|
+
replyStatus: string | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface Milestone {
|
|
51
|
+
name: string;
|
|
52
|
+
landed: boolean;
|
|
53
|
+
/** the ledger's "what" once landed */
|
|
54
|
+
what: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SessionRun {
|
|
58
|
+
id: string;
|
|
59
|
+
title: string;
|
|
60
|
+
repo: string;
|
|
61
|
+
createdAt: string;
|
|
62
|
+
continued: "here" | "elsewhere" | null;
|
|
63
|
+
continuedIn: string | null;
|
|
64
|
+
/** the budget's spent fraction, null without a cap */
|
|
65
|
+
budget: number | null;
|
|
66
|
+
milestones: Milestone[];
|
|
67
|
+
roles: RoleRow[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SessionDetail {
|
|
71
|
+
session: SessionRow;
|
|
72
|
+
runs: SessionRun[];
|
|
73
|
+
/** the run folders to watch for changes */
|
|
74
|
+
dirs: string[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface RoleDetail {
|
|
78
|
+
run: string;
|
|
79
|
+
runTitle: string;
|
|
80
|
+
dispatchId: string;
|
|
81
|
+
name: string;
|
|
82
|
+
role: string;
|
|
83
|
+
rung: string;
|
|
84
|
+
/** running | starting | finished */
|
|
85
|
+
state: string;
|
|
86
|
+
brief: string;
|
|
87
|
+
reply: string;
|
|
88
|
+
record: RunRecord | null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface MilestoneDetail {
|
|
92
|
+
run: string;
|
|
93
|
+
runTitle: string;
|
|
94
|
+
name: string;
|
|
95
|
+
landed: boolean;
|
|
96
|
+
what: string;
|
|
97
|
+
/** R/digests/<name>.md; null when land has not written one (not landed, or landed by 1.0) or it is empty */
|
|
98
|
+
digest: string | null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const keyOf = (g: SessionGroup): string | null => g.session?.sessionId ?? null;
|
|
102
|
+
|
|
103
|
+
/** The runs that live on in a session now (it owns them): each run's live roles and landings count once. */
|
|
104
|
+
const ownRuns = (g: SessionGroup): Run[] => g.runs.filter((x) => x.current).map((x) => x.run);
|
|
105
|
+
|
|
106
|
+
function rowOf(deps: Deps, g: SessionGroup): SessionRow {
|
|
107
|
+
const own = ownRuns(g);
|
|
108
|
+
return {
|
|
109
|
+
key: keyOf(g),
|
|
110
|
+
name: g.session?.name ?? "earlier runs",
|
|
111
|
+
live: g.session?.live ?? false,
|
|
112
|
+
runs: g.runs.length,
|
|
113
|
+
liveRoles: own.reduce((n, r) => n + liveDispatches(r, deps.now()).length, 0),
|
|
114
|
+
landed: own.reduce((n, r) => n + Math.max(0, nonBlankLines(runPaths(r.dir).ledger).length - 1), 0),
|
|
115
|
+
lastActivity: new Date(g.lastActivity).toISOString(),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The Runs tab's top level: every session, newest activity first, "earlier runs" last. */
|
|
120
|
+
export function sessionRows(deps: Deps): { rows: SessionRow[]; warnings: string[] } {
|
|
121
|
+
const { runs, corrupt } = listRuns();
|
|
122
|
+
return {
|
|
123
|
+
rows: groupRuns(runs).map((g) => rowOf(deps, g)),
|
|
124
|
+
warnings: corrupt.map((c) => `skipped run ${c.id}: ${c.reason}`),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The ledger's landed milestones, then each milestone a lane file names that has not landed. */
|
|
129
|
+
function milestonesOf(run: Run): Milestone[] {
|
|
130
|
+
const landed = nonBlankLines(runPaths(run.dir).ledger)
|
|
131
|
+
.slice(1)
|
|
132
|
+
.map((l) => l.split(" | "))
|
|
133
|
+
.map(([name, what]) => ({ name: (name ?? "").trim(), landed: true, what: (what ?? "").trim() }));
|
|
134
|
+
const lanes = existsSync(runPaths(run.dir).lanes) ? readdirSync(runPaths(run.dir).lanes) : [];
|
|
135
|
+
const open = [...new Set(lanes.map((f) => /^(M\d+)\./.exec(f)?.[1]).filter((m): m is string => !!m))]
|
|
136
|
+
.filter((m) => !landed.some((l) => l.name === m))
|
|
137
|
+
.map((name) => ({ name, landed: false, what: "" }));
|
|
138
|
+
const n = (m: string) => Number(/\d+/.exec(m)?.[0] ?? 0);
|
|
139
|
+
return [...landed, ...open.sort((a, b) => n(a.name) - n(b.name))];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Each role's latest dispatch, live ones first, then the newest finished first. */
|
|
143
|
+
function rolesOf(deps: Deps, run: Run): RoleRow[] {
|
|
144
|
+
const now = deps.now();
|
|
145
|
+
const records = new Map(readRecords(run).records.map((r) => [r.dispatchId, r]));
|
|
146
|
+
const latest = new Map<string, ReturnType<typeof listDispatches>[number]>();
|
|
147
|
+
for (const d of listDispatches(run)) latest.set(d.admit.name, d);
|
|
148
|
+
const rows = [...latest.values()].map((d): RoleRow => {
|
|
149
|
+
const r = records.get(d.admit.dispatchId);
|
|
150
|
+
const state = r ? "finished" : dispatchState(d, now);
|
|
151
|
+
const live = state !== "finished";
|
|
152
|
+
return {
|
|
153
|
+
run: run.id,
|
|
154
|
+
dispatchId: d.admit.dispatchId,
|
|
155
|
+
name: d.admit.name,
|
|
156
|
+
role: d.admit.role,
|
|
157
|
+
rung: d.admit.rung,
|
|
158
|
+
status: r?.status ?? state,
|
|
159
|
+
live,
|
|
160
|
+
since: d.admit.admittedAt,
|
|
161
|
+
secs: r?.secs ?? null,
|
|
162
|
+
lastEvent: live ? lastActivity(d) : null,
|
|
163
|
+
replyStatus: r?.replyStatus ?? null,
|
|
164
|
+
};
|
|
165
|
+
});
|
|
166
|
+
return rows.sort((a, b) =>
|
|
167
|
+
a.live !== b.live ? (a.live ? -1 : 1) : b.dispatchId.localeCompare(a.dispatchId),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function sessionRun(deps: Deps, x: SessionGroup["runs"][number]): SessionRun {
|
|
172
|
+
const run = x.run;
|
|
173
|
+
let budget: number | null = null;
|
|
174
|
+
try {
|
|
175
|
+
const b = budgetStatus(
|
|
176
|
+
spendOf(run, readRecords(run).records, liveDispatches(run, deps.now()), deps.now()),
|
|
177
|
+
deps.profiles.forRepo(run.meta.repo).budget,
|
|
178
|
+
);
|
|
179
|
+
budget = b?.fraction ?? null;
|
|
180
|
+
} catch {
|
|
181
|
+
// an unreadable profile: no bar, the rest of the page stands
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
id: run.id,
|
|
185
|
+
title: run.meta.title,
|
|
186
|
+
repo: run.meta.repo,
|
|
187
|
+
createdAt: run.meta.createdAt,
|
|
188
|
+
continued: x.continued,
|
|
189
|
+
continuedIn: x.continuedIn,
|
|
190
|
+
budget,
|
|
191
|
+
milestones: milestonesOf(run),
|
|
192
|
+
roles: rolesOf(deps, run),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A session's screen: its runs, newest first, each with its milestones and roles. */
|
|
197
|
+
export function sessionDetail(deps: Deps, key: string | null): SessionDetail {
|
|
198
|
+
const g = groupRuns(listRuns().runs).find((x) => keyOf(x) === key);
|
|
199
|
+
if (!g) throw new Error(key === null ? "no earlier runs" : `no session "${key}" in the runs`);
|
|
200
|
+
return {
|
|
201
|
+
session: rowOf(deps, g),
|
|
202
|
+
runs: g.runs.map((x) => sessionRun(deps, x)),
|
|
203
|
+
dirs: g.runs.map((x) => x.run.dir),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const text = (file: string): string => {
|
|
208
|
+
try {
|
|
209
|
+
return readFileSync(file, "utf8");
|
|
210
|
+
} catch {
|
|
211
|
+
return "";
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/** A role's screen: its brief, its reply and its record. */
|
|
216
|
+
export function roleDetail(deps: Deps, runId: string, dispatchId: string): RoleDetail {
|
|
217
|
+
const run = findRun(runId);
|
|
218
|
+
const d = listDispatches(run).find((x) => x.admit.dispatchId === dispatchId);
|
|
219
|
+
if (!d) throw new Error(`no dispatch ${dispatchId} in run ${runId}`);
|
|
220
|
+
const record = readRecords(run).records.find((r) => r.dispatchId === dispatchId) ?? null;
|
|
221
|
+
const p = dispatchPaths(d.dir);
|
|
222
|
+
return {
|
|
223
|
+
run: run.id,
|
|
224
|
+
runTitle: run.meta.title,
|
|
225
|
+
dispatchId,
|
|
226
|
+
name: d.admit.name,
|
|
227
|
+
role: d.admit.role,
|
|
228
|
+
rung: d.admit.rung,
|
|
229
|
+
state: record ? "finished" : dispatchState(d, deps.now()),
|
|
230
|
+
brief: text(p.brief),
|
|
231
|
+
reply: text(p.reply),
|
|
232
|
+
record,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Spec 1.1 §10: a milestone's screen, its digest. */
|
|
237
|
+
export function milestoneDetail(runId: string, name: string): MilestoneDetail {
|
|
238
|
+
const run = findRun(runId);
|
|
239
|
+
// the id rule land writes digests under (assertId): never a path out of the run folder
|
|
240
|
+
const valid = ID_PATTERN.test(name) && !name.includes("..");
|
|
241
|
+
const m = valid ? milestonesOf(run).find((x) => x.name === name) : undefined;
|
|
242
|
+
if (!m) throw new Error(`no milestone "${name}" in run ${runId}`);
|
|
243
|
+
const file = join(run.dir, "digests", `${name}.md`);
|
|
244
|
+
const text = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
245
|
+
return {
|
|
246
|
+
run: run.id,
|
|
247
|
+
runTitle: run.meta.title,
|
|
248
|
+
name,
|
|
249
|
+
landed: m.landed,
|
|
250
|
+
what: m.what,
|
|
251
|
+
// an empty file is no digest either: land writes the whole digest at once
|
|
252
|
+
digest: text.trim() ? text : null,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { liveSessionFile, readSessionFiles, type SessionFile } from "../infra/claude-session.ts";
|
|
3
|
+
import { type Run, runPaths } from "./run-store.ts";
|
|
4
|
+
import { readSessionRows } from "./sessions.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Spec §4: runs grouped by the Claude Code session that drove them. A run belongs to the session that started it
|
|
8
|
+
* (`meta.startedBy`); a run listed in another session's rows of `sessions.jsonl` also appears under that session,
|
|
9
|
+
* as "continued here", and under its starting session it says "continued in <name>", naming the session that
|
|
10
|
+
* owns it now (the last row of `sessions.jsonl`; a run that came back to its starter is not "continued" at
|
|
11
|
+
* all). Only the current owner's group counts its live roles and landings. Runs from 1.0 (no
|
|
12
|
+
* `startedBy`) go under "earlier runs".
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** A session as the runs page shows it: its name read live while it runs, else the last one recorded. */
|
|
16
|
+
export interface RunSession {
|
|
17
|
+
sessionId: string;
|
|
18
|
+
hostSessionId: string | null;
|
|
19
|
+
name: string;
|
|
20
|
+
/** the session's process runs now */
|
|
21
|
+
live: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface GroupedRun {
|
|
25
|
+
run: Run;
|
|
26
|
+
/** "here": this session continued a run another started; "elsewhere": another session continued it */
|
|
27
|
+
continued: "here" | "elsewhere" | null;
|
|
28
|
+
/** the session that owns it now, for "elsewhere" */
|
|
29
|
+
continuedIn: string | null;
|
|
30
|
+
/** the run lives on in this session now (it is the run's current owner): its live roles count here */
|
|
31
|
+
current: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SessionGroup {
|
|
35
|
+
/** null: "earlier runs", the 1.0 runs no session is recorded for */
|
|
36
|
+
session: RunSession | null;
|
|
37
|
+
runs: GroupedRun[];
|
|
38
|
+
/** when anything last happened in one of its runs, ms since the epoch */
|
|
39
|
+
lastActivity: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Newest of the run's creation and its state and record files' last writes. */
|
|
43
|
+
export function runActivity(run: Run): number {
|
|
44
|
+
const p = runPaths(run.dir);
|
|
45
|
+
let t = Date.parse(run.meta.createdAt) || 0;
|
|
46
|
+
for (const f of [p.stateJson, p.runs, p.state, p.agents]) {
|
|
47
|
+
try {
|
|
48
|
+
t = Math.max(t, statSync(f).mtimeMs);
|
|
49
|
+
} catch {
|
|
50
|
+
// not written yet
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return t;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The sessions each run has had, oldest first: its starter, then each one that took it over. */
|
|
57
|
+
function trailOf(
|
|
58
|
+
run: Run,
|
|
59
|
+
): { sessionId: string; hostSessionId: string | null; name: string | null; at: string }[] {
|
|
60
|
+
const rows = readSessionRows(run);
|
|
61
|
+
const s = run.meta.startedBy;
|
|
62
|
+
if (s && !rows.some((r) => r.sessionId === s.sessionId)) return [{ ...s, at: run.meta.createdAt }, ...rows];
|
|
63
|
+
return rows;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Every run grouped by session, the groups newest activity first, the runs in each newest first. */
|
|
67
|
+
export function groupRuns(runs: Run[], files: SessionFile[] = readSessionFiles()): SessionGroup[] {
|
|
68
|
+
// the last name recorded for each session, in any run
|
|
69
|
+
const recorded = new Map<string, { name: string | null; host: string | null; at: string }>();
|
|
70
|
+
const trails = new Map(runs.map((r) => [r.id, trailOf(r)]));
|
|
71
|
+
for (const t of trails.values())
|
|
72
|
+
for (const row of t) {
|
|
73
|
+
const was = recorded.get(row.sessionId);
|
|
74
|
+
if (!was || row.at >= was.at)
|
|
75
|
+
recorded.set(row.sessionId, {
|
|
76
|
+
name: row.name ?? was?.name ?? null,
|
|
77
|
+
host: row.hostSessionId ?? was?.host ?? null,
|
|
78
|
+
at: row.at,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const sessionOf = (id: string): RunSession => {
|
|
82
|
+
const live = liveSessionFile(id, files);
|
|
83
|
+
const rec = recorded.get(id);
|
|
84
|
+
return {
|
|
85
|
+
sessionId: id,
|
|
86
|
+
hostSessionId: live?.hostSessionId ?? rec?.host ?? null,
|
|
87
|
+
name: live?.name ?? rec?.name ?? `session ${id.slice(0, 8)}`,
|
|
88
|
+
live: live !== null,
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
const groups = new Map<string | null, SessionGroup>();
|
|
92
|
+
const add = (id: string | null, g: GroupedRun, at: number) => {
|
|
93
|
+
let group = groups.get(id);
|
|
94
|
+
if (!group) {
|
|
95
|
+
group = { session: id === null ? null : sessionOf(id), runs: [], lastActivity: 0 };
|
|
96
|
+
groups.set(id, group);
|
|
97
|
+
}
|
|
98
|
+
group.runs.push(g);
|
|
99
|
+
group.lastActivity = Math.max(group.lastActivity, at);
|
|
100
|
+
};
|
|
101
|
+
for (const run of runs) {
|
|
102
|
+
const at = runActivity(run);
|
|
103
|
+
const trail = trails.get(run.id) ?? [];
|
|
104
|
+
const starter = run.meta.startedBy?.sessionId ?? null;
|
|
105
|
+
// the run lives on in its current owner: the last session that took it (a run can come back to its starter)
|
|
106
|
+
const owner = trail.at(-1)?.sessionId ?? starter;
|
|
107
|
+
const moved = owner !== starter && owner !== null;
|
|
108
|
+
// a run that lives on elsewhere counts, in a session it left, from when that session last took it
|
|
109
|
+
const tookAt = (id: string | null): number =>
|
|
110
|
+
Date.parse(trail.findLast((t) => t.sessionId === id)?.at ?? run.meta.createdAt) || 0;
|
|
111
|
+
add(
|
|
112
|
+
starter,
|
|
113
|
+
{
|
|
114
|
+
run,
|
|
115
|
+
continued: moved ? "elsewhere" : null,
|
|
116
|
+
continuedIn: moved ? sessionOf(owner).name : null,
|
|
117
|
+
current: !moved,
|
|
118
|
+
},
|
|
119
|
+
moved ? tookAt(starter) : at,
|
|
120
|
+
);
|
|
121
|
+
for (const id of new Set(trail.map((t) => t.sessionId)))
|
|
122
|
+
if (id !== starter)
|
|
123
|
+
add(
|
|
124
|
+
id,
|
|
125
|
+
{ run, continued: "here", continuedIn: null, current: id === owner },
|
|
126
|
+
id === owner ? at : tookAt(id),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
const out = [...groups.values()];
|
|
130
|
+
for (const g of out)
|
|
131
|
+
g.runs.sort(
|
|
132
|
+
(a, b) =>
|
|
133
|
+
runActivity(b.run) - runActivity(a.run) || b.run.meta.createdAt.localeCompare(a.run.meta.createdAt),
|
|
134
|
+
);
|
|
135
|
+
// "earlier runs" last, whatever its activity: 1.0 runs are history
|
|
136
|
+
return out.sort((a, b) =>
|
|
137
|
+
a.session === null ? 1 : b.session === null ? -1 : b.lastActivity - a.lastActivity,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The session a run started in, as the runs page names it now (null for a run from before 1.1), and the session
|
|
143
|
+
* that continued it, if another did.
|
|
144
|
+
*/
|
|
145
|
+
export function sessionFacts(
|
|
146
|
+
run: Run,
|
|
147
|
+
files: SessionFile[] = readSessionFiles(),
|
|
148
|
+
): { session: RunSession | null; continuedIn: string | null } {
|
|
149
|
+
const starter = run.meta.startedBy?.sessionId ?? null;
|
|
150
|
+
const own = groupRuns([run], files).find((g) => (g.session?.sessionId ?? null) === starter);
|
|
151
|
+
return { session: own?.session ?? null, continuedIn: own?.runs[0]?.continuedIn ?? null };
|
|
152
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { sessionFileFor } from "../infra/claude-session.ts";
|
|
3
|
+
import { withFileLock } from "../infra/filelock.ts";
|
|
4
|
+
import { appendJsonl, ensureJsonlHeader, readJsonl, writeJsonAtomic } from "../infra/store.ts";
|
|
5
|
+
import type { Deps } from "./ports.ts";
|
|
6
|
+
import { type Run, runPaths } from "./run-store.ts";
|
|
7
|
+
import { readNotes } from "./state.ts";
|
|
8
|
+
|
|
9
|
+
/** Spec §4: a Claude Code session as catherd records it. */
|
|
10
|
+
export interface SessionRef {
|
|
11
|
+
sessionId: string;
|
|
12
|
+
hostSessionId: string | null;
|
|
13
|
+
name: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The session this process serves (spec §3.3), read live: its registry file's id and name when it has one (the
|
|
18
|
+
* id in the environment goes stale after /clear), else the environment's. Null outside Claude Code.
|
|
19
|
+
*/
|
|
20
|
+
export function currentSession(deps: Deps): SessionRef | null {
|
|
21
|
+
const env = deps.session;
|
|
22
|
+
if (!env) return null;
|
|
23
|
+
const file = sessionFileFor(env);
|
|
24
|
+
const sessionId = file?.sessionId ?? env.sessionId;
|
|
25
|
+
if (!sessionId) return null;
|
|
26
|
+
return { sessionId, hostSessionId: file?.hostSessionId ?? env.hostSessionId, name: file?.name ?? null };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const OwnerSchema = z.object({ sessionId: z.string(), since: z.string() });
|
|
30
|
+
export type Owner = z.infer<typeof OwnerSchema>;
|
|
31
|
+
|
|
32
|
+
/** The run's owner session (state.json `owner`), or null for a run no session has owned (1.0, a terminal). */
|
|
33
|
+
export function runOwner(run: Run): Owner | null {
|
|
34
|
+
const r = OwnerSchema.safeParse((readNotes(run) as Record<string, unknown>).owner);
|
|
35
|
+
return r.success ? r.data : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Whether this process serves a session and that session owns the run now. */
|
|
39
|
+
export function ownsRun(deps: Deps, run: Run): boolean {
|
|
40
|
+
const me = currentSession(deps);
|
|
41
|
+
return me !== null && runOwner(run)?.sessionId === me.sessionId;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const SessionRowSchema = z.looseObject({
|
|
45
|
+
sessionId: z.string(),
|
|
46
|
+
hostSessionId: z.string().nullable(),
|
|
47
|
+
name: z.string().nullable(),
|
|
48
|
+
at: z.string(),
|
|
49
|
+
});
|
|
50
|
+
export type SessionRow = z.infer<typeof SessionRowSchema>;
|
|
51
|
+
|
|
52
|
+
/** `R/sessions.jsonl`: every session that has owned the run, in the order they took it. */
|
|
53
|
+
export function readSessionRows(run: Run): SessionRow[] {
|
|
54
|
+
return readJsonl<unknown>(runPaths(run.dir).sessions).rows.flatMap((row) => {
|
|
55
|
+
const r = SessionRowSchema.safeParse(row);
|
|
56
|
+
return r.success ? [r.data] : [];
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Spec §3.3: `run_start`, `dispatch` and `peek` make the calling session the run's owner when it is not, and
|
|
62
|
+
* append it to `R/sessions.jsonl`: that is how a run moves when it is continued from another session. Outside
|
|
63
|
+
* Claude Code it changes nothing. Returns whether the owner changed.
|
|
64
|
+
*/
|
|
65
|
+
export async function claimRun(deps: Deps, run: Run): Promise<boolean> {
|
|
66
|
+
const me = currentSession(deps);
|
|
67
|
+
if (!me) return false;
|
|
68
|
+
const p = runPaths(run.dir);
|
|
69
|
+
const at = new Date(deps.now()).toISOString();
|
|
70
|
+
const row = (since: string): void => {
|
|
71
|
+
ensureJsonlHeader(p.sessions, "sessions");
|
|
72
|
+
appendJsonl(p.sessions, {
|
|
73
|
+
sessionId: me.sessionId,
|
|
74
|
+
hostSessionId: me.hostSessionId,
|
|
75
|
+
name: me.name,
|
|
76
|
+
at: since,
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
// the trail row goes in under the same lock: the trail's last row is the owner (session-view reads it so)
|
|
80
|
+
return withFileLock(p.stateJson, () => {
|
|
81
|
+
const notes = readNotes(run);
|
|
82
|
+
const owner = runOwner(run);
|
|
83
|
+
if (owner?.sessionId === me.sessionId) {
|
|
84
|
+
// a crash between the state.json write and the append left the trail without its owner: repaired here
|
|
85
|
+
if (readSessionRows(run).at(-1)?.sessionId !== me.sessionId) row(owner.since);
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
writeJsonAtomic(p.stateJson, { ...notes, owner: { sessionId: me.sessionId, since: at } });
|
|
89
|
+
row(at);
|
|
90
|
+
return true;
|
|
91
|
+
});
|
|
92
|
+
}
|
package/src/services/state.ts
CHANGED
|
@@ -2,12 +2,13 @@ import { relative } from "node:path";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { errorMessage, isCatherdError } from "../domain/errors.ts";
|
|
4
4
|
import { overlaps } from "../domain/lane.ts";
|
|
5
|
-
import { renderState } from "../domain/state.ts";
|
|
5
|
+
import { renderState, withParked } from "../domain/state.ts";
|
|
6
6
|
import { dispatchPaths } from "../infra/dispatch-dir.ts";
|
|
7
7
|
import { withFileLock } from "../infra/filelock.ts";
|
|
8
8
|
import { gitHead, statusSnapshot } from "../infra/git.ts";
|
|
9
9
|
import { readVersioned, writeJsonAtomic, writeTextAtomic } from "../infra/store.ts";
|
|
10
10
|
import { liveDispatches } from "./dispatches.ts";
|
|
11
|
+
import { protocolNext } from "./protocol.ts";
|
|
11
12
|
import { type Run, runPaths } from "./run-store.ts";
|
|
12
13
|
|
|
13
14
|
/** state.json: the orchestrator's notes that state.md shows beside the live facts. */
|
|
@@ -16,10 +17,15 @@ const NotesSchema = z.looseObject({
|
|
|
16
17
|
next: z.string(),
|
|
17
18
|
lastCheck: z.string().nullable(),
|
|
18
19
|
lastLandedAt: z.string().nullable(),
|
|
20
|
+
/** spec 1.1 §8: the milestones waiting on the owner */
|
|
21
|
+
parked: z.array(z.string()).optional(),
|
|
19
22
|
});
|
|
20
23
|
export type Notes = z.infer<typeof NotesSchema>;
|
|
21
24
|
export type NotesPatch = Partial<Omit<Notes, "schema">>;
|
|
22
25
|
|
|
26
|
+
/** Every writer's next step keeps the parked milestones in front of it (spec 1.1 §8). */
|
|
27
|
+
const parkedNext = (n: Notes): Notes => ({ ...n, next: withParked(n.next, n.parked ?? []) });
|
|
28
|
+
|
|
23
29
|
const FRESH: Notes = { schema: 1, next: "plan the milestones", lastCheck: null, lastLandedAt: null };
|
|
24
30
|
|
|
25
31
|
export function readNotes(run: Run): Notes {
|
|
@@ -42,7 +48,7 @@ export function updateState(run: Run, change: NotesPatch | ((n: Notes) => NotesP
|
|
|
42
48
|
// git first: a failed snapshot throws before either file is written, so they never disagree
|
|
43
49
|
const [head, snap] = await Promise.all([gitHead(run.meta.repo), statusSnapshot(run.meta.repo)]);
|
|
44
50
|
const notes = readNotes(run);
|
|
45
|
-
const next
|
|
51
|
+
const next = parkedNext({ ...notes, ...(typeof change === "function" ? change(notes) : change) });
|
|
46
52
|
writeJsonAtomic(p.stateJson, next);
|
|
47
53
|
const live = liveDispatches(run);
|
|
48
54
|
const text = renderState({
|
|
@@ -63,6 +69,7 @@ export function updateState(run: Run, change: NotesPatch | ((n: Notes) => NotesP
|
|
|
63
69
|
})),
|
|
64
70
|
lastCheck: next.lastCheck,
|
|
65
71
|
next: next.next,
|
|
72
|
+
protocol: protocolNext(run, next.parked ?? []),
|
|
66
73
|
});
|
|
67
74
|
writeTextAtomic(p.state, text);
|
|
68
75
|
return text;
|
|
@@ -92,7 +99,7 @@ export async function refreshState(
|
|
|
92
99
|
try {
|
|
93
100
|
await withFileLock(stateJson, () => {
|
|
94
101
|
const notes = readNotes(run);
|
|
95
|
-
writeJsonAtomic(stateJson, { ...notes, ...apply(notes) });
|
|
102
|
+
writeJsonAtomic(stateJson, parkedNext({ ...notes, ...apply(notes) }));
|
|
96
103
|
});
|
|
97
104
|
} catch (e2) {
|
|
98
105
|
// the lock outwaited (a slow git under another refresh): still never fail the call
|
package/src/services/summary.ts
CHANGED
|
@@ -5,7 +5,10 @@ import type { Tokens } from "../domain/record.ts";
|
|
|
5
5
|
import { median } from "../domain/util.ts";
|
|
6
6
|
import { nonBlankLines, readJsonl } from "../infra/store.ts";
|
|
7
7
|
import { spendOf } from "./budget.ts";
|
|
8
|
+
import { latestVerifierStep, type VerifierStep } from "./gate-service.ts";
|
|
9
|
+
import { type OpenQuestion, openQuestions } from "./questions.ts";
|
|
8
10
|
import { type DispatchState, liveDispatches } from "./dispatches.ts";
|
|
11
|
+
import { type RunSession, sessionFacts } from "./session-view.ts";
|
|
9
12
|
import type { Deps } from "./ports.ts";
|
|
10
13
|
import {
|
|
11
14
|
findRun,
|
|
@@ -19,9 +22,15 @@ import {
|
|
|
19
22
|
|
|
20
23
|
export interface RunSummary {
|
|
21
24
|
id: string;
|
|
25
|
+
/** spec 1.1 §8: the owner questions not answered yet, listed first */
|
|
26
|
+
questions: OpenQuestion[];
|
|
22
27
|
title: string;
|
|
23
28
|
repo: string;
|
|
24
29
|
createdAt: string;
|
|
30
|
+
/** spec §4: the Claude Code session that started it (null before 1.1), named live while it runs */
|
|
31
|
+
session: RunSession | null;
|
|
32
|
+
/** the session that continued it, when another did */
|
|
33
|
+
continuedIn: string | null;
|
|
25
34
|
stateTail: string[];
|
|
26
35
|
live: { name: string; rung: string; state: DispatchState; secs: number; dispatchId: string }[];
|
|
27
36
|
totals: { runs: number; ok: number; notOk: string[]; tokens: Tokens; costUsd: number; wallMinutes: number };
|
|
@@ -32,6 +41,8 @@ export interface RunSummary {
|
|
|
32
41
|
harness: { backend: string; native: number; isolated: number }[];
|
|
33
42
|
budget: BudgetStatus | null;
|
|
34
43
|
milestones: string[];
|
|
44
|
+
/** spec 1.1 §7: the verifier's latest gate_check, so the user sees where it is */
|
|
45
|
+
verifier: VerifierStep | null;
|
|
35
46
|
warnings: string[];
|
|
36
47
|
}
|
|
37
48
|
|
|
@@ -52,9 +63,11 @@ export function summarizeRun(deps: Deps, run: Run): RunSummary {
|
|
|
52
63
|
}
|
|
53
64
|
return {
|
|
54
65
|
id: run.id,
|
|
66
|
+
questions: openQuestions(run),
|
|
55
67
|
title: run.meta.title,
|
|
56
68
|
repo: run.meta.repo,
|
|
57
69
|
createdAt: run.meta.createdAt,
|
|
70
|
+
...sessionFacts(run),
|
|
58
71
|
stateTail: nonBlankLines(runPaths(run.dir).state).slice(-3),
|
|
59
72
|
live: live.map((d) => ({
|
|
60
73
|
name: d.admit.name,
|
|
@@ -88,6 +101,7 @@ export function summarizeRun(deps: Deps, run: Run): RunSummary {
|
|
|
88
101
|
}),
|
|
89
102
|
budget,
|
|
90
103
|
milestones: nonBlankLines(runPaths(run.dir).ledger).slice(1),
|
|
104
|
+
verifier: latestVerifierStep(run),
|
|
91
105
|
warnings,
|
|
92
106
|
};
|
|
93
107
|
}
|