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.
Files changed (159) hide show
  1. package/README.md +144 -42
  2. package/catalog/ATTRIBUTION.md +12 -4
  3. package/catalog/models.json +8 -0
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +11 -0
  7. package/plugin/.mcp-codex.json +10 -0
  8. package/plugin/bin/catherd-mcp +1 -1
  9. package/plugin/skills/catherd/SKILL.md +150 -79
  10. package/plugin/skills/catherd-setup/SKILL.md +24 -8
  11. package/src/adapters/access.ts +7 -1
  12. package/src/adapters/antigravity/index.ts +18 -3
  13. package/src/adapters/backend.ts +18 -2
  14. package/src/adapters/claude-code/index.ts +126 -14
  15. package/src/adapters/cli.ts +1 -64
  16. package/src/adapters/codex/events.ts +10 -0
  17. package/src/adapters/codex/index.ts +17 -5
  18. package/src/adapters/cursor/index.ts +7 -1
  19. package/src/adapters/grok/home.ts +75 -20
  20. package/src/adapters/grok/index.ts +27 -8
  21. package/src/adapters/opencode/index.ts +38 -1
  22. package/src/cli.ts +53 -19
  23. package/src/domain/agents.ts +3 -1
  24. package/src/domain/brief.ts +91 -0
  25. package/src/domain/calibration.ts +7 -0
  26. package/src/domain/catalog.ts +12 -1
  27. package/src/domain/changes.ts +23 -1
  28. package/src/domain/cost.ts +8 -3
  29. package/src/domain/errors.ts +4 -0
  30. package/src/domain/failover.ts +17 -5
  31. package/src/domain/gate-brief.ts +52 -0
  32. package/src/domain/hints.ts +13 -2
  33. package/src/domain/host.ts +17 -0
  34. package/src/domain/lane.ts +99 -3
  35. package/src/domain/notice.ts +14 -4
  36. package/src/domain/profile-rules.ts +129 -16
  37. package/src/domain/profile.ts +56 -5
  38. package/src/domain/record.ts +17 -2
  39. package/src/domain/reply.ts +29 -0
  40. package/src/domain/role-prompts.ts +56 -16
  41. package/src/domain/role-scope.ts +106 -0
  42. package/src/domain/role-tools.ts +34 -0
  43. package/src/domain/route.ts +73 -0
  44. package/src/domain/select.ts +177 -25
  45. package/src/domain/state.ts +5 -0
  46. package/src/domain/util.ts +22 -0
  47. package/src/domain/workspace.ts +130 -0
  48. package/src/entry/catalog-command.ts +18 -8
  49. package/src/entry/cli-kit.ts +17 -1
  50. package/src/entry/deps.ts +8 -3
  51. package/src/entry/doctor-command.ts +29 -2
  52. package/src/entry/host-arg.ts +56 -0
  53. package/src/entry/init-command.ts +20 -8
  54. package/src/entry/knowledge-command.ts +78 -3
  55. package/src/entry/lock-command.ts +111 -10
  56. package/src/entry/mcp/dispatch-tools.ts +3 -3
  57. package/src/entry/mcp/handshake.ts +8 -2
  58. package/src/entry/mcp/lane-tools.ts +55 -8
  59. package/src/entry/mcp/protocol-tools.ts +10 -4
  60. package/src/entry/mcp/push-tools.ts +16 -0
  61. package/src/entry/mcp/role-bin.ts +20 -0
  62. package/src/entry/mcp/role-server.ts +116 -0
  63. package/src/entry/mcp/run-tools.ts +25 -6
  64. package/src/entry/mcp/server.ts +224 -44
  65. package/src/entry/mcp/setup-tools.ts +11 -5
  66. package/src/entry/mcp/workspace-tools.ts +142 -0
  67. package/src/entry/pause-command.ts +67 -0
  68. package/src/entry/profile-command.ts +131 -16
  69. package/src/entry/role-cli-command.ts +115 -0
  70. package/src/entry/runs-command.ts +157 -11
  71. package/src/entry/supervise-command.ts +30 -3
  72. package/src/entry/tui/effects.ts +23 -8
  73. package/src/entry/tui/fixtures.ts +33 -10
  74. package/src/entry/tui/run.tsx +10 -4
  75. package/src/entry/tui/state.ts +56 -16
  76. package/src/entry/tui/stories.ts +11 -4
  77. package/src/entry/tui/views/profile-actions.ts +4 -2
  78. package/src/entry/tui/views/profiles.tsx +3 -3
  79. package/src/entry/tui/views/runs.tsx +66 -20
  80. package/src/entry/tui/views/save-dialog.tsx +8 -4
  81. package/src/entry/tui/views/status.tsx +6 -2
  82. package/src/entry/workspace-command.ts +44 -0
  83. package/src/infra/boot-lock.ts +25 -0
  84. package/src/infra/cli.ts +65 -0
  85. package/src/infra/codex-queue.ts +165 -0
  86. package/src/infra/delivery.ts +86 -0
  87. package/src/infra/dispatch-dir.ts +3 -0
  88. package/src/infra/env.ts +15 -2
  89. package/src/infra/git.ts +6 -0
  90. package/src/infra/heavy-lock.ts +93 -4
  91. package/src/infra/host-context.ts +58 -0
  92. package/src/infra/host-probe.ts +16 -0
  93. package/src/infra/lock-activity.ts +88 -0
  94. package/src/infra/login-env.ts +48 -0
  95. package/src/infra/paths.ts +35 -0
  96. package/src/infra/peer-inbox.ts +4 -1
  97. package/src/infra/role-mcp.ts +69 -0
  98. package/src/infra/sources/arena.ts +11 -3
  99. package/src/infra/sources/artificial-analysis.ts +8 -1
  100. package/src/infra/sources/cache.ts +2 -0
  101. package/src/infra/supervisor.ts +110 -18
  102. package/src/services/admission.ts +117 -26
  103. package/src/services/agent-links.ts +30 -5
  104. package/src/services/backends.ts +7 -0
  105. package/src/services/budget.ts +26 -6
  106. package/src/services/catalog-service.ts +20 -12
  107. package/src/services/credentials.ts +11 -6
  108. package/src/services/dispatch-service.ts +220 -22
  109. package/src/services/dispatches.ts +36 -3
  110. package/src/services/doctor-access.ts +8 -3
  111. package/src/services/doctor-backends.ts +30 -7
  112. package/src/services/doctor-checks.ts +20 -5
  113. package/src/services/doctor-docker.ts +221 -0
  114. package/src/services/doctor-push.ts +69 -90
  115. package/src/services/doctor-role-mcp.ts +65 -0
  116. package/src/services/doctor.ts +172 -38
  117. package/src/services/end-push.ts +157 -0
  118. package/src/services/finalize.ts +13 -3
  119. package/src/services/gate-env.ts +218 -0
  120. package/src/services/gate-service.ts +216 -45
  121. package/src/services/global-install.ts +46 -13
  122. package/src/services/lane-edit.ts +154 -0
  123. package/src/services/lane-service.ts +258 -52
  124. package/src/services/milestones.ts +137 -26
  125. package/src/services/notifier.ts +377 -72
  126. package/src/services/orchestrator-wait.ts +82 -0
  127. package/src/services/pause.ts +161 -0
  128. package/src/services/peek.ts +122 -23
  129. package/src/services/ports.ts +39 -4
  130. package/src/services/preflight.ts +153 -19
  131. package/src/services/profile-service.ts +117 -30
  132. package/src/services/profile-store.ts +31 -7
  133. package/src/services/protocol.ts +119 -25
  134. package/src/services/provenance.ts +16 -12
  135. package/src/services/questions.ts +15 -0
  136. package/src/services/reconcile.ts +22 -1
  137. package/src/services/reentry.ts +7 -6
  138. package/src/services/role-access.ts +27 -0
  139. package/src/services/routing-service.ts +150 -37
  140. package/src/services/run-debug.ts +98 -4
  141. package/src/services/run-pin.ts +125 -0
  142. package/src/services/run-service.ts +153 -19
  143. package/src/services/run-store.ts +119 -19
  144. package/src/services/runs-page.ts +45 -9
  145. package/src/services/scratch.ts +60 -0
  146. package/src/services/session-view.ts +18 -13
  147. package/src/services/sessions.ts +34 -16
  148. package/src/services/setup.ts +31 -10
  149. package/src/services/source-derive.ts +28 -5
  150. package/src/services/source-sync.ts +9 -2
  151. package/src/services/standins.ts +40 -6
  152. package/src/services/state.ts +21 -0
  153. package/src/services/summary.ts +86 -11
  154. package/src/services/treat-likes.ts +41 -12
  155. package/src/services/verifier-brief.ts +23 -0
  156. package/src/services/verifier-step.ts +48 -0
  157. package/src/services/workspace-admission.ts +267 -0
  158. package/src/services/workspace-service.ts +254 -0
  159. 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 { gitToplevel } from "../infra/git.ts";
