catherd-cli 1.0.0 → 1.1.1

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 (83) hide show
  1. package/README.md +66 -42
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin/.mcp.json +2 -2
  5. package/plugin/bin/catherd-mcp +30 -0
  6. package/plugin/commands/catherd.md +1 -1
  7. package/plugin/skills/catherd/SKILL.md +112 -95
  8. package/plugin/skills/catherd-setup/SKILL.md +3 -3
  9. package/src/adapters/access.ts +118 -0
  10. package/src/adapters/backend.ts +19 -4
  11. package/src/adapters/claude-code/index.ts +95 -1
  12. package/src/adapters/codex/index.ts +78 -23
  13. package/src/adapters/opencode/index.ts +17 -0
  14. package/src/domain/errors.ts +2 -0
  15. package/src/domain/failover.ts +119 -0
  16. package/src/domain/lane.ts +23 -0
  17. package/src/domain/notice.ts +95 -0
  18. package/src/domain/profile-rules.ts +43 -9
  19. package/src/domain/profile.ts +12 -6
  20. package/src/domain/record.ts +2 -0
  21. package/src/domain/role-prompts.ts +33 -1
  22. package/src/domain/state.ts +18 -2
  23. package/src/entry/deps.ts +2 -1
  24. package/src/entry/doctor-command.ts +7 -1
  25. package/src/entry/glyphs.ts +2 -1
  26. package/src/entry/init-command.ts +41 -1
  27. package/src/entry/mcp/command.ts +5 -1
  28. package/src/entry/mcp/dispatch-tools.ts +10 -40
  29. package/src/entry/mcp/handshake.ts +19 -9
  30. package/src/entry/mcp/lane-tools.ts +7 -6
  31. package/src/entry/mcp/protocol-tools.ts +57 -0
  32. package/src/entry/mcp/run-tools.ts +4 -3
  33. package/src/entry/mcp/server.ts +17 -1
  34. package/src/entry/mcp/setup-tools.ts +1 -1
  35. package/src/entry/profile-command.ts +3 -5
  36. package/src/entry/runs-command.ts +64 -21
  37. package/src/entry/tui/commands.ts +1 -1
  38. package/src/entry/tui/effects.ts +98 -56
  39. package/src/entry/tui/fixtures.ts +352 -85
  40. package/src/entry/tui/profile-edits.ts +2 -2
  41. package/src/entry/tui/profile-tree.ts +2 -1
  42. package/src/entry/tui/providers/data.tsx +35 -8
  43. package/src/entry/tui/run.tsx +11 -1
  44. package/src/entry/tui/state.ts +26 -6
  45. package/src/entry/tui/stories.ts +36 -4
  46. package/src/entry/tui/text.ts +12 -0
  47. package/src/entry/tui/theme.ts +1 -0
  48. package/src/entry/tui/views/runs.tsx +409 -117
  49. package/src/entry/tui/views/status.tsx +5 -2
  50. package/src/entry/tui/widgets/list.tsx +4 -3
  51. package/src/infra/claude-session.ts +98 -0
  52. package/src/infra/dispatch-dir.ts +14 -6
  53. package/src/infra/env.ts +26 -2
  54. package/src/infra/peer-inbox.ts +92 -0
  55. package/src/infra/supervisor.ts +33 -3
  56. package/src/services/admission.ts +38 -12
  57. package/src/services/dispatch-service.ts +338 -197
  58. package/src/services/dispatches.ts +30 -1
  59. package/src/services/doctor-access.ts +231 -0
  60. package/src/services/doctor-backends.ts +3 -18
  61. package/src/services/doctor-checks.ts +46 -1
  62. package/src/services/doctor-push.ts +109 -0
  63. package/src/services/doctor.ts +59 -79
  64. package/src/services/finalize.ts +43 -25
  65. package/src/services/gate-service.ts +288 -0
  66. package/src/services/global-install.ts +99 -0
  67. package/src/services/lane-service.ts +150 -8
  68. package/src/services/milestones.ts +208 -0
  69. package/src/services/notifier.ts +296 -0
  70. package/src/services/peek.ts +103 -0
  71. package/src/services/ports.ts +7 -4
  72. package/src/services/preflight.ts +17 -3
  73. package/src/services/protocol.ts +212 -0
  74. package/src/services/questions.ts +102 -0
  75. package/src/services/reconcile.ts +40 -11
  76. package/src/services/reentry.ts +22 -0
  77. package/src/services/run-service.ts +42 -9
  78. package/src/services/run-store.ts +18 -0
  79. package/src/services/runs-page.ts +254 -0
  80. package/src/services/session-view.ts +152 -0
  81. package/src/services/sessions.ts +92 -0
  82. package/src/services/state.ts +10 -3
  83. package/src/services/summary.ts +14 -0
