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
@@ -2,18 +2,24 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import type { RunRecord } from "../domain/record.ts";
4
4
  import type { RouteRow } from "../domain/route.ts";
5
+ import { parseLaneHeader } from "../domain/lane.ts";
5
6
  import { ensurePrivateDir, readJsonl, writeTextAtomic } from "../infra/store.ts";
6
7
  import { type Dispatch, listDispatches, liveDispatches } from "./dispatches.ts";
7
- import type { VerifierStep } from "./gate-service.ts";
8
+ import { RECHECK_COMMAND_MIN } from "../domain/gate-brief.ts";
9
+ import { failedItems, type VerifierStep } from "./gate-service.ts";
8
10
  import {
11
+ countFindings,
12
+ FINDING,
9
13
  landedMilestones,
10
14
  milestoneReviewer,
11
15
  milestoneVerifier,
12
16
  milestoneStart,
13
17
  namesMilestone,
18
+ openFindings,
19
+ partialReviewer,
14
20
  reviewerPassed,
15
- verifierPassed,
16
21
  } from "./milestones.ts";
22
+ import { openQuestions } from "./questions.ts";
17
23
  import { readAgentRuns, readRecords, readRoutes, type Run, runPaths } from "./run-store.ts";
18
24
 
19
25
  // Spec 1.1 §10: the protocol's next step, derived from the run's own files so a session that lost its
@@ -44,30 +50,48 @@ function laneIds(run: Run): string[] {
44
50
  .sort(byNumber);
45
51
  }
46
52
 
53
+ /** What laneDone reads of the run, read once for every lane of the milestone. */
54
+ interface LaneEvidence {
55
+ routes: RouteRow[];
56
+ live: Dispatch[];
57
+ records: Map<string, RunRecord>;
58
+ dispatches: Dispatch[];
59
+ agents: ReturnType<typeof readAgentRuns>;
60
+ }
61
+
62
+ function laneEvidence(run: Run, routes: RouteRow[], live: Dispatch[]): LaneEvidence {
63
+ return {
64
+ routes,
65
+ live,
66
+ records: new Map(readRecords(run).records.map((r) => [r.dispatchId, r])),
67
+ dispatches: listDispatches(run),
68
+ agents: readAgentRuns(run),
69
+ };
70
+ }
71
+
47
72
  /**
48
73
  * Whether `lane` needs no dispatch now: its latest dispatch or native agent run since its latest climb is
49
74
  * still running, or ended ok with no blocked or refused reply. A lane whose last try failed, hit a limit,
50
75
  * was blocked, or was climbed since goes back to dispatch; a plain re-route leaves an ok try standing.
51
76
  */
