catherd-cli 1.4.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 (139) hide show
  1. package/README.md +93 -30
  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 +1 -1
  7. package/plugin/bin/catherd-mcp +1 -1
  8. package/plugin/skills/catherd/SKILL.md +110 -45
  9. package/plugin/skills/catherd-setup/SKILL.md +2 -2
  10. package/src/adapters/antigravity/index.ts +18 -3
  11. package/src/adapters/backend.ts +18 -2
  12. package/src/adapters/claude-code/index.ts +126 -14
  13. package/src/adapters/codex/events.ts +10 -0
  14. package/src/adapters/codex/index.ts +17 -5
  15. package/src/adapters/cursor/index.ts +7 -1
  16. package/src/adapters/grok/home.ts +75 -20
  17. package/src/adapters/grok/index.ts +27 -8
  18. package/src/adapters/opencode/index.ts +38 -1
  19. package/src/cli.ts +5 -0
  20. package/src/domain/agents.ts +3 -1
  21. package/src/domain/brief.ts +91 -0
  22. package/src/domain/calibration.ts +7 -0
  23. package/src/domain/catalog.ts +12 -1
  24. package/src/domain/changes.ts +23 -1
  25. package/src/domain/cost.ts +8 -3
  26. package/src/domain/errors.ts +4 -0
  27. package/src/domain/failover.ts +17 -5
  28. package/src/domain/gate-brief.ts +52 -0
  29. package/src/domain/hints.ts +13 -2
  30. package/src/domain/lane.ts +99 -3
  31. package/src/domain/notice.ts +9 -2
  32. package/src/domain/profile-rules.ts +112 -16
  33. package/src/domain/profile.ts +24 -0
  34. package/src/domain/record.ts +16 -2
  35. package/src/domain/reply.ts +29 -0
  36. package/src/domain/role-prompts.ts +56 -16
  37. package/src/domain/role-scope.ts +106 -0
  38. package/src/domain/role-tools.ts +34 -0
  39. package/src/domain/route.ts +73 -0
  40. package/src/domain/select.ts +177 -25
  41. package/src/domain/state.ts +5 -0
  42. package/src/domain/util.ts +22 -0
  43. package/src/domain/workspace.ts +130 -0
  44. package/src/entry/catalog-command.ts +14 -6
  45. package/src/entry/cli-kit.ts +17 -1
  46. package/src/entry/deps.ts +2 -0
  47. package/src/entry/doctor-command.ts +20 -2
  48. package/src/entry/host-arg.ts +28 -0
  49. package/src/entry/init-command.ts +2 -1
  50. package/src/entry/knowledge-command.ts +78 -3
  51. package/src/entry/lock-command.ts +104 -8
  52. package/src/entry/mcp/dispatch-tools.ts +3 -3
  53. package/src/entry/mcp/handshake.ts +6 -2
  54. package/src/entry/mcp/lane-tools.ts +55 -8
  55. package/src/entry/mcp/protocol-tools.ts +10 -4
  56. package/src/entry/mcp/push-tools.ts +16 -0
  57. package/src/entry/mcp/role-bin.ts +20 -0
  58. package/src/entry/mcp/role-server.ts +116 -0
  59. package/src/entry/mcp/run-tools.ts +20 -4
  60. package/src/entry/mcp/server.ts +98 -7
  61. package/src/entry/mcp/setup-tools.ts +4 -3
  62. package/src/entry/mcp/workspace-tools.ts +142 -0
  63. package/src/entry/pause-command.ts +67 -0
  64. package/src/entry/profile-command.ts +23 -4
  65. package/src/entry/role-cli-command.ts +115 -0
  66. package/src/entry/runs-command.ts +78 -8
  67. package/src/entry/supervise-command.ts +30 -3
  68. package/src/entry/tui/effects.ts +4 -0
  69. package/src/entry/tui/state.ts +37 -10
  70. package/src/entry/tui/views/profiles.tsx +1 -1
  71. package/src/entry/tui/views/runs.tsx +66 -20
  72. package/src/entry/tui/views/save-dialog.tsx +6 -2
  73. package/src/entry/tui/views/status.tsx +6 -2
  74. package/src/entry/workspace-command.ts +44 -0
  75. package/src/infra/boot-lock.ts +25 -0
  76. package/src/infra/delivery.ts +3 -0
  77. package/src/infra/dispatch-dir.ts +2 -0
  78. package/src/infra/env.ts +11 -1
  79. package/src/infra/git.ts +6 -0
  80. package/src/infra/heavy-lock.ts +93 -4
  81. package/src/infra/host-probe.ts +16 -0
  82. package/src/infra/lock-activity.ts +88 -0
  83. package/src/infra/login-env.ts +48 -0
  84. package/src/infra/paths.ts +35 -0
  85. package/src/infra/role-mcp.ts +69 -0
  86. package/src/infra/sources/arena.ts +11 -3
  87. package/src/infra/sources/artificial-analysis.ts +8 -1
  88. package/src/infra/sources/cache.ts +2 -0
  89. package/src/infra/supervisor.ts +107 -16
  90. package/src/services/admission.ts +110 -23
  91. package/src/services/budget.ts +26 -6
  92. package/src/services/catalog-service.ts +20 -12
  93. package/src/services/credentials.ts +11 -6
  94. package/src/services/dispatch-service.ts +204 -16
  95. package/src/services/dispatches.ts +30 -2
  96. package/src/services/doctor-access.ts +8 -3
  97. package/src/services/doctor-docker.ts +221 -0
  98. package/src/services/doctor-role-mcp.ts +65 -0
  99. package/src/services/doctor.ts +68 -6
  100. package/src/services/end-push.ts +157 -0
  101. package/src/services/finalize.ts +12 -2
  102. package/src/services/gate-env.ts +218 -0
  103. package/src/services/gate-service.ts +216 -45
  104. package/src/services/global-install.ts +46 -13
  105. package/src/services/lane-edit.ts +154 -0
  106. package/src/services/lane-service.ts +257 -52
  107. package/src/services/milestones.ts +137 -26
  108. package/src/services/notifier.ts +56 -3
  109. package/src/services/orchestrator-wait.ts +82 -0
  110. package/src/services/pause.ts +161 -0
  111. package/src/services/peek.ts +79 -17
  112. package/src/services/ports.ts +31 -2
  113. package/src/services/preflight.ts +153 -19
  114. package/src/services/profile-service.ts +1 -0
  115. package/src/services/profile-store.ts +10 -3
  116. package/src/services/protocol.ts +119 -25
  117. package/src/services/provenance.ts +16 -12
  118. package/src/services/questions.ts +15 -0
  119. package/src/services/reconcile.ts +22 -1
  120. package/src/services/reentry.ts +7 -6
  121. package/src/services/role-access.ts +27 -0
  122. package/src/services/routing-service.ts +144 -36
  123. package/src/services/run-pin.ts +125 -0
  124. package/src/services/run-service.ts +151 -19
  125. package/src/services/run-store.ts +109 -15
  126. package/src/services/runs-page.ts +42 -7
  127. package/src/services/scratch.ts +60 -0
  128. package/src/services/sessions.ts +3 -1
  129. package/src/services/source-derive.ts +28 -5
  130. package/src/services/source-sync.ts +9 -2
  131. package/src/services/standins.ts +40 -6
  132. package/src/services/state.ts +21 -0
  133. package/src/services/summary.ts +49 -9
  134. package/src/services/treat-likes.ts +19 -3
  135. package/src/services/verifier-brief.ts +23 -0
  136. package/src/services/verifier-step.ts +48 -0
  137. package/src/services/workspace-admission.ts +267 -0
  138. package/src/services/workspace-service.ts +254 -0
  139. package/src/services/workspace-store.ts +95 -0