@@ -0,0 +1,99 @@
1
+ import { homedir } from "node:os";
2
+ import { delimiter, join } from "node:path";
3
+ import { errorMessage } from "../domain/errors.ts";
4
+ import { scrubSecrets } from "../infra/env.ts";
5
+
6
+ /** How `init` finds and installs the global `catherd` (injectable: tests never reach the registry). */
7
+ export interface GlobalInstallDeps {
8
+ /**
9
+ * what the `catherd` in bun's global bin prints for --version; null when there is none or it does not
10
+ * answer. Never PATH: under `bunx catherd-cli init`, PATH first finds bunx's own transient shim.
11
+ */
12
+ globalVersion(): Promise<string | null>;
13
+ /** what the first `catherd` on PATH outside a bunx transient install prints for --version, or null */
14
+ pathVersion(): Promise<string | null>;
15
+ /** `bun add -g catherd-cli@<version>`, with its output */
16
+ install(version: string): Promise<{ ok: boolean; output: string }>;
17
+ }
18
+
19
+ export type GlobalInstall =
20
+ | { state: "current" }
21
+ | { state: "installed" }
22
+ /** installed, but the `catherd` first on PATH is another version (or none), so the launcher still runs bunx */
23
+ | { state: "shadowed"; onPath: string | null }
24
+ | { state: "failed"; reason: string };
25
+
26
+ async function run(cmd: string[]): Promise<{ ok: boolean; stdout: string; output: string }> {
27
+ const p = Bun.spawn(cmd, {
28
+ env: scrubSecrets(process.env),
29
+ stdin: "ignore",
30
+ stdout: "pipe",
31
+ stderr: "pipe",
32
+ });
33
+ const [out, err, code] = await Promise.all([
34
+ new Response(p.stdout).text(),
35
+ new Response(p.stderr).text(),
36
+ p.exited,
37
+ ]);
38
+ return { ok: code === 0, stdout: out, output: `${err}${out}` };
39
+ }
40
+
41
+ /** what `bin --version` prints on stdout (a warning on stderr must not make it look like another version) */
42
+ async function versionOf(bin: string | null): Promise<string | null> {
43
+ if (!bin) return null;
44
+ const r = await run([bin, "--version"]).catch(() => null);
45
+ return r?.ok ? r.stdout.trim() || null : null;
46
+ }
47
+
48
+ /**
49
+ * bun's global bin folder: `bun pm bin -g`, else where bun puts it by default. `bun pm bin -g` fails
50
+ * (Bun 1.4.2) while the global folder exists without a package.json, which is before the first global install.
51
+ */
52
+ async function globalBin(): Promise<string> {
53
+ const r = await run([process.execPath, "pm", "bin", "-g"]).catch(() => null);
54
+ const dir = r?.ok ? r.stdout.trim() : "";
55
+ if (dir) return dir;
56
+ return process.env.BUN_INSTALL_BIN || join(process.env.BUN_INSTALL || join(homedir(), ".bun"), "bin");
57
+ }
58
+
59
+ /** `bunx` runs a package from `<tmp>/bunx-<uid>-<package>@<tag>/node_modules/.bin`, prepended to PATH */
60
+ const isBunxBin = (dir: string) => dir.split(/[\\/]/).some((part) => part.startsWith("bunx-"));
61
+
62
+ export const realGlobalInstall: GlobalInstallDeps = {
63
+ async globalVersion() {
64
+ return versionOf(Bun.which("catherd", { PATH: await globalBin() }));
65
+ },
66
+ async pathVersion() {
67
+ const PATH = (process.env.PATH ?? "")
68
+ .split(delimiter)
69
+ .filter((dir) => dir && !isBunxBin(dir))
70
+ .join(delimiter);
71
+ return versionOf(Bun.which("catherd", { PATH }));
72
+ },
73
+ install: (version) =>
74
+ run([process.execPath, "add", "-g", `catherd-cli@${version}`]).catch((e: unknown) => ({
75
+ ok: false,
76
+ output: errorMessage(e),
77
+ })),
78
+ };
79
+
80
+ /**
81
+ * Spec 1.1 §12: the global CLI at `version`, so the plugin's launcher starts it without a bunx resolve.
82
+ * `installing` runs before anything is resolved, so the user sees why the next seconds pass.
83
+ */
84
+ export async function ensureGlobal(
85
+ version: string,
86
+ deps: GlobalInstallDeps,
87
+ installing: () => void,
88
+ ): Promise<GlobalInstall> {
89
+ if ((await deps.globalVersion()) === version) return { state: "current" };
90
+ installing();
91
+ // never rejects: a failed install is reported, and init goes on
92
+ const r = await deps.install(version).catch((e: unknown) => ({ ok: false, output: errorMessage(e) }));
93
+ if (r.ok) {
94
+ const onPath = await deps.pathVersion().catch(() => null);
95
+ return onPath === version ? { state: "installed" } : { state: "shadowed", onPath };
96
+ }
97
+ const reason = r.output.split("\n").find((l) => l.trim()) ?? "bun add -g failed";
98
+ return { state: "failed", reason: reason.trim() };
99
+ }
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { relative, sep } from "node:path";
3
3
  import { CatherdError } from "../domain/errors.ts";