52
- function laneDone(run: Run, lane: string, routes: RouteRow[], live: Dispatch[]): boolean {
77
+ function laneDone(lane: string, e: LaneEvidence): boolean {
53
78
  const from = Math.max(
54
- ...routes.filter((r) => r.lane === lane && r.source === "climb").map((r) => Date.parse(r.at)),
79
+ ...e.routes.filter((r) => r.lane === lane && r.source === "climb").map((r) => Date.parse(r.at)),
55
80
  );
56
81
  // a dispatch routes its lane first, so a try at the route's own time counts as after it
57
82
  const after = (t: string) => !(Date.parse(t) < from);
58
- const records = new Map(readRecords(run).records.map((r) => [r.dispatchId, r]));
59
- const running = new Set(live.map((d) => d.admit.dispatchId));
83
+ const running = new Set(e.live.map((d) => d.admit.dispatchId));
60
84
  const tries = [
61
- ...listDispatches(run)
85
+ ...e.dispatches
62
86
  .filter((d) => d.admit.lane === lane && after(d.admit.admittedAt))
63
87
  .map((d) => {
64
- const r = records.get(d.admit.dispatchId);
88
+ const r = e.records.get(d.admit.dispatchId);
65
89
  const ok = r
66
90
  ? r.status === "ok" && r.replyStatus !== "blocked" && r.replyStatus !== "refused"
67
91
  : running.has(d.admit.dispatchId);
68
92
  return { at: Date.parse(d.admit.admittedAt), ok };
69
93
  }),
70
- ...readAgentRuns(run)
94
+ ...e.agents
71
95
  .filter((a) => a.lane === lane && after(a.at))
72
96
  .map((a) => ({ at: Date.parse(a.at), ok: a.status === "ok" })),
73
97
  ];
@@ -78,6 +102,27 @@ function laneDone(run: Run, lane: string, routes: RouteRow[], live: Dispatch[]):
78
102
  return latest?.ok ?? false;
79
103
  }
80
104
 
105
+ /** The lane's After: lanes, from its file; none when it has none or cannot be read. */
106
+ function afterOf(run: Run, lane: string): string[] {
107
+ try {
108
+ return parseLaneHeader(readFileSync(join(runPaths(run.dir).lanes, `${lane}.md`), "utf8")).after;
109
+ } catch {
110
+ return [];
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Spec 1.5 "Lane editing": the lanes `lane`'s After: line names that have not finished: their milestone has
116
+ * not landed and their latest try since the latest climb is still running, or did not end ok.
117
+ */
118
+ export function unfinishedAfter(run: Run, lane: string, routes: RouteRow[] = readRoutes(run)): string[] {
119
+ const landed = new Set(landedMilestones(run));
120
+ const after = afterOf(run, lane).filter((a) => !landed.has(milestoneOf(a)));
121
+ if (after.length === 0) return [];
122
+ const evidence = laneEvidence(run, routes, []);
123
+ return after.filter((a) => !laneDone(a, evidence));
124
+ }
125
+
81
126
  /**
82
127
  * The step the milestone loop is at: the first milestone neither landed nor parked, and within it the
83
128
  * first of route, dispatch, collect, reviewer, verifier and land that is still to do.
@@ -98,20 +143,49 @@ export function protocolNext(run: Run, parked: string[], now = Date.now()): stri
98
143
  const routed = new Set(routes.map((r) => r.lane));
99
144
  if (mine.some((l) => !routed.has(l))) return `route and preflight ${m}'s lanes`;
100
145
  const live = liveDispatches(run, now);
101
- const notDone = mine.filter((l) => !laneDone(run, l, routes, live));
146
+ const evidence = laneEvidence(run, routes, live);
147
+ const notDone = mine.filter((l) => !laneDone(l, evidence));
102
148
  // spec 1.1 §9: a climb past the top rung (from === rung) leaves nothing to climb to; that is a decision
103
149
  const spent = notDone.filter((l) => {
104
150
  const last = routes.findLast((r) => r.lane === l);
105
151
  return last?.source === "climb" && last.from === last.rung;
106
152
  });
107
153
  const waiting = notDone.filter((l) => !spent.includes(l));
108
- if (waiting.length) return `dispatch ${waiting.join(", ")}`;
154
+ // an After: line keeps a lane back until the lanes it names have finished
155
+ const held = new Map(waiting.map((l) => [l, unfinishedAfter(run, l, routes)]));
156
+ const ready = waiting.filter((l) => held.get(l)?.length === 0);
157
+ const order = waiting
158
+ .filter((l) => !ready.includes(l))
159
+ .map((l) => `${l} after ${held.get(l)?.join(", ")}`)
160
+ .join("; ");
161
+ if (ready.length) return `dispatch ${ready.join(", ")}${order ? `; then ${order}` : ""}`;
109
162
  if (spent.length) return `${spent.join(", ")}: out of rungs — ask finding, then the architect or park ${m}`;
110
163
  const running = live.filter((d) => d.admit.lane !== null && mine.includes(d.admit.lane));
111
- if (running.length) return `${m}: lanes running (${running.map((d) => d.admit.name).join(", ")})`;
164
+ if (running.length)
165
+ return `${m}: lanes running (${running.map((d) => d.admit.name).join(", ")})${order ? `; then ${order}` : ""}`;
166
+ if (order) return `${m}: ${order} (After:), not done: finish those lanes first`;
112
167
  const start = milestoneStart(run, m);
113
- if (!reviewerPassed(run, m, start)) return `${m}: reviewer`;
114
- if (!verifierPassed(run, m, start)) return `${m}: verifier`;
168
+ if (!reviewerPassed(run, m, start)) {
169
+ // plan 23: a partial review is not the review: a second pass, scoped to what it did not read
170
+ const partial = partialReviewer(run, m, start);
171
+ return partial
172
+ ? `${m}: ${partial.name} stopped partial: a scoped second pass (reviewer-${m}-2) over the files it did not read`
173
+ : `${m}: reviewer`;
174
+ }
175
+ // plan 23: open BLOCKER and BUG lines go to the fix round before the verifier
176
+ const open = openFindings(run, m, start);
177
+ if (open)
178
+ return `${m}: fix round for ${open.name}'s findings (${open.blocker} BLOCKER, ${open.bug} BUG), then the verifier`;
179
+ const verdict = milestoneVerifier(run, m, start);
180
+ if (!verdict) return `${m}: verifier`;
181
+ // plan 23: the machine, not the work: the owner hears of it, and no fix round runs
182
+ if (verdict.blocked !== null)
183
+ return `${m}: ${verdict.name} is blocked by the environment${verdict.blocked ? ` (${verdict.blocked})` : ""}: surface it to the owner (park ${m}), not a fix round`;
184
+ if (!verdict.passed) {
185
+ // plan 23: a resumed verifier can hang; a re-check goes to a fresh one, told what failed
186
+ const failed = failedItems(run, m);
187
+ return `${m}: ${verdict.name} failed: the owning lanes fix it, then a fresh verifier (not a resume) re-checks ${failed.length ? failed.join(", ") : "the failed items"}, each command capped at ${RECHECK_COMMAND_MIN} min`;
188
+ }
115
189
  return `land ${m}`;
116
190
  }
117
191
 
@@ -121,21 +195,24 @@ export const protocolView = (run: Run, parked: string[], now?: number) => ({
121
195
  checklist: PROTOCOL_CHECKLIST,
122
196
  });
123
197
 
124
- const FINDING = /^\s*(?:[-*]\s*)?(BLOCKER|BUG|NIT)\b/;
125
-
126
198
  function findingCounts(run: Run, r: RunRecord | undefined): string {
127
199
  if (!r?.replyPath) return "no reply";
128
- const file = join(run.dir, r.replyPath);
129
- const text = existsSync(file) ? readFileSync(file, "utf8") : "";
130
- const n = { BLOCKER: 0, BUG: 0, NIT: 0 };
131
- for (const line of text.split("\n")) {
132
- const k = FINDING.exec(line)?.[1] as keyof typeof n | undefined;
133
- if (k) n[k]++;
134
- }
200
+ const n = countFindings(run, r) ?? { BLOCKER: 0, BUG: 0, NIT: 0 };
135
201
  const total = n.BLOCKER + n.BUG + n.NIT;
136
202
  return `${total} finding(s): ${n.BLOCKER} BLOCKER, ${n.BUG} BUG, ${n.NIT} NIT`;
137
203
  }
138
204
 
205
+ /** The reviewer's BLOCKER and BUG lines, as it wrote them: what the digest lists as findings. */
206
+ function findingLines(run: Run, r: RunRecord | null | undefined): string[] {
207
+ if (!r?.replyPath) return [];
208
+ const file = join(run.dir, r.replyPath);
209
+ const text = existsSync(file) ? readFileSync(file, "utf8") : "";
210
+ return text
211
+ .split("\n")
212
+ .filter((l) => /^(BLOCKER|BUG)$/.test(FINDING.exec(l)?.[1] ?? ""))
213
+ .map((l) => l.trim().replace(/^[-*]\s*/, ""));
214
+ }
215
+
139
216
  const k = (x: number) => (x >= 1000 ? `${Math.round(x / 1000)}k` : String(x));
140
217
 
141
218
  /**
@@ -145,7 +222,18 @@ const k = (x: number) => (x >= 1000 ? `${Math.round(x / 1000)}k` : String(x));
145
222
  */
146
223
  export function writeDigest(
147
224
  run: Run,
148
- i: { milestone: string; what: string; commit: string; evidence: string; minutes: number; at: string },
225
+ i: {
226
+ milestone: string;
227
+ what: string;
228
+ commit: string;
229
+ evidence: string;
230
+ minutes: number;
231
+ at: string;
232
+ /** spec 1.5: the commits the milestone landed, `<hash> <subject>`, oldest first */
233
+ commits?: string[];
234
+ /** spec 1.5: the parked and paused minutes `minutes` leaves out */
235
+ pausedMinutes?: number;
236
+ },
149
237
  ): string {
150
238
  const m = i.milestone;
151
239
  const start = milestoneStart(run, m);
@@ -163,6 +251,9 @@ export function writeDigest(
163
251
  const records = readRecords(run).records;
164
252
  // the reviewer the gate counted: since the milestone's lanes started, a dispatch or a native subagent
165
253
  const reviewer = milestoneReviewer(run, m, start);
254
+ // spec 1.5: the review's BLOCKER and BUG lines, and the owner questions still open in the run
255
+ const findings = findingLines(run, reviewer?.record);
256
+ const open = openQuestions(run);
166
257
  const agents = readAgentRuns(run);
167
258
  // the verdict the gate counted: the latest attempt, native or headless, a FAIL shown as such
168
259
  const verdict = milestoneVerifier(run, m, start);
@@ -193,7 +284,8 @@ export function writeDigest(
193
284
  const text = [
194
285
  `# ${m} — ${i.what}`,
195
286
  "",
196
- `Commit ${i.commit} · ${i.minutes} min · landed ${i.at}`,
287
+ `Commit ${i.commit} · ${i.minutes} min${i.pausedMinutes ? ` (${i.pausedMinutes} min parked or paused left out)` : ""} · landed ${i.at}`,
288
+ ...(i.commits?.length ? [`Commits: ${i.commits.join("; ")}`] : []),
197
289
  `A-lines: ${aLines.length ? aLines.join("; ") : "none named in what or evidence"}`,
198
290
  "",
199
291
  "Lanes:",
@@ -201,6 +293,8 @@ export function writeDigest(
201
293
  "",
202
294
  `Reviewer: ${reviewer ? `${reviewer.name} · ${reviewer.record ? findingCounts(run, reviewer.record) : "a Claude subagent (findings in its reply)"}` : "none"}`,
203
295
  `Verifier: ${verdict ? `${verdict.passed ? "PASS" : `FAIL: ${verdict.verdict}`} (${verdict.name}${verdict.headless ? ", headless" : ""})` : "none"}${steps.length ? ` · carried: ${steps.map((s) => `${s.item}${s.commit ? ` from ${s.commit}` : ""}`).join(", ")}` : ""}`,
296
+ `Findings: ${findings.length ? findings.join(" · ") : "no BLOCKER or BUG lines"}`,
297
+ `Open: ${open.length ? open.map((q) => `${q.milestone} parked: ${q.question}`).join(" · ") : "none"}`,
204
298
  `Evidence: ${i.evidence}`,
205
299
  `Tokens: ${k(tokens.input)} in (${k(tokens.cached)} cached) · ${k(tokens.output)} out · Claude subagents ${k(reported)} (reported)`,
206
300
  "",
@@ -17,6 +17,8 @@ export interface ValueUsed {
17
17
  inferred: boolean;
18
18
  /** the rung the value belongs to, when it is not this one */
19
19
  from: string | null;
20
+ /** what lent it: a treat-like (a mapping, `like X`) or an inferred stand-in (a guess); null for its own */
21
+ lent: "treat-like" | "stand-in" | null;
20
22
  }
21
23
 
22
24
  /** Spec 1.2 §5.3: one threshold of the lane's bar, the value used against it, and whether it clears. */
@@ -40,8 +42,11 @@ export interface Provenance {
40
42
  /** facts that never carry a bar (spec 1.2 §4.1): `<source>.<field>` → value */
41
43
  speed: Record<string, number>;
42
44
  cost: Cost;
43
- /** spec 1.2 §8: its runs for the lane's kind (or every kind) and over every kind; never used for routing */
44
- evidence: { kind: string | null; all: string | null };
45
+ /**
46
+ * spec 1.2 §8: its runs for the lane's kind (or every kind) and over every kind; never used for routing;
47
+ * null when the runs could not be read
48
+ */
49
+ evidence: { kind: string | null; all: string | null } | null;
45
50
  }
46
51
 
47
52
  /** Spec 1.2 §5.3: each value a canonical rung has, with its confidence and source, borrowed ones marked. */
@@ -51,7 +56,8 @@ export function valuesUsed(c: Catalog, canonical: string): ValueUsed[] {
51
56
  return DIMS.flatMap((dim) => {
52
57
  const r = s.records[dim];
53
58
  if (!r) return [];
54
- const from = s.borrowed.includes(dim) ? s.via : (s.standIns[dim] ?? null);
59
+ const borrowed = s.borrowed.includes(dim);
60
+ const from = borrowed ? s.via : (s.standIns[dim] ?? null);
55
61
  return [
56
62
  {
57
63
  dim,
@@ -63,6 +69,7 @@ export function valuesUsed(c: Catalog, canonical: string): ValueUsed[] {
63
69
  url: r.url,
64
70
  inferred: from !== null,
65
71
  from,
72
+ lent: from === null ? null : borrowed ? "treat-like" : "stand-in",
66
73
  },
67
74
  ];
68
75
  });
@@ -75,7 +82,7 @@ export function provenanceOf(
75
82
  kind: Kind | null,
76
83
  difficulty: Difficulty | null,
77
84
  billing: Partial<Record<string, BillingMode>>,
78
- evidence: EvidenceTable,
85
+ evidence: EvidenceTable | null,
79
86
  ): Provenance {
80
87
  const info = rungInfo(c, rung);
81
88
  const values = valuesUsed(c, info.canonical);
@@ -90,7 +97,10 @@ export function provenanceOf(
90
97
  min,
91
98
  used,
92
99
  clears: used !== null && used.value >= min,
93
- why: c.barsWhy[dim]?.[difficulty] ?? null,
100
+ // a threshold the user's override set is theirs, whatever the default's why says (1.2 minor)
101
+ why: c.userBars?.includes(`${kind}/${difficulty}/${dim}`)
102
+ ? "your override"
103
+ : (c.barsWhy[dim]?.[difficulty] ?? null),
94
104
  },
95
105
  ];
96
106
  });
@@ -101,15 +111,9 @@ export function provenanceOf(
101
111
  values,
102
112
  speed: info.family?.speed ?? {},
103
113
  cost: costOf(info.family, info.parsed.effort, billing[info.key] ?? DEFAULT_BILLING[info.key]),
104
- evidence: {
114
+ evidence: evidence && {
105
115
  kind: evidenceLine(evidenceOf(evidence, info.canonical, kind)),
106
116
  all: evidenceLine(evidenceOf(evidence, info.canonical)),
107
117
  },
108
118
  };
109
119
  }
110
-
111
- /** A value as one line: `repo_code 66.6 (adjacent, shipped DeepSWE 1.1, 2026-09-22)`, marking a guess. */
112
- export function valueWords(v: ValueUsed): string {
113
- const lent = v.inferred ? `, inferred from ${v.from}` : "";
114
- return `${v.dim} ${v.value} (${v.confidence}, ${v.source} ${v.benchmark}, ${v.date}${lent})`;
115
- }
@@ -1,6 +1,7 @@
1
1
  import { join } from "node:path";
2
2
  import { CatherdError } from "../domain/errors.ts";
3
3
  import { assertId } from "../domain/ids.ts";
4
+ import type { Span } from "../domain/util.ts";
4
5
  import { appendJsonl, ensureJsonlHeader, readJsonl } from "../infra/store.ts";
5
6
  import type { Deps } from "./ports.ts";
6
7
  import { findRun, type Run } from "./run-store.ts";
@@ -38,6 +39,20 @@ export function openQuestions(run: Run): OpenQuestion[] {
38
39
  return [...open.values()];
39
40
  }
40
41
 
42
+ /** Spec 1.5 "The ledger": when `milestone` was parked, question to answer; the last one open while unanswered. */
43
+ export function parkedSpans(run: Run, milestone: string): Span[] {
44
+ const out: Span[] = [];
45
+ for (const r of rows(run)) {
46
+ if (r.milestone !== milestone) continue;
47
+ const at = Date.parse(r.at);
48
+ if (Number.isNaN(at)) continue;
49
+ const open = out.at(-1);
50
+ if (r.kind === "question" && (!open || open.to !== null)) out.push({ from: at, to: null });
51
+ else if (r.kind === "answer" && open && open.to === null) open.to = at;
52
+ }
53
+ return out;
54
+ }
55
+
41
56
  function append(run: Run, row: QuestionRow): void {
42
57
  const file = questionsFile(run);
43
58
  ensureJsonlHeader(file, "questions");
@@ -3,7 +3,7 @@ import { errorMessage } from "../domain/errors.ts";
3
3
  import { dispatchPaths } from "../infra/dispatch-dir.ts";
4
4
  import { log } from "../infra/log.ts";
5
5
  import { writeJsonAtomic } from "../infra/store.ts";
6
- import { settle, stallPoll, unsettledLimits, watching } from "./dispatch-service.ts";
6
+ import { recoverOwned, settle, stallPoll, unsettledLimits, watching } from "./dispatch-service.ts";
7
7
  import { type Dispatch, listDispatches, pendingDispatches } from "./dispatches.ts";
8
8
  import { finalizeDispatch, waitForFinish } from "./finalize.ts";
9
9
  import type { Deps } from "./ports.ts";
@@ -57,6 +57,27 @@ async function watchAndFinalize(deps: Deps, run: Run, d: Dispatch): Promise<void
57
57
  }
58
58
  }
59
59
 
60
+ /**
61
+ * Plan 22, final-review I2: a server that does not lead the boot still watches the live roles of the runs its
62
+ * own session owns, so a coordinator's server that restarts mid-run (an /mcp reconnect, a crash) while another
63
+ * session's server holds the boot lock still finalizes and announces them. It also records and announces the
64
+ * roles of those runs that finished while it was away (no live process, no record: PR #47 P1), as a claim
65
+ * does. A second watcher of a dispatch the lead also watches is harmless: finalize and failover run once, and
66
+ * a role another live process is finalizing is left to it. A run that cannot be read is skipped, logged.
67
+ */
68
+ export function adoptOwned(deps: Deps): void {
69
+ for (const run of listRuns().runs) {
70
+ try {
71
+ if (!ownsRun(deps, run)) continue;
72
+ recoverOwned(deps, run, { background: true }).catch((e: unknown) =>
73
+ log("warn", "reconcile", { run: run.id, error: errorMessage(e) }),
74
+ );
75
+ } catch (e) {
76
+ log("warn", "reconcile", { run: run.id, error: errorMessage(e) });
77
+ }
78
+ }
79
+ }
80
+
60
81
  /**
61
82
  * Spec §4.7: on server start, finalize and settle each finished dispatch that has no record, settle each
62
83
  * unread usage limit that was never failed over (its server died between the two), and watch each live one
@@ -1,8 +1,8 @@
1
- import { latestVerifierStep, type VerifierStep } from "./gate-service.ts";
2
1
  import { protocolView } from "./protocol.ts";
3
2
  import { type OpenQuestion, openQuestions } from "./questions.ts";
4
3
  import type { Run } from "./run-store.ts";
5
- import { readNotes } from "./state.ts";
4
+ import { type Notes, readNotes } from "./state.ts";
5
+ import { verifierStepView, type VerifierStepView } from "./verifier-step.ts";
6
6
 
7
7
  // Spec 1.1 §8, §7 and §10: what a session re-entering a run needs first, as peek returns it: the owner questions still open, the protocol's next step with its checklist,
8
8
  // and the verifier's latest step.
@@ -10,13 +10,14 @@ import { readNotes } from "./state.ts";
10
10
  export interface Reentry {
11
11
  questions: OpenQuestion[];
12
12
  protocol: { next: string; checklist: string[] };
13
- verifier: VerifierStep | null;
13
+ verifier: VerifierStepView | null;
14
14
  }
15
15
 
16
- export function reentry(run: Run, now?: number): Reentry {
16
+ /** `notes`: state.json as the caller already read it (peek), so it is read once. */
17
+ export function reentry(run: Run, now?: number, notes: Notes = readNotes(run)): Reentry {
17
18
  return {
18
19
  questions: openQuestions(run),
19
- protocol: protocolView(run, readNotes(run).parked ?? [], now),
20
- verifier: latestVerifierStep(run),
20
+ protocol: protocolView(run, notes.parked ?? [], now),
21
+ verifier: verifierStepView(run, now ?? Date.now()),
21
22
  };
22
23
  }
@@ -0,0 +1,27 @@
1
+ import { CatherdError } from "../domain/errors.ts";
2
+ import type { RoleScope } from "../domain/role-scope.ts";
3
+ import { roleMcpTools } from "../domain/role-tools.ts";
4
+ import { latestDispatch } from "./dispatches.ts";
5
+ import { findRun } from "./run-store.ts";
6
+
7
+ /**
8
+ * Spec 1.5 plan 21: what the role server lets a role do, for the CLI forms any role can run
9
+ * (`catherd run-file`, `catherd gate`). Outside a role (`scope` null: the user's own terminal) everything is
10
+ * allowed. In a role, only its own run, and only the operations its role server would list (`tool`).
11
+ */
12
+ export function assertRoleMay(scope: RoleScope | null, run: string, tool: string): void {
13
+ if (!scope) return;
14
+ if (scope.run !== run)
15
+ throw new CatherdError(
16
+ "E_ROLE_SCOPE",
17
+ `this process runs ${scope.name} of run ${scope.run}, not of run ${run}`,
18
+ { fix: `use run ${scope.run}` },
19
+ );
20
+ const role = latestDispatch(findRun(run), scope.name)?.admit.role;
21
+ if (!role || !roleMcpTools(role).includes(tool))
22
+ throw new CatherdError(
23
+ "E_ROLE_SCOPE",
24
+ `${scope.name} ${role ? `is a ${role}, which has no ${tool}` : `is no dispatch of run ${run}`}`,
25
+ { fix: "report it in your reply; the orchestrator does it" },
26
+ );
27
+ }