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
@@ -0,0 +1,154 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { CatherdError } from "../domain/errors.ts";
4
+ import { assertId } from "../domain/ids.ts";
5
+ import {
6
+ assertLaneValues,
7
+ headerBlock,
8
+ headerLine,
9
+ type LaneHeader,
10
+ normalizeOwned,
11
+ overlaps,
12
+ parseLaneHeader,
13
+ } from "../domain/lane.ts";
14
+ import { cell } from "../domain/util.ts";
15
+ import { withFileLock } from "../infra/filelock.ts";
16
+ import { writeTextAtomic } from "../infra/store.ts";
17
+ import { pendingDispatches } from "./dispatches.ts";
18
+ import type { Deps } from "./ports.ts";
19
+ import { findRun, type Run, runPaths } from "./run-store.ts";
20
+
21
+ /** lanes/<lane>.md in the run folder (admission's laneFile, kept here so finalize can import this module). */
22
+ const laneFile = (run: Run, lane: string): string => join(runPaths(run.dir).lanes, `${lane}.md`);
23
+
24
+ // Spec 1.5 "Lane editing": one header line at a time, validated, instead of `sed` on the run folder; and an
25
+ // Owns list that can grow mid-lane, with the overlap re-checked.
26
+
27
+ /** The header lines lane_set writes, and their labels in the lane file. */
28
+ export const LANE_FIELDS = {
29
+ owns: "Owns",
30
+ fast_check: "Fast check",
31
+ kind: "Kind",
32
+ difficulty: "Difficulty",
33
+ after: "After",
34
+ allow: "Allow",
35
+ } as const;
36
+ export type LaneField = keyof typeof LANE_FIELDS;
37
+
38
+ /** `text` with `label`'s header line set to `value`: replaced where it is, else added atop the header block. */
39
+ export function setHeaderLine(text: string, label: string, value: string): string {
40
+ const line = `${label}: ${value}`;
41
+ const lines = text.split("\n");
42
+ const [start, end] = headerBlock(lines);
43
+ const re = headerLine(label);
44
+ const at = lines.slice(start, end).findIndex((l) => re.test(l));
45
+ if (at >= 0) lines[start + at] = line;
46
+ else lines.splice(start, 0, line);
47
+ return lines.join("\n");
48
+ }
49
+
50
+ function readLane(run: Run, lane: string): { file: string; text: string } {
51
+ assertId("lane", lane);
52
+ const file = laneFile(run, lane);
53
+ if (!existsSync(file))
54
+ throw new CatherdError("E_LANE_INVALID", `no lane file lanes/${lane}.md`, {
55
+ fix: "write it with write_run_file first",
56
+ });
57
+ return { file, text: readFileSync(file, "utf8") };
58
+ }
59
+
60
+ /**
61
+ * Paths of `owns` another running lane owns: refused as admission refuses them. Other lanes' Owns lines
62
+ * that overlap come back as hints: they are not running, so nothing collides yet.
63
+ */
64
+ function overlapCheck(deps: Deps, run: Run, lane: string, owns: string[]): string[] {
65
+ for (const d of pendingDispatches(run, deps.now())) {
66
+ if (d.admit.lane === lane) continue;
67
+ // a running lane holds what an earlier owns_add granted it, too
68
+ const held = d.admit.lane ? [...d.admit.owns, ...currentOwns(run, d.admit.lane)] : d.admit.owns;
69
+ const shared = overlaps(owns, held);
70
+ if (shared.length)
71
+ throw new CatherdError(
72
+ "E_ADMIT_OVERLAP",
73
+ `${lane} would own ${shared.join(", ")}, which ${d.admit.name} (running) owns`,
74
+ { fix: `add it once ${d.admit.name} finishes, or give the work to ${d.admit.lane ?? d.admit.name}` },
75
+ );
76
+ }
77
+ const dir = runPaths(run.dir).lanes;
78
+ const hints: string[] = [];
79
+ for (const f of existsSync(dir) ? readdirSync(dir) : []) {
80
+ const other = f.endsWith(".md") ? f.slice(0, -3) : null;
81
+ if (!other || other === lane) continue;
82
+ try {
83
+ const shared = overlaps(owns, parseLaneHeader(readFileSync(join(dir, f), "utf8")).owns);
84
+ if (shared.length) hints.push(`lanes/${f} also owns ${shared.join(", ")}: do not run the two together`);
85
+ } catch {
86
+ // an unreadable lane is preflight's to report
87
+ }
88
+ }
89
+ return hints;
90
+ }
91
+
92
+ /**
93
+ * `lane_set(run, lane, field, value)`: one header line, validated as `write_run_file` validates a lane; an
94
+ * Owns line also re-checks overlap with the running lanes. Returns the line and the header as it now reads.
95
+ */
96
+ export async function laneSet(
97
+ deps: Deps,
98
+ i: { run: string; lane: string; field: LaneField; value: string },
99
+ ): Promise<{ lane: string; line: string; header: LaneHeader; hints?: string[] }> {
100
+ const run = findRun(i.run);
101
+ const label = LANE_FIELDS[i.field];
102
+ if (!label)
103
+ throw new CatherdError("E_INPUT_INVALID", `no lane field "${i.field}"`, {
104
+ fix: `one of ${Object.keys(LANE_FIELDS).join(", ")}`,
105
+ });
106
+ const value = i.value.replace(/\s*\n\s*/g, " ").trim();
107
+ const { file } = readLane(run, i.lane);
108
+ return withFileLock(file, () => {
109
+ const next = setHeaderLine(readLane(run, i.lane).text, label, value);
110
+ const header = assertLaneValues(next, `lanes/${i.lane}.md`);
111
+ const hints = i.field === "owns" ? overlapCheck(deps, run, i.lane, header.owns) : [];
112
+ writeTextAtomic(file, next);
113
+ return { lane: i.lane, line: `${label}: ${value}`, header, ...(hints.length ? { hints } : {}) };
114
+ });
115
+ }
116
+
117
+ /**
118
+ * `owns_add(run, lane, paths, why)`: grows a lane's Owns mid-lane (a test file the plan's grep missed, a clone
119
+ * found after the gate ran), refused when a running lane owns one of them. The why is kept in the lane file.
120
+ * A running dispatch of the lane is held to the new list when it finishes.
121
+ */
122
+ export async function ownsAdd(
123
+ deps: Deps,
124
+ i: { run: string; lane: string; paths: string[]; why: string },
125
+ ): Promise<{ lane: string; owns: string[]; added: string[]; hints?: string[] }> {
126
+ const run = findRun(i.run);
127
+ if (!i.why.trim())
128
+ throw new CatherdError("E_INPUT_INVALID", "owns_add needs a why", {
129
+ fix: "say what made the lane need the paths, in one line",
130
+ });
131
+ const paths = i.paths.map(normalizeOwned);
132
+ const { file } = readLane(run, i.lane);
133
+ return withFileLock(file, () => {
134
+ const text = readLane(run, i.lane).text;
135
+ const owns = parseLaneHeader(text).owns;
136
+ const added = paths.filter((p) => !owns.includes(p));
137
+ const merged = [...owns, ...added];
138
+ const hints = overlapCheck(deps, run, i.lane, added);
139
+ const note = `\nOwns added ${new Date(deps.now()).toISOString()}: ${added.join(", ")} — ${cell(i.why)}\n`;
140
+ const next = setHeaderLine(text, "Owns", merged.join(", "));
141
+ assertLaneValues(next, `lanes/${i.lane}.md`);
142
+ if (added.length) writeTextAtomic(file, `${next.replace(/\n*$/, "\n")}${note}`);
143
+ return { lane: i.lane, owns: merged, added, ...(hints.length ? { hints } : {}) };
144
+ });
145
+ }
146
+
147
+ /** The lane's Owns as its file reads now: a finishing dispatch is held to what owns_add granted since. */
148
+ export function currentOwns(run: Run, lane: string): string[] {
149
+ try {
150
+ return parseLaneHeader(readFileSync(laneFile(run, lane), "utf8")).owns;
151
+ } catch {
152
+ return [];
153
+ }
154
+ }
@@ -1,22 +1,33 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { relative, sep } from "node:path";
2
+ import { join, 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 { assertLaneHeader, type Difficulty, type Kind } from "../domain/lane.ts";
5
+ import { quotaUsage } from "../domain/select.ts";
6
+ import { assertLaneHeader } from "../domain/lane.ts";
6
7
  import type { Role } from "../domain/roles.ts";
7
- import { cell } from "../domain/util.ts";
8
+ import { cell, coveredMs } from "../domain/util.ts";
8
9
  import {
9
10
  type ClimbReason,
10
11
  currentRoute,
11
12
  laneOutcome,
12
13
  nextRung,
13
- type RouteJev,
14
- type RouteSource,
14
+ type OutcomeRow,
15
+ outcomeRouteRow,
16
+ type RouteRow,
15
17
  } from "../domain/route.ts";
16
18
  import { withFileLock } from "../infra/filelock.ts";
17
19
  import { commitExists } from "../infra/git.ts";
18
20
  import { laneFile } from "./admission.ts";
19
21
  import { budgetOf } from "./budget.ts";
22
+ import { pendingDispatches } from "./dispatches.ts";
23
+ import { workspaceBudget } from "./workspace-admission.ts";
24
+ import {
25
+ findWorkspace,
26
+ WORKSPACE_LOCK_WAIT_MS,
27
+ workspaceChildren,
28
+ workspaceDirectory,
29
+ workspacePaths,
30
+ } from "./workspace-store.ts";
20
31
  import {
21
32
  isDocPath,
22
33
  isSourcePath,
@@ -24,7 +35,9 @@ import {
24
35
  fullCommit,
25
36
  milestoneFiles,
26
37
  milestoneStart,
38
+ partialReviewer,
27
39
  reviewerPassed,
40
+ milestoneCommits,
28
41
  milestoneVerifier,
29
42
  } from "./milestones.ts";
30
43
  import type { Deps, Verdict } from "./ports.ts";
@@ -34,31 +47,35 @@ import {
34
47
  appendOutcome,
35
48
  appendRoute,
36
49
  findRun,
50
+ readAgentRuns,
51
+ readRecords,
37
52
  readRoutes,
38
53
  type Run,
39
54
  runFile,
40
55
  runPaths,
41
56
  } from "./run-store.ts";
42
57
  import { writeDigest } from "./protocol.ts";
43
- import { openQuestions } from "./questions.ts";
44
- import { type Notes, type NotesPatch, refreshState } from "./state.ts";
58
+ import { pauseSpans } from "./pause.ts";
59
+ import { openQuestions, parkedSpans } from "./questions.ts";
60
+ import { runProfile } from "./run-pin.ts";
61
+ import { type Notes, type NotesPatch, patchNotes, refreshState } from "./state.ts";
45
62
 
46
63
  const withHints = (hints: string[]) => (hints.length ? { hints } : {});
47
64
 
65
+ /**
66
+ * Spec 1.5 plan 24: what `route` returns: little, since every call lands in the coordinator's context. The
67
+ * decision's source, kind, difficulty, Jev's answer and the provenance go to routes.jsonl.
68
+ */
48
69
  export interface RouteResult {
49
70
  lane: string | null;
50
71
  role: Role;
51
72
  rung: string;
52
73
  ladder: string[];
53
- source: RouteSource;
54
- kind: Kind | null;
55
- difficulty: Difficulty | null;
56
74
  backend: string;
57
75
  /** the native agent to run a `claude:` rung as */
58
76
  agent: string | null;
59
- /** the Jev question set asked, and what it said, when Jev was asked */
60
- questionSet: string | null;
61
- jev: RouteJev | null;
77
+ /** the decision in one line */
78
+ why: string;
62
79
  }
63
80
 
64
81
  function readLaneFile(run: Run, path: string): { lane: string; text: string } {
@@ -79,28 +96,89 @@ function readLaneFile(run: Run, path: string): { lane: string; text: string } {
79
96
  return { lane, text };
80
97
  }
81
98
 
82
- /** Spec §5.4 through the routing port; a lane's route is recorded in routes.jsonl. */
99
+ /**
100
+ * Spec 1.5 plan 24: the run's dispatches per quota, plus each lane routed but not dispatched yet at its current
101
+ * rung, so a tie between quotas goes to the one the run will have used least.
102
+ */
103
+ function runUsage(run: Run): Record<string, number> {
104
+ const records = readRecords(run).records;
105
+ const dispatched = new Set(records.flatMap((r) => (r.lane ? [r.lane] : [])));
106
+ const routes = readRoutes(run);
107
+ const waiting = [...new Set(routes.map((r) => r.lane))]
108
+ .filter((l) => !dispatched.has(l))
109
+ .flatMap((l) => currentRoute(routes, l)?.rung ?? []);
110
+ return quotaUsage([...records.map((r) => r.rung), ...waiting]);
111
+ }
112
+
113
+ /** Spec §5.4 through the routing port; every decision is recorded in routes.jsonl. */
83
114
  export async function route(
84
115
  deps: Deps,
85
116
  i: { run: string; laneFile?: string; role: Role },
86
117
  ): Promise<RouteResult> {
118
+ const [one] = await routeLanes(deps, { run: i.run, laneFiles: [i.laneFile], role: i.role });
119
+ return one as RouteResult;
120
+ }
121
+
122
+ /**
123
+ * Spec 1.5 plan 24, batch `route`: several lanes of one role in one call (Jev asked about all at once), each
124
+ * recorded as `route` records one. `laneFiles` holds `undefined` for a role routed without a lane.
125
+ */
126
+ export async function routeLanes(
127
+ deps: Deps,
128
+ i: { run: string; laneFiles: (string | undefined)[]; role: Role },
129
+ ): Promise<RouteResult[]> {
87
130
  const run = findRun(i.run);
88
- const lane = i.laneFile === undefined ? null : readLaneFile(run, i.laneFile);
89
- const profile = deps.profiles.forRepo(run.meta.repo);
90
- const a = await deps.routing.route({
131
+ const lanes = i.laneFiles.map((f) => (f === undefined ? null : readLaneFile(run, f)));
132
+ const dup = lanes.find((l, n) => l && lanes.findIndex((x) => x?.lane === l.lane) !== n);
133
+ if (dup)
134
+ throw new CatherdError("E_INPUT_INVALID", `route: lane ${dup.lane} is named twice`, {
135
+ fix: "name each lane file once",
136
+ });
137
+ // spec 1.5: the run's pinned profile, whatever the repo runs on now
138
+ const profile = runProfile(deps, run);
139
+ const spentFraction = Math.max(
140
+ budgetOf(run, profile.budget, deps.now())?.fraction ?? 0,
141
+ (await workspaceBudget(run, deps.now()))?.fraction ?? 0,
142
+ );
143
+ const reqs = lanes.map((lane) => ({
144
+ host: deps.host.host,
91
145
  runDir: run.dir,
92
146
  repo: run.meta.repo,
93
147
  profile,
94
148
  role: i.role,
95
149
  lane: lane?.lane ?? null,
96
150
  laneText: lane?.text ?? null,
97
- spentFraction: budgetOf(run, profile.budget, deps.now())?.fraction ?? 0,
98
- });
151
+ usage: runUsage(run),
152
+ spentFraction,
153
+ }));
154
+ const answers =
155
+ reqs.length === 1
156
+ ? [await deps.routing.route(reqs[0] as (typeof reqs)[number])]
157
+ : await deps.routing.routeMany(reqs);
158
+ return answers.map((a, n) => record(deps, run, i.role, lanes[n] ?? null, a));
159
+ }
160
+
161
+ /** One decision into routes.jsonl (spec 1.5 plan 24: every role's, with its source, ladder and provenance). */
162
+ function record(
163
+ deps: Deps,
164
+ run: Run,
165
+ role: Role,
166
+ lane: { lane: string } | null,
167
+ a: Awaited<ReturnType<Deps["routing"]["route"]>>,
168
+ ): RouteResult {
169
+ const at = new Date(deps.now()).toISOString();
170
+ const detail = {
171
+ why: a.why,
172
+ ...(a.jevSaid ? { jevSaid: a.jevSaid } : {}),
173
+ ...(a.noClear ? { noClear: a.noClear } : {}),
174
+ ...(a.tie ? { tie: a.tie } : {}),
175
+ ...(a.provenance ? { provenance: a.provenance } : {}),
176
+ };
99
177
  if (lane)
100
178
  appendRoute(run, {
101
- at: new Date(deps.now()).toISOString(),
179
+ at,
102
180
  lane: lane.lane,
103
- role: i.role,
181
+ role,
104
182
  rung: a.rung,
105
183
  ladder: a.ladder,
106
184
  source: "route",
@@ -111,18 +189,38 @@ export async function route(
111
189
  difficulty: a.difficulty,
112
190
  questionSet: a.questionSet,
113
191
  jev: a.jev,
192
+ ...detail,
193
+ });
194
+ else
195
+ appendRoute(run, {
196
+ at,
197
+ lane: null,
198
+ role,
199
+ name: null,
200
+ rung: a.rung,
201
+ ladder: a.ladder,
202
+ source: "route",
203
+ decidedBy: a.source,
204
+ ...detail,
114
205
  });
115
206
  return {
116
207
  lane: lane?.lane ?? null,
117
- role: i.role,
118
- ...a,
208
+ role,
209
+ rung: a.rung,
210
+ ladder: a.ladder,
119
211
  backend: parseRung(a.rung).backend,
120
- agent: deps.profiles.agentFor(run.meta.repo, i.role, a.rung),
212
+ agent: deps.profiles.agentFor(run.meta.repo, role, a.rung),
213
+ why: a.why,
121
214
  };
122
215
  }
123
216
 
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;
217
+ /**
218
+ * Evidence that the lane's own ownership is the problem: a design question, never a capability one. "owned by"
219
+ * counts only for a lane (another lane, lane X, Mx.Ly), never for a user or a process: a socket "owned by root"
220
+ * is the environment's.
221
+ */
222
+ const OWNERSHIP =
223
+ /outside (the )?lane('s)? ownership|owned by (?:another |a different |the other )?lane\b|owned by [A-Za-z0-9_-]+\.L\d+\b/i;
126
224
 
127
225
  const CLIMB_DESIGN_FIX = "send it to the architect (ask/architect delta), not up the ladder";
128
226
 
@@ -155,6 +253,15 @@ async function refuseDesign(
155
253
  });
156
254
  }
157
255
 
256
+ /**
257
+ * A lane's final outcome: its outcomes.jsonl row (spec §5.6) and, beside Jev's answer, its routes.jsonl row
258
+ * (spec 1.5 plan 24). Called under the routes lock.
259
+ */
260
+ function writeOutcome(run: Run, routes: RouteRow[], o: OutcomeRow): void {
261
+ appendOutcome(run, o);
262
+ appendRoute(run, outcomeRouteRow(routes, o));
263
+ }
264
+
158
265
  /** Spec §4.5: one rung up the lane's ladder, on a fresh thread; the reason goes to routes.jsonl. */
159
266
  export async function climb(
160
267
  deps: Deps,
@@ -175,6 +282,18 @@ export async function climb(
175
282
  });
176
283
  // an unrouted lane is refused before Jev is asked about its evidence (no call, no jev.jsonl row)
177
284
  if (!currentRoute(readRoutes(run), i.lane)) throw unrouted();
285
+ // plan 23: a reply that named the environment (ENV: …) would stop a higher rung the same way
286
+ const last = readRecords(run)
287
+ .records.filter((r) => r.lane === i.lane)
288
+ .at(-1);
289
+ if (last?.environment)
290
+ throw new CatherdError(
291
+ "E_CLIMB_ENV",
292
+ `climb ${i.lane}: ${last.name} was stopped by the environment (${last.environment}), which a higher rung cannot fix`,
293
+ {
294
+ fix: `fix the environment (doctor; catherd knowledge env set for the gate environment) and dispatch ${i.lane} again at the same rung, or park the milestone when only the owner can fix it`,
295
+ },
296
+ );
178
297
  await refuseDesign(deps, run, i);
179
298
  const { cur, next } = await withFileLock(runPaths(run.dir).routes, () => {
180
299
  const cur = currentRoute(readRoutes(run), i.lane);
@@ -191,8 +310,9 @@ export async function climb(
191
310
  });
192
311
  // spec §5.6: a climb past the top rung ends the lane open
193
312
  if (!next) {
194
- const o = laneOutcome(readRoutes(run), i.lane, false, new Date(deps.now()).toISOString());
195
- if (o) appendOutcome(run, o);
313
+ const routes = readRoutes(run);
314
+ const o = laneOutcome(routes, i.lane, false, new Date(deps.now()).toISOString());
315
+ if (o) writeOutcome(run, routes, o);
196
316
  }
197
317
  return { cur, next };
198
318
  });
@@ -263,8 +383,27 @@ async function gate(run: Run, m: string, commit: string, skip: LandSkip | undefi
263
383
  );
264
384
  }
265
385
  const start = milestoneStart(run, m);
386
+ // plan 23: a reviewer that stopped partial read part of the diff; that is not the milestone's review
387
+ const partial = partialReviewer(run, m, start);
388
+ if (partial)
389
+ throw new CatherdError(
390
+ "E_LAND_GATE",
391
+ `land ${m}: ${partial.name} replied STATUS: partial, which is not the milestone's review`,
392
+ {
393
+ fix: `dispatch a scoped second pass, reviewer-${m}-2, over the files ${partial.name} did not read (its reply names them), then land again`,
394
+ },
395
+ );
266
396
  // the latest verifier attempt, passed or not: a FAIL after a PASS undoes it
267
397
  const verdict = milestoneVerifier(run, m, start);
398
+ // plan 23: a verdict on the machine is a blocker for the owner, never a fix round
399
+ if (verdict?.blocked != null)
400
+ throw new CatherdError(
401
+ "E_LAND_GATE",
402
+ `land ${m}: ${verdict.name} is blocked by the environment${verdict.blocked ? `: ${verdict.blocked}` : ""}`,
403
+ {
404
+ fix: `surface it to the owner: park(run, "${m}", <the blocker and its probe>); once the environment is fixed, run a fresh verifier on ${m}. A fix round or a climb cannot help`,
405
+ },
406
+ );
268
407
  const missing = [
269
408
  ...(reviewerPassed(run, m, start)
270
409
  ? []
@@ -274,15 +413,20 @@ async function gate(run: Run, m: string, commit: string, skip: LandSkip | undefi
274
413
  ...(verdict?.passed
275
414
  ? []
276
415
  : [
277
- `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}` : ""}`,
416
+ `a verifier verdict (record_agent_run with role verifier named exactly verifier-${m}, status ok; a headless verifier-${m}'s reply opening VERDICT: PASS)${verdict ? `: the latest, ${verdict.name}${verdict.headless ? " (headless)" : ""}, is ${verdict.verdict}` : ""}`,
278
417
  ]),
279
418
  ];
419
+ // a headless verifier that wrapped its verdict in markdown (**VERDICT: PASS**) gave no verdict: fail safe, and say why
420
+ const marked =
421
+ verdict?.headless === true && !verdict.passed && /VERDICT:\s*PASS\b/.test(verdict.verdict)
422
+ ? `the reply's first line must be the bare VERDICT: PASS, and ${verdict.name}'s is "${verdict.verdict}", which counts as no verdict (markdown around it, or text before it): dispatch the verifier again; otherwise `
423
+ : "";
280
424
  if (missing.length)
281
425
  throw new CatherdError(
282
426
  "E_LAND_GATE",
283
427
  `land ${m}: missing ${missing.join(" and ")}, since its lanes started`,
284
428
  {
285
- 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"`,
429
+ fix: `${marked}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"`,
286
430
  },
287
431
  );
288
432
  }
@@ -291,20 +435,72 @@ async function gate(run: Run, m: string, commit: string, skip: LandSkip | undefi
291
435
  * Spec §4.7: the full five-column ledger row, with the minutes since the previous landing (or the
292
436
  * run's start), and `learned` appended to the repo's knowledge.md.
293
437
  */
294
- export async function land(
295
- deps: Deps,
296
- i: {
297
- run: string;
298
- milestone: string;
299
- what: string;
300
- commit: string;
301
- evidence: string;
302
- next: string;
303
- learned?: string;
304
- skip?: LandSkip;
305
- },
306
- ): Promise<{ ledger: string; minutes: number; digest: string; hints?: string[] }> {
438
+ type LandInput = {
439
+ run: string;
440
+ milestone: string;
441
+ what: string;
442
+ commit: string;
443
+ evidence: string;
444
+ next: string;
445
+ learned?: string;
446
+ skip?: LandSkip;
447
+ };
448
+
449
+ type LandResult = { ledger: string; minutes: number; digest: string; digestPath: string; hints?: string[] };
450
+
451
+ export async function land(deps: Deps, i: LandInput): Promise<LandResult> {
307
452
  const run = findRun(i.run);
453
+ const link = run.meta.workspace;
454
+ if (!link) return landRun(deps, i, run);
455
+ const workspace = findWorkspace(link.id);
456
+ const step = workspace.steps.find((s) => s.id === link.step);
457
+ if (!step || !workspaceChildren(workspace).some((child) => child.dir === run.dir))
458
+ throw new CatherdError("E_RUN_CORRUPT", "the run is not a member of its workspace execution");
459
+ // #43 finding 3: only the step's completion milestone waits for the child's dispatches
460
+ if (i.milestone !== step.milestone) {
461
+ const landed = await landRun(deps, i, run);
462
+ if (i.milestone.toLowerCase() !== step.milestone.toLowerCase()) return landed;
463
+ // #43 finding 7: m1 is not M1; the dependents wait on the step's own milestone
464
+ const hint = `${i.milestone} is not ${step.milestone}, the milestone workspace step ${step.id} completes on: its dependents still wait`;
465
+ return { ...landed, hints: [...(landed.hints ?? []), hint] };
466
+ }
467
+ // An unreadable newer native verdict must not leave an older PASS standing.
468
+ readAgentRuns(run, true);
469
+ // Completion and dispatch admission share a boundary, held only for the check and the ledger row: the git
470
+ // work runs outside it (#43 finding 5). Landing remains available at a spent budget.
471
+ return landRun(deps, i, run, (write) =>
472
+ withFileLock(
473
+ workspacePaths(workspaceDirectory(workspace.id)).admission,
474
+ async () => {
475
+ const pending = await withFileLock(runPaths(run.dir).runs, () => {
476
+ const { records, corrupt } = readRecords(run);
477
+ if (corrupt)
478
+ throw new CatherdError("E_RUN_CORRUPT", "workspace completion has unreadable dispatch records", {
479
+ fix: "repair the child's dispatch records before landing its milestone",
480
+ });
481
+ return pendingDispatches(run, deps.now(), records, true);
482
+ });
483
+ if (pending.length)
484
+ throw new CatherdError("E_LAND_GATE", "workspace completion waits for all admitted dispatches", {
485
+ fix: "finish and collect the child's dispatches before landing its milestone",
486
+ });
487
+ return write();
488
+ },
489
+ { timeoutMs: WORKSPACE_LOCK_WAIT_MS },
490
+ ),
491
+ );
492
+ }
493
+
494
+ /**
495
+ * The checks (commit, gate) first, then the ledger row and the notes, inside `critical` (a workspace
496
+ * completion's lock), with no git; then state.md, the lane outcomes, knowledge and the digest.
497
+ */
498
+ async function landRun(
499
+ deps: Deps,
500
+ i: LandInput,
501
+ run: Run,
502
+ critical: (write: () => Promise<Notes>) => Promise<Notes> = (write) => write(),
503
+ ): Promise<LandResult> {
308
504
  // the digest is named after the milestone: an id, checked before anything is written
309
505
  assertId("milestone", i.milestone);
310
506
  // commitExists throws E_IO_UNEXPECTED on a timeout, which reaches the caller as is
@@ -313,20 +509,27 @@ export async function land(
313
509
  fix: "commit the milestone first, then pass its hash",
314
510
  });
315
511
  await gate(run, i.milestone, i.commit, i.skip);
512
+ // read before the ledger row: the range runs from the previous landing to this commit
513
+ const commits = await milestoneCommits(run, i.commit);
316
514
  const now = new Date(deps.now());
317
515
  let row = "";
318
516
  let minutes = 0;
517
+ let pausedMinutes = 0;
518
+ // spec 1.5 "The ledger": the minutes leave out the time the milestone was parked and any machine or
519
+ // workspace pause, so a night parked on a question does not count
520
+ const paused = [...parkedSpans(run, i.milestone), ...pauseSpans(run)];
319
521
  const landRow = (notes: Notes): NotesPatch => {
320
- minutes = Math.max(
321
- 0,
322
- Math.round((now.getTime() - Date.parse(notes.lastLandedAt ?? run.meta.createdAt)) / 60_000),
323
- );
522
+ const from = Date.parse(notes.lastLandedAt ?? run.meta.createdAt);
523
+ const left = coveredMs(from, now.getTime(), paused);
524
+ pausedMinutes = Math.round(left / 60_000);
525
+ minutes = Math.max(0, Math.round((now.getTime() - from - left) / 60_000));
324
526
  row = [i.milestone, i.what, i.commit, String(minutes), i.evidence].map(cell).join(" | ");
325
527
  appendLedger(run, row);
326
528
  return { lastCheck: cell(i.evidence), next: i.next, lastLandedAt: now.toISOString() };
327
529
  };
328
- // on a failed refresh the notes still reach state.json, so the next landing counts its minutes from this one
329
- const { hints } = await refreshState(run, landRow);
530
+ // the notes reach state.json first, with no git: the next landing counts its minutes from this one
531
+ await critical(() => patchNotes(run, landRow));
532
+ const { hints } = await refreshState(run);
330
533
  // spec §5.6: every routed lane of the milestone lands with it
331
534
  // under the routes lock, so a racing climb cannot slip between the read and the rows
332
535
  let routed: string[] = [];
@@ -338,7 +541,7 @@ export async function land(
338
541
  if (lane.startsWith(`${i.milestone}.`)) {
339
542
  landed++;
340
543
  const o = laneOutcome(routes, lane, true, now.toISOString());
341
- if (o) appendOutcome(run, o);
544
+ if (o) writeOutcome(run, routes, o);
342
545
  }
343
546
  });
344
547
  // a milestone name no routed lane starts with is most likely a typo: say so rather than record nothing
@@ -346,7 +549,7 @@ export async function land(
346
549
  hints.push(
347
550
  `land: no routed lane is in milestone "${i.milestone}" (routed: ${routed.slice(0, 5).join(", ")}${routed.length > 5 ? ", …" : ""}); check its name: no lane outcome was recorded`,
348
551
  );
349
- if (i.learned) appendKnowledge(run.meta.repo, now, `${run.meta.title} ${i.milestone}`, i.learned);
552
+ if (i.learned) await appendKnowledge(run.meta.repo, now, `${run.meta.title} ${i.milestone}`, i.learned);
350
553
  // spec 1.1 §10: the milestone's digest, which the milestone push links
351
554
  const digest = writeDigest(run, {
352
555
  milestone: i.milestone,
@@ -355,8 +558,11 @@ export async function land(
355
558
  evidence: i.evidence,
356
559
  minutes,
357
560
  at: now.toISOString(),
561
+ commits,
562
+ pausedMinutes,
358
563
  });
359
- return { ledger: row, minutes, digest, ...withHints(hints) };
564
+ // spec 1.5: the digest's full path, for the milestone push to link
565
+ return { ledger: row, minutes, digest, digestPath: join(run.dir, digest), ...withHints(hints) };
360
566
  }
361
567
 
362
568
  /** Jev's `finding` or `same-defect` answer, through the routing port. */