@@ -13,11 +13,13 @@ import {
13
13
  import { type Difficulty, type Kind, parseLaneHeader } from "../domain/lane.ts";
14
14
  import type { Role } from "../domain/roles.ts";
15
15
  import type { RouteJev } from "../domain/route.ts";
16
+ import type { Catalog } from "../domain/catalog.ts";
16
17
  import {
17
18
  candidates,
18
19
  defaultDifficulty,
19
20
  defaultLadder,
20
21
  type Pick,
22
+ quotaUsage,
21
23
  type RoutingProfile,
22
24
  select,
23
25
  } from "../domain/select.ts";
@@ -26,11 +28,17 @@ import { catalogQuery, freshenDiscovery, loadCatalog } from "./catalog-service.t
26
28
  import { type Asked, askJev, type JevOpts, jevQuestions, logJev } from "./jev-service.ts";
27
29
  import type { ProfileView, RouteAnswer, RouteRequest, RoutingPort, Verdict } from "./ports.ts";
28
30
  import { provenanceOf } from "./provenance.ts";
29
- import { runEvidence } from "./run-evidence.ts";
31
+ import { type EvidenceTable, runEvidence } from "./run-evidence.ts";
30
32
 
31
- function routingProfile(v: ProfileView, role: Role, spentFraction: number): RoutingProfile {
33
+ function routingProfile(
34
+ v: ProfileView,
35
+ role: Role,
36
+ spentFraction: number,
37
+ usage: Record<string, number> = {},
38
+ ): RoutingProfile {
32
39
  const rc = v.roles[role];
33
40
  return {
41
+ usage,
34
42
  // spec §4.6: from 80 % of the budget on, start at the cheapest rung that clears the bar
35
43
  objective: spentFraction >= BUDGET_CHEAP_AT ? "cost" : v.objective,
36
44
  billing: v.billing,
@@ -50,14 +58,60 @@ const answer = (
50
58
  asked: Asked | null,
51
59
  jev: RouteJev | null,
52
60
  ): RouteAnswer => ({
53
- ...pick,
61
+ rung: pick.rung,
62
+ ladder: pick.ladder,
54
63
  source,
55
64
  kind,
56
65
  difficulty,
57
66
  questionSet: asked?.answers ? asked.meta.questionSet : null,
58
67
  jev,
68
+ jevSaid: null,
69
+ why: "",
70
+ ...(pick.noClear ? { noClear: pick.noClear } : {}),
71
+ ...(pick.tie ? { tie: pick.tie } : {}),
59
72
  });
60
73
 
74
+ /** Spec 1.5 plan 24: what Jev said when it differs from the kind and difficulty the route used, else null. */
75
+ function jevSaid(
76
+ judged: ReturnType<typeof judgeRoute> | null,
77
+ kind: Kind | null,
78
+ difficulty: Difficulty | null,
79
+ ): string | null {
80
+ if (!judged) return null;
81
+ const k = judged.kind;
82
+ const d = judged.track ? judged.difficulty : null;
83
+ if ((k === null || k === kind) && (d === null || d === difficulty)) return null;
84
+ return `Jev said ${k ?? "no sure kind"}/${d ?? "no sure difficulty"}`;
85
+ }
86
+
87
+ /** Spec 1.5 plan 24: the route's one-line why, for the coordinator; the provenance goes to routes.jsonl. */
88
+ function whyOf(a: RouteAnswer, hasLane: boolean): string {
89
+ const what = `${a.kind}/${a.difficulty}`;
90
+ const head =
91
+ a.source === "lane"
92
+ ? `the lane's Kind/Difficulty, ${what}`
93
+ : a.source === "jev"
94
+ ? `Jev: ${what}`
95
+ : a.source === "jev-kind"
96
+ ? `Jev's kind with ${what}`
97
+ : hasLane
98
+ ? "the role's default rung: neither Jev nor the lane file gave a kind and difficulty"
99
+ : "the role's default rung";
100
+ const start =
101
+ a.noClear ?? (a.source === "default" ? null : "the first rung in objective order that clears it");
102
+ return [head, a.jevSaid, start, a.tie].filter((x): x is string => Boolean(x)).join("; ");
103
+ }
104
+
105
+ /** Spec 1.2 §8 evidence is display only: an unreadable run on this machine never fails a route. */
106
+ function evidenceOrNone(c: Catalog): EvidenceTable | null {
107
+ try {
108
+ return runEvidence(c);
109
+ } catch (e) {
110
+ log("debug", "route", { evidence: errorMessage(e) });
111
+ return null;
112
+ }
113
+ }
114
+
61
115
  /** How long `route` waits on the daily discovery refresh before routing on the cached listing. */
62
116
  const DISCOVERY_BUDGET_MS = 5_000;
63
117
 
@@ -79,53 +133,77 @@ async function freshenWithin(rungs: string[], repo: string, ms: number): Promise
79
133
  clearTimeout(timer);
80
134
  }
81
135
 
136
+ /** What every route of one call shares: the role's routing profile and the catalog. */
137
+ interface Prepared {
138
+ p: RoutingProfile;
139
+ c: Catalog;
140
+ }
141
+
142
+ async function prepare(req: RouteRequest, o: RoutingOpts): Promise<Prepared> {
143
+ const p = routingProfile(req.profile, req.role, req.spentFraction, req.usage);
144
+ await freshenWithin(p.role.rungs, req.repo, o.discoveryBudgetMs ?? DISCOVERY_BUDGET_MS);
145
+ return { p, c: loadCatalog({ repo: req.repo }) };
146
+ }
147
+
148
+ /** Jev's answer about a lane; null when the route reads no lane, empty when it asks nothing (Jev off, one usable rung). */
149
+ interface Judged {
150
+ asked: Asked | null;
151
+ judged: ReturnType<typeof judgeRoute> | null;
152
+ }
153
+
154
+ async function judge(req: RouteRequest, { p, c }: Prepared, o: RoutingOpts): Promise<Judged | null> {
155
+ if (req.laneText === null) return null;
156
+ // one usable rung: nothing for Jev to choose, but the lane's header still names its kind and difficulty
157
+ if (req.profile.jev.use === "off" || candidates(c, p, req.role).length <= 1)
158
+ return { asked: null, judged: null };
159
+ const asked = await askJev(req.runDir, "route-v2", laneState(req.laneText), o);
160
+ const judged = asked.answers ? judgeRoute(jevQuestions().sets["route-v2"].rule, asked.answers) : null;
161
+ return { asked, judged };
162
+ }
163
+
82
164
  /**
83
- * Spec §5.4: kind and difficulty from Jev (§5.5's rule), else the lane file's `Kind:`/`Difficulty:`,
84
- * else the role's default rung. A kind Jev is sure of survives a difficulty in the dead band: the
85
- * difficulty then comes from the lane, else the role's default difficulty (`jev-kind`). A role with one
86
- * usable rung never asks Jev.
165
+ * Spec §5.4: kind and difficulty from the lane file's `Kind:`/`Difficulty:` when it declares both (spec 1.5
166
+ * plan 24: a declared header wins, and `jevSaid` names where Jev disagreed), else from Jev (§5.5's rule), else
167
+ * the role's default rung. A kind Jev is sure of survives a difficulty in the dead band: the difficulty then
168
+ * comes from the lane, else the role's default difficulty (`jev-kind`). A role with one usable rung never asks
169
+ * Jev.
87
170
  */
88
- async function route(req: RouteRequest, o: RoutingOpts): Promise<RouteAnswer> {
89
- const p = routingProfile(req.profile, req.role, req.spentFraction);
90
- await freshenWithin(p.role.rungs, req.repo, o.discoveryBudgetMs ?? DISCOVERY_BUDGET_MS);
91
- const c = loadCatalog({ repo: req.repo });
171
+ function decide(req: RouteRequest, { p, c }: Prepared, j: Judged | null): RouteAnswer {
92
172
  const fallback = () => defaultLadder(c, p, req.role);
93
- if (req.laneText === null || candidates(c, p, req.role).length <= 1) {
94
- const out = answer(fallback(), "default", null, null, null, null);
95
- return { ...out, provenance: provenanceOf(c, out.rung, null, null, p.billing, runEvidence(c)) };
96
- }
173
+ const finish = (out: RouteAnswer): RouteAnswer => {
174
+ out.why = whyOf(out, req.laneText !== null);
175
+ out.provenance = provenanceOf(c, out.rung, out.kind, out.difficulty, p.billing, evidenceOrNone(c));
176
+ return out;
177
+ };
178
+ if (req.laneText === null || j === null)
179
+ return finish(answer(fallback(), "default", null, null, null, null));
97
180
  const lane = parseLaneHeader(req.laneText);
98
- let asked: Asked | null = null;
99
- let judged: ReturnType<typeof judgeRoute> | null = null;
100
- if (req.profile.jev.use !== "off") {
101
- asked = await askJev(req.runDir, "route-v2", laneState(req.laneText), o);
102
- if (asked.answers) judged = judgeRoute(jevQuestions().sets["route-v2"].rule, asked.answers);
103
- }
181
+ const { asked, judged } = j;
104
182
  const jev: RouteJev | null = judged
105
183
  ? { pKind: judged.pKind, pA: judged.pA, pB: judged.pB, nouls: judged.nouls }
106
184
  : null;
107
185
  let out: RouteAnswer;
108
- if (judged?.track && judged.difficulty) {
109
- const kind = judged.kind ?? lane.kind ?? "repo_code";
110
- out = answer(select(c, p, req.role, kind, judged.difficulty), "jev", kind, judged.difficulty, asked, jev);
111
- } else if (judged?.kind) {
112
- const difficulty = lane.difficulty ?? defaultDifficulty(c, p, req.role, judged.kind);
186
+ if (lane.kind && lane.difficulty) {
113
187
  out = answer(
114
- select(c, p, req.role, judged.kind, difficulty),
115
- "jev-kind",
116
- judged.kind,
117
- difficulty,
188
+ select(c, p, req.role, lane.kind, lane.difficulty),
189
+ "lane",
190
+ lane.kind,
191
+ lane.difficulty,
118
192
  asked,
119
193
  jev,
120
194
  );
195
+ } else if (judged?.track && judged.difficulty) {
196
+ const kind = lane.kind ?? judged.kind ?? "repo_code";
197
+ out = answer(select(c, p, req.role, kind, judged.difficulty), "jev", kind, judged.difficulty, asked, jev);
198
+ } else if (judged?.kind || (judged && lane.kind)) {
199
+ const kind = (lane.kind ?? judged.kind) as Kind;
200
+ const difficulty = lane.difficulty ?? defaultDifficulty(c, p, req.role, kind);
201
+ out = answer(select(c, p, req.role, kind, difficulty), "jev-kind", kind, difficulty, asked, jev);
121
202
  } else {
122
- const kind = lane.kind;
123
- out =
124
- kind && lane.difficulty
125
- ? answer(select(c, p, req.role, kind, lane.difficulty), "lane", kind, lane.difficulty, asked, jev)
126
- : answer(fallback(), "default", null, null, asked, jev);
203
+ out = answer(fallback(), "default", null, null, asked, jev);
127
204
  }
128
- out.provenance = provenanceOf(c, out.rung, out.kind, out.difficulty, p.billing, runEvidence(c));
205
+ out.jevSaid = out.source === "default" ? null : jevSaid(judged, out.kind, out.difficulty);
206
+ finish(out);
129
207
  if (asked) {
130
208
  logJev(req.runDir, {
131
209
  ...asked.meta,
@@ -143,6 +221,31 @@ async function route(req: RouteRequest, o: RoutingOpts): Promise<RouteAnswer> {
143
221
  return out;
144
222
  }
145
223
 
224
+ async function route(req: RouteRequest, o: RoutingOpts): Promise<RouteAnswer> {
225
+ const prep = await prepare(req, o);
226
+ return decide(req, prep, await judge(req, prep, o));
227
+ }
228
+
229
+ /**
230
+ * Spec 1.5 plan 24, batch `route`: the lanes of one role (one run, one profile) share one discovery refresh and
231
+ * one catalog, and Jev is asked about all of them at once (Ruling 6: one request per lane, all in flight
232
+ * together, so each keeps its own cache key and jev.jsonl row). Then each lane is decided in order, every
233
+ * routed start counting as a use of its quota, so lanes that tie spread over the quotas.
234
+ */
235
+ async function routeMany(reqs: RouteRequest[], o: RoutingOpts): Promise<RouteAnswer[]> {
236
+ const first = reqs[0];
237
+ if (!first) return [];
238
+ const prep = await prepare(first, o);
239
+ const judged = await Promise.all(reqs.map((r) => judge(r, prep, o)));
240
+ const usage: Record<string, number> = { ...first.usage };
241
+ return reqs.map((req, i) => {
242
+ const out = decide(req, { ...prep, p: { ...prep.p, usage: { ...usage } } }, judged[i] ?? null);
243
+ const q = quotaUsage([out.rung]);
244
+ for (const [k, n] of Object.entries(q)) usage[k] = (usage[k] ?? 0) + n;
245
+ return out;
246
+ });
247
+ }
248
+
146
249
  async function verdict<T extends string>(
147
250
  runDir: string,
148
251
  set: Extract<SetName, "finding" | "same-defect">,
@@ -177,6 +280,11 @@ export function routingService(o: RoutingOpts = {}): RoutingPort {
177
280
  assertNativeHost(result.rung, req.host ?? "unknown");
178
281
  return result;
179
282
  },
283
+ routeMany: async (reqs) => {
284
+ const results = await routeMany(reqs, o);
285
+ results.forEach((r, i) => assertNativeHost(r.rung, reqs[i]?.host ?? "unknown"));
286
+ return results;
287
+ },
180
288
  finding: (runDir, laneText, finding, use) =>
181
289
  verdict(
182
290
  runDir,
@@ -0,0 +1,125 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import { errorMessage } from "../domain/errors.ts";
5
+ import { ACCESS } from "../domain/record.ts";
6
+ import { ROLES } from "../domain/roles.ts";
7
+ import { readVersioned, writeJsonAtomic } from "../infra/store.ts";
8
+ import type { Deps, ProfileView } from "./ports.ts";
9
+ import { findRun, type Run } from "./run-store.ts";
10
+ import { refreshState } from "./state.ts";
11
+
12
+ // Spec 1.5 "Pinned per run": run_start records the profile the run started on, each role's access and each
13
+ // backend's isolation. A later change (another active profile, a toggled isolation, a version that changed a
14
+ // default) is logged in state.md and status, and dispatch keeps the pinned values until the owner re-pins.
15
+
16
+ const PinSchema = z.looseObject({
17
+ schema: z.literal(1),
18
+ at: z.string(),
19
+ profile: z.string(),
20
+ access: z.partialRecord(z.enum(ROLES), z.enum(ACCESS)),
21
+ isolated: z.record(z.string(), z.boolean()),
22
+ });
23
+ export type Pin = z.infer<typeof PinSchema>;
24
+
25
+ export const pinFile = (run: Run): string => join(run.dir, "pin.json");
26
+
27
+ /** The run's pin, or null: a run from before 1.5, or a pin that cannot be read (the profile then rules). */
28
+ export function readPin(run: Run): Pin | null {
29
+ if (!existsSync(pinFile(run))) return null;
30
+ try {
31
+ return readVersioned(pinFile(run), PinSchema, 1);
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ const pinOf = (view: ProfileView, at: string): Pin => ({
38
+ schema: 1,
39
+ at,
40
+ profile: view.name,
41
+ access: Object.fromEntries(
42
+ Object.entries(view.roles).flatMap(([role, rc]) => (rc ? [[role, rc.access]] : [])),
43
+ ),
44
+ isolated: Object.fromEntries(Object.entries(view.isolated).map(([k, v]) => [k, v === true])),
45
+ });
46
+
47
+ /** Records what the run runs on now: the profile its repo runs on, each role's access, each backend's isolation. */
48
+ export function writePin(deps: Deps, run: Run): Pin {
49
+ const pin = pinOf(deps.profiles.forRepo(run.meta.repo), new Date(deps.now()).toISOString());
50
+ writeJsonAtomic(pinFile(run), pin);
51
+ return pin;
52
+ }
53
+
54
+ /** The pinned profile's view, or null with why when it can no longer be read (deleted, invalid). */
55
+ function pinnedView(deps: Deps, run: Run, pin: Pin): { view: ProfileView | null; why: string | null } {
56
+ const current = deps.profiles.forRepo(run.meta.repo);
57
+ if (current.name === pin.profile) return { view: current, why: null };
58
+ try {
59
+ return { view: deps.profiles.get(pin.profile, run.meta.repo).profile, why: null };
60
+ } catch (e) {
61
+ return { view: null, why: errorMessage(e) };
62
+ }
63
+ }
64
+
65
+ /**
66
+ * The profile a run's dispatch, routing and failover read: its pinned profile, with its pinned access and
67
+ * isolation laid over it. A run with no pin, or whose pinned profile is gone, reads its repo's profile.
68
+ */
69
+ export function runProfile(deps: Deps, run: Run): ProfileView {
70
+ const pin = readPin(run);
71
+ if (!pin) return deps.profiles.forRepo(run.meta.repo);
72
+ const view = pinnedView(deps, run, pin).view ?? deps.profiles.forRepo(run.meta.repo);
73
+ const roles: ProfileView["roles"] = {};
74
+ for (const [role, rc] of Object.entries(view.roles) as [
75
+ keyof ProfileView["roles"],
76
+ NonNullable<ProfileView["roles"][keyof ProfileView["roles"]]>,
77
+ ][])
78
+ roles[role] = { ...rc, access: pin.access[role] ?? rc.access };
79
+ // a backend the pin does not name ran with no isolation when the run started
80
+ const backends = new Set([...Object.keys(view.isolated), ...Object.keys(pin.isolated)]);
81
+ const isolated = Object.fromEntries([...backends].map((k) => [k, pin.isolated[k] === true]));
82
+ return { ...view, roles, isolated };
83
+ }
84
+
85
+ /** What changed since the pin, one line each, in words; none while the run's repo still runs as pinned. */
86
+ export function pinChanges(deps: Deps, run: Run): string[] {
87
+ const pin = readPin(run);
88
+ if (!pin) return [];
89
+ let now: ProfileView;
90
+ try {
91
+ now = deps.profiles.forRepo(run.meta.repo);
92
+ } catch {
93
+ // a profile that cannot be read is doctor's and profile validate's to report, not a pin change
94
+ return [];
95
+ }
96
+ const out: string[] = [];
97
+ if (now.name !== pin.profile) {
98
+ const { why } = pinnedView(deps, run, pin);
99
+ out.push(
100
+ `profile: pinned ${pin.profile}, the repo now runs on ${now.name}${why ? ` (${pin.profile} cannot be read: ${why}; dispatch reads ${now.name})` : ""}`,
101
+ );
102
+ }
103
+ for (const [role, access] of Object.entries(pin.access)) {
104
+ const was = now.roles[role as keyof ProfileView["roles"]]?.access;
105
+ if (was !== undefined && was !== access) out.push(`${role} access: pinned ${access}, now ${was}`);
106
+ }
107
+ for (const backend of new Set([...Object.keys(pin.isolated), ...Object.keys(now.isolated)])) {
108
+ const isolated = pin.isolated[backend] === true;
109
+ const was = now.isolated[backend] === true;
110
+ if (was !== isolated) out.push(`${backend} isolated: pinned ${isolated}, now ${was}`);
111
+ }
112
+ return out;
113
+ }
114
+
115
+ /** `run_pin`: the owner re-pins the run to what its repo runs on now. Returns the new pin and what it changed. */
116
+ export async function repin(
117
+ deps: Deps,
118
+ i: { run: string },
119
+ ): Promise<{ pin: Pin; changed: string[]; hints?: string[] }> {
120
+ const run = findRun(i.run);
121
+ const changed = pinChanges(deps, run);
122
+ const pin = writePin(deps, run);
123
+ const { hints } = await refreshState(run, { pinChanges: [] });
124
+ return { pin, changed, ...(hints.length ? { hints } : {}) };
125
+ }
@@ -1,17 +1,20 @@
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";
@@ -22,17 +25,91 @@ import {
22
25
  appendKnowledge,
23
26
  createRun,
24
27
  findRun,
25
- knowledgeFile,
28
+ knowledgeFor,
26
29
  readRecords,
27
30
  runFile,
31
+ type Run,
32
+ runPaths,
33
+ supersededBy,
34
+ supersededFile,
28
35
  } from "./run-store.ts";
29
- import { protocolView } from "./protocol.ts";
36
+ import { protocolNext, protocolView } from "./protocol.ts";
37
+ import { writePin } from "./run-pin.ts";
30
38
  import { claimRun, currentSession } from "./sessions.ts";
31
- 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
+ }
32
109
 
33
110
  export async function startRun(
34
111
  deps: Deps,
35
- i: { repo: string; title: string; aLines: string[] },
112
+ i: { repo: string; title: string; aLines: string[]; from?: string },
36
113
  ): Promise<{
37
114
  run: string;
38
115
  dir: string;
@@ -44,17 +121,39 @@ export async function startRun(
44
121
  throw new CatherdError("E_IO_PATH", `${i.repo} is not inside a git repository`, {
45
122
  fix: "pass the path of the repository to work in",
46
123
  });
47
- const run = createRun({
48
- repo: top,
49
- title: i.title,
50
- aLines: i.aLines,
51
- version: deps.version,
52
- now: new Date(deps.now()),
53
- startedBy: currentSession(deps),
54
- });
55
- 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) : {};
56
154
  // a failed state.md refresh never fails the start: the run exists and is usable, so a retry would orphan it
57
155
  const { hints } = await refreshState(run);
156
+ hints.push(...(superseded.hints ?? []));
58
157
  // spec 1.1 §10: the milestone loop, so the orchestrator starts on the protocol
59
158
  return { run: run.id, dir: run.dir, protocol: protocolView(run, []), ...(hints.length ? { hints } : {}) };
60
159
  }
@@ -63,7 +162,11 @@ export function writeRunFile(i: { run: string; path: string; content: string }):
63
162
  path: string;
64
163
  bytes: number;
65
164
  } {
66
- 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`);
67
170
  writeTextAtomic(file, i.content);
68
171
  return { path: file, bytes: Buffer.byteLength(i.content) };
69
172
  }
@@ -99,6 +202,28 @@ function capReply(reply: string, path: string): string {
99
202
  return `${head.slice(0, CAP_CHARS)}\n[capped: the full reply is ${path}]`;
100
203
  }
101
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
+
102
227
  /**
103
228
  * A role's latest dispatch: its record once finished, its capped reply and its hints. Spec §3.7: reading a
104
229
  * finished record marks it read, with the collect lease (each record is marked read once, whoever reads it);
@@ -134,6 +259,7 @@ export async function result(
134
259
  if (x.admit.dispatchId !== d.admit.dispatchId) hints.push(...recordHints(run, x, r));
135
260
  }
136
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);
137
263
  const reply = dispatchPaths(d.dir).reply;
138
264
  const text = existsSync(reply) ? readFileSync(reply, "utf8") : "";
139
265
  return {
@@ -159,6 +285,10 @@ export function recordAgentRun(
159
285
  durationMs?: number;
160
286
  status?: AgentRun["status"];
161
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"]>;
162
292
  },
163
293
  ): AgentRun {
164
294
  const run = findRun(i.run);
@@ -180,6 +310,8 @@ export function recordAgentRun(
180
310
  secs: i.durationMs === undefined ? null : Math.round(i.durationMs / 1000),
181
311
  status: i.status ?? "ok",
182
312
  lane: i.lane ?? null,
313
+ ...(i.verdict ? { verdict: i.verdict.trim().split("\n")[0] } : {}),
314
+ ...(i.replyStatus ? { replyStatus: i.replyStatus } : {}),
183
315
  };
184
316
  appendAgentRun(run, row);
185
317
  return row;
@@ -197,7 +329,7 @@ async function repoTop(repo: string): Promise<string> {
197
329
 
198
330
  /** What past runs of the repo learned; `repo` may be any path inside it. */
199
331
  export async function readKnowledge(repo: string): Promise<string> {
200
- const file = knowledgeFile(await repoTop(repo));
332
+ const file = await knowledgeFor(await repoTop(repo));
201
333
  const text = existsSync(file) ? readFileSync(file, "utf8") : "";
202
334
  return text.trim() ? text : "catherd: no knowledge recorded yet for this repo";
203
335
  }
@@ -205,7 +337,7 @@ export async function readKnowledge(repo: string): Promise<string> {
205
337
  /** Where the repo's knowledge.md is (it may not exist yet); `repo` may be any path inside it. */
206
338
  export async function knowledgePath(repo: string): Promise<{ repo: string; path: string }> {
207
339
  const top = await repoTop(repo);
208
- return { repo: top, path: knowledgeFile(top) };
340
+ return { repo: top, path: await knowledgeFor(top) };
209
341
  }
210
342
 
211
343
  /** The repo's knowledge.md as its lines, none when it is missing or blank; `repo` may be any path inside it. */