4
4
  import { assertId, ID_PATTERN, parseRung } from "../domain/ids.ts";
5
- import type { Difficulty, Kind } from "../domain/lane.ts";
5
+ import { assertLaneHeader, type Difficulty, type Kind } from "../domain/lane.ts";
6
6
  import type { Role } from "../domain/roles.ts";
7
7
  import {
8
8
  type ClimbReason,
@@ -14,7 +14,18 @@ import {
14
14
  } from "../domain/route.ts";
15
15
  import { withFileLock } from "../infra/filelock.ts";
16
16
  import { commitExists } from "../infra/git.ts";
17
+ import { laneFile } from "./admission.ts";
17
18
  import { budgetOf } from "./budget.ts";
19
+ import {
20
+ isDocPath,
21
+ isSourcePath,
22
+ landedMilestones,
23
+ fullCommit,
24
+ milestoneFiles,
25
+ milestoneStart,
26
+ reviewerPassed,
27
+ milestoneVerifier,
28
+ } from "./milestones.ts";
18
29
  import type { Deps, Verdict } from "./ports.ts";
19
30
  import {
20
31
  appendLedger,
@@ -27,6 +38,8 @@ import {
27
38
  runFile,
28
39
  runPaths,
29
40
  } from "./run-store.ts";
41
+ import { writeDigest } from "./protocol.ts";
42
+ import { openQuestions } from "./questions.ts";
30
43
  import { type Notes, type NotesPatch, refreshState } from "./state.ts";
31
44
  import { appendPrivate } from "../infra/store.ts";
32
45
 
@@ -60,7 +73,10 @@ function readLaneFile(run: Run, path: string): { lane: string; text: string } {
60
73
  throw new CatherdError("E_LANE_INVALID", `no lane file ${path}`, {
61
74
  fix: "write it with write_run_file first",
62
75
  });
63
- return { lane, text: readFileSync(file, "utf8") };
76
+ const text = readFileSync(file, "utf8");
77
+ // spec 1.1 §6: a lane routes only on values the catalog knows
78
+ assertLaneHeader(text, `lanes/${lane}.md`);
79
+ return { lane, text };
64
80
  }
65
81
 
66
82
  /** Spec §5.4 through the routing port; a lane's route is recorded in routes.jsonl. */
@@ -105,6 +121,40 @@ export async function route(
105
121
  };
106
122
  }
107
123
 
124
+ /** Evidence that the lane's own ownership is the problem: a design question, never a capability one. */
125
+ const OWNERSHIP = /outside (the )?lane('s)? ownership|owned by/i;
126
+
127
+ const CLIMB_DESIGN_FIX = "send it to the architect (ask/architect delta), not up the ladder";
128
+
129
+ /**
130
+ * Spec 1.1 §9: a climb is for capability. Evidence that points at the plan (a contradiction, a file the
131
+ * lane does not own, a cross-lane interface) goes to the architect: ownership evidence on a `blocked`
132
+ * climb is refused outright; with Jev on, its `finding` answer `design` refuses any climb with evidence.
133
+ * A climb the environment caused (`env`) is never a design question.
134
+ */
135
+ async function refuseDesign(
136
+ deps: Deps,
137
+ run: Run,
138
+ i: { lane: string; reason: ClimbReason; evidence?: string; env?: boolean },
139
+ ): Promise<void> {
140
+ if (!i.evidence || i.env) return;
141
+ if (i.reason === "blocked" && OWNERSHIP.test(i.evidence))
142
+ throw new CatherdError(
143
+ "E_CLIMB_DESIGN",
144
+ `climb ${i.lane}: the evidence is about lane ownership, which a higher rung cannot fix`,
145
+ { fix: CLIMB_DESIGN_FIX },
146
+ );
147
+ const use = deps.profiles.forRepo(run.meta.repo).jev.use;
148
+ if (use === "off") return;
149
+ const file = laneFile(run, i.lane);
150
+ if (!existsSync(file)) return;
151
+ const v = await deps.routing.finding(run.dir, readFileSync(file, "utf8"), i.evidence, use);
152
+ if (v.value === "design")
153
+ throw new CatherdError("E_CLIMB_DESIGN", `climb ${i.lane}: Jev calls the evidence a design finding`, {
154
+ fix: CLIMB_DESIGN_FIX,
155
+ });
156
+ }
157
+
108
158
  /** Spec §4.5: one rung up the lane's ladder, on a fresh thread; the reason goes to routes.jsonl. */
109
159
  export async function climb(
110
160
  deps: Deps,
@@ -119,12 +169,16 @@ export async function climb(
119
169
  }> {
120
170
  const run = findRun(i.run);
121
171
  assertId("lane", i.lane);
172
+ const unrouted = () =>
173
+ new CatherdError("E_LANE_INVALID", `lane ${i.lane} was never routed`, {
174
+ fix: `route(run, "lanes/${i.lane}.md") first`,
175
+ });
176
+ // an unrouted lane is refused before Jev is asked about its evidence (no call, no jev.jsonl row)
177
+ if (!currentRoute(readRoutes(run), i.lane)) throw unrouted();
178
+ await refuseDesign(deps, run, i);
122
179
  const { cur, next } = await withFileLock(runPaths(run.dir).routes, () => {
123
180
  const cur = currentRoute(readRoutes(run), i.lane);
124
- if (!cur)
125
- throw new CatherdError("E_LANE_INVALID", `lane ${i.lane} was never routed`, {
126
- fix: `route(run, "lanes/${i.lane}.md") first`,
127
- });
181
+ if (!cur) throw unrouted();
128
182
  const next = nextRung(cur.ladder, cur.rung);
129
183
  appendRoute(run, {
130
184
  ...cur,
@@ -160,6 +214,81 @@ export async function climb(
160
214
 
161
215
  const cell = (s: string) => s.replace(/[|\n]/g, "/").replace(/\s+/g, " ").trim();
162
216
 
217
+ export const LAND_SKIPS = ["docs-only", "no-code"] as const;
218
+ export type LandSkip = (typeof LAND_SKIPS)[number];
219
+
220
+ /**
221
+ * Spec 1.1 §6: a milestone lands only with a reviewer record and a verifier verdict since its lanes
222
+ * started, or with a `skip` the commit range bears out. Throws E_LAND_GATE naming what is missing.
223
+ */
224
+ async function gate(run: Run, m: string, commit: string, skip: LandSkip | undefined): Promise<void> {
225
+ // spec 1.1 §8: a parked milestone waits on the owner, whatever else it has
226
+ const question = openQuestions(run).find((q) => q.milestone === m);
227
+ if (question)
228
+ throw new CatherdError(
229
+ "E_LAND_GATE",
230
+ `land ${m}: it is parked, waiting on the owner: ${question.question}`,
231
+ {
232
+ fix: `when the owner answers, call answer(run, "${m}", <their answer>), finish ${m}, then land it`,
233
+ },
234
+ );
235
+ if (skip) {
236
+ // the range a skip is judged on must be the milestone as it stands: an older commit would leave
237
+ // later source commits unreviewed
238
+ const [landed, head] = await Promise.all([fullCommit(run, commit), fullCommit(run, "HEAD")]);
239
+ if (landed !== head)
240
+ throw new CatherdError(
241
+ "E_LAND_GATE",
242
+ `land ${m}: skip "${skip}" refused: ${commit} is not HEAD (${head.slice(0, 7)}): commits after it would land unreviewed`,
243
+ {
244
+ fix: `land ${m} with HEAD (${head.slice(0, 7)}) once it holds the milestone; if source changed, run reviewer-${m} and the verifier and land without skip`,
245
+ },
246
+ );
247
+ const files = await milestoneFiles(run, commit);
248
+ // an empty range lands nothing: the milestone's work is most likely not committed yet
249
+ if (files.length === 0) {
250
+ const last = landedMilestones(run).at(-1);
251
+ throw new CatherdError(
252
+ "E_LAND_GATE",
253
+ `land ${m}: skip "${skip}" refused: ${last ? `the commit range changed nothing since ${last} landed` : `${commit} changed nothing`}`,
254
+ { fix: `commit the milestone first, then land ${m} with that commit` },
255
+ );
256
+ }
257
+ const against =
258
+ skip === "docs-only" ? files.filter((f) => !isDocPath(f)) : files.filter((f) => isSourcePath(f));
259
+ if (against.length === 0) return;
260
+ const shown = `${against.slice(0, 5).join(", ")}${against.length > 5 ? `, and ${against.length - 5} more` : ""}`;
261
+ throw new CatherdError(
262
+ "E_LAND_GATE",
263
+ `land ${m}: skip "${skip}" refused: ${skip === "docs-only" ? "files outside the docs changed" : "source files changed"}: ${shown}`,
264
+ { fix: `run the reviewer (reviewer-${m}) and the verifier on ${m}, then land it without skip` },
265
+ );
266
+ }
267
+ const start = milestoneStart(run, m);
268
+ // the latest verifier attempt, passed or not: a FAIL after a PASS undoes it
269
+ const verdict = milestoneVerifier(run, m, start);
270
+ const missing = [
271
+ ...(reviewerPassed(run, m, start)
272
+ ? []
273
+ : [
274
+ `a reviewer record (a dispatch named reviewer-${m}, or record_agent_run with role reviewer and that name, status ok)`,
275
+ ]),
276
+ ...(verdict?.passed
277
+ ? []
278
+ : [
279
+ `a verifier verdict (record_agent_run with role verifier and a name holding ${m}, status ok; a headless verifier's reply opening VERDICT: PASS)${verdict ? `: the latest, ${verdict.name}${verdict.headless ? " (headless)" : ""}, is ${verdict.verdict}` : ""}`,
280
+ ]),
281
+ ];
282
+ if (missing.length)
283
+ throw new CatherdError(
284
+ "E_LAND_GATE",
285
+ `land ${m}: missing ${missing.join(" and ")}, since its lanes started`,
286
+ {
287
+ fix: `run reviewer-${m} (a dispatch, or a Claude subagent recorded with record_agent_run(name: "reviewer-${m}")) and the verifier on ${m}, recording it with record_agent_run(name: "verifier-${m}") (a FAIL with status "failed"), then land again; a docs-only milestone passes skip: "docs-only"`,
288
+ },
289
+ );
290
+ }
291
+
163
292
  /**
164
293
  * Spec §4.7: the full five-column ledger row, with the minutes since the previous landing (or the
165
294
  * run's start), and `learned` appended to the repo's knowledge.md.
@@ -174,14 +303,18 @@ export async function land(
174
303
  evidence: string;
175
304
  next: string;
176
305
  learned?: string;
306
+ skip?: LandSkip;
177
307
  },
178
- ): Promise<{ ledger: string; minutes: number; hints?: string[] }> {
308
+ ): Promise<{ ledger: string; minutes: number; digest: string; hints?: string[] }> {
179
309
  const run = findRun(i.run);
310
+ // the digest is named after the milestone: an id, checked before anything is written
311
+ assertId("milestone", i.milestone);
180
312
  // commitExists throws E_IO_UNEXPECTED on a timeout, which reaches the caller as is
181
313
  if (!/^[0-9a-f]{7,40}$/.test(i.commit) || !(await commitExists(run.meta.repo, i.commit)))
182
314
  throw new CatherdError("E_RUN_COMMIT", `no commit ${i.commit} in ${run.meta.repo}`, {
183
315
  fix: "commit the milestone first, then pass its hash",
184
316
  });
317
+ await gate(run, i.milestone, i.commit, i.skip);
185
318
  const now = new Date(deps.now());
186
319
  let row = "";
187
320
  let minutes = 0;
@@ -221,7 +354,16 @@ export async function land(
221
354
  `- ${now.toISOString().slice(0, 10)} ${run.meta.title} ${i.milestone}: ${cell(i.learned)}\n`,
222
355
  );
223
356
  }
224
- return { ledger: row, minutes, ...withHints(hints) };
357
+ // spec 1.1 §10: the milestone's digest, which the milestone push links
358
+ const digest = writeDigest(run, {
359
+ milestone: i.milestone,
360
+ what: i.what,
361
+ commit: i.commit,
362
+ evidence: i.evidence,
363
+ minutes,
364
+ at: now.toISOString(),
365
+ });
366
+ return { ledger: row, minutes, digest, ...withHints(hints) };
225
367
  }
226
368
 
227
369
  /** Jev's `finding` or `same-defect` answer, through the routing port. */
@@ -0,0 +1,208 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { CatherdError } from "../domain/errors.ts";
4
+ import type { RunRecord } from "../domain/record.ts";
5
+ import { git } from "../infra/git.ts";
6
+ import { nonBlankLines } from "../infra/store.ts";
7
+ import { listDispatches } from "./dispatches.ts";
8
+ import { readAgentRuns, readRecords, readRoutes, type Run, runPaths } from "./run-store.ts";
9
+
10
+ // Spec 1.1 §6 and §10: what a milestone has been through, read from the run's own records. `land` gates on
11
+ // it, and the protocol's next step is derived from it.
12
+
13
+ /** Where a milestone name may end inside a longer name: the end, '-', '.', '_' or whitespace (M1-fix, M1.2). */
14
+ const MILESTONE_END = String.raw`(?=$|[-._\s])`;
15
+ const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
16
+
17
+ /** `name` names milestone `m` as a word: verifier-M1 and M1-verifier do; verifier-M10 and verifier-M1fix do not. */
18
+ export const namesMilestone = (name: string, m: string): boolean =>
19
+ new RegExp(`(^|[^A-Za-z0-9])${escapeRe(m)}${MILESTONE_END}`).test(name);
20
+
21
+ /** `name` is milestone `m`'s reviewer: reviewer-<m>, the milestone ending as in namesMilestone (reviewer-M10 is not M1's). */
22
+ export const reviewsMilestone = (name: string, m: string): boolean =>
23
+ new RegExp(`^reviewer-${escapeRe(m)}${MILESTONE_END}`).test(name);
24
+
25
+ const inMilestone = (lane: string | null | undefined, m: string): boolean =>
26
+ typeof lane === "string" && lane.startsWith(`${m}.`);
27
+
28
+ /**
29
+ * When the milestone's lanes started: its earliest route row or lane dispatch; null when neither exists
30
+ * (a milestone with no lanes, such as a docs pass), and every record of the run then counts.
31
+ */
32
+ export function milestoneStart(run: Run, m: string): string | null {
33
+ const times = [
34
+ ...readRoutes(run)
35
+ .filter((r) => inMilestone(r.lane, m))
36
+ .map((r) => r.at),
37
+ ...listDispatches(run)
38
+ .filter((d) => inMilestone(d.admit.lane, m))
39
+ .map((d) => d.admit.admittedAt),
40
+ ].filter((t) => !Number.isNaN(Date.parse(t)));
41
+ return times.sort()[0] ?? null;
42
+ }
43
+
44
+ const since = (at: string, start: string | null) => start === null || Date.parse(at) >= Date.parse(start);
45
+
46
+ /** The milestone's reviewer: its dispatch record, or null for a native Claude subagent (record_agent_run). */
47
+ export interface MilestoneReviewer {
48
+ name: string;
49
+ at: string;
50
+ record: RunRecord | null;
51
+ }
52
+
53
+ /**
54
+ * The milestone's latest passing reviewer since its lanes started: a dispatch named reviewer-<m>…, status
55
+ * ok; or a record_agent_run row with role reviewer and the same name, status ok. Null when there is none.
56
+ */
57
+ export function milestoneReviewer(
58
+ run: Run,
59
+ m: string,
60
+ start = milestoneStart(run, m),
61
+ ): MilestoneReviewer | null {
62
+ const found: MilestoneReviewer[] = [
63
+ ...readRecords(run)
64
+ .records.filter((r) => reviewsMilestone(r.name, m) && r.status === "ok" && since(r.endedAt, start))
65
+ .map((r) => ({ name: r.name, at: r.endedAt, record: r })),
66
+ ...readAgentRuns(run)
67
+ .filter(
68
+ (a) =>
69
+ a.role === "reviewer" && reviewsMilestone(a.name, m) && a.status === "ok" && since(a.at, start),
70
+ )
71
+ .map((a) => ({ name: a.name, at: a.at, record: null })),
72
+ ];
73
+ return found.sort((a, b) => Date.parse(a.at) - Date.parse(b.at)).at(-1) ?? null;
74
+ }
75
+
76
+ /** A reviewer record for the milestone since its lanes started (see milestoneReviewer). */
77
+ export const reviewerPassed = (run: Run, m: string, start = milestoneStart(run, m)): boolean =>
78
+ milestoneReviewer(run, m, start) !== null;
79
+
80
+ /** The verifier's contract puts `VERDICT: PASS` or `VERDICT: FAIL` on the reply's first line (role-prompts.ts). */
81
+ const VERDICT_PASS = /^VERDICT: PASS\b/;
82
+
83
+ /** A headless verifier's reply: its first non-blank line, trimmed; null when there is no reply to read. */
84
+ export function replyVerdict(run: Run, r: RunRecord): string | null {
85
+ const file = join(run.dir, r.replyPath);
86
+ if (!r.replyPath || !existsSync(file)) return null;
87
+ return (
88
+ readFileSync(file, "utf8")
89
+ .split("\n")
90
+ .map((l) => l.trim())
91
+ .find(Boolean) ?? null
92
+ );
93
+ }
94
+
95
+ /** A headless verifier's reply opens with VERDICT: PASS: its first non-blank line; no reply is no verdict. */
96
+ export function replyPasses(run: Run, r: RunRecord): boolean {
97
+ const first = replyVerdict(run, r);
98
+ return first !== null && VERDICT_PASS.test(first);
99
+ }
100
+
101
+ /** The milestone's latest verifier attempt: a native Claude subagent or a headless dispatch, passed or not. */
102
+ export interface MilestoneVerifier {
103
+ name: string;
104
+ at: string;
105
+ headless: boolean;
106
+ passed: boolean;
107
+ /** what the attempt said: a native row's status, or a headless reply's first non-blank line ("no reply") */
108
+ verdict: string;
109
+ }
110
+
111
+ /**
112
+ * The milestone's latest verifier attempt since its lanes started, whatever it said: a record_agent_run row
113
+ * with role verifier whose name names the milestone (the skill records a FAIL as failed), passing when its
114
+ * status is ok; or a headless verifier's dispatch record of the same shape, passing when it exited ok and its
115
+ * reply opens VERDICT: PASS (status ok means only the CLI exited). A later FAIL undoes an earlier PASS. Null
116
+ * when there is none.
117
+ */
118
+ export function milestoneVerifier(
119
+ run: Run,
120
+ m: string,
121
+ start = milestoneStart(run, m),
122
+ ): MilestoneVerifier | null {
123
+ const found: MilestoneVerifier[] = [
124
+ ...readAgentRuns(run)
125
+ .filter((a) => a.role === "verifier" && namesMilestone(a.name, m) && since(a.at, start))
126
+ .map((a) => ({
127
+ name: a.name,
128
+ at: a.at,
129
+ headless: false,
130
+ passed: a.status === "ok",
131
+ verdict: a.status,
132
+ })),
133
+ ...readRecords(run)
134
+ .records.filter((r) => r.role === "verifier" && namesMilestone(r.name, m) && since(r.endedAt, start))
135
+ .map((r) => {
136
+ const first = replyVerdict(run, r);
137
+ const passed = r.status === "ok" && first !== null && VERDICT_PASS.test(first);
138
+ const said = first === null ? "no reply" : first.length > 80 ? `${first.slice(0, 80)}…` : first;
139
+ return {
140
+ name: r.name,
141
+ at: r.endedAt,
142
+ headless: true,
143
+ passed,
144
+ verdict: r.status === "ok" ? said : `${r.status}, ${said}`,
145
+ };
146
+ }),
147
+ ];
148
+ // a stable sort: attempts at the same instant keep their file order, the later row winning
149
+ return found.sort((a, b) => Date.parse(a.at) - Date.parse(b.at)).at(-1) ?? null;
150
+ }
151
+
152
+ /** The milestone's latest verifier attempt since its lanes started passed (see milestoneVerifier). */
153
+ export const verifierPassed = (run: Run, m: string, start = milestoneStart(run, m)): boolean =>
154
+ milestoneVerifier(run, m, start)?.passed === true;
155
+
156
+ /** The commits of the landed milestones, oldest first, from the ledger. */
157
+ export function landedCommits(run: Run): string[] {
158
+ return nonBlankLines(runPaths(run.dir).ledger)
159
+ .slice(1)
160
+ .map((row) => row.split(" | ")[2]?.trim() ?? "")
161
+ .filter((c) => /^[0-9a-f]{7,40}$/.test(c));
162
+ }
163
+
164
+ /** The milestones the ledger holds, in landing order. */
165
+ export function landedMilestones(run: Run): string[] {
166
+ return nonBlankLines(runPaths(run.dir).ledger)
167
+ .slice(1)
168
+ .map((row) => row.split(" | ")[0]?.trim() ?? "")
169
+ .filter(Boolean);
170
+ }
171
+
172
+ /**
173
+ * The files the milestone's commit range changed: from the previous landed commit (else the commit's own
174
+ * parent) to `commit`. Throws E_IO_UNEXPECTED when git cannot say.
175
+ */
176
+ export async function milestoneFiles(run: Run, commit: string): Promise<string[]> {
177
+ const base = landedCommits(run).at(-1);
178
+ const repo = run.meta.repo;
179
+ const r = base
180
+ ? await git(repo, ["diff", "--name-only", base, commit])
181
+ : await git(repo, ["show", "--name-only", "--format=", "--first-parent", commit]);
182
+ if (r.kind !== "ok")
183
+ throw new CatherdError("E_IO_UNEXPECTED", `git could not list the files ${commit} changed`, {
184
+ fix: `check that git works in ${repo}`,
185
+ });
186
+ return r.out.split("\n").filter(Boolean);
187
+ }
188
+
189
+ /** `rev`'s full commit hash in the run's repo. Throws E_IO_UNEXPECTED when git cannot say. */
190
+ export async function fullCommit(run: Run, rev: string): Promise<string> {
191
+ const r = await git(run.meta.repo, ["rev-parse", "--verify", "--quiet", `${rev}^{commit}`]);
192
+ const sha = r.kind === "ok" ? r.out.trim() : "";
193
+ if (!sha)
194
+ throw new CatherdError("E_IO_UNEXPECTED", `git could not resolve ${rev} in ${run.meta.repo}`, {
195
+ fix: `check that git works in ${run.meta.repo}`,
196
+ });
197
+ return sha;
198
+ }
199
+
200
+ /** the repo's own docs/ folder, or a doc extension anywhere: app/docs/page.tsx is code */
201
+ const DOC = /^docs\/|\.(md|mdx|markdown|txt|rst|adoc)$/i;
202
+ /** .txt files that configure a build or pin dependencies: not docs */
203
+ const TXT_MANIFEST = /(^|\/)(requirements[^/]*|constraints[^/]*|CMakeLists)\.txt$/i;
204
+ const SOURCE =
205
+ /\.(ts|tsx|js|jsx|mjs|cjs|go|py|rs|java|kt|kts|swift|rb|php|c|h|cc|cpp|hpp|cs|m|scala|sh|bash|zsh|sql|vue|svelte|css|scss|sass|less|html|dart|ex|exs|erl|zig|lua)$/i;
206
+
207
+ export const isDocPath = (p: string): boolean => DOC.test(p) && !TXT_MANIFEST.test(p);
208
+ export const isSourcePath = (p: string): boolean => SOURCE.test(p) && !isDocPath(p);