catherd-cli 1.3.0 → 1.5.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 +144 -42
- package/catalog/ATTRIBUTION.md +12 -4
- package/catalog/models.json +8 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +11 -0
- package/plugin/.mcp-codex.json +10 -0
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +150 -79
- package/plugin/skills/catherd-setup/SKILL.md +24 -8
- package/src/adapters/access.ts +7 -1
- package/src/adapters/antigravity/index.ts +18 -3
- package/src/adapters/backend.ts +18 -2
- package/src/adapters/claude-code/index.ts +126 -14
- package/src/adapters/cli.ts +1 -64
- package/src/adapters/codex/events.ts +10 -0
- package/src/adapters/codex/index.ts +17 -5
- package/src/adapters/cursor/index.ts +7 -1
- package/src/adapters/grok/home.ts +75 -20
- package/src/adapters/grok/index.ts +27 -8
- package/src/adapters/opencode/index.ts +38 -1
- package/src/cli.ts +53 -19
- package/src/domain/agents.ts +3 -1
- package/src/domain/brief.ts +91 -0
- package/src/domain/calibration.ts +7 -0
- package/src/domain/catalog.ts +12 -1
- package/src/domain/changes.ts +23 -1
- package/src/domain/cost.ts +8 -3
- package/src/domain/errors.ts +4 -0
- package/src/domain/failover.ts +17 -5
- package/src/domain/gate-brief.ts +52 -0
- package/src/domain/hints.ts +13 -2
- package/src/domain/host.ts +17 -0
- package/src/domain/lane.ts +99 -3
- package/src/domain/notice.ts +14 -4
- package/src/domain/profile-rules.ts +129 -16
- package/src/domain/profile.ts +56 -5
- package/src/domain/record.ts +17 -2
- package/src/domain/reply.ts +29 -0
- package/src/domain/role-prompts.ts +56 -16
- package/src/domain/role-scope.ts +106 -0
- package/src/domain/role-tools.ts +34 -0
- package/src/domain/route.ts +73 -0
- package/src/domain/select.ts +177 -25
- package/src/domain/state.ts +5 -0
- package/src/domain/util.ts +22 -0
- package/src/domain/workspace.ts +130 -0
- package/src/entry/catalog-command.ts +18 -8
- package/src/entry/cli-kit.ts +17 -1
- package/src/entry/deps.ts +8 -3
- package/src/entry/doctor-command.ts +29 -2
- package/src/entry/host-arg.ts +56 -0
- package/src/entry/init-command.ts +20 -8
- package/src/entry/knowledge-command.ts +78 -3
- package/src/entry/lock-command.ts +111 -10
- package/src/entry/mcp/dispatch-tools.ts +3 -3
- package/src/entry/mcp/handshake.ts +8 -2
- package/src/entry/mcp/lane-tools.ts +55 -8
- package/src/entry/mcp/protocol-tools.ts +10 -4
- package/src/entry/mcp/push-tools.ts +16 -0
- package/src/entry/mcp/role-bin.ts +20 -0
- package/src/entry/mcp/role-server.ts +116 -0
- package/src/entry/mcp/run-tools.ts +25 -6
- package/src/entry/mcp/server.ts +224 -44
- package/src/entry/mcp/setup-tools.ts +11 -5
- package/src/entry/mcp/workspace-tools.ts +142 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +131 -16
- package/src/entry/role-cli-command.ts +115 -0
- package/src/entry/runs-command.ts +157 -11
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/tui/effects.ts +23 -8
- package/src/entry/tui/fixtures.ts +33 -10
- package/src/entry/tui/run.tsx +10 -4
- package/src/entry/tui/state.ts +56 -16
- package/src/entry/tui/stories.ts +11 -4
- package/src/entry/tui/views/profile-actions.ts +4 -2
- package/src/entry/tui/views/profiles.tsx +3 -3
- package/src/entry/tui/views/runs.tsx +66 -20
- package/src/entry/tui/views/save-dialog.tsx +8 -4
- package/src/entry/tui/views/status.tsx +6 -2
- package/src/entry/workspace-command.ts +44 -0
- package/src/infra/boot-lock.ts +25 -0
- package/src/infra/cli.ts +65 -0
- package/src/infra/codex-queue.ts +165 -0
- package/src/infra/delivery.ts +86 -0
- package/src/infra/dispatch-dir.ts +3 -0
- package/src/infra/env.ts +15 -2
- package/src/infra/git.ts +6 -0
- package/src/infra/heavy-lock.ts +93 -4
- package/src/infra/host-context.ts +58 -0
- package/src/infra/host-probe.ts +16 -0
- package/src/infra/lock-activity.ts +88 -0
- package/src/infra/login-env.ts +48 -0
- package/src/infra/paths.ts +35 -0
- package/src/infra/peer-inbox.ts +4 -1
- package/src/infra/role-mcp.ts +69 -0
- package/src/infra/sources/arena.ts +11 -3
- package/src/infra/sources/artificial-analysis.ts +8 -1
- package/src/infra/sources/cache.ts +2 -0
- package/src/infra/supervisor.ts +110 -18
- package/src/services/admission.ts +117 -26
- package/src/services/agent-links.ts +30 -5
- package/src/services/backends.ts +7 -0
- package/src/services/budget.ts +26 -6
- package/src/services/catalog-service.ts +20 -12
- package/src/services/credentials.ts +11 -6
- package/src/services/dispatch-service.ts +220 -22
- package/src/services/dispatches.ts +36 -3
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-backends.ts +30 -7
- package/src/services/doctor-checks.ts +20 -5
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-push.ts +69 -90
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +172 -38
- package/src/services/end-push.ts +157 -0
- package/src/services/finalize.ts +13 -3
- package/src/services/gate-env.ts +218 -0
- package/src/services/gate-service.ts +216 -45
- package/src/services/global-install.ts +46 -13
- package/src/services/lane-edit.ts +154 -0
- package/src/services/lane-service.ts +258 -52
- package/src/services/milestones.ts +137 -26
- package/src/services/notifier.ts +377 -72
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +122 -23
- package/src/services/ports.ts +39 -4
- package/src/services/preflight.ts +153 -19
- package/src/services/profile-service.ts +117 -30
- package/src/services/profile-store.ts +31 -7
- package/src/services/protocol.ts +119 -25
- package/src/services/provenance.ts +16 -12
- package/src/services/questions.ts +15 -0
- package/src/services/reconcile.ts +22 -1
- package/src/services/reentry.ts +7 -6
- package/src/services/role-access.ts +27 -0
- package/src/services/routing-service.ts +150 -37
- package/src/services/run-debug.ts +98 -4
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-service.ts +153 -19
- package/src/services/run-store.ts +119 -19
- package/src/services/runs-page.ts +45 -9
- package/src/services/scratch.ts +60 -0
- package/src/services/session-view.ts +18 -13
- package/src/services/sessions.ts +34 -16
- package/src/services/setup.ts +31 -10
- package/src/services/source-derive.ts +28 -5
- package/src/services/source-sync.ts +9 -2
- package/src/services/standins.ts +40 -6
- package/src/services/state.ts +21 -0
- package/src/services/summary.ts +86 -11
- package/src/services/treat-likes.ts +41 -12
- package/src/services/verifier-brief.ts +23 -0
- package/src/services/verifier-step.ts +48 -0
- package/src/services/workspace-admission.ts +267 -0
- package/src/services/workspace-service.ts +254 -0
- package/src/services/workspace-store.ts +95 -0
|
@@ -1,37 +1,115 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { relative } from "node:path";
|
|
2
|
+
import { relative, sep } from "node:path";
|
|
3
3
|
import { CatherdError } from "../domain/errors.ts";
|
|
4
4
|
import { assertId, parseRung } from "../domain/ids.ts";
|
|
5
|
+
import { assertLaneValues } from "../domain/lane.ts";
|
|
5
6
|
import type { RunRecord } from "../domain/record.ts";
|
|
6
7
|
import type { Role } from "../domain/roles.ts";
|
|
7
8
|
import { awaitsCollect, dispatchPaths, endCollect, tryCollect } from "../infra/dispatch-dir.ts";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
9
|
+
import { withFileLock } from "../infra/filelock.ts";
|
|
10
|
+
import { git, gitToplevel } from "../infra/git.ts";
|
|
11
|
+
import { writeJsonAtomic, writeTextAtomic } from "../infra/store.ts";
|
|
10
12
|
import {
|
|
11
13
|
dispatchState,
|
|
12
14
|
type DispatchState,
|
|
13
15
|
latestDispatch,
|
|
14
16
|
listDispatches,
|
|
17
|
+
liveDispatches,
|
|
15
18
|
recordHints,
|
|
16
19
|
} from "./dispatches.ts";
|
|
17
20
|
import type { Deps } from "./ports.ts";
|
|
21
|
+
import { assertNativeHost } from "./backends.ts";
|
|
18
22
|
import {
|
|
19
23
|
type AgentRun,
|
|
20
24
|
appendAgentRun,
|
|
21
25
|
appendKnowledge,
|
|
22
26
|
createRun,
|
|
23
27
|
findRun,
|
|
24
|
-
|
|
28
|
+
knowledgeFor,
|
|
25
29
|
readRecords,
|
|
26
30
|
runFile,
|
|
31
|
+
type Run,
|
|
32
|
+
runPaths,
|
|
33
|
+
supersededBy,
|
|
34
|
+
supersededFile,
|
|
27
35
|
} from "./run-store.ts";
|
|
28
|
-
import { protocolView } from "./protocol.ts";
|
|
36
|
+
import { protocolNext, protocolView } from "./protocol.ts";
|
|
37
|
+
import { writePin } from "./run-pin.ts";
|
|
29
38
|
import { claimRun, currentSession } from "./sessions.ts";
|
|
30
|
-
import { refreshState } from "./state.ts";
|
|
39
|
+
import { readNotes, refreshState } from "./state.ts";
|
|
40
|
+
|
|
41
|
+
/** The run `id` was superseded by, or undefined: an open run, or one that cannot be found. */
|
|
42
|
+
function nextInChain(id: string): string | undefined {
|
|
43
|
+
try {
|
|
44
|
+
return supersededBy(findRun(id))?.by;
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `runs supersede <run> --by <run>`: closes `run` with a pointer to the run that took it over (a planning
|
|
52
|
+
* run handed to the execution run in a worktree). Status hides it; dispatch refuses it. A run with live
|
|
53
|
+
* roles is refused: cancel them, or let them finish, first.
|
|
54
|
+
*/
|
|
55
|
+
export async function supersedeRun(
|
|
56
|
+
deps: Deps,
|
|
57
|
+
i: { run: string; by: string },
|
|
58
|
+
): Promise<{ run: string; by: string; at: string; hints?: string[] }> {
|
|
59
|
+
const run = findRun(i.run);
|
|
60
|
+
const by = findRun(i.by);
|
|
61
|
+
if (run.id === by.id)
|
|
62
|
+
throw new CatherdError("E_INPUT_INVALID", `run ${run.id} cannot supersede itself`, {
|
|
63
|
+
fix: "pass the run that took over as --by",
|
|
64
|
+
});
|
|
65
|
+
// walk the chain the new pointer would join: any run it reaches back to `run` closes a cycle
|
|
66
|
+
const seen = new Set<string>([by.id]);
|
|
67
|
+
for (let hop = supersededBy(by)?.by; hop !== undefined && !seen.has(hop);) {
|
|
68
|
+
if (hop === run.id)
|
|
69
|
+
throw new CatherdError("E_INPUT_INVALID", `run ${by.id} is itself superseded by ${run.id}`, {
|
|
70
|
+
fix: "supersede the older run by the newer one",
|
|
71
|
+
});
|
|
72
|
+
seen.add(hop);
|
|
73
|
+
hop = nextInChain(hop);
|
|
74
|
+
}
|
|
75
|
+
// under the run's admission lock: a dispatch admitted meanwhile is either live here, or sees the pointer
|
|
76
|
+
const at = await withFileLock(runPaths(run.dir).admission, () => {
|
|
77
|
+
assertNoLiveRoles(
|
|
78
|
+
deps,
|
|
79
|
+
run,
|
|
80
|
+
`cancel them (cancel(run, name)) or let them finish, then supersede ${run.id}`,
|
|
81
|
+
);
|
|
82
|
+
return writeSuperseded(deps, run, by.id);
|
|
83
|
+
});
|
|
84
|
+
return { run: run.id, by: by.id, at, ...(await supersededHints(run, by.id)) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Held under `run`'s admission lock: no role of it may be live when it closes. */
|
|
88
|
+
function assertNoLiveRoles(deps: Deps, run: Run, fix: string): void {
|
|
89
|
+
const live = liveDispatches(run, deps.now());
|
|
90
|
+
if (live.length)
|
|
91
|
+
throw new CatherdError(
|
|
92
|
+
"E_INPUT_INVALID",
|
|
93
|
+
`run ${run.id} still has live roles: ${live.map((d) => d.admit.name).join(", ")}`,
|
|
94
|
+
{ fix },
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Held under `run`'s admission lock: the pointer to the run that took it over; its time. */
|
|
99
|
+
function writeSuperseded(deps: Deps, run: Run, by: string): string {
|
|
100
|
+
const when = new Date(deps.now()).toISOString();
|
|
101
|
+
writeJsonAtomic(supersededFile(run), { schema: 1, by, at: when });
|
|
102
|
+
return when;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function supersededHints(run: Run, by: string): Promise<{ hints?: string[] }> {
|
|
106
|
+
const { hints } = await refreshState(run, { next: `superseded by ${by}: continue there` });
|
|
107
|
+
return hints.length ? { hints } : {};
|
|
108
|
+
}
|
|
31
109
|
|
|
32
110
|
export async function startRun(
|
|
33
111
|
deps: Deps,
|
|
34
|
-
i: { repo: string; title: string; aLines: string[] },
|
|
112
|
+
i: { repo: string; title: string; aLines: string[]; from?: string },
|
|
35
113
|
): Promise<{
|
|
36
114
|
run: string;
|
|
37
115
|
dir: string;
|
|
@@ -43,17 +121,39 @@ export async function startRun(
|
|
|
43
121
|
throw new CatherdError("E_IO_PATH", `${i.repo} is not inside a git repository`, {
|
|
44
122
|
fix: "pass the path of the repository to work in",
|
|
45
123
|
});
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
124
|
+
// where the first milestone's commit range starts; a repo with no commit yet has none
|
|
125
|
+
const head = await git(top, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
|
|
126
|
+
const open = async (): Promise<Run> => {
|
|
127
|
+
const run = createRun({
|
|
128
|
+
repo: top,
|
|
129
|
+
title: i.title,
|
|
130
|
+
aLines: i.aLines,
|
|
131
|
+
version: deps.version,
|
|
132
|
+
startHead: head.kind === "ok" ? head.out.trim() || null : null,
|
|
133
|
+
now: new Date(deps.now()),
|
|
134
|
+
startedBy: currentSession(deps),
|
|
135
|
+
});
|
|
136
|
+
// spec 1.5 "Pinned per run": what the run starts on stays what it dispatches on
|
|
137
|
+
writePin(deps, run);
|
|
138
|
+
await claimRun(deps, run);
|
|
139
|
+
return run;
|
|
140
|
+
};
|
|
141
|
+
// the run this one takes over must be free to close before anything is created, and stay so until its pointer
|
|
142
|
+
// is written: its admission lock is held from the check to the pointer, so no dispatch gets in between and
|
|
143
|
+
// leaves the new run open beside it (PR #50). It is the only lock taken first: the new run's are uncontended.
|
|
144
|
+
const from = i.from === undefined ? null : findRun(i.from);
|
|
145
|
+
const run = from
|
|
146
|
+
? await withFileLock(runPaths(from.dir).admission, async () => {
|
|
147
|
+
assertNoLiveRoles(deps, from, `cancel them or let them finish, then run_start with from: ${from.id}`);
|
|
148
|
+
const opened = await open();
|
|
149
|
+
writeSuperseded(deps, from, opened.id);
|
|
150
|
+
return opened;
|
|
151
|
+
})
|
|
152
|
+
: await open();
|
|
153
|
+
const superseded = from ? await supersededHints(from, run.id) : {};
|
|
55
154
|
// a failed state.md refresh never fails the start: the run exists and is usable, so a retry would orphan it
|
|
56
155
|
const { hints } = await refreshState(run);
|
|
156
|
+
hints.push(...(superseded.hints ?? []));
|
|
57
157
|
// spec 1.1 §10: the milestone loop, so the orchestrator starts on the protocol
|
|
58
158
|
return { run: run.id, dir: run.dir, protocol: protocolView(run, []), ...(hints.length ? { hints } : {}) };
|
|
59
159
|
}
|
|
@@ -62,7 +162,11 @@ export function writeRunFile(i: { run: string; path: string; content: string }):
|
|
|
62
162
|
path: string;
|
|
63
163
|
bytes: number;
|
|
64
164
|
} {
|
|
65
|
-
const
|
|
165
|
+
const run = findRun(i.run);
|
|
166
|
+
const file = runFile(run, i.path, "write");
|
|
167
|
+
// spec 1.5 "Lane editing": a lane's header values are checked when it is written, not first at preflight
|
|
168
|
+
const lane = /^lanes\/([^/]+)\.md$/.exec(relative(run.dir, file).split(sep).join("/"));
|
|
169
|
+
if (lane) assertLaneValues(i.content, `lanes/${lane[1]}.md`);
|
|
66
170
|
writeTextAtomic(file, i.content);
|
|
67
171
|
return { path: file, bytes: Buffer.byteLength(i.content) };
|
|
68
172
|
}
|
|
@@ -98,6 +202,28 @@ function capReply(reply: string, path: string): string {
|
|
|
98
202
|
return `${head.slice(0, CAP_CHARS)}\n[capped: the full reply is ${path}]`;
|
|
99
203
|
}
|
|
100
204
|
|
|
205
|
+
/** Whether `text` names `token` (a role name or lane id) as a whole word, a full stop after it allowed. */
|
|
206
|
+
export function names(text: string, token: string): boolean {
|
|
207
|
+
const t = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
208
|
+
return new RegExp(`(?<![\\w.-])${t}(?![\\w-]|\\.\\w)`).test(text);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Plan 22: state.md's Next never outlives the step it names. When it names a role whose record `result` returns
|
|
213
|
+
* (by its name or its lane), it moves on to the protocol's next step. A refresh that fails comes back as hints.
|
|
214
|
+
*/
|
|
215
|
+
async function advanceNext(run: Run, record: RunRecord): Promise<string[]> {
|
|
216
|
+
const stale = (next: string) =>
|
|
217
|
+
names(next, record.name) || (record.lane !== null && names(next, record.lane));
|
|
218
|
+
if (!stale(readNotes(run).next)) return [];
|
|
219
|
+
const { hints } = await refreshState(run, (n) =>
|
|
220
|
+
stale(n.next)
|
|
221
|
+
? { next: `after ${record.name} (${record.status}): ${protocolNext(run, n.parked ?? [])}` }
|
|
222
|
+
: {},
|
|
223
|
+
);
|
|
224
|
+
return hints;
|
|
225
|
+
}
|
|
226
|
+
|
|
101
227
|
/**
|
|
102
228
|
* A role's latest dispatch: its record once finished, its capped reply and its hints. Spec §3.7: reading a
|
|
103
229
|
* finished record marks it read, with the collect lease (each record is marked read once, whoever reads it);
|
|
@@ -133,6 +259,7 @@ export async function result(
|
|
|
133
259
|
if (x.admit.dispatchId !== d.admit.dispatchId) hints.push(...recordHints(run, x, r));
|
|
134
260
|
}
|
|
135
261
|
if (record) for (const h of recordHints(run, d, record)) if (!hints.includes(h)) hints.push(h);
|
|
262
|
+
if (record) for (const h of await advanceNext(run, record)) if (!hints.includes(h)) hints.push(h);
|
|
136
263
|
const reply = dispatchPaths(d.dir).reply;
|
|
137
264
|
const text = existsSync(reply) ? readFileSync(reply, "utf8") : "";
|
|
138
265
|
return {
|
|
@@ -158,6 +285,10 @@ export function recordAgentRun(
|
|
|
158
285
|
durationMs?: number;
|
|
159
286
|
status?: AgentRun["status"];
|
|
160
287
|
lane?: string;
|
|
288
|
+
/** plan 23: a native verifier's first reply line, so a VERDICT: BLOCKED: environment is told from a FAIL */
|
|
289
|
+
verdict?: string;
|
|
290
|
+
/** plan 23: the STATUS word of the subagent's reply */
|
|
291
|
+
replyStatus?: NonNullable<AgentRun["replyStatus"]>;
|
|
161
292
|
},
|
|
162
293
|
): AgentRun {
|
|
163
294
|
const run = findRun(i.run);
|
|
@@ -167,6 +298,7 @@ export function recordAgentRun(
|
|
|
167
298
|
throw new CatherdError("E_ADMIT_RUNG", `${i.rung} is not a native Claude rung`, {
|
|
168
299
|
fix: "record_agent_run is for Agent subagents; dispatch records every other run itself",
|
|
169
300
|
});
|
|
301
|
+
assertNativeHost(i.rung, deps.host.conflict ? "unknown" : deps.host.host);
|
|
170
302
|
const row: AgentRun = {
|
|
171
303
|
at: new Date(deps.now()).toISOString(),
|
|
172
304
|
name: i.name,
|
|
@@ -178,6 +310,8 @@ export function recordAgentRun(
|
|
|
178
310
|
secs: i.durationMs === undefined ? null : Math.round(i.durationMs / 1000),
|
|
179
311
|
status: i.status ?? "ok",
|
|
180
312
|
lane: i.lane ?? null,
|
|
313
|
+
...(i.verdict ? { verdict: i.verdict.trim().split("\n")[0] } : {}),
|
|
314
|
+
...(i.replyStatus ? { replyStatus: i.replyStatus } : {}),
|
|
181
315
|
};
|
|
182
316
|
appendAgentRun(run, row);
|
|
183
317
|
return row;
|
|
@@ -195,7 +329,7 @@ async function repoTop(repo: string): Promise<string> {
|
|
|
195
329
|
|
|
196
330
|
/** What past runs of the repo learned; `repo` may be any path inside it. */
|
|
197
331
|
export async function readKnowledge(repo: string): Promise<string> {
|
|
198
|
-
const file =
|
|
332
|
+
const file = await knowledgeFor(await repoTop(repo));
|
|
199
333
|
const text = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
200
334
|
return text.trim() ? text : "catherd: no knowledge recorded yet for this repo";
|
|
201
335
|
}
|
|
@@ -203,7 +337,7 @@ export async function readKnowledge(repo: string): Promise<string> {
|
|
|
203
337
|
/** Where the repo's knowledge.md is (it may not exist yet); `repo` may be any path inside it. */
|
|
204
338
|
export async function knowledgePath(repo: string): Promise<{ repo: string; path: string }> {
|
|
205
339
|
const top = await repoTop(repo);
|
|
206
|
-
return { repo: top, path:
|
|
340
|
+
return { repo: top, path: await knowledgeFor(top) };
|
|
207
341
|
}
|
|
208
342
|
|
|
209
343
|
/** The repo's knowledge.md as its lines, none when it is missing or blank; `repo` may be any path inside it. */
|
|
@@ -1,19 +1,21 @@
|
|
|
1
|
-
import { existsSync, lstatSync, mkdirSync, readdirSync, realpathSync } from "node:fs";
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, renameSync } from "node:fs";
|
|
2
2
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { CatherdError } from "../domain/errors.ts";
|
|
5
5
|
import { assertId } from "../domain/ids.ts";
|
|
6
6
|
import { type RunRecord, RunRecordSchema } from "../domain/record.ts";
|
|
7
|
-
import type { OutcomeRow, RouteRow } from "../domain/route.ts";
|
|
7
|
+
import type { OutcomeRouteRow, OutcomeRow, RoleRouteRow, RouteRow } from "../domain/route.ts";
|
|
8
8
|
import { cell, slug } from "../domain/util.ts";
|
|
9
9
|
import { withFileLock } from "../infra/filelock.ts";
|
|
10
|
-
import {
|
|
10
|
+
import { gitOrigin } from "../infra/git.ts";
|
|
11
|
+
import { dataDir, originDir, repoDir, runsDir } from "../infra/paths.ts";
|
|
11
12
|
import {
|
|
12
13
|
appendJsonl,
|
|
13
14
|
ensureJsonlHeader,
|
|
14
15
|
ensurePrivateDir,
|
|
15
16
|
PRIVATE_DIR,
|
|
16
17
|
appendPrivate,
|
|
18
|
+
nonBlankLines,
|
|
17
19
|
readJsonl,
|
|
18
20
|
readVersioned,
|
|
19
21
|
writeJsonAtomic,
|
|
@@ -21,7 +23,8 @@ import {
|
|
|
21
23
|
} from "../infra/store.ts";
|
|
22
24
|
|
|
23
25
|
/** Spec §3.3: the Claude Code session that started a run; absent on 1.0 runs and outside Claude Code. */
|
|
24
|
-
const StartedBySchema = z.
|
|
26
|
+
const StartedBySchema = z.looseObject({
|
|
27
|
+
host: z.enum(["claude-code", "codex"]).default("claude-code"),
|
|
25
28
|
sessionId: z.string(),
|
|
26
29
|
hostSessionId: z.string().nullable(),
|
|
27
30
|
name: z.string().nullable(),
|
|
@@ -37,6 +40,9 @@ const RunMetaSchema = z.looseObject({
|
|
|
37
40
|
createdAt: z.string(),
|
|
38
41
|
catherdVersion: z.string(),
|
|
39
42
|
startedBy: StartedBySchema.nullable().optional(),
|
|
43
|
+
workspace: z.strictObject({ id: z.string(), step: z.string() }).optional(),
|
|
44
|
+
/** the repo's HEAD when run_start ran: where the first milestone's commit range starts (absent before 1.5) */
|
|
45
|
+
startHead: z.string().optional(),
|
|
40
46
|
});
|
|
41
47
|
type RunMeta = z.infer<typeof RunMetaSchema>;
|
|
42
48
|
|
|
@@ -65,6 +71,8 @@ export function runPaths(dir: string) {
|
|
|
65
71
|
sessions: join(dir, "sessions.jsonl"),
|
|
66
72
|
roles: join(dir, "roles"),
|
|
67
73
|
shots: join(dir, "shots"),
|
|
74
|
+
/** per dispatch name, the role's TMPDIR (spec 1.5 plan 21): `scratch/<name>/` */
|
|
75
|
+
scratch: join(dir, "scratch"),
|
|
68
76
|
/** the admission lock's target: `admission.lock` guards dispatch admission */
|
|
69
77
|
admission: join(dir, "admission"),
|
|
70
78
|
};
|
|
@@ -82,8 +90,19 @@ export function createRun(o: {
|
|
|
82
90
|
aLines: string[];
|
|
83
91
|
version: string;
|
|
84
92
|
now?: Date;
|
|
85
|
-
|
|
93
|
+
workspace?: { id: string; step: string };
|
|
94
|
+
/** the repo's full HEAD at the start, when it has a commit */
|
|
95
|
+
startHead?: string | null;
|
|
96
|
+
startedBy?: {
|
|
97
|
+
host?: StartedBy["host"];
|
|
98
|
+
sessionId: string;
|
|
99
|
+
hostSessionId: string | null;
|
|
100
|
+
name: string | null;
|
|
101
|
+
} | null;
|
|
86
102
|
}): Run {
|
|
103
|
+
const workspace = o.workspace
|
|
104
|
+
? { id: assertId("workspace", o.workspace.id), step: assertId("workspace step", o.workspace.step) }
|
|
105
|
+
: undefined;
|
|
87
106
|
const now = o.now ?? new Date();
|
|
88
107
|
const root = runsDir(o.repo);
|
|
89
108
|
ensurePrivateDir(root);
|
|
@@ -123,7 +142,9 @@ export function createRun(o: {
|
|
|
123
142
|
aLines: o.aLines,
|
|
124
143
|
createdAt: now.toISOString(),
|
|
125
144
|
catherdVersion: o.version,
|
|
126
|
-
...(o.startedBy ? { startedBy: o.startedBy } : {}),
|
|
145
|
+
...(o.startedBy ? { startedBy: StartedBySchema.parse(o.startedBy) } : {}),
|
|
146
|
+
...(workspace ? { workspace } : {}),
|
|
147
|
+
...(o.startHead ? { startHead: o.startHead } : {}),
|
|
127
148
|
};
|
|
128
149
|
writeJsonAtomic(p.meta, meta);
|
|
129
150
|
return { id, dir, meta };
|
|
@@ -135,14 +156,18 @@ export interface RunListing {
|
|
|
135
156
|
corrupt: { id: string; dir: string; reason: string }[];
|
|
136
157
|
}
|
|
137
158
|
|
|
138
|
-
/** Every run
|
|
139
|
-
export function listRuns(): RunListing {
|
|
159
|
+
/** Every run, newest first; an explicit repo scope avoids reading unrelated histories. */
|
|
160
|
+
export function listRuns(repos?: readonly string[]): RunListing {
|
|
140
161
|
const root = join(dataDir(), "repos");
|
|
141
162
|
const out: RunListing = { runs: [], corrupt: [] };
|
|
142
163
|
if (!existsSync(root)) return out;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
164
|
+
const directories = repos
|
|
165
|
+
? [...new Set(repos.map(runsDir))]
|
|
166
|
+
: readdirSync(root, { withFileTypes: true })
|
|
167
|
+
.filter((repo) => repo.isDirectory())
|
|
168
|
+
.map((repo) => join(root, repo.name, "runs"));
|
|
169
|
+
for (const runs of directories) {
|
|
170
|
+
if (!existsSync(runs)) continue;
|
|
146
171
|
for (const e of readdirSync(runs, { withFileTypes: true })) {
|
|
147
172
|
if (!e.isDirectory()) continue;
|
|
148
173
|
const dir = join(runs, e.name);
|
|
@@ -196,7 +221,7 @@ export function readRecords(run: Run): { records: RunRecord[]; corrupt: number }
|
|
|
196
221
|
if (!r.success) bad++;
|
|
197
222
|
else if (!seen.has(r.data.dispatchId)) {
|
|
198
223
|
seen.add(r.data.dispatchId);
|
|
199
|
-
records.push(r.data);
|
|
224
|
+
records.push(r.data.sessionId && !r.data.host ? { ...r.data, host: "claude-code" } : r.data);
|
|
200
225
|
}
|
|
201
226
|
}
|
|
202
227
|
return { records, corrupt: bad };
|
|
@@ -214,13 +239,32 @@ export function appendRecord(run: Run, r: RunRecord): Promise<RunRecord> {
|
|
|
214
239
|
});
|
|
215
240
|
}
|
|
216
241
|
|
|
242
|
+
/** The run's lane rows (routes and climbs); a role's rows outside a lane are left out (`readRoleRoutes`). */
|
|
217
243
|
export function readRoutes(run: Run): RouteRow[] {
|
|
218
244
|
return readJsonl<RouteRow>(runPaths(run.dir).routes).rows.filter(
|
|
219
|
-
(r) =>
|
|
245
|
+
(r) =>
|
|
246
|
+
typeof r?.lane === "string" &&
|
|
247
|
+
(r.source === "route" || r.source === "climb") &&
|
|
248
|
+
Array.isArray(r.ladder) &&
|
|
249
|
+
typeof r.rung === "string",
|
|
220
250
|
);
|
|
221
251
|
}
|
|
222
252
|
|
|
223
|
-
|
|
253
|
+
/** Spec 1.5 plan 24: the lanes' outcome rows of routes.jsonl, oldest first (the last per lane wins). */
|
|
254
|
+
export function readOutcomeRoutes(run: Run): OutcomeRouteRow[] {
|
|
255
|
+
return readJsonl<OutcomeRouteRow>(runPaths(run.dir).routes).rows.filter(
|
|
256
|
+
(r) => typeof r?.lane === "string" && r.source === "outcome",
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Spec 1.5 plan 24: the run's role decisions outside a lane, oldest first. */
|
|
261
|
+
export function readRoleRoutes(run: Run): RoleRouteRow[] {
|
|
262
|
+
return readJsonl<RoleRouteRow>(runPaths(run.dir).routes).rows.filter(
|
|
263
|
+
(r) => r?.lane === null && typeof r.role === "string" && typeof r.rung === "string",
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function appendRoute(run: Run, row: RouteRow | RoleRouteRow | OutcomeRouteRow): void {
|
|
224
268
|
const file = runPaths(run.dir).routes;
|
|
225
269
|
ensureJsonlHeader(file, "routes");
|
|
226
270
|
appendJsonl(file, row);
|
|
@@ -249,13 +293,22 @@ const AgentRunSchema = z.looseObject({
|
|
|
249
293
|
status: z.enum(["ok", "failed", "cancelled"]),
|
|
250
294
|
/** the lane the subagent worked, so catalog timings count it under that lane's kind; rows before it lack it */
|
|
251
295
|
lane: z.string().nullable().optional(),
|
|
296
|
+
/** plan 23: a native verifier's verdict line (VERDICT: BLOCKED: environment — <probe>); rows before it lack it */
|
|
297
|
+
verdict: z.string().optional(),
|
|
298
|
+
/** plan 23: the STATUS word of the subagent's reply (a reviewer's partial is no review); rows before it lack it */
|
|
299
|
+
replyStatus: z.enum(["complete", "partial", "blocked", "refused", "flaky"]).optional(),
|
|
252
300
|
});
|
|
253
301
|
export type AgentRun = z.infer<typeof AgentRunSchema>;
|
|
254
302
|
|
|
255
303
|
/** Native Claude subagent runs, as the orchestrator reported them through record_agent_run. */
|
|
256
|
-
export function readAgentRuns(run: Run): AgentRun[] {
|
|
257
|
-
|
|
304
|
+
export function readAgentRuns(run: Run, strict = false): AgentRun[] {
|
|
305
|
+
const { rows, corrupt } = readJsonl<unknown>(runPaths(run.dir).agents);
|
|
306
|
+
if (strict && corrupt)
|
|
307
|
+
throw new CatherdError("E_RUN_CORRUPT", `run ${run.id} has unreadable native cost records`);
|
|
308
|
+
return rows.flatMap((row) => {
|
|
258
309
|
const r = AgentRunSchema.safeParse(row);
|
|
310
|
+
if (strict && !r.success)
|
|
311
|
+
throw new CatherdError("E_RUN_CORRUPT", `run ${run.id} has invalid native cost records`);
|
|
259
312
|
return r.success ? [r.data] : [];
|
|
260
313
|
});
|
|
261
314
|
}
|
|
@@ -270,20 +323,67 @@ export function appendLedger(run: Run, row: string): void {
|
|
|
270
323
|
appendPrivate(runPaths(run.dir).ledger, `${row}\n`);
|
|
271
324
|
}
|
|
272
325
|
|
|
273
|
-
/** Spec §4.7: what past runs of a repo learned, keyed by its git toplevel. */
|
|
326
|
+
/** Spec §4.7: what past runs of a repo learned, keyed by its git toplevel (before 1.5, and with no origin). */
|
|
274
327
|
export const knowledgeFile = (toplevel: string): string => join(repoDir(toplevel), "knowledge.md");
|
|
275
328
|
|
|
329
|
+
/**
|
|
330
|
+
* Spec 1.5 "Knowledge keyed by git origin": the knowledge.md every worktree and clone of one repository
|
|
331
|
+
* shares, keyed by its `origin` remote, else by its toplevel. A toplevel-keyed file this worktree left before
|
|
332
|
+
* is migrated on read: its lines not there yet are appended, and it is renamed `knowledge.md.migrated`.
|
|
333
|
+
*/
|
|
334
|
+
export async function knowledgeFor(toplevel: string): Promise<string> {
|
|
335
|
+
const origin = await gitOrigin(toplevel);
|
|
336
|
+
const legacy = knowledgeFile(toplevel);
|
|
337
|
+
if (!origin) return legacy;
|
|
338
|
+
const file = join(originDir(origin), "knowledge.md");
|
|
339
|
+
if (!existsSync(legacy)) return file;
|
|
340
|
+
ensurePrivateDir(dirname(file));
|
|
341
|
+
await withFileLock(file, () => {
|
|
342
|
+
if (!existsSync(legacy)) return;
|
|
343
|
+
const have = new Set(existsSync(file) ? nonBlankLines(file) : []);
|
|
344
|
+
const add = nonBlankLines(legacy).filter((l) => !have.has(l));
|
|
345
|
+
if (add.length) appendPrivate(file, `${add.join("\n")}\n`);
|
|
346
|
+
renameSync(legacy, `${legacy}.migrated`);
|
|
347
|
+
});
|
|
348
|
+
return file;
|
|
349
|
+
}
|
|
350
|
+
|
|
276
351
|
/**
|
|
277
352
|
* Appends one line to the repo's knowledge.md, `- <date> <source>: <text>` with `text` on one line, and returns
|
|
278
353
|
* it. The single writer of knowledge.md: `land`'s `learned` and `catherd knowledge add` both come through here.
|
|
279
354
|
*/
|
|
280
|
-
export function appendKnowledge(
|
|
355
|
+
export async function appendKnowledge(
|
|
356
|
+
toplevel: string,
|
|
357
|
+
at: Date,
|
|
358
|
+
source: string,
|
|
359
|
+
text: string,
|
|
360
|
+
): Promise<string> {
|
|
281
361
|
const line = `- ${at.toISOString().slice(0, 10)} ${source}: ${cell(text)}`;
|
|
282
|
-
appendPrivate(
|
|
362
|
+
appendPrivate(await knowledgeFor(toplevel), `${line}\n`);
|
|
283
363
|
return line;
|
|
284
364
|
}
|
|
285
365
|
|
|
366
|
+
/** Spec 1.5 "runs supersede": the run that took over a closed one, and when. */
|
|
367
|
+
const SupersededSchema = z.looseObject({ schema: z.literal(1), by: z.string(), at: z.string() });
|
|
368
|
+
export type Superseded = z.infer<typeof SupersededSchema>;
|
|
369
|
+
|
|
370
|
+
export const supersededFile = (run: Run): string => join(run.dir, "superseded.json");
|
|
371
|
+
|
|
372
|
+
/** The pointer `runs supersede` left, or null: an open run, or a pointer that cannot be read. */
|
|
373
|
+
export function supersededBy(run: Run): Superseded | null {
|
|
374
|
+
const file = supersededFile(run);
|
|
375
|
+
if (!existsSync(file)) return null;
|
|
376
|
+
try {
|
|
377
|
+
return readVersioned(file, SupersededSchema, 1);
|
|
378
|
+
} catch {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
286
383
|
const SERVER_OWNED = new Set([
|
|
384
|
+
"superseded.json",
|
|
385
|
+
"pin.json",
|
|
386
|
+
"workspace-contract.md",
|
|
287
387
|
"meta.json",
|
|
288
388
|
"state.md",
|
|
289
389
|
"state.json",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sessionKey } from "../domain/host.ts";
|
|
1
2
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { budgetStatus } from "../domain/budget.ts";
|
|
@@ -11,6 +12,7 @@ import { lastActivity } from "./finalize.ts";
|
|
|
11
12
|
import type { Deps } from "./ports.ts";
|
|
12
13
|
import { findRun, listRuns, readRecords, type Run, runPaths } from "./run-store.ts";
|
|
13
14
|
import { groupRuns, type SessionGroup } from "./session-view.ts";
|
|
15
|
+
import { orchestratorWait, type OrchestratorWait } from "./orchestrator-wait.ts";
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* Spec §4: what the Runs tab shows. The top level is the sessions; a session's screen holds its runs, each with its
|
|
@@ -50,11 +52,12 @@ export interface RoleRow {
|
|
|
50
52
|
export interface Milestone {
|
|
51
53
|
name: string;
|
|
52
54
|
landed: boolean;
|
|
53
|
-
/** the ledger's "what" once landed */
|
|
55
|
+
/** the ledger's "what" once landed; before that, plan.md's line for the milestone ("" when it has none) */
|
|
54
56
|
what: string;
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
export interface SessionRun {
|
|
60
|
+
waiting?: OrchestratorWait | null;
|
|
58
61
|
id: string;
|
|
59
62
|
title: string;
|
|
60
63
|
repo: string;
|
|
@@ -98,7 +101,7 @@ export interface MilestoneDetail {
|
|
|
98
101
|
digest: string | null;
|
|
99
102
|
}
|
|
100
103
|
|
|
101
|
-
const keyOf = (g: SessionGroup): string | null => g.session
|
|
104
|
+
const keyOf = (g: SessionGroup): string | null => (g.session ? sessionKey(g.session) : null);
|
|
102
105
|
|
|
103
106
|
/** The runs that live on in a session now (it owns them): each run's live roles and landings count once. */
|
|
104
107
|
const ownRuns = (g: SessionGroup): Run[] => g.runs.filter((x) => x.current).map((x) => x.run);
|
|
@@ -125,18 +128,50 @@ export function sessionRows(deps: Deps): { rows: SessionRow[]; warnings: string[
|
|
|
125
128
|
};
|
|
126
129
|
}
|
|
127
130
|
|
|
128
|
-
/**
|
|
131
|
+
/**
|
|
132
|
+
* A milestone's line in plan.md: a heading, list item or plain line that opens with its id and a separator
|
|
133
|
+
* (`## M1 — the jobs list`, `- **M2:** export`). The id stops at the separator, so `M1.L1 — …` is a lane's.
|
|
134
|
+
*/
|
|
135
|
+
const PLAN_LINE =
|
|
136
|
+
/^\s*(?:#{1,6}\s+|[-*]\s+|\d+[.)]\s+)?(?:\*\*)?([A-Za-z0-9_-]+)(?::\*\*|(?:\*\*)?\s*(?:—|–|:|-))\s*(.+?)\s*$/;
|
|
137
|
+
|
|
138
|
+
/** The description plan.md gives each of `names`, from the first line that opens with it; none for the rest. */
|
|
139
|
+
function planWhat(run: Run, names: string[]): Map<string, string> {
|
|
140
|
+
const out = new Map<string, string>();
|
|
141
|
+
const file = runPaths(run.dir).plan;
|
|
142
|
+
if (names.length === 0 || !existsSync(file)) return out;
|
|
143
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
144
|
+
const m = PLAN_LINE.exec(line);
|
|
145
|
+
const name = m?.[1];
|
|
146
|
+
if (m && name && names.includes(name) && !out.has(name)) out.set(name, (m[2] ?? "").trim());
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const byNumber = (a: string, b: string) => a.localeCompare(b, "en", { numeric: true });
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The ledger's landed milestones, then each milestone a lane file names that has not landed, whatever its id:
|
|
155
|
+
* lane `<milestone>.<lane>` belongs to the milestone before its first dot, as protocol.ts reads it. An open
|
|
156
|
+
* milestone's description is plan.md's line for it, when there is one.
|
|
157
|
+
*/
|
|
129
158
|
function milestonesOf(run: Run): Milestone[] {
|
|
130
159
|
const landed = nonBlankLines(runPaths(run.dir).ledger)
|
|
131
160
|
.slice(1)
|
|
132
161
|
.map((l) => l.split(" | "))
|
|
133
162
|
.map(([name, what]) => ({ name: (name ?? "").trim(), landed: true, what: (what ?? "").trim() }));
|
|
134
163
|
const lanes = existsSync(runPaths(run.dir).lanes) ? readdirSync(runPaths(run.dir).lanes) : [];
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
164
|
+
const names = [
|
|
165
|
+
...new Set(
|
|
166
|
+
lanes
|
|
167
|
+
.filter((f) => f.endsWith(".md") && f.slice(0, -".md".length).includes("."))
|
|
168
|
+
.map((f) => f.split(".")[0] as string),
|
|
169
|
+
),
|
|
170
|
+
]
|
|
171
|
+
.filter((m) => m !== "" && !landed.some((l) => l.name === m))
|
|
172
|
+
.sort(byNumber);
|
|
173
|
+
const what = planWhat(run, names);
|
|
174
|
+
return [...landed, ...names.map((name) => ({ name, landed: false, what: what.get(name) ?? "" }))];
|
|
140
175
|
}
|
|
141
176
|
|
|
142
177
|
/** Each role's latest dispatch, live ones first, then the newest finished first. */
|
|
@@ -174,7 +209,7 @@ function sessionRun(deps: Deps, x: SessionGroup["runs"][number]): SessionRun {
|
|
|
174
209
|
try {
|
|
175
210
|
const b = budgetStatus(
|
|
176
211
|
spendOf(run, readRecords(run).records, liveDispatches(run, deps.now()), deps.now()),
|
|
177
|
-
deps.profiles.forRepo(run.meta.repo).budget,
|
|
212
|
+
deps.profiles.budgetFor?.(run.meta.repo) ?? deps.profiles.forRepo(run.meta.repo).budget,
|
|
178
213
|
);
|
|
179
214
|
budget = b?.fraction ?? null;
|
|
180
215
|
} catch {
|
|
@@ -182,6 +217,7 @@ function sessionRun(deps: Deps, x: SessionGroup["runs"][number]): SessionRun {
|
|
|
182
217
|
}
|
|
183
218
|
return {
|
|
184
219
|
id: run.id,
|
|
220
|
+
waiting: orchestratorWait(run, deps.now()),
|
|
185
221
|
title: run.meta.title,
|
|
186
222
|
repo: run.meta.repo,
|
|
187
223
|
createdAt: run.meta.createdAt,
|