9
- import { writeTextAtomic } from "../infra/store.ts";
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
- knowledgeFile,
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
- const run = createRun({
47
- repo: top,
48
- title: i.title,
49
- aLines: i.aLines,
50
- version: deps.version,
51
- now: new Date(deps.now()),
52
- startedBy: currentSession(deps),
53
- });
54
- await claimRun(deps, run);
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 file = runFile(findRun(i.run), i.path, "write");
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 = knowledgeFile(await repoTop(repo));
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: knowledgeFile(top) };
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 { dataDir, repoDir, runsDir } from "../infra/paths.ts";
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.object({
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
- startedBy?: StartedBy | null;
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 of every repo, newest first. */
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
- for (const repo of readdirSync(root, { withFileTypes: true })) {
144
- const runs = join(root, repo.name, "runs");
145
- if (!repo.isDirectory() || !existsSync(runs)) continue;
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) => typeof r?.lane === "string" && Array.isArray(r.ladder) && typeof r.rung === "string",
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
- export function appendRoute(run: Run, row: RouteRow): void {
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
- return readJsonl<unknown>(runPaths(run.dir).agents).rows.flatMap((row) => {
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(toplevel: string, at: Date, source: string, text: string): string {
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(knowledgeFile(toplevel), `${line}\n`);
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?.sessionId ?? null;
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
- /** The ledger's landed milestones, then each milestone a lane file names that has not landed. */
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 open = [...new Set(lanes.map((f) => /^(M\d+)\./.exec(f)?.[1]).filter((m): m is string => !!m))]
136
- .filter((m) => !landed.some((l) => l.name === m))
137
- .map((name) => ({ name, landed: false, what: "" }));
138
- const n = (m: string) => Number(/\d+/.exec(m)?.[0] ?? 0);
139
- return [...landed, ...open.sort((a, b) => n(a.name) - n(b.name))];
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,