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,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 {
|
|
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
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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,
|
|
54
|
-
*
|
|
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
|
|
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 {
|
|
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<{
|
|
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
|
-
|
|
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
|
-
/**
|
|
85
|
-
|
|
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
|
|
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`, {
|