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
@@ -49,6 +49,45 @@ export interface PeekRun extends Reentry {
49
49
  native: { name: string; role: string; rung: string; status: string; at: string } | null;
50
50
  /** the run's next step (state.md's last line) */
51
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 };
52
91
  }
53
92
 
54
93
  function peekRun(deps: Deps, run: Run, name: string | undefined): PeekRun {
@@ -57,23 +96,31 @@ function peekRun(deps: Deps, run: Run, name: string | undefined): PeekRun {
57
96
  const records = new Map(readRecords(run).records.map((r) => [r.dispatchId, r]));
58
97
  const agents = readAgentRuns(run).filter((a) => name === undefined || a.name === name);
59
98
  const native = agents.at(-1);
60
- 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
+ );
61
110
  return {
62
- questions: r.questions,
111
+ questions: re.questions,
63
112
  run: run.id,
64
113
  title: run.meta.title,
65
114
  owner: runOwner(run)?.sessionId ?? null,
66
115
  ownerHost: runOwner(run)?.host ?? null,
67
- live: liveDispatches(run, now)
68
- .filter(mine)
69
- .map((d) => ({
70
- name: d.admit.name,
71
- role: d.admit.role,
72
- rung: d.admit.rung,
73
- state: d.state,
74
- secs: Math.max(0, Math.round((now - Date.parse(d.admit.admittedAt)) / 1000)),
75
- lastEvent: lastActivity(d),
76
- })),
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
+ })),
77
124
  delivery: listDispatches(run)
78
125
  .filter(mine)
79
126
  .flatMap((d) =>
@@ -103,9 +150,10 @@ function peekRun(deps: Deps, run: Run, name: string | undefined): PeekRun {
103
150
  native: native
104
151
  ? { name: native.name, role: native.role, rung: native.rung, status: native.status, at: native.at }
105
152
  : null,
106
- next: readNotes(run).next,
107
- protocol: r.protocol,
108
- verifier: r.verifier,
153
+ next: notes.next,
154
+ protocol: re.protocol,
155
+ verifier: re.verifier,
156
+ ...act,
109
157
  };
110
158
  }
111
159
 
