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,161 @@
1
+ import { join } from "node:path";
2
+ import { CatherdError } from "../domain/errors.ts";
3
+ import type { Span } from "../domain/util.ts";
4
+ import { dataDir } from "../infra/paths.ts";
5
+ import { appendJsonl, ensureJsonlHeader, readJsonl } from "../infra/store.ts";
6
+ /** What a pause is checked against: a run, or a workspace step about to start one. */
7
+ type Covered = { meta: { workspace?: { id: string; step: string } } };
8
+ import { findWorkspace, workspaceDirectory } from "./workspace-store.ts";
9
+
10
+ // Spec 1.5 plan 25, "Group pause": one environment blocker (a VPN, Docker down) pauses every run on the
11
+ // machine, or every child of a workspace, at once: admission refuses with the reason until it is resumed.
12
+
13
+ /** One row per pause or resume, oldest first; `reason` null is a resume. */
14
+ interface PauseRow {
15
+ at: string;
16
+ reason: string | null;
17
+ }
18
+
19
+ /** A pause in force: whose (the machine, or one workspace), why and since when. */
20
+ export interface Pause {
21
+ scope: "machine" | "workspace";
22
+ /** the workspace id, for a workspace pause */
23
+ workspace?: string;
24
+ reason: string;
25
+ since: string;
26
+ }
27
+
28
+ /** A stretch of time something was paused; `to` null while it still is. */
29
+ export interface PauseInterval {
30
+ from: string;
31
+ to: string | null;
32
+ }
33
+
34
+ export const machinePauseFile = (): string => join(dataDir(), "pauses.jsonl");
35
+ export const workspacePauseFile = (id: string): string => join(workspaceDirectory(id), "pauses.jsonl");
36
+
37
+ const rows = (file: string): PauseRow[] =>
38
+ readJsonl<PauseRow>(file).rows.filter(
39
+ (r) => typeof r?.at === "string" && (r.reason === null || typeof r.reason === "string"),
40
+ );
41
+
42
+ /** The pause in force in `file`, or null: its last row, when that row pauses. */
43
+ function current(file: string): { reason: string; since: string } | null {
44
+ const last = rows(file).at(-1);
45
+ return last && last.reason !== null ? { reason: last.reason, since: last.at } : null;
46
+ }
47
+
48
+ /** Every paused stretch `file` records, oldest first. */
49
+ export function pauseIntervals(file: string): PauseInterval[] {
50
+ const out: PauseInterval[] = [];
51
+ for (const r of rows(file)) {
52
+ const open = out.at(-1);
53
+ if (r.reason !== null && (!open || open.to !== null)) out.push({ from: r.at, to: null });
54
+ else if (r.reason === null && open && open.to === null) open.to = r.at;
55
+ }
56
+ return out;
57
+ }
58
+
59
+ function append(file: string, row: PauseRow): void {
60
+ ensureJsonlHeader(file, "pauses");
61
+ appendJsonl(file, row);
62
+ }
63
+
64
+ const PAUSE_HINT = "push the reason to the owner once (PushNotification); every run it covers waits on it";
65
+
66
+ /** `catherd pause --machine <reason>`: every dispatch on this machine is refused until resumed. */
67
+ export function pauseMachine(now: number, reason: string): { pause: Pause; hints: string[] } {
68
+ if (!reason.trim())
69
+ throw new CatherdError("E_INPUT_INVALID", "a pause needs a reason", {
70
+ fix: 'catherd pause --machine "the VPN takes the default route"',
71
+ });
72
+ const at = new Date(now).toISOString();
73
+ append(machinePauseFile(), { at, reason });
74
+ return { pause: { scope: "machine", reason, since: at }, hints: [PAUSE_HINT] };
75
+ }
76
+
77
+ /** `catherd resume --machine`: lifts the machine pause; the previous one, or null when none was in force. */
78
+ export function resumeMachine(now: number): { resumed: Pause | null } {
79
+ const was = current(machinePauseFile());
80
+ if (was) append(machinePauseFile(), { at: new Date(now).toISOString(), reason: null });
81
+ return { resumed: was ? { scope: "machine", ...was } : null };
82
+ }
83
+
84
+ /** `workspace_pause(workspace, reason)`: every child of the workspace is refused admission until resumed. */
85
+ export function pauseWorkspace(
86
+ now: number,
87
+ i: { workspace: string; reason: string },
88
+ ): { pause: Pause; hints: string[] } {
89
+ const workspace = findWorkspace(i.workspace);
90
+ if (!i.reason.trim())
91
+ throw new CatherdError("E_INPUT_INVALID", "a pause needs a reason", {
92
+ fix: 'workspace_pause(workspace, "the VPN takes the default route")',
93
+ });
94
+ const at = new Date(now).toISOString();
95
+ append(workspacePauseFile(workspace.id), { at, reason: i.reason });
96
+ return {
97
+ pause: { scope: "workspace", workspace: workspace.id, reason: i.reason, since: at },
98
+ hints: [PAUSE_HINT],
99
+ };
100
+ }
101
+
102
+ /** `workspace_resume(workspace)`: lifts the workspace's pause; the previous one, or null. */
103
+ export function resumeWorkspace(now: number, i: { workspace: string }): { resumed: Pause | null } {
104
+ const workspace = findWorkspace(i.workspace);
105
+ const file = workspacePauseFile(workspace.id);
106
+ const was = current(file);
107
+ if (was) append(file, { at: new Date(now).toISOString(), reason: null });
108
+ return { resumed: was ? { scope: "workspace", workspace: workspace.id, ...was } : null };
109
+ }
110
+
111
+ /** The pauses in force over `run`: the machine's, then its workspace's. */
112
+ export function pausesFor(run: Covered | null): Pause[] {
113
+ const out: Pause[] = [];
114
+ const machine = current(machinePauseFile());
115
+ if (machine) out.push({ scope: "machine", ...machine });
116
+ const id = run?.meta.workspace?.id;
117
+ const ws = id ? current(workspacePauseFile(id)) : null;
118
+ if (id && ws) out.push({ scope: "workspace", workspace: id, ...ws });
119
+ return out;
120
+ }
121
+
122
+ /** Every stretch the machine, or the run's workspace, was paused, in epoch ms: `land` leaves them out. */
123
+ export function pauseSpans(run: Covered): Span[] {
124
+ const id = run.meta.workspace?.id;
125
+ const files = [machinePauseFile(), ...(id ? [workspacePauseFile(id)] : [])];
126
+ return files.flatMap((f) =>
127
+ pauseIntervals(f).map((p) => ({ from: Date.parse(p.from), to: p.to === null ? null : Date.parse(p.to) })),
128
+ );
129
+ }
130
+
131
+ /** The pauses in force over any of `runs`, each once, the machine's first. */
132
+ export function pausesOver(runs: Covered[]): Pause[] {
133
+ const seen = new Set<string>();
134
+ const out: Pause[] = [];
135
+ for (const p of [pausesFor(null), ...runs.map(pausesFor)].flat()) {
136
+ const key = `${p.scope} ${p.workspace ?? ""}`;
137
+ if (!seen.has(key)) {
138
+ seen.add(key);
139
+ out.push(p);
140
+ }
141
+ }
142
+ return out;
143
+ }
144
+
145
+ const describe = (p: Pause): string =>
146
+ `${p.scope === "machine" ? "the machine" : `workspace ${p.workspace}`} is paused since ${p.since}: ${p.reason}`;
147
+
148
+ /** One line per pause, for status and the admission refusal. */
149
+ export const pauseLine = describe;
150
+
151
+ /** Admission refuses while a pause covers the run, naming its reason and how to lift it. */
152
+ export function assertNotPaused(run: Covered): void {
153
+ const [p] = pausesFor(run);
154
+ if (!p) return;
155
+ throw new CatherdError("E_ADMIT_PAUSED", describe(p), {
156
+ fix:
157
+ p.scope === "machine"
158
+ ? "when the blocker is gone, the owner runs: catherd resume --machine"
159
+ : `when the blocker is gone, call workspace_resume(${p.workspace})`,
160
+ });
161
+ }
@@ -1,5 +1,9 @@
1
+ import type { HostContext, HostSessionRef, KnownHost } from "../domain/host.ts";
1
2
  import { assertId } from "../domain/ids.ts";
