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.
Files changed (82) 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 +63 -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/state.ts +26 -6
  44. package/src/entry/tui/stories.ts +36 -4
  45. package/src/entry/tui/text.ts +12 -0
  46. package/src/entry/tui/theme.ts +1 -0
  47. package/src/entry/tui/views/runs.tsx +409 -117
  48. package/src/entry/tui/views/status.tsx +5 -2
  49. package/src/entry/tui/widgets/list.tsx +4 -3
  50. package/src/infra/claude-session.ts +98 -0
  51. package/src/infra/dispatch-dir.ts +14 -6
  52. package/src/infra/env.ts +26 -2
  53. package/src/infra/peer-inbox.ts +92 -0
  54. package/src/infra/supervisor.ts +33 -3
  55. package/src/services/admission.ts +38 -12
  56. package/src/services/dispatch-service.ts +338 -197
  57. package/src/services/dispatches.ts +30 -1
  58. package/src/services/doctor-access.ts +231 -0
  59. package/src/services/doctor-backends.ts +3 -18
  60. package/src/services/doctor-checks.ts +46 -1
  61. package/src/services/doctor-push.ts +109 -0
  62. package/src/services/doctor.ts +59 -79
  63. package/src/services/finalize.ts +43 -25
  64. package/src/services/gate-service.ts +288 -0
  65. package/src/services/global-install.ts +99 -0
  66. package/src/services/lane-service.ts +150 -8
  67. package/src/services/milestones.ts +208 -0
  68. package/src/services/notifier.ts +296 -0
  69. package/src/services/peek.ts +103 -0
  70. package/src/services/ports.ts +7 -4
  71. package/src/services/preflight.ts +17 -3
  72. package/src/services/protocol.ts +212 -0
  73. package/src/services/questions.ts +102 -0
  74. package/src/services/reconcile.ts +40 -11
  75. package/src/services/reentry.ts +22 -0
  76. package/src/services/run-service.ts +42 -9
  77. package/src/services/run-store.ts +18 -0
  78. package/src/services/runs-page.ts +254 -0
  79. package/src/services/session-view.ts +152 -0
  80. package/src/services/sessions.ts +92 -0
  81. package/src/services/state.ts +10 -3
  82. package/src/services/summary.ts +14 -0