@@ -117,7 +165,15 @@ function peekRun(deps: Deps, run: Run, name: string | undefined): PeekRun {
117
165
  export async function peek(
118
166
  deps: Deps,
119
167
  i: { run?: string; name?: string },
120
- ): Promise<{ runs: PeekRun[]; hints: string[]; host: HostContext; queue: QueueCapability | null }> {
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
+ }> {
121
177
  if (i.name !== undefined) assertId("role name", i.name);
122
178
  let runs: Run[];
123
179
  if (i.run) {
@@ -131,8 +187,14 @@ export async function peek(
131
187
  runs = owned.length ? owned : all.slice(0, 1);
132
188
  }
133
189
  const hints = runs.length === 0 ? ["no runs yet: run_start(repo, title, a_lines) starts one"] : [];
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("; ");
134
194
  return {
135
- runs: runs.map((r) => peekRun(deps, r, i.name)),
195
+ actionable: views.length === 0 || doing.length > 0,
196
+ reason: views.length === 0 ? "no runs yet" : shown(doing.length ? doing : views),
197
+ runs: views,
136
198
  hints,
137
199
  host: inspectionHost(deps.host),
138
200
  queue: deps.host.host === "codex" && !deps.host.conflict ? knownQueueCapability(process.env) : null,
@@ -7,6 +7,7 @@ import type { Verdict } from "../domain/jev.ts";
7
7
  import type { Difficulty, Kind } from "../domain/lane.ts";
8
8
  import type { Access } from "../domain/record.ts";
9
9
  import type { Role } from "../domain/roles.ts";
10
+ import type { RoleScope } from "../domain/role-scope.ts";
10
11
  import type { Change, ProfileDoc, ProfilePatch } from "../domain/profile.ts";
11
12
  import type { Issue } from "../domain/profile-rules.ts";
12
13
  import type { RouteJev, RouteSource } from "../domain/route.ts";
@@ -18,7 +19,18 @@ export interface ProfileView {
18
19
  objective: "cost" | "speed";
19
20
  /** `defaultRung`, when set, is where a lane starts without a kind and difficulty (spec §5.4) */
20
21
  roles: Partial<
21
- 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
+ >
22
34
  >;
23
35
  /** per billing key (spec §7.1); a missing key bills as DEFAULT_BILLING */
24
36
  billing: Partial<Record<string, BillingMode>>;
@@ -82,6 +94,8 @@ export interface RouteRequest {
82
94
  lane: string | null;
83
95
  laneText: string | null;
84
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>;
85
99
  }
86
100
 
87
101
  export interface RouteAnswer {
@@ -93,6 +107,14 @@ export interface RouteAnswer {
93
107
  /** the Jev question set asked, and its summed probabilities, for outcomes.jsonl (spec §5.6) */
94
108
  questionSet: string | null;
95
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;
96
118
  /** spec 1.2 §5.3, §8: the chosen rung's thresholds, values, sources, facts and run evidence */
97
119
  provenance?: Provenance;
98
120
  }
@@ -111,6 +133,8 @@ export interface CatalogFilter {
111
133
 
112
134
  export interface RoutingPort {
113
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[]>;
114
138
  /** `use` is the repo profile's `jev.use`: "off" answers with the rule's default and never asks Jev */
115
139
  finding(
116
140
  runDir: string,
@@ -143,5 +167,10 @@ export interface Deps {
143
167
  session: SessionEnv | null;
144
168
  now: () => number;
145
169
  /** spec 1.2 §3.2 `catalog_sync`; default: the real sync (tests inject one that never reaches the network) */
146
- 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;
147
176
  }
@@ -1,16 +1,19 @@
1
1
  import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { CatherdError, errorMessage } from "../domain/errors.ts";
4
- import { assertLaneHeader, LANE_HEADER_FIX, parseLaneHeader } from "../domain/lane.ts";
4
+ import { assertLaneHeader, LANE_HEADER_FIX, onlyAllowedHits, parseLaneHeader } from "../domain/lane.ts";
5
5
  import { checkEnv } from "../infra/env.ts";
6
- import { heavySlots, withHeavySlot } from "../infra/heavy-lock.ts";
6
+ import { heavySlots, withHeavySlotWithin } from "../infra/heavy-lock.ts";
7
+ import { loginEnv } from "../infra/login-env.ts";
7
8
  import { killGroup } from "../infra/proc.ts";
9
+ import { readGateEnv, resolveGateEnv } from "./gate-env.ts";
10
+ import { landedMilestones } from "./milestones.ts";
8
11
  import type { Deps } from "./ports.ts";
9
12
  import { TAIL_LINES } from "./run-debug.ts";
10
13
  import { findRun, type Run, runPaths } from "./run-store.ts";
11
14
 
12
- /** Spec §4.7. Only `cannot-start` blocks the run. */
13
- export type PreflightOutcome = "pass" | "fails-as-expected" | "skipped" | "cannot-start";
15
+ /** Spec §4.7. Only `cannot-start` blocks the run; `lock-busy` (plan 23) means: run preflight again. */
16
+ export type PreflightOutcome = "pass" | "fails-as-expected" | "skipped" | "cannot-start" | "lock-busy";
14
17
 
15
18
  interface PreflightResult {
16
19
  lane: string;
@@ -28,6 +31,8 @@ export type PreflightReport =
28
31
  commands: { lane: string; check: string | null }[];
29
32
  results: PreflightResult[];
30
33
  blocked: boolean;
34
+ /** plan 23: lanes whose fast check runs no linter while the repo has one */
35
+ warnings: string[];
31
36
  };
32
37
 
33
38
  const CHECK_TIMEOUT_MS = 120_000;
@@ -47,6 +52,8 @@ interface LaneCheck {
47
52
  lane: string;
48
53
  check: string | null;
49
54
  owns: string[];
55
+ /** spec 1.5: the Allow: exceptions of the lane's absence check */
56
+ allow: string[];
50
57
  problem: string | null;
51
58
  /** spec 1.1 §6: why its Kind: or Difficulty: line is refused */
52
59
  invalid: string | null;
@@ -73,15 +80,19 @@ function laneChecks(run: Run): LaneCheck[] {
73
80
  lane,
74
81
  check: h.fastCheck,
75
82
  owns: h.owns,
83
+ allow: h.allow,
76
84
  problem: h.fastCheck ? null : `lanes/${f} has no Fast check: line`,
77
85
  invalid,
78
86
  };
79
87
  } catch (e) {
80
- return { lane, check: null, owns: [], problem: (e as Error).message, invalid };
88
+ return { lane, check: null, owns: [], allow: [], problem: (e as Error).message, invalid };
81
89
  }
82
90
  });
83
91
  }
84
92
 
93
+ /** How many output lines a check's Allow: exceptions are matched against; past it, the check fails as before. */
94
+ const ALLOW_LINES = 1_000;
95
+
85
96
  /** How long the pipes may stay open after the check's own process exits. */
86
97
  export const DRAIN_MS = 500;
87
98
 
@@ -116,10 +127,11 @@ export async function runCheck(
116
127
  repo: string,
117
128
  check: string,
118
129
  timeoutMs: number,
119
- ): Promise<{ code: number | null; timedOut: boolean; tail: string[] }> {
130
+ env: Record<string, string> = checkEnv(process.env, repo),
131
+ ): Promise<{ code: number | null; timedOut: boolean; tail: string[]; lines: string[] }> {
120
132
  const p = Bun.spawn(["sh", "-c", check], {
121
133
  cwd: repo,
122
- env: checkEnv(process.env, repo),
134
+ env,
123
135
  stdin: "ignore",
124
136
  stdout: "pipe",
125
137
  stderr: "pipe",
@@ -141,11 +153,10 @@ export async function runCheck(
141
153
  Bun.sleep(DRAIN_MS).then(() => false),
142
154
  ]);
143
155
  if (!drained) killGroup(p.pid, "SIGKILL"); // leftovers still in the check's group
144
- const tail = `${out.text()}${err.text()}`
145
- .split("\n")
146
- .filter((l) => l.trim())
147
- .slice(-TAIL_LINES);
148
- return { code: timedOut ? null : code, timedOut, tail };
156
+ // every line, up to a bound, for a lane's Allow: exceptions; the tail is what the report shows
157
+ const all = `${out.text()}${err.text()}`.split("\n").filter((l) => l.trim());
158
+ const lines = all.length <= ALLOW_LINES ? all : [];
159
+ return { code: timedOut ? null : code, timedOut, tail: all.slice(-TAIL_LINES), lines };
149
160
  } finally {
150
161
  clearTimeout(timer);
151
162
  out.stop();
@@ -153,10 +164,100 @@ export async function runCheck(
153
164
  }
154
165
  }
155
166
 
156
- export function classify(r: { code: number | null; timedOut: boolean; tail: string[] }): PreflightOutcome {
167
+ /**
168
+ * Plan 23: a check that failed on the machine, not on the code: no Docker (or none at DOCKER_HOST), no DNS, a
169
+ * permission the sandbox or the OS denied. Such a check cannot start; it never fails as expected.
170
+ */
171
+ const ENVIRONMENT = [
172
+ /command not found/i,
173
+ /Cannot connect to the Docker daemon/i,
174
+ /rootless Docker not found/i,
175
+ /Could not find a valid Docker environment/i,
176
+ /docker\.sock: connect: no such file or directory/i,
177
+ /Temporary failure in name resolution|Could not resolve host|no such host|Name or service not known|nodename nor servname|getaddrinfo (ENOTFOUND|EAI_AGAIN)/i,
178
+ /permission denied|operation not permitted|\bEACCES\b|\bEPERM\b/i,
179
+ ];
180
+
181
+ /** pnpm exits 0 on a `--filter` that matches no package: the lane creates it, so there is nothing to run yet. */
182
+ const EMPTY_FILTER = /No projects matched the filters/i;
183
+
184
+ /** The first tail line an ENVIRONMENT pattern matches, or null. */
185
+ export const environmentLine = (tail: string[]): string | null =>
186
+ tail.find((l) => ENVIRONMENT.some((re) => re.test(l))) ?? null;
187
+
188
+ /** One `pnpm … --filter …` command, nothing chained, piped or backgrounded beside it. */
189
+ const SINGLE_PNPM_FILTER = /^\s*pnpm\s(?:[^;&|\n`$()<>]*\s)?(?:--filter|-F)(?:[\s=])[^;&|\n`$()<>]*$/;
190
+
191
+ /**
192
+ * An unmatched pnpm filter is "skipped" only when it is provably the check's sole failure: the whole check
193
+ * exited 0, or the check is a single pnpm --filter command. In a compound check (`pnpm --filter new test;
194
+ * docker compose up`) a later command's failure decides.
195
+ */
196
+ export function classify(
197
+ r: { code: number | null; timedOut: boolean; tail: string[] },
198
+ command?: string,
199
+ ): PreflightOutcome {
157
200
  if (r.timedOut || r.code === 126 || r.code === 127) return "cannot-start";
201
+ const soleFailure = r.code === 0 || (command !== undefined && SINGLE_PNPM_FILTER.test(command));
202
+ if (soleFailure && r.tail.some((l) => EMPTY_FILTER.test(l))) return "skipped";
158
203
  if (r.code === 0) return "pass";
159
- return r.tail.some((l) => /command not found/i.test(l)) ? "cannot-start" : "fails-as-expected";
204
+ return environmentLine(r.tail) !== null ? "cannot-start" : "fails-as-expected";
205
+ }
206
+
207
+ /** Why a classified result says what it says, for its note. */
208
+ function noteOf(
209
+ r: { code: number | null; timedOut: boolean; tail: string[] },
210
+ outcome: PreflightOutcome,
211
+ timeoutMs: number,
212
+ ): string | null {
213
+ if (r.timedOut) return `timed out after ${timeoutMs / 1000} s`;
214
+ if (outcome === "skipped") return "the pnpm filter matches no package yet; the lane creates it";
215
+ const env = outcome === "cannot-start" ? environmentLine(r.tail) : null;
216
+ return env ? `environment: ${env.trim()}` : null;
217
+ }
218
+
219
+ /** How long one check waits for a heavy slot before it reports lock-busy; tests shorten it. */
220
+ export const preflightLimits = { lockWaitMs: 60_000 };
221
+
222
+ /**
223
+ * Plan 23: a check runs in the user's login environment (DOCKER_HOST and the rest a login shell sets), through
224
+ * the allowlist, plus the repo's gate environment. A login PATH comes first, with the server's own after it.
225
+ */
226
+ export async function checkEnvFor(repo: string): Promise<Record<string, string>> {
227
+ const login = loginEnv();
228
+ const base: Record<string, string | undefined> = { ...process.env, ...login };
229
+ const path = [
230
+ ...new Set([...(login.PATH ?? "").split(":"), ...(process.env.PATH ?? "").split(":")].filter(Boolean)),
231
+ ];
232
+ if (path.length) base.PATH = path.join(":");
233
+ return { ...checkEnv(base, repo), ...resolveGateEnv(await readGateEnv(repo), base).env };
234
+ }
235
+
236
+ /** What a fast check names when it lints: a lint script, or a linter by name. */
237
+ const LINT_STEP = /\b(lint|eslint|biome|oxlint|golangci-lint|ruff|clippy|vet|check)\b/i;
238
+
239
+ /** Root files that say the repo has a linter, by the linter they configure. */
240
+ const LINT_CONFIGS: [RegExp, string][] = [
241
+ [/^\.golangci\.(ya?ml|toml|json)$/, "golangci-lint"],
242
+ [/^(biome\.jsonc?)$/, "biome"],
243
+ [/^(eslint\.config\.[cm]?[jt]s|\.eslintrc(\.[a-z]+)?)$/, "eslint"],
244
+ [/^\.?oxlintrc\.json$/, "oxlint"],
245
+ [/^\.?ruff\.toml$/, "ruff"],
246
+ ];
247
+
248
+ /** The linter the repo has, by name, or null: a root lint config, or a `lint` script in package.json. */
249
+ export function repoLinter(repo: string): string | null {
250
+ for (const name of existsSync(repo) ? readdirSync(repo) : [])
251
+ for (const [re, linter] of LINT_CONFIGS) if (re.test(name)) return linter;
252
+ try {
253
+ const pkg = JSON.parse(readFileSync(join(repo, "package.json"), "utf8")) as {
254
+ scripts?: Record<string, unknown>;
255
+ };
256
+ if (typeof pkg.scripts?.lint === "string") return "the lint script";
257
+ } catch {
258
+ // no package.json, or one that does not parse: no lint script
259
+ }
260
+ return null;
160
261
  }
161
262
 
162
263
  /**
@@ -165,11 +266,16 @@ export function classify(r: { code: number | null; timedOut: boolean; tail: stri
165
266
  */
166
267
  export async function preflight(
167
268
  deps: Deps,
168
- i: { run: string; confirmed?: boolean; timeoutMs?: number },
269
+ i: { run: string; confirmed?: boolean; timeoutMs?: number; milestone?: string },
169
270
  ): Promise<PreflightReport> {
170
271
  const run = findRun(i.run);
171
272
  const profile = deps.profiles.forRepo(run.meta.repo);
172
- const lanes = laneChecks(run);
273
+ // plan 23: one milestone's lanes, or by default every lane whose milestone has not landed
274
+ const landed = new Set(landedMilestones(run));
275
+ const milestoneOf = (lane: string) => lane.split(".")[0] as string;
276
+ const lanes = laneChecks(run).filter((l) =>
277
+ i.milestone ? milestoneOf(l.lane) === i.milestone : !landed.has(milestoneOf(l.lane)),
278
+ );
173
279
  // spec 1.1 §6: refuse before running anything, naming every lane whose header the catalog cannot route
174
280
  const invalid = lanes.flatMap((l) => (l.invalid ? [l.invalid] : []));
175
281
  if (invalid.length) throw new CatherdError("E_LANE_INVALID", invalid.join("; "), { fix: LANE_HEADER_FIX });
@@ -177,6 +283,8 @@ export async function preflight(
177
283
  if (profile.preflight.confirm && !i.confirmed) return { needsConfirmation: true, commands };
178
284
  const timeoutMs = i.timeoutMs ?? CHECK_TIMEOUT_MS;
179
285
  const results: PreflightResult[] = [];
286
+ // the login env is captured once per server; the gate env is read once per preflight
287
+ let env: Record<string, string> | undefined;
180
288
  for (const l of lanes) {
181
289
  const check = l.check;
182
290
  if (!check) {
@@ -202,20 +310,46 @@ export async function preflight(
202
310
  results.push({ lane: l.lane, check, outcome: "skipped", exitCode: null, tail: [], note });
203
311
  continue;
204
312
  }
205
- const r = await withHeavySlot(heavySlots(profile.heavy), () => runCheck(run.meta.repo, check, timeoutMs));
313
+ env ??= await checkEnvFor(run.meta.repo);
314
+ const checkEnvironment = env;
315
+ // plan 23: each check waits for its slot within its own budget, and says lock-busy past it
316
+ const slot = await withHeavySlotWithin(heavySlots(profile.heavy), preflightLimits.lockWaitMs, () =>
317
+ runCheck(run.meta.repo, check, timeoutMs, checkEnvironment),
318
+ );
319
+ if (slot.busy) {
320
+ const note = `every heavy slot stayed busy for ${preflightLimits.lockWaitMs / 1000} s (other lanes' checks); run preflight again when they finish`;
321
+ results.push({ lane: l.lane, check, outcome: "lock-busy", exitCode: null, tail: [], note });
322
+ continue;
323
+ }
324
+ const r = slot.value;
325
+ // spec 1.5: an absence check whose every hit is one of the lane's Allow: exceptions has passed
326
+ const classified = classify(r, check);
327
+ const allowed = classified === "fails-as-expected" && onlyAllowedHits(r.lines, l.allow);
328
+ const outcome = allowed ? "pass" : classified;
206
329
  results.push({
207
330
  lane: l.lane,
208
331
  check,
209
- outcome: classify(r),
332
+ outcome,
210
333
  exitCode: r.code,
211
334
  tail: r.tail,
212
- note: r.timedOut ? `timed out after ${timeoutMs / 1000} s` : null,
335
+ note: allowed ? `every hit is an Allow: exception (${r.lines.length})` : noteOf(r, outcome, timeoutMs),
213
336
  });
214
337
  }
338
+ // plan 23: lint reaches the worker only through its fast check
339
+ const linter = repoLinter(run.meta.repo);
340
+ const warnings = linter
341
+ ? lanes
342
+ .filter((l) => l.check && !LINT_STEP.test(l.check))
343
+ .map(
344
+ (l) =>
345
+ `${l.lane}: its fast check runs no linter, and the repo has one (${linter}): add the linter of every package the lane touches to its Fast check: line`,
346
+ )
347
+ : [];
215
348
  return {
216
349
  needsConfirmation: false,
217
350
  commands,
218
351
  results,
219
352
  blocked: results.some((r) => r.outcome === "cannot-start"),
353
+ warnings,
220
354
  };
221
355
  }
@@ -333,6 +333,7 @@ export function profileService(host: () => HostContext): ProfilePort {
333
333
  name === undefined ? activeName(repo) : requireProfile(name),
334
334
  repo,
335
335
  host().host,
336
+ { reach: true },
336
337
  );
337
338
  return { valid: v.errors.length === 0, ...v };
338
339
  },
@@ -15,7 +15,12 @@ import {
15
15
  ProfileDocSchema,
16
16
  resolveProfile,
17
17
  } from "../domain/profile.ts";
18
- import { type Issue, type Validation, validateProfile } from "../domain/profile-rules.ts";
18
+ import {
19
+ type Issue,
20
+ type ValidateOptions,
21
+ type Validation,
22
+ validateProfile,
23
+ } from "../domain/profile-rules.ts";
19
24
  import type { Catalog } from "../domain/catalog.ts";
20
25
  import type { Access } from "../domain/record.ts";
21
26
  import { ROLES, type Role } from "../domain/roles.ts";
@@ -220,8 +225,9 @@ export function validateHere(
220
225
  c: Catalog,
221
226
  doc?: ProfileDoc,
222
227
  host: OrchestrationHost = "unknown",
228
+ o: ValidateOptions = {},
223
229
  ): Validation {
224
- const v = validateProfile(p, c, runnableBackends(), doc, host);
230
+ const v = validateProfile(p, c, runnableBackends(), doc, host, o);
225
231
  return {
226
232
  errors: [...v.errors, ...isolationKeyErrors(p), ...isolatedOnlyErrors(p)],
227
233
  warnings: [...v.warnings, ...budgetUsdWarnings(p)],
@@ -233,12 +239,13 @@ export function validateNamed(
233
239
  name: string | undefined,
234
240
  repo: string | null,
235
241
  host: OrchestrationHost,
242
+ o: ValidateOptions = {},
236
243
  ): Validation {
237
244
  const n = name ?? activeName(repo);
238
245
  const doc = readProfileDoc(n);
239
246
  const catalog = loadCatalog({ timings: false, ...(repo === null ? {} : { repo }) });
240
247
  try {
241
- return validateHere(resolveProfile(doc, n, host), catalog, doc, host);
248
+ return validateHere(resolveProfile(doc, n, host), catalog, doc, host, o);
242
249
  } catch (e) {
243
250
  if (!(e instanceof CatherdError)) throw e;
244
251
  return { errors: [{ path: "roles", message: e.message, fix: e.fix }], warnings: [] };