2
3
  import { noticeHeader } from "../domain/notice.ts";
4
+ import { knownQueueCapability, type QueueCapability } from "../infra/codex-queue.ts";
5
+ import type { DeliveryState } from "../infra/delivery.ts";
6
+ import { inspectionHost, inspectDelivery, inspectDeliveries, type DeliveryInspection } from "./run-debug.ts";
3
7
  import { awaitsCollect } from "../infra/dispatch-dir.ts";
4
8
  import { claim } from "./dispatch-service.ts";
5
9
  import { type Dispatch, type DispatchState, listDispatches, liveDispatches } from "./dispatches.ts";
@@ -8,7 +12,7 @@ import { finishedNotice } from "./notifier.ts";
8
12
  import type { Deps } from "./ports.ts";
9
13
  import { type Reentry, reentry } from "./reentry.ts";
10
14
  import { findRun, listRuns, readAgentRuns, readRecords, type Run } from "./run-store.ts";
11
- import { currentSession, runOwner } from "./sessions.ts";
15
+ import { ownsRun, runOwner } from "./sessions.ts";
12
16
  import { readNotes } from "./state.ts";
13
17
 
14
18
  export interface PeekRole {
@@ -28,13 +32,62 @@ export interface PeekRun extends Reentry {
28
32
  title: string;
29
33
  /** the session that owns the run now, null when none has */
30
34
  owner: string | null;
35
+ ownerHost: KnownHost | null;
31
36
  live: PeekRole[];
37
+ /** Stalled notices are separate from unread finished role results. */
38
+ delivery: (DeliveryInspection & { name: string; dispatchId: string })[];
32
39
  /** each finished record not yet read, as the first line of its message */
33
- unread: { name: string; dispatchId: string; header: string }[];
40
+ unread: {
41
+ name: string;
42
+ dispatchId: string;
43
+ header: string;
44
+ eventId: string;
45
+ delivery: Exclude<DeliveryState, "collected">;
46
+ receipt: { msgId: string; target: HostSessionRef; at: string } | null;
47
+ }[];
34
48
  /** the latest native Claude run recorded with record_agent_run */
35
49
  native: { name: string; role: string; rung: string; status: string; at: string } | null;
36
50
  /** the run's next step (state.md's last line) */
37
51
  next: string;
52
+ /** plan 22, goal mode: whether anything in this run is the coordinator's to do now, and why (not) */
53
+ actionable: boolean;
54
+ reason: string;
55
+ }
56
+
57
+ /**
58
+ * Plan 22, goal mode: whether the run holds anything for the coordinator. An unread record is its to read; with
59
+ * roles live, a protocol step one of them is doing (lanes running, the reviewer or verifier step with that role
60
+ * live, the plan while an architect or researcher runs) or the owner's question is nobody's to act on, so the
61
+ * coordinator ends its turn with no tool call and the next catherd message wakes it.
62
+ */
63
+ export function actionability(
64
+ live: { name: string; role: string }[],
65
+ unread: { name: string }[],
66
+ protocolNext: string,
67
+ ): { actionable: boolean; reason: string } {
68
+ if (unread.length)
69
+ return {
70
+ actionable: true,
71
+ reason: `unread: ${unread.map((u) => `result(run, "${u.name}")`).join(", ")}`,
72
+ };
73
+ if (protocolNext.endsWith(" parked: wait for the owner"))
74
+ return {
75
+ actionable: false,
76
+ reason: `${protocolNext.replace(/: wait for the owner$/, "")}: the owner's answer comes first; end the turn with no tool call`,
77
+ };
78
+ const running = (role: string) => live.some((l) => l.role === role);
79
+ const covered =
80
+ /: lanes running \(/.test(protocolNext) ||
81
+ (protocolNext.endsWith(": reviewer") && running("reviewer")) ||
82
+ (protocolNext.endsWith(": verifier") && running("verifier")) ||
83
+ (protocolNext.startsWith("plan:") && (running("architect") || running("researcher"))) ||
84
+ (protocolNext.startsWith("finish:") && (running("verifier") || running("writer")));
85
+ if (live.length && covered)
86
+ return {
87
+ actionable: false,
88
+ reason: `only roles are live (${live.map((l) => l.name).join(", ")}): their results arrive as catherd messages; end the turn with no tool call`,
89
+ };
90
+ return { actionable: true, reason: protocolNext };
38
91
  }
39
92
 
40
93
  function peekRun(deps: Deps, run: Run, name: string | undefined): PeekRun {
@@ -43,36 +96,64 @@ function peekRun(deps: Deps, run: Run, name: string | undefined): PeekRun {
43
96
  const records = new Map(readRecords(run).records.map((r) => [r.dispatchId, r]));
44
97
  const agents = readAgentRuns(run).filter((a) => name === undefined || a.name === name);
45
98
  const native = agents.at(-1);
46
- const r = reentry(run, now);
99
+ const notes = readNotes(run);
100
+ const re = reentry(run, now, notes);
101
+ const live = liveDispatches(run, now);
102
+ // the whole run decides it, whatever `name` narrows the view to
103
+ const act = actionability(
104
+ live.map((d) => ({ name: d.admit.name, role: d.admit.role })),
105
+ listDispatches(run).flatMap((d) =>
106
+ records.has(d.admit.dispatchId) && awaitsCollect(d.dir) ? [{ name: d.admit.name }] : [],
107
+ ),
108
+ re.protocol.next,
109
+ );
47
110
  return {
48
- questions: r.questions,
111
+ questions: re.questions,
49
112
  run: run.id,
50
113
  title: run.meta.title,
51
114
  owner: runOwner(run)?.sessionId ?? null,
52
- live: liveDispatches(run, now)
115
+ ownerHost: runOwner(run)?.host ?? null,
116
+ live: live.filter(mine).map((d) => ({
117
+ name: d.admit.name,
118
+ role: d.admit.role,
119
+ rung: d.admit.rung,
120
+ state: d.state,
121
+ secs: Math.max(0, Math.round((now - Date.parse(d.admit.admittedAt)) / 1000)),
122
+ lastEvent: lastActivity(d),
123
+ })),
124
+ delivery: listDispatches(run)
53
125
  .filter(mine)
54
- .map((d) => ({
55
- name: d.admit.name,
56
- role: d.admit.role,
57
- rung: d.admit.rung,
58
- state: d.state,
59
- secs: Math.max(0, Math.round((now - Date.parse(d.admit.admittedAt)) / 1000)),
60
- lastEvent: lastActivity(d),
61
- })),
126
+ .flatMap((d) =>
127
+ inspectDeliveries(run, d, false).map((inspection) => ({
128
+ name: d.admit.name,
129
+ dispatchId: d.admit.dispatchId,
130
+ ...inspection,
131
+ })),
132
+ ),
62
133
  unread: listDispatches(run)
63
134
  .filter(mine)
64
135
  .flatMap((d) => {
65
136
  const r = records.get(d.admit.dispatchId);
66
- return r && awaitsCollect(d.dir)
67
- ? [{ name: r.name, dispatchId: r.dispatchId, header: noticeHeader(finishedNotice(run, d, r)) }]
68
- : [];
137
+ if (!r || !awaitsCollect(d.dir)) return [];
138
+ const delivery = inspectDelivery(run, d, true);
139
+ if (delivery.delivery === "collected") return [];
140
+ return [
141
+ {
142
+ ...delivery,
143
+ delivery: delivery.delivery,
144
+ name: r.name,
145
+ dispatchId: r.dispatchId,
146
+ header: noticeHeader(finishedNotice(run, d, r)),
147
+ },
148
+ ];
69
149
  }),
70
150
  native: native
71
151
  ? { name: native.name, role: native.role, rung: native.rung, status: native.status, at: native.at }
72
152
  : null,
73
- next: readNotes(run).next,
74
- protocol: r.protocol,
75
- verifier: r.verifier,
153
+ next: notes.next,
154
+ protocol: re.protocol,
155
+ verifier: re.verifier,
156
+ ...act,
76
157
  };
77
158
  }
78
159
 
@@ -84,7 +165,15 @@ function peekRun(deps: Deps, run: Run, name: string | undefined): PeekRun {
84
165
  export async function peek(
85
166
  deps: Deps,
86
167
  i: { run?: string; name?: string },
87
- ): Promise<{ runs: PeekRun[]; hints: string[] }> {
168
+ ): Promise<{
169
+ /** plan 22, goal mode: false when no run holds anything for the coordinator; `reason` says why either way */
170
+ actionable: boolean;
171
+ reason: string;
172
+ runs: PeekRun[];
173
+ hints: string[];
174
+ host: HostContext;
175
+ queue: QueueCapability | null;
176
+ }> {
88
177
  if (i.name !== undefined) assertId("role name", i.name);
89
178
  let runs: Run[];
90
179
  if (i.run) {
@@ -94,10 +183,20 @@ export async function peek(
94
183
  runs = [run];
95
184
  } else {
96
185
  const all = listRuns().runs;
97
- const me = currentSession(deps);
98
- const owned = me ? all.filter((r) => runOwner(r)?.sessionId === me.sessionId) : [];
186
+ const owned = all.filter((r) => ownsRun(deps, r));
99
187
  runs = owned.length ? owned : all.slice(0, 1);
100
188
  }
101
189
  const hints = runs.length === 0 ? ["no runs yet: run_start(repo, title, a_lines) starts one"] : [];
102
- return { runs: runs.map((r) => peekRun(deps, r, i.name)), hints };
190
+ const views = runs.map((r) => peekRun(deps, r, i.name));
191
+ const doing = views.filter((v) => v.actionable);
192
+ const shown = (vs: PeekRun[]) =>
193
+ vs.map((v) => (views.length > 1 ? `${v.title}: ${v.reason}` : v.reason)).join("; ");
194
+ return {
195
+ actionable: views.length === 0 || doing.length > 0,
196
+ reason: views.length === 0 ? "no runs yet" : shown(doing.length ? doing : views),
197
+ runs: views,
198
+ hints,
199
+ host: inspectionHost(deps.host),
200
+ queue: deps.host.host === "codex" && !deps.host.conflict ? knownQueueCapability(process.env) : null,
201
+ };
103
202
  }
@@ -1,3 +1,4 @@
1
+ import type { HostContext } from "../domain/host.ts";
1
2
  import type { Provenance } from "./provenance.ts";
2
3
  import type { Budget } from "../domain/budget.ts";
3
4
  import type { SessionEnv } from "../infra/claude-session.ts";
@@ -6,7 +7,8 @@ import type { Verdict } from "../domain/jev.ts";
6
7
  import type { Difficulty, Kind } from "../domain/lane.ts";
7
8
  import type { Access } from "../domain/record.ts";
8
9
  import type { Role } from "../domain/roles.ts";
9
- import type { Change, ProfilePatch } from "../domain/profile.ts";
10
+ import type { RoleScope } from "../domain/role-scope.ts";
11
+ import type { Change, ProfileDoc, ProfilePatch } from "../domain/profile.ts";
10
12
  import type { Issue } from "../domain/profile-rules.ts";
11
13
  import type { RouteJev, RouteSource } from "../domain/route.ts";
12
14
  import type { SyncReport } from "./source-sync.ts";
@@ -17,7 +19,18 @@ export interface ProfileView {
17
19
  objective: "cost" | "speed";
18
20
  /** `defaultRung`, when set, is where a lane starts without a kind and difficulty (spec §5.4) */
19
21
  roles: Partial<
20
- Record<Role, { enabled: boolean; access: Access; rungs: string[]; defaultRung?: string; network?: false }>
22
+ Record<
23
+ Role,
24
+ {
25
+ enabled: boolean;
26
+ access: Access;
27
+ rungs: string[];
28
+ defaultRung?: string;
29
+ network?: false;
30
+ /** plan 23: the role's own timeouts over the profile's */
31
+ timeouts?: { idleMin?: number; wallMin?: number };
32
+ }
33
+ >
21
34
  >;
22
35
  /** per billing key (spec §7.1); a missing key bills as DEFAULT_BILLING */
23
36
  billing: Partial<Record<string, BillingMode>>;
@@ -46,6 +59,9 @@ export interface ProfileSaved {
46
59
  }
47
60
 
48
61
  export interface ProfilePort {
62
+ withHost?(host: () => HostContext): ProfilePort;
63
+ budgetFor?(repo: string | null): Budget;
64
+ raw?(name?: string, repo?: string | null): ProfileDoc;
49
65
  /** The profile bound to `repo` (a git toplevel), else the active one; null asks for the active one. */
50
66
  forRepo(repo: string | null): ProfileView;
51
67
  /** Without a name (get, validate, set): the profile `repo`, a repository's toplevel, runs on. */
@@ -69,6 +85,7 @@ export interface ProfilePort {
69
85
  }
70
86
 
71
87
  export interface RouteRequest {
88
+ host?: HostContext["host"];
72
89
  runDir: string;
73
90
  repo: string;
74
91
  profile: ProfileView;
@@ -77,6 +94,8 @@ export interface RouteRequest {
77
94
  lane: string | null;
78
95
  laneText: string | null;
79
96
  spentFraction: number;
97
+ /** spec 1.5 plan 24: the run's dispatches so far per quota, for a tie between rungs of equal scores */
98
+ usage?: Record<string, number>;
80
99
  }
81
100
 
82
101
  export interface RouteAnswer {
@@ -88,6 +107,14 @@ export interface RouteAnswer {
88
107
  /** the Jev question set asked, and its summed probabilities, for outcomes.jsonl (spec §5.6) */
89
108
  questionSet: string | null;
90
109
  jev: RouteJev | null;
110
+ /** spec 1.5 plan 24: where Jev disagreed with a declared lane header (`Jev said logic/hard`), else null */
111
+ jevSaid: string | null;
112
+ /** spec 1.5 plan 24: no rung clears the lane's bar, and the closest one */
113
+ noClear?: string;
114
+ /** spec 1.5 plan 24: rungs of equal scores on other quotas competed for the start, and what decided */
115
+ tie?: string;
116
+ /** spec 1.5 plan 24: the decision in one line, for the coordinator */
117
+ why: string;
91
118
  /** spec 1.2 §5.3, §8: the chosen rung's thresholds, values, sources, facts and run evidence */
92
119
  provenance?: Provenance;
93
120
  }
@@ -106,6 +133,8 @@ export interface CatalogFilter {
106
133
 
107
134
  export interface RoutingPort {
108
135
  route(req: RouteRequest): Promise<RouteAnswer>;
136
+ /** spec 1.5 plan 24: the lanes of one role in one call; Jev is asked about all of them at once */
137
+ routeMany(reqs: RouteRequest[]): Promise<RouteAnswer[]>;
109
138
  /** `use` is the repo profile's `jev.use`: "off" answers with the rule's default and never asks Jev */
110
139
  finding(
111
140
  runDir: string,
@@ -128,14 +157,20 @@ export interface RoutingPort {
128
157
 
129
158
  /** Everything a service needs from outside it; the entry layer builds one, tests build fakes. */
130
159
  export interface Deps {
160
+ host: HostContext;
131
161
  profiles: ProfilePort;
132
162
  routing: RoutingPort;
133
163
  version: string;
134
164
  /** how often the supervisor and the dispatch watchers poll, in ms */
135
165
  pollMs: number;
136
- /** the Claude Code session this process serves (spec §3.3), from its environment; null outside one */
166
+ /** Claude transport credentials and registry refresh; host identity lives in `host`. */
137
167
  session: SessionEnv | null;
138
168
  now: () => number;
139
169
  /** spec 1.2 §3.2 `catalog_sync`; default: the real sync (tests inject one that never reaches the network) */
140
- sync?: (o: { force: boolean }) => Promise<SyncReport>;
170
+ sync?: (o: { force: boolean; wait?: boolean }) => Promise<SyncReport>;
171
+ /**
172
+ * Spec 1.5 plan 21: the role this process works for (CATHERD_ROLE, or a role's scratch TMPDIR); null or
173
+ * absent for the orchestrator's own server. A role never claims a run and never reaches a coordinator tool.
174
+ */
175
+ role?: RoleScope | null;
141
176
  }