@@ -0,0 +1,212 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { RunRecord } from "../domain/record.ts";
4
+ import type { RouteRow } from "../domain/route.ts";
5
+ import { ensurePrivateDir, readJsonl, writeTextAtomic } from "../infra/store.ts";
6
+ import { type Dispatch, listDispatches, liveDispatches } from "./dispatches.ts";
7
+ import type { VerifierStep } from "./gate-service.ts";
8
+ import {
9
+ landedMilestones,
10
+ milestoneReviewer,
11
+ milestoneVerifier,
12
+ milestoneStart,
13
+ namesMilestone,
14
+ reviewerPassed,
15
+ verifierPassed,
16
+ } from "./milestones.ts";
17
+ import { readAgentRuns, readRecords, readRoutes, type Run, runPaths } from "./run-store.ts";
18
+
19
+ // Spec 1.1 §10: the protocol's next step, derived from the run's own files so a session that lost its
20
+ // context (a compaction, a restart) re-enters the milestone loop where it stands; and each landed
21
+ // milestone's digest.
22
+
23
+ /** The milestone loop, six lines, as run_start and peek return it. */
24
+ export const PROTOCOL_CHECKLIST = [
25
+ "1. route and preflight the milestone's lanes (dispatch routes a lane not routed yet)",
26
+ "2. dispatch every independent lane one after another, then end your turn: results arrive as catherd messages",
27
+ "3. per result: result(run, name), its STATUS and changedOwned, then its fast check once",
28
+ "4. reviewer-<M> once over the milestone diff, then one fix round",
29
+ "5. the verifier in the foreground, with gate_check and gate_pass, recorded as record_agent_run(name: verifier-<M>)",
30
+ "6. commit, then land(run, <M>, …); an owner question parks the milestone (park) and the run goes on",
31
+ ];
32
+
33
+ const milestoneOf = (lane: string): string => lane.split(".")[0] as string;
34
+ const byNumber = (a: string, b: string) => a.localeCompare(b, "en", { numeric: true });
35
+
36
+ /** The run's lanes, from lanes/*.md, in milestone and lane order. */
37
+ function laneIds(run: Run): string[] {
38
+ const dir = runPaths(run.dir).lanes;
39
+ if (!existsSync(dir)) return [];
40
+ return readdirSync(dir)
41
+ .filter((f) => f.endsWith(".md"))
42
+ .map((f) => f.slice(0, -".md".length))
43
+ .filter((l) => l.includes("."))
44
+ .sort(byNumber);
45
+ }
46
+
47
+ /**
48
+ * Whether `lane` needs no dispatch now: its latest dispatch or native agent run since its latest climb is
49
+ * still running, or ended ok with no blocked or refused reply. A lane whose last try failed, hit a limit,
50
+ * was blocked, or was climbed since goes back to dispatch; a plain re-route leaves an ok try standing.
51
+ */
52
+ function laneDone(run: Run, lane: string, routes: RouteRow[], live: Dispatch[]): boolean {
53
+ const from = Math.max(
54
+ ...routes.filter((r) => r.lane === lane && r.source === "climb").map((r) => Date.parse(r.at)),
55
+ );
56
+ // a dispatch routes its lane first, so a try at the route's own time counts as after it
57
+ const after = (t: string) => !(Date.parse(t) < from);
58
+ const records = new Map(readRecords(run).records.map((r) => [r.dispatchId, r]));
59
+ const running = new Set(live.map((d) => d.admit.dispatchId));
60
+ const tries = [
61
+ ...listDispatches(run)
62
+ .filter((d) => d.admit.lane === lane && after(d.admit.admittedAt))
63
+ .map((d) => {
64
+ const r = records.get(d.admit.dispatchId);
65
+ const ok = r
66
+ ? r.status === "ok" && r.replyStatus !== "blocked" && r.replyStatus !== "refused"
67
+ : running.has(d.admit.dispatchId);
68
+ return { at: Date.parse(d.admit.admittedAt), ok };
69
+ }),
70
+ ...readAgentRuns(run)
71
+ .filter((a) => a.lane === lane && after(a.at))
72
+ .map((a) => ({ at: Date.parse(a.at), ok: a.status === "ok" })),
73
+ ];
74
+ const latest = tries.reduce<(typeof tries)[number] | null>(
75
+ (best, t) => (best === null || t.at >= best.at ? t : best),
76
+ null,
77
+ );
78
+ return latest?.ok ?? false;
79
+ }
80
+
81
+ /**
82
+ * The step the milestone loop is at: the first milestone neither landed nor parked, and within it the
83
+ * first of route, dispatch, collect, reviewer, verifier and land that is still to do.
84
+ */
85
+ export function protocolNext(run: Run, parked: string[], now = Date.now()): string {
86
+ const lanes = laneIds(run);
87
+ if (lanes.length === 0)
88
+ return "plan: the architect writes plan.md and lanes/Mx.Ly.md (or you, for a fix run)";
89
+ const landed = new Set(landedMilestones(run));
90
+ const milestones = [...new Set(lanes.map(milestoneOf))].sort(byNumber);
91
+ const m = milestones.find((x) => !landed.has(x) && !parked.includes(x));
92
+ if (!m)
93
+ return parked.length
94
+ ? `${parked.join(", ")} parked: wait for the owner`
95
+ : "finish: the final gate, then the report";
96
+ const mine = lanes.filter((l) => milestoneOf(l) === m);
97
+ const routes = readRoutes(run);
98
+ const routed = new Set(routes.map((r) => r.lane));
99
+ if (mine.some((l) => !routed.has(l))) return `route and preflight ${m}'s lanes`;
100
+ const live = liveDispatches(run, now);
101
+ const notDone = mine.filter((l) => !laneDone(run, l, routes, live));
102
+ // spec 1.1 §9: a climb past the top rung (from === rung) leaves nothing to climb to; that is a decision
103
+ const spent = notDone.filter((l) => {
104
+ const last = routes.findLast((r) => r.lane === l);
105
+ return last?.source === "climb" && last.from === last.rung;
106
+ });
107
+ const waiting = notDone.filter((l) => !spent.includes(l));
108
+ if (waiting.length) return `dispatch ${waiting.join(", ")}`;
109
+ if (spent.length) return `${spent.join(", ")}: out of rungs — ask finding, then the architect or park ${m}`;
110
+ const running = live.filter((d) => d.admit.lane !== null && mine.includes(d.admit.lane));
111
+ if (running.length) return `${m}: lanes running (${running.map((d) => d.admit.name).join(", ")})`;
112
+ const start = milestoneStart(run, m);
113
+ if (!reviewerPassed(run, m, start)) return `${m}: reviewer`;
114
+ if (!verifierPassed(run, m, start)) return `${m}: verifier`;
115
+ return `land ${m}`;
116
+ }
117
+
118
+ /** What run_start and peek return beside the step: the six-line loop. */
119
+ export const protocolView = (run: Run, parked: string[], now?: number) => ({
120
+ next: protocolNext(run, parked, now),
121
+ checklist: PROTOCOL_CHECKLIST,
122
+ });
123
+
124
+ const FINDING = /^\s*(?:[-*]\s*)?(BLOCKER|BUG|NIT)\b/;
125
+
126
+ function findingCounts(run: Run, r: RunRecord | undefined): string {
127
+ if (!r?.replyPath) return "no reply";
128
+ const file = join(run.dir, r.replyPath);
129
+ const text = existsSync(file) ? readFileSync(file, "utf8") : "";
130
+ const n = { BLOCKER: 0, BUG: 0, NIT: 0 };
131
+ for (const line of text.split("\n")) {
132
+ const k = FINDING.exec(line)?.[1] as keyof typeof n | undefined;
133
+ if (k) n[k]++;
134
+ }
135
+ const total = n.BLOCKER + n.BUG + n.NIT;
136
+ return `${total} finding(s): ${n.BLOCKER} BLOCKER, ${n.BUG} BUG, ${n.NIT} NIT`;
137
+ }
138
+
139
+ const k = (x: number) => (x >= 1000 ? `${Math.round(x / 1000)}k` : String(x));
140
+
141
+ /**
142
+ * Spec 1.1 §10: `R/digests/<m>.md`, written by `land`: the A-lines it names, the commit, the lanes with
143
+ * their rungs and climbs, the reviewer's findings, the verifier's verdict with its carried items, minutes
144
+ * and tokens. Returns its path relative to the run folder.
145
+ */
146
+ export function writeDigest(
147
+ run: Run,
148
+ i: { milestone: string; what: string; commit: string; evidence: string; minutes: number; at: string },
149
+ ): string {
150
+ const m = i.milestone;
151
+ const start = milestoneStart(run, m);
152
+ const inM = (lane: string | null | undefined) => typeof lane === "string" && lane.startsWith(`${m}.`);
153
+ const named = new Set([...`${i.what} ${i.evidence}`.matchAll(/\bA(\d+)\b/g)].map((x) => `A${x[1]}`));
154
+ const aLines = run.meta.aLines.filter((a) => named.has(/^\s*(A\d+)/.exec(a)?.[1] ?? ""));
155
+ const routes = readRoutes(run).filter((r) => inM(r.lane));
156
+ const lanes = [...new Set(routes.map((r) => r.lane))].sort(byNumber).map((lane) => {
157
+ const rows = routes.filter((r) => r.lane === lane);
158
+ const climbs = rows.filter((r) => r.source === "climb" && r.from !== r.rung);
159
+ const first = rows[0]?.rung ?? "?";
160
+ const last = rows.at(-1)?.rung ?? first;
161
+ return `- ${lane} · ${first}${last !== first ? ` → ${last}` : ""}${climbs.length ? ` · climbs: ${climbs.map((c) => c.reason ?? "").join("; ")}` : ""}`;
162
+ });
163
+ const records = readRecords(run).records;
164
+ // the reviewer the gate counted: since the milestone's lanes started, a dispatch or a native subagent
165
+ const reviewer = milestoneReviewer(run, m, start);
166
+ const agents = readAgentRuns(run);
167
+ // the verdict the gate counted: the latest attempt, native or headless, a FAIL shown as such
168
+ const verdict = milestoneVerifier(run, m, start);
169
+ // the milestone's carried items: once any step names its milestone (gate_check's milestone), only the steps
170
+ // naming this one; before that, every step since the milestone's lanes started
171
+ const allSteps = readJsonl<VerifierStep>(join(run.dir, "verifier.jsonl")).rows.filter(
172
+ (s) => typeof s?.item === "string",
173
+ );
174
+ const byMilestone = allSteps.some((s) => typeof s.milestone === "string");
175
+ const steps = allSteps.filter(
176
+ (s) =>
177
+ s.carried &&
178
+ (start === null || Date.parse(s.at) >= Date.parse(start)) &&
179
+ (!byMilestone || s.milestone === m),
180
+ );
181
+ const mineRecords = records.filter((r) => inM(r.lane) || namesMilestone(r.name, m));
182
+ const tokens = mineRecords.reduce(
183
+ (t, r) => ({
184
+ input: t.input + r.tokens.input,
185
+ cached: t.cached + r.tokens.cached,
186
+ output: t.output + r.tokens.output,
187
+ }),
188
+ { input: 0, cached: 0, output: 0 },
189
+ );
190
+ const reported = agents
191
+ .filter((a) => inM(a.lane) || namesMilestone(a.name, m))
192
+ .reduce((n, a) => n + a.totalTokens, 0);
193
+ const text = [
194
+ `# ${m} — ${i.what}`,
195
+ "",
196
+ `Commit ${i.commit} · ${i.minutes} min · landed ${i.at}`,
197
+ `A-lines: ${aLines.length ? aLines.join("; ") : "none named in what or evidence"}`,
198
+ "",
199
+ "Lanes:",
200
+ ...(lanes.length ? lanes : ["- none routed"]),
201
+ "",
202
+ `Reviewer: ${reviewer ? `${reviewer.name} · ${reviewer.record ? findingCounts(run, reviewer.record) : "a Claude subagent (findings in its reply)"}` : "none"}`,
203
+ `Verifier: ${verdict ? `${verdict.passed ? "PASS" : `FAIL: ${verdict.verdict}`} (${verdict.name}${verdict.headless ? ", headless" : ""})` : "none"}${steps.length ? ` · carried: ${steps.map((s) => `${s.item}${s.commit ? ` from ${s.commit}` : ""}`).join(", ")}` : ""}`,
204
+ `Evidence: ${i.evidence}`,
205
+ `Tokens: ${k(tokens.input)} in (${k(tokens.cached)} cached) · ${k(tokens.output)} out · Claude subagents ${k(reported)} (reported)`,
206
+ "",
207
+ ].join("\n");
208
+ const dir = join(run.dir, "digests");
209
+ ensurePrivateDir(dir);
210
+ writeTextAtomic(join(dir, `${m}.md`), text);
211
+ return `digests/${m}.md`;
212
+ }
@@ -0,0 +1,102 @@
1
+ import { join } from "node:path";
2
+ import { CatherdError } from "../domain/errors.ts";
3
+ import { assertId } from "../domain/ids.ts";
4
+ import { appendJsonl, ensureJsonlHeader, readJsonl } from "../infra/store.ts";
5
+ import type { Deps } from "./ports.ts";
6
+ import { findRun, type Run } from "./run-store.ts";
7
+ import { type Notes, readNotes, refreshState } from "./state.ts";
8
+
9
+ // Spec 1.1 §8: an owner question parks its milestone instead of stopping the run.
10
+
11
+ interface QuestionRow {
12
+ at: string;
13
+ milestone: string;
14
+ kind: "question" | "answer";
15
+ text: string;
16
+ }
17
+
18
+ export interface OpenQuestion {
19
+ milestone: string;
20
+ question: string;
21
+ at: string;
22
+ }
23
+
24
+ const questionsFile = (run: Run): string => join(run.dir, "questions.jsonl");
25
+
26
+ const rows = (run: Run): QuestionRow[] =>
27
+ readJsonl<QuestionRow>(questionsFile(run)).rows.filter(
28
+ (r) => typeof r?.milestone === "string" && typeof r.text === "string",
29
+ );
30
+
31
+ /** The questions not answered yet, oldest first; a milestone answered after its question is no longer open. */
32
+ export function openQuestions(run: Run): OpenQuestion[] {
33
+ const open = new Map<string, OpenQuestion>();
34
+ for (const r of rows(run)) {
35
+ if (r.kind === "question") open.set(r.milestone, { milestone: r.milestone, question: r.text, at: r.at });
36
+ else open.delete(r.milestone);
37
+ }
38
+ return [...open.values()];
39
+ }
40
+
41
+ function append(run: Run, row: QuestionRow): void {
42
+ const file = questionsFile(run);
43
+ ensureJsonlHeader(file, "questions");
44
+ appendJsonl(file, row);
45
+ }
46
+
47
+ /** `park`: the milestone waits on the owner; the rest of the run goes on. */
48
+ export async function park(
49
+ deps: Deps,
50
+ i: { run: string; milestone: string; question: string },
51
+ ): Promise<{ parked: string[]; hints: string[] }> {
52
+ const run = findRun(i.run);
53
+ assertId("milestone", i.milestone);
54
+ append(run, {
55
+ at: new Date(deps.now()).toISOString(),
56
+ milestone: i.milestone,
57
+ kind: "question",
58
+ text: i.question,
59
+ });
60
+ let parked: string[] = [];
61
+ const { hints } = await refreshState(run, (n: Notes) => {
62
+ parked = [...new Set([...(n.parked ?? []), i.milestone])];
63
+ return { parked };
64
+ });
65
+ return {
66
+ parked,
67
+ hints: [
68
+ `push the full question to the owner now (PushNotification): ${i.milestone}: ${i.question}`,
69
+ `continue with the milestones and runs that do not depend on ${i.milestone}; when the owner answers, call answer(run, "${i.milestone}", <their answer>)`,
70
+ ...hints,
71
+ ],
72
+ };
73
+ }
74
+
75
+ /** `answer`: records the owner's answer and unparks the milestone. */
76
+ export async function answer(
77
+ deps: Deps,
78
+ i: { run: string; milestone: string; answer: string },
79
+ ): Promise<{ milestone: string; parked: string[]; hints?: string[] }> {
80
+ const run = findRun(i.run);
81
+ assertId("milestone", i.milestone);
82
+ const open = openQuestions(run).some((q) => q.milestone === i.milestone);
83
+ // answered already, but the state save that unparks it failed: this call only unparks
84
+ const stillParked = !open && (readNotes(run).parked ?? []).includes(i.milestone);
85
+ if (!open && !stillParked)
86
+ throw new CatherdError("E_INPUT_INVALID", `${i.milestone} has no open question`, {
87
+ fix: "status(run) lists the open questions; park(run, milestone, question) opens one",
88
+ });
89
+ if (open)
90
+ append(run, {
91
+ at: new Date(deps.now()).toISOString(),
92
+ milestone: i.milestone,
93
+ kind: "answer",
94
+ text: i.answer,
95
+ });
96
+ let parked: string[] = [];
97
+ const { hints } = await refreshState(run, (n: Notes) => {
98
+ parked = (n.parked ?? []).filter((m) => m !== i.milestone);
99
+ return { parked, next: `${i.milestone}: the owner answered; continue it` };
100
+ });
101
+ return { milestone: i.milestone, parked, ...(hints.length ? { hints } : {}) };
102
+ }
@@ -3,11 +3,12 @@ import { errorMessage } from "../domain/errors.ts";
3
3
  import { dispatchPaths } from "../infra/dispatch-dir.ts";
4
4
  import { log } from "../infra/log.ts";
5
5
  import { writeJsonAtomic } from "../infra/store.ts";
6
+ import { settle, stallPoll, unsettledLimits, watching } from "./dispatch-service.ts";
6
7
  import { type Dispatch, listDispatches, pendingDispatches } from "./dispatches.ts";
7
8
  import { finalizeDispatch, waitForFinish } from "./finalize.ts";
8
9
  import type { Deps } from "./ports.ts";
9
10
  import { listRuns, type Run } from "./run-store.ts";
10
- import { refreshState } from "./state.ts";
11
+ import { ownsRun } from "./sessions.ts";
11
12
 
12
13
  /**
13
14
  * Builds before the spec.json fix (plan-2 review I1) wrote every env value of the MCP server into
@@ -39,19 +40,31 @@ export interface ReconcileReport {
39
40
  }
40
41
 
41
42
  /**
42
- * Waits for a live dispatch this process did not start, then finalizes it. A state.md refresh that
43
- * fails rejects, after the record is written, with a message that says so.
43
+ * Waits for a live dispatch this process did not start, then finalizes and settles it (plan 10). A state.md
44
+ * refresh that fails rejects, after the record is written, with a message that says so.
44
45
  */
45
46
  async function watchAndFinalize(deps: Deps, run: Run, d: Dispatch): Promise<void> {
46
- await waitForFinish(d, { pollMs: deps.pollMs, tickMs: Number.POSITIVE_INFINITY, now: deps.now });
47
- await finalizeDispatch(run, d);
48
- const { hints } = await refreshState(run);
49
- if (hints[0]) throw new Error(hints[0]);
47
+ watching.add(d.admit.dispatchId);
48
+ try {
49
+ await waitForFinish(d, { pollMs: deps.pollMs, now: deps.now, onPoll: stallPoll(run, d) });
50
+ const record = await finalizeDispatch(run, d);
51
+ // a limit of a run this session does not own waits, unread, for the session that claims the run
52
+ if (record.status === "limit" && !ownsRun(deps, run)) return;
53
+ const s = await settle(deps, run, d, record);
54
+ if (s.stateHints[0]) throw new Error(s.stateHints[0]);
55
+ } finally {
56
+ watching.delete(d.admit.dispatchId);
57
+ }
50
58
  }
51
59
 
52
60
  /**
53
- * Spec §4.7: on server start, finalize each finished dispatch that has no record, and watch each live
54
- * one until it finishes. A run that cannot be read is skipped with a warning; it never stops the server.
61
+ * Spec §4.7: on server start, finalize and settle each finished dispatch that has no record, settle each
62
+ * unread usage limit that was never failed over (its server died between the two), and watch each live one
63
+ * until it finishes. A run that cannot be read is skipped with a warning; it never stops the server.
64
+ *
65
+ * Plan 10 fix-round ruling: a usage limit fails over here only in a run this session owns. A limit of a run
66
+ * another session owns, or no session does (a 1.0 run, a run started outside Claude Code), is only recorded:
67
+ * the session that claims the run settles it then (`claim`), so no stale run starts a stand-in on its own.
55
68
  */
56
69
  export async function reconcileAll(deps: Deps): Promise<ReconcileReport> {
57
70
  const { runs, corrupt } = listRuns();
@@ -75,20 +88,36 @@ export async function reconcileAll(deps: Deps): Promise<ReconcileReport> {
75
88
  warn(run, e);
76
89
  continue;
77
90
  }
91
+ const mine = ownsRun(deps, run);
78
92
  for (const d of pending) {
79
93
  if (d.state !== "finished") {
80
94
  report.watching.push(d.admit.dispatchId);
81
95
  watchers.push(watchAndFinalize(deps, run, d).catch((e: unknown) => warn(run, e)));
82
96
  continue;
83
97
  }
98
+ let hints: string[] = [];
84
99
  try {
85
- await finalizeDispatch(run, d);
100
+ const record = await finalizeDispatch(run, d);
101
+ if (record.status !== "limit" || mine) hints = (await settle(deps, run, d, record)).stateHints;
86
102
  } catch (e) {
87
103
  warn(run, e);
88
104
  continue;
89
105
  }
90
106
  report.finalized.push(d.admit.dispatchId);
91
- for (const h of (await refreshState(run)).hints) warn(run, h);
107
+ for (const h of hints) warn(run, h);
108
+ }
109
+ let limits: ReturnType<typeof unsettledLimits> = [];
110
+ try {
111
+ if (mine) limits = unsettledLimits(run);
112
+ } catch (e) {
113
+ warn(run, e);
114
+ }
115
+ for (const { d, record } of limits) {
116
+ try {
117
+ for (const h of (await settle(deps, run, d, record)).stateHints) warn(run, h);
118
+ } catch (e) {
119
+ warn(run, e);
120
+ }
92
121
  }
93
122
  }
94
123
  log("info", "reconcile", {
@@ -0,0 +1,22 @@
1
+ import { latestVerifierStep, type VerifierStep } from "./gate-service.ts";
2
+ import { protocolView } from "./protocol.ts";
3
+ import { type OpenQuestion, openQuestions } from "./questions.ts";
4
+ import type { Run } from "./run-store.ts";
5
+ import { readNotes } from "./state.ts";
6
+
7
+ // Spec 1.1 §8, §7 and §10: what a session re-entering a run needs first, as peek returns it: the owner questions still open, the protocol's next step with its checklist,
8
+ // and the verifier's latest step.
9
+
10
+ export interface Reentry {
11
+ questions: OpenQuestion[];
12
+ protocol: { next: string; checklist: string[] };
13
+ verifier: VerifierStep | null;
14
+ }
15
+
16
+ export function reentry(run: Run, now?: number): Reentry {
17
+ return {
18
+ questions: openQuestions(run),
19
+ protocol: protocolView(run, readNotes(run).parked ?? [], now),
20
+ verifier: latestVerifierStep(run),
21
+ };
22
+ }
@@ -4,10 +4,16 @@ import { CatherdError } from "../domain/errors.ts";
4
4
  import { assertId, parseRung } from "../domain/ids.ts";
5
5
  import type { RunRecord } from "../domain/record.ts";
6
6
  import type { Role } from "../domain/roles.ts";
7
- import { dispatchPaths } from "../infra/dispatch-dir.ts";
7
+ import { awaitsCollect, dispatchPaths, endCollect, tryCollect } from "../infra/dispatch-dir.ts";
8
8
  import { gitToplevel } from "../infra/git.ts";
9
9
  import { writeTextAtomic } from "../infra/store.ts";
10
- import { dispatchState, type DispatchState, latestDispatch } from "./dispatches.ts";
10
+ import {
11
+ dispatchState,
12
+ type DispatchState,
13
+ latestDispatch,
14
+ listDispatches,
15
+ recordHints,
16
+ } from "./dispatches.ts";
11
17
  import type { Deps } from "./ports.ts";
12
18
  import {
13
19
  type AgentRun,
@@ -18,12 +24,19 @@ import {
18
24
  readRecords,
19
25
  runFile,
20
26
  } from "./run-store.ts";
27
+ import { protocolView } from "./protocol.ts";
28
+ import { claimRun, currentSession } from "./sessions.ts";
21
29
  import { refreshState } from "./state.ts";
22
30
 
23
31
  export async function startRun(
24
32
  deps: Deps,
25
33
  i: { repo: string; title: string; aLines: string[] },
26
- ): Promise<{ run: string; dir: string; hints?: string[] }> {
34
+ ): Promise<{
35
+ run: string;
36
+ dir: string;
37
+ protocol: { next: string; checklist: string[] };
38
+ hints?: string[];
39
+ }> {
27
40
  const top = await gitToplevel(i.repo);
28
41
  if (!top)
29
42
  throw new CatherdError("E_IO_PATH", `${i.repo} is not inside a git repository`, {
@@ -35,10 +48,13 @@ export async function startRun(
35
48
  aLines: i.aLines,
36
49
  version: deps.version,
37
50
  now: new Date(deps.now()),
51
+ startedBy: currentSession(deps),
38
52
  });
53
+ await claimRun(deps, run);
39
54
  // a failed state.md refresh never fails the start: the run exists and is usable, so a retry would orphan it
40
55
  const { hints } = await refreshState(run);
41
- return { run: run.id, dir: run.dir, ...(hints.length ? { hints } : {}) };
56
+ // spec 1.1 §10: the milestone loop, so the orchestrator starts on the protocol
57
+ return { run: run.id, dir: run.dir, protocol: protocolView(run, []), ...(hints.length ? { hints } : {}) };
42
58
  }
43
59
 
44
60
  export function writeRunFile(i: { run: string; path: string; content: string }): {
@@ -81,17 +97,23 @@ function capReply(reply: string, path: string): string {
81
97
  return `${head.slice(0, CAP_CHARS)}\n[capped: the full reply is ${path}]`;
82
98
  }
83
99
 
84
- /** A role's latest dispatch: its record once finished, and its capped reply. Reads only. */
85
- export function result(
100
+ /**
101
+ * A role's latest dispatch: its record once finished, its capped reply and its hints. Spec §3.7: reading a
102
+ * finished record marks it read, with the collect lease (each record is marked read once, whoever reads it);
103
+ * an earlier record of the same name still unread (a usage limit its stand-in replaced) is marked read with it,
104
+ * and its hints come first.
105
+ */
106
+ export async function result(
86
107
  deps: Deps,
87
108
  i: { run: string; name: string },
88
- ): {
109
+ ): Promise<{
89
110
  name: string;
90
111
  state: DispatchState | null;
91
112
  record: RunRecord | null;
92
113
  reply: string;
93
114
  replyPath: string | null;
94
- } {
115
+ hints: string[];
116
+ }> {
95
117
  const run = findRun(i.run);
96
118
  assertId("role name", i.name);
97
119
  const d = latestDispatch(run, i.name);
@@ -99,7 +121,17 @@ export function result(
99
121
  throw new CatherdError("E_RUN_NOT_FOUND", `${i.name} was never dispatched in this run`, {
100
122
  fix: "status(run) lists the roles",
101
123
  });
102
- const record = readRecords(run).records.find((r) => r.dispatchId === d.admit.dispatchId) ?? null;
124
+ const records = new Map(readRecords(run).records.map((r) => [r.dispatchId, r]));
125
+ const record = records.get(d.admit.dispatchId) ?? null;
126
+ const hints: string[] = [];
127
+ for (const x of listDispatches(run).filter((x) => x.admit.name === i.name)) {
128
+ const r = records.get(x.admit.dispatchId);
129
+ if (!r || !awaitsCollect(x.dir) || !(await tryCollect(x.dir))) continue;
130
+ // read at once: the lease ends as the record goes out
131
+ endCollect(x.dir);
132
+ if (x.admit.dispatchId !== d.admit.dispatchId) hints.push(...recordHints(run, x, r));
133
+ }
134
+ if (record) for (const h of recordHints(run, d, record)) if (!hints.includes(h)) hints.push(h);
103
135
  const reply = dispatchPaths(d.dir).reply;
104
136
  const text = existsSync(reply) ? readFileSync(reply, "utf8") : "";
105
137
  return {
@@ -108,6 +140,7 @@ export function result(
108
140
  record,
109
141
  reply: capReply(text, relative(run.dir, reply)),
110
142
  replyPath: relative(run.dir, reply),
143
+ hints,
111
144
  };
112
145
  }
113
146
 
@@ -20,6 +20,14 @@ import {
20
20
  writeTextAtomic,
21
21
  } from "../infra/store.ts";
22
22
 
23
+ /** Spec §3.3: the Claude Code session that started a run; absent on 1.0 runs and outside Claude Code. */
24
+ const StartedBySchema = z.object({
25
+ sessionId: z.string(),
26
+ hostSessionId: z.string().nullable(),
27
+ name: z.string().nullable(),
28
+ });
29
+ export type StartedBy = z.infer<typeof StartedBySchema>;
30
+
23
31
  const RunMetaSchema = z.looseObject({
24
32
  schema: z.literal(1),
25
33
  id: z.string(),
@@ -28,6 +36,7 @@ const RunMetaSchema = z.looseObject({
28
36
  aLines: z.array(z.string()),
29
37
  createdAt: z.string(),
30
38
  catherdVersion: z.string(),
39
+ startedBy: StartedBySchema.nullable().optional(),
31
40
  });
32
41
  type RunMeta = z.infer<typeof RunMetaSchema>;
33
42
 
@@ -52,6 +61,8 @@ export function runPaths(dir: string) {
52
61
  outcomes: join(dir, "outcomes.jsonl"),
53
62
  agents: join(dir, "agents.jsonl"),
54
63
  harness: join(dir, "harness.jsonl"),
64
+ /** every session that has owned the run, in order (spec §3.3) */
65
+ sessions: join(dir, "sessions.jsonl"),
55
66
  roles: join(dir, "roles"),
56
67
  shots: join(dir, "shots"),
57
68
  /** the admission lock's target: `admission.lock` guards dispatch admission */
@@ -71,6 +82,7 @@ export function createRun(o: {
71
82
  aLines: string[];
72
83
  version: string;
73
84
  now?: Date;
85
+ startedBy?: StartedBy | null;
74
86
  }): Run {
75
87
  const now = o.now ?? new Date();
76
88
  const root = runsDir(o.repo);
@@ -111,6 +123,7 @@ export function createRun(o: {
111
123
  aLines: o.aLines,
112
124
  createdAt: now.toISOString(),
113
125
  catherdVersion: o.version,
126
+ ...(o.startedBy ? { startedBy: o.startedBy } : {}),
114
127
  };
115
128
  writeJsonAtomic(p.meta, meta);
116
129
  return { id, dir, meta };
@@ -270,7 +283,10 @@ const SERVER_OWNED = new Set([
270
283
  "jev.jsonl",
271
284
  "agents.jsonl",
272
285
  "harness.jsonl",
286
+ "verifier.jsonl",
287
+ "questions.jsonl",
273
288
  "outcomes.jsonl",
289
+ "sessions.jsonl",
274
290
  ]);
275
291
 
276
292
  const present = (p: string) => {
@@ -299,6 +315,8 @@ export function runFile(run: Run, path: string, mode: "read" | "write"): string
299
315
  (SERVER_OWNED.has(folded) ||
300
316
  folded === "roles" ||
301
317
  folded.startsWith(`roles${sep}`) ||
318
+ folded === "digests" ||
319
+ folded.startsWith(`digests${sep}`) ||
302
320
  folded.endsWith(".lock"))
303
321
  )
304
322
  throw new CatherdError("E_IO_PATH", `${rel} is written by catherd itself`, {