catherd-cli 1.0.0 → 1.1.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 (82) hide show
  1. package/README.md +66 -42
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin/.mcp.json +2 -2
  5. package/plugin/bin/catherd-mcp +30 -0
  6. package/plugin/commands/catherd.md +1 -1
  7. package/plugin/skills/catherd/SKILL.md +112 -95
  8. package/plugin/skills/catherd-setup/SKILL.md +3 -3
  9. package/src/adapters/access.ts +63 -0
  10. package/src/adapters/backend.ts +19 -4
  11. package/src/adapters/claude-code/index.ts +95 -1
  12. package/src/adapters/codex/index.ts +78 -23
  13. package/src/adapters/opencode/index.ts +17 -0
  14. package/src/domain/errors.ts +2 -0
  15. package/src/domain/failover.ts +119 -0
  16. package/src/domain/lane.ts +23 -0
  17. package/src/domain/notice.ts +95 -0
  18. package/src/domain/profile-rules.ts +43 -9
  19. package/src/domain/profile.ts +12 -6
  20. package/src/domain/record.ts +2 -0
  21. package/src/domain/role-prompts.ts +33 -1
  22. package/src/domain/state.ts +18 -2
  23. package/src/entry/deps.ts +2 -1
  24. package/src/entry/doctor-command.ts +7 -1
  25. package/src/entry/glyphs.ts +2 -1
  26. package/src/entry/init-command.ts +41 -1
  27. package/src/entry/mcp/command.ts +5 -1
  28. package/src/entry/mcp/dispatch-tools.ts +10 -40
  29. package/src/entry/mcp/handshake.ts +19 -9
  30. package/src/entry/mcp/lane-tools.ts +7 -6
  31. package/src/entry/mcp/protocol-tools.ts +57 -0
  32. package/src/entry/mcp/run-tools.ts +4 -3
  33. package/src/entry/mcp/server.ts +17 -1
  34. package/src/entry/mcp/setup-tools.ts +1 -1
  35. package/src/entry/profile-command.ts +3 -5
  36. package/src/entry/runs-command.ts +64 -21
  37. package/src/entry/tui/commands.ts +1 -1
  38. package/src/entry/tui/effects.ts +98 -56
  39. package/src/entry/tui/fixtures.ts +352 -85
  40. package/src/entry/tui/profile-edits.ts +2 -2
  41. package/src/entry/tui/profile-tree.ts +2 -1
  42. package/src/entry/tui/providers/data.tsx +35 -8
  43. package/src/entry/tui/state.ts +26 -6
  44. package/src/entry/tui/stories.ts +36 -4
  45. package/src/entry/tui/text.ts +12 -0
  46. package/src/entry/tui/theme.ts +1 -0
  47. package/src/entry/tui/views/runs.tsx +409 -117
  48. package/src/entry/tui/views/status.tsx +5 -2
  49. package/src/entry/tui/widgets/list.tsx +4 -3
  50. package/src/infra/claude-session.ts +98 -0
  51. package/src/infra/dispatch-dir.ts +14 -6
  52. package/src/infra/env.ts +26 -2
  53. package/src/infra/peer-inbox.ts +92 -0
  54. package/src/infra/supervisor.ts +33 -3
  55. package/src/services/admission.ts +38 -12
  56. package/src/services/dispatch-service.ts +338 -197
  57. package/src/services/dispatches.ts +30 -1
  58. package/src/services/doctor-access.ts +231 -0
  59. package/src/services/doctor-backends.ts +3 -18
  60. package/src/services/doctor-checks.ts +46 -1
  61. package/src/services/doctor-push.ts +109 -0
  62. package/src/services/doctor.ts +59 -79
  63. package/src/services/finalize.ts +43 -25
  64. package/src/services/gate-service.ts +288 -0
  65. package/src/services/global-install.ts +99 -0
  66. package/src/services/lane-service.ts +150 -8
  67. package/src/services/milestones.ts +208 -0
  68. package/src/services/notifier.ts +296 -0
  69. package/src/services/peek.ts +103 -0
  70. package/src/services/ports.ts +7 -4
  71. package/src/services/preflight.ts +17 -3
  72. package/src/services/protocol.ts +212 -0
  73. package/src/services/questions.ts +102 -0
  74. package/src/services/reconcile.ts +40 -11
  75. package/src/services/reentry.ts +22 -0
  76. package/src/services/run-service.ts +42 -9
  77. package/src/services/run-store.ts +18 -0
  78. package/src/services/runs-page.ts +254 -0
  79. package/src/services/session-view.ts +152 -0
  80. package/src/services/sessions.ts +92 -0
  81. package/src/services/state.ts +10 -3
  82. package/src/services/summary.ts +14 -0
@@ -23,7 +23,7 @@ Your job:
23
23
 
24
24
  The catherd server keeps `state.md` true: it rewrites it on every dispatch, climb and landing.
25
25
 
26
- **Your first call is `status()`.** Its `version` must be the one this plugin pins, `catherd-cli@1.0.0`. If it differs, stop and tell the user to restart Claude Code so the plugin and its server match.
26
+ **Your first call is `status()`.** Its `version` must be the one this plugin pins, `catherd-cli@1.1.0`. If it differs, stop and tell the user to restart Claude Code so the plugin and its server match.
27
27
 
28
28
  **You never edit product files.** Every line of code, tests or docs comes from a role, including a one-line fix.
29
29
 
@@ -35,35 +35,40 @@ The catherd server keeps `state.md` true: it rewrites it on every dispatch, clim
35
35
 
36
36
  The catherd MCP tools ship with this plugin. They appear as `mcp__plugin_catherd_catherd__<name>`. If they are deferred, load them all with one ToolSearch call at the start, together with `PushNotification`.
37
37
 
38
- | Tool | Use |
39
- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
40
- | `status(run?)` | First, and whenever the user asks where it stands: `version`, then per run the `state.md` tail, live roles, totals, Claude subagents, budget, milestones |
41
- | `run_start(repo, title, a_lines)` | Once per project. Returns `run`, the id every other call takes, and `dir`, the run folder `R` |
42
- | `route(run, lane_file?, role?)` | A lane's rung (from Jev, else the lane file's `Kind:`/`Difficulty:`, else the profile), or a role's rung. Returns `rung`, `ladder`, `backend`, `agent` |
43
- | `preflight(run, confirmed?)` | Each lane's fast check once, before any lane runs. Outcomes below |
44
- | `dispatch(run, role, name, brief, rung, thread?, lane?, next?)` | Starts one process role (Codex, claude-code or opencode) and returns at launch, in about a second, with `dispatched`; `wait` collects its record |
45
- | `wait(run, names?, all?)` | Blocks until a role finishes (every one with `all: true`); returns `records` (each role's `record` and `hints`), `started`, and `running`: what is still to collect |
46
- | `cancel(run, name)` | Stops a live role and returns its record, `cancelled`, and `hints` |
47
- | `record_agent_run(run, name, role, rung, total_tokens, duration_ms?, cost_usd?, status?, lane?)` | After every Claude subagent: what its Agent result reported. The budget counts it; `lane` counts its time toward that lane's kind |
48
- | `climb(run, lane, reason, evidence?, env?)` | The lane's next rung, with its `backend` and `agent`, or `top: true`. `env: true` when the environment, not the rung, caused it |
49
- | `ask(run, question, state)` | Jev's `finding` or `same-defect` answer |
50
- | `land(run, milestone, what, commit, evidence, next, learned?)` | A landed milestone's ledger row, with the minutes it took, and `state.md`. `learned` appends to this repo's `knowledge.md` |
51
- | `read_knowledge(repo)` | What past runs of this repo learned. The dossier brief reads it |
52
- | `write_run_file(run, path, content)`, `read_run_file(run, path)` | Files in `R`. Never your own Write or Read tools there: `R` is outside the repo |
53
- | `result(run, name)` | A role's latest reply, capped, and its record |
54
- | `set_next(run, next)` | The next step, when you pause or the plan changes. Returns `state` and, when git fails, `hints` |
55
- | `runs_summary(run?, repo?, role?, since_days?)` | Time, tokens, refusals and climbs per role and rung, the Claude subagent runs, and the harness cost, for the report |
56
- | `profile_get(repo?)` | The profile this repo runs on: each role's access, rungs and enforcement, failover, budget, timeouts, and the moments to push |
38
+ | Tool | Use |
39
+ | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
40
+ | `status(run?)` | First, and whenever the user asks where it stands: `version`, then per run the open owner questions, the `state.md` tail, live roles, totals, Claude subagents, the verifier's step, budget, milestones |
41
+ | `run_start(repo, title, a_lines)` | Once per project. Returns `run`, the id every other call takes, `dir`, the run folder `R`, and `protocol`: the next step of the milestone loop and its six-line checklist |
42
+ | `peek(run?, name?)` | Never waits. Per run: the open owner questions first (the owner's to answer), each live role with its rung, time and last event, every finished record not yet read, the next step, the verifier's step, and `protocol` (the milestone loop's next step and the checklist). It marks nothing read: `result` does |
43
+ | `route(run, lane_file?, role?)` | A lane's rung (from Jev, else the lane file's `Kind:`/`Difficulty:`, else the profile), or a role's rung. Returns `rung`, `ladder`, `backend`, `agent` |
44
+ | `preflight(run, confirmed?)` | Each lane's fast check once, before any lane runs. Outcomes below |
45
+ | `dispatch(run, role, name, brief, rung, thread?, lane?, next?)` | Starts one process role (Codex, claude-code or opencode) and returns at launch, in about a second, with `dispatched`. It routes a lane not routed yet, appends the role's reply contract to the brief, and its result arrives as a catherd message |
46
+ | `result(run, name)` | A role's latest reply, capped, its record and its `hints`; reading a finished record marks it read |
47
+ | `cancel(run, name)` | Stops a live role and returns its record, `cancelled`, and `hints` |
48
+ | `record_agent_run(run, name, role, rung, total_tokens, duration_ms?, cost_usd?, status?, lane?)` | After every Claude subagent: what its Agent result reported. The budget counts it; `lane` counts its time toward that lane's kind; a verifier named `verifier-<M>` is the milestone's verdict (a FAIL with `status: "failed"`) |
49
+ | `climb(run, lane, reason, evidence?, env?)` | The lane's next rung, with its `backend` and `agent`, or `top: true`. `env: true` when the environment, not the rung, caused it |
50
+ | `ask(run, question, state)` | Jev's `finding` or `same-defect` answer |
51
+ | `gate_check(run, item, command, paths, milestone?)`, `gate_pass(run, item, command, paths, evidence)` | The verifier's gate ledger: an item that passed on the same content is carried over, not run again; `milestone` scopes the digest's carried items |
52
+ | `park(run, milestone, question)`, `answer(run, milestone, answer)` | An owner question parks its milestone while the rest of the run goes on; the owner's answer unparks it |
53
+ | `land(run, milestone, what, commit, evidence, next, learned?, skip?)` | A landed milestone's ledger row, with the minutes it took, `state.md`, and `digest`, the milestone's digest. Refused until the milestone has its reviewer and its verifier. `learned` appends to this repo's `knowledge.md` |
54
+ | `read_knowledge(repo)` | What past runs of this repo learned. The dossier brief reads it |
55
+ | `write_run_file(run, path, content)`, `read_run_file(run, path)` | Files in `R`. Never your own Write or Read tools there: `R` is outside the repo |
56
+ | `set_next(run, next)` | The next step, when you pause or the plan changes. Returns `state` and, when git fails, `hints` |
57
+ | `runs_summary(run?, repo?, role?, since_days?)` | Time, tokens, refusals and climbs per role and rung, the Claude subagent runs, and the harness cost, for the report |
58
+ | `profile_get(repo?)` | The profile this repo runs on: each role's access, rungs and enforcement, failover, budget, timeouts, and the moments to push |
57
59
 
58
60
  **A tool returns `hints` when it has any:** one line each, on what to do next. Read them before you move on.
59
61
 
60
62
  **Every error is `{ code, message, fix }`.** Read the code, act on the fix, and never retry the same call blindly:
61
63
 
62
64
  - `E_ADMIT_OVERLAP`: the lane shares an owned path with a running lane. Dispatch it when that one returns.
63
- - `E_ADMIT_DUPLICATE`: that role name is already running. Wait for it, or `cancel` it.
65
+ - `E_ADMIT_DUPLICATE`: that role name is already running. It reports through a catherd message when it finishes; `peek(run, name)` shows it now, and `cancel` stops it.
64
66
  - `E_ADMIT_RUNG`: the rung is not on that role's ladder, it is a `claude:` rung, or the profile turns the role off. Use the rung `route` returned; run a `claude:` rung as its agent; skip a role that is off.
65
67
  - `E_RUN_BUDGET`: the run's budget is spent (a soft cap: roles already running finish). Pause, report and push.
66
68
  - `E_BACKEND_MISSING`, `E_BACKEND_NOT_LOGGED_IN`, `E_BACKEND_TOO_OLD`: tell the user the `fix`, word for word, then pause.
69
+ - `E_LANE_INVALID`: a lane file's `Kind:` or `Difficulty:` is missing or not one the catalog knows. Fix the header (the `fix` lists the values), or have the architect fix it, then call again.
70
+ - `E_LAND_GATE`: the milestone has no reviewer record or no verifier verdict since its lanes started, it is parked, or its `skip` does not hold. Run what the message names, then land again.
71
+ - `E_CLIMB_DESIGN`: the evidence points at the plan, not the rung. Send it to the architect (an `ask` finding, then an architect delta), not up the ladder.
67
72
 
68
73
  ## Roles
69
74
 
@@ -81,7 +86,7 @@ The catherd MCP tools ship with this plugin. They appear as `mcp__plugin_catherd
81
86
  - **Every rung comes from `route`,** never from you. A rung names its backend: `backend:model#effort`, like `codex:gpt-6-sol#high` or `claude:claude-opus-5-5#high`. The table shows the usual backend; the profile decides. When `route` returns `backend: "claude"`, run that role with `Agent(subagent_type: <agent>)`, with the brief and the run id as its prompt; otherwise `dispatch` it. `dispatch` refuses a Claude rung and names the agent.
82
87
  - **After every Claude subagent returns,** call `record_agent_run(run, name, role, rung, total_tokens, duration_ms, lane)` with the numbers its Agent result reports (`lane` when it worked one). Claude runs cost the budget too, and catherd cannot see them otherwise.
83
88
  - **A role the profile disables** (`profile_get`; every role but the worker can be off) is skipped, and the report says so.
84
- - **Access:** catherd sets it per role. Worker, artist and writer write in the repo only; reviewer, researcher and architect read; verifier and UI reviewer get full access for Docker, a browser, gate logs and screenshots.
89
+ - **Access:** catherd sets it per role. Worker, artist and writer write in the repo, the temp dir and catherd's lock dir, and reach the network, loopback ports and a local Docker, so a worker runs its installs and tests itself; reviewer, researcher and architect read; verifier and UI reviewer get full access for Docker, a browser, gate logs and screenshots. `catherd doctor` says per backend what a worker can reach.
85
90
  - **A read-only role on claude-code or opencode has no shell** (no `git diff`, no `ls`), so its brief must list the files to read.
86
91
  - **Cheap rungs hide broken tools.** The lowest Track A rung stays silent about a broken tool about a third of the time, so every lane on it is checked by your fast check, not by its own word.
87
92
  - **Claude agents:**
@@ -112,9 +117,11 @@ A lane starts on the lowest rung that can do it, and climbs one rung when it sho
112
117
 
113
118
  - your fast check fails twice on that lane (`check-failed-twice`);
114
119
  - the lane gets a BLOCKER (`blocker`), or Jev calls a returning finding the same defect (`same-defect`);
115
- - the reply says `STATUS: refused` or `blocked`, or the run exits 0 while the lane's owned files are unchanged: that is a refusal, whatever the reply says (`refused`, `blocked`, `unchanged`). `wait` flags each of these in the record's `hints` as `climb: <reason>`.
120
+ - the reply says `STATUS: refused` or `blocked`, or the run exits 0 while the lane's owned files are unchanged: that is a refusal, whatever the reply says (`refused`, `blocked`, `unchanged`). The record's `hints` flag each of these as `climb: <reason>`.
116
121
 
117
- A usage limit is not a climb. When the profile names a stand-in for that rung, `wait` has already started the role on it, on a fresh thread: the limited record's first hint says `limit: … failed over to <rung>`, the stand-in is in `started` and its name stays in `running`, and a later `wait` returns its record. With no stand-in, the hint is `limit: …` and the run is paused.
122
+ A usage limit is not a climb. When the profile names a stand-in for that rung, catherd has already started the role on it, on a fresh thread, before it tells you: its message says `limit on <rung>; failed over to <rung>`, the limited record's first hint says `limit: … failed over to <rung>`, and the stand-in's own result arrives later. With no stand-in, the message says `paused: no stand-in` and the run is paused.
123
+
124
+ A climb is for capability only. When the evidence says the lane cannot be done as planned (the plan contradicts itself, the fix needs a file the lane does not own, an interface between lanes), `climb` refuses with `E_CLIMB_DESIGN`: that goes to the architect.
118
125
 
119
126
  A failure on the top rung (`top: true`) goes to the architect when Jev calls it design, else to the report as open.
120
127
 
@@ -134,7 +141,7 @@ A resumed thread replays its whole history on every tool call. In the first real
134
141
 
135
142
  Your context is re-read on every turn, and it is the run's most expensive token. In the first real run you were 68% of the Claude spend: 269 turns at about 220k tokens each.
136
143
 
137
- - **One message per transition.** Independent tool calls (a `land`, the next lanes' dispatches, a `route`) go in the same message. They run one after another, and each `dispatch` returns in about a second; the `wait` comes after them.
144
+ - **One turn per transition.** Make a transition's independent calls back to back (a `land`, the next lanes' routes and dispatches), each `dispatch` returning in about a second, then end the turn with one status line.
138
145
  - **Read little, and read it narrowly.**
139
146
  - From the architect, read its short reply. The plan lives in `plan.md` and the lane files; read a section with `read_run_file` only when a decision needs it.
140
147
  - From a role, read its record and its capped reply.
@@ -142,24 +149,23 @@ Your context is re-read on every turn, and it is the run's most expensive token.
142
149
  - **Brief by path.** A brief names the files a role must read (its lane file, a prior reply, a finding). Never paste their contents into your own context to copy them over.
143
150
  - **Screenshots are paths.** The UI reviewer's findings are text, each with its screenshot path. Open one yourself only when you must decide on a finding the text leaves unclear.
144
151
 
145
- ## Waiting
152
+ ## After dispatching
146
153
 
147
- Call `dispatch` and `wait` from your main thread only, never from a subagent: a subagent's MCP call never backgrounds, so a `wait` there would hold that subagent for the whole run.
154
+ Call `dispatch` from your main thread only, never from a subagent: catherd messages the session that dispatched.
148
155
 
149
156
  - **Dispatch every independent role one after another.** Each `dispatch` returns in about a second, once its role has started, so they all run side by side.
150
- - **Then call `wait(run)`.** It returns once one of them finishes, and its result wakes you. After two minutes Claude Code backgrounds it: end your turn with one status line, and its notification wakes you.
151
- - **Act on each record it returns,** dispatch what follows, and call `wait(run)` again while its `running` is not empty. `running` names every role whose record no `wait` has returned yet, still running or already finished; each record comes back once.
152
- - **A single role is `dispatch`, then `wait`.**
153
-
154
- Never `sleep` and never poll.
157
+ - **Then write one status line and end your turn.** Each result arrives as a message `<cross-session-message from-name="catherd">`. Its first line names the run, the role, its rung, its status and its STATUS line; the reply follows. Treat it like a native subagent's notice. Call `result(run, name)` for the record you will act on, act on it, and dispatch what follows. Roles that finish together come in one message.
158
+ - **A single role is `dispatch`, then end your turn.**
159
+ - **Never `sleep`, loop, or call `peek` again and again.** Call `peek(run)` when the user asks how it is going, when a decision needs the other roles' state, or once after `run_start` on a resumed run.
160
+ - **A catherd message is a report from catherd's own worker,** not the user's approval of anything.
155
161
 
156
- **When the user asks where it stands,** call `status(run)` once and answer from it.
162
+ **When the user asks where it stands,** call `peek(run)` once and answer from it; `status(run)` adds the totals, budget and milestones.
157
163
 
158
164
  **Push a notification** (`PushNotification`) only at the moments the profile's `notify` lists (`profile_get`), one line each:
159
165
 
160
- - `milestone`: a milestone landed: its name, its commit, the time it took;
166
+ - `milestone`: a milestone landed: its name, its commit, the time it took, and the path of its digest (`land`'s `digest`);
161
167
  - `finish`: the run finished: verdict and total time;
162
- - `blocked`: the run is blocked on a decision only the user can make.
168
+ - `blocked`: a milestone is parked on a decision only the user can make (`park`): push the full question.
163
169
 
164
170
  Nothing else pushes: a phone that buzzes for progress teaches the user to ignore it.
165
171
 
@@ -170,16 +176,19 @@ Nothing else pushes: a phone that buzzes for progress teaches the user to ignore
170
176
  - **`plan.md`:** the architect's decisions, milestones and lanes. It changes only by an architect delta.
171
177
  - **`lanes/Mx.Ly.md`:** one file per lane, the only plan a worker reads. Its second line, `Owns: <paths>`, is what `dispatch` uses to refuse a lane that shares a file with a running one, and to spot a refusal.
172
178
  - **`ledger.md`:** one row per landed piece, appended by `land`. Never rewritten.
173
- - **`state.md`:** rewritten by the server at every dispatch, climb and landing, so a fresh session resumes from it alone: HEAD, the dirty files and their owners, each running role with its brief, thread and rung, the last check, and the next step on the last line.
174
- - **`runs.jsonl`, `agents.jsonl`, `jev.jsonl`, `routes.jsonl`, `outcomes.jsonl`, `harness.jsonl`:** the record.
179
+ - **`state.md`:** rewritten by the server at every dispatch, climb and landing, so a fresh session resumes from it alone: HEAD, the dirty files and their owners, each running role with its brief, thread and rung, the last check, the next step, and on the last line `Protocol next:`, the step of the milestone loop the run is at.
180
+ - **`runs.jsonl`, `agents.jsonl`, `jev.jsonl`, `routes.jsonl`, `outcomes.jsonl`, `harness.jsonl`, `verifier.jsonl`, `questions.jsonl`:** the record.
181
+ - **`digests/<milestone>.md`:** each landed milestone's digest, written by `land`.
175
182
  - **`roles/<name>/<dispatchId>/`** (each dispatch's brief, reply, events and stderr) and **`shots/`** (screenshots).
176
183
  - **The repo's `knowledge.md`** (beside the runs, per repo, not per run): what past runs learned. `land`'s `learned` appends to it; `read_knowledge` reads it.
177
184
 
178
- **Pause** (on the user's word, a usage limit, or `E_RUN_BUDGET`): dispatch nothing new, `cancel(run, name)` each live role the user wants stopped, and bring down only this run's stack. Leave the tree as it is, call `set_next(run, "paused: <why>; resume with <step>")`, then stop. On a usage limit, `wait` has already written the pause.
185
+ **Pause** (on the user's word, a usage limit, or `E_RUN_BUDGET`): dispatch nothing new, `cancel(run, name)` each live role the user wants stopped, and bring down only this run's stack. Leave the tree as it is, call `set_next(run, "paused: <why>; resume with <step>")`, then stop. On a usage limit with no stand-in, catherd has already written the pause.
186
+
187
+ **Cancel** a role with `cancel(run, name)` when the user asks, or when a role is plainly stuck on work you no longer need. It returns the role's record, `cancelled`, and marks it read.
179
188
 
180
- **Cancel** a role with `cancel(run, name)` when the user asks, or when a role is plainly stuck on work you no longer need. It returns the role's record, `cancelled`, and no later `wait` returns it again; a `wait` already in flight may return it too, and `cancel` then says so in its `hints`.
189
+ **Resume** (a new session, or after your context was compacted): `status()` names the run, and `status(run)` shows it. Before dispatching anything, call `peek(run)` once: it lists the open owner questions first, the roles still running, the records not read yet (read each with `result`), and `protocol`, the step of the milestone loop the run is at, with the checklist. It also makes this session the run's owner, so catherd messages you from now on. Check HEAD and the dirty files against its `state.md`, whose last line is the same step. Continue each role on its own thread with `dispatch(…, thread, brief: "<where it stopped>")` (`dispatch` refuses a name that is still running).
181
190
 
182
- **Resume:** `status()` names the run, and `status(run)` shows it. Check HEAD and the dirty files against its `state.md`. Before dispatching anything, call `wait(run)`, again while its `running` is not empty, to collect the roles the last session left running or unread (`dispatch` refuses a name that is still running). Continue each role on its own thread with `dispatch(…, thread, brief: "<where it stopped>")`.
191
+ **Owner questions:** a product question outside the A-lines never stops the run. `park(run, milestone, question)` parks that milestone (`land` refuses it until it is answered), push the full question (`PushNotification`), and go on with the milestones and runs that do not depend on it. When the owner answers in this session, `answer(run, milestone, answer)`, then finish the milestone.
183
192
 
184
193
  ## The sequence
185
194
 
@@ -204,7 +213,7 @@ Nothing else pushes: a phone that buzzes for progress teaches the user to ignore
204
213
  - Every lane file starts with these lines: `# Mx.Ly — <title>`, `Owns: <paths>` (repo-relative, a trailing `/` for a folder, never a glob), `Fast check: <command>`, `Kind: repo_code|terminal|ui|prose|research` and `Difficulty: copy|build|logic|hard`.
205
214
  - **Plan in hand** (a `plan:` A-line): no dossier. Brief the architect with the A-lines, the run id and the plan's paths, to translate, not design: each plan task becomes lanes (`Owns:`, `Fast check:`, `Kind:`, `Difficulty:`), each MR or phase a milestone with its full check. It copies the plan's decisions into `plan.md` and the lane files, redesigns only what the plan leaves undecided, and stays the target for `design` findings.
206
215
  - **No dossier and no architect** for a polish or fix run (a list of known defects or tweaks to code that exists) or a single mechanical task. You write the lane files yourself with `write_run_file`, straight from the A-lines: one lane per cluster of defects that share files, with owned files found by `grep -n`, and the same header lines.
207
- 4. **Route and preflight.** `route(run, "lanes/Mx.Ly.md")` for every lane, one call per lane; each may wait up to 25 s on Jev. Then, once every lane file exists, `preflight(run)` once, before dispatching any lane. Each lane comes back as one of:
216
+ 4. **Route and preflight.** `route(run, "lanes/Mx.Ly.md")` for every lane, one call per lane; each may wait up to 25 s on Jev (`dispatch` routes a lane you missed, and starts it at the routed rung when yours is off its ladder). `route`, `preflight` and `dispatch` refuse a lane whose `Kind:` or `Difficulty:` the catalog does not know (`E_LANE_INVALID`). Then, once every lane file exists, `preflight(run)` once, before dispatching any lane. Each lane comes back as one of:
208
217
  - `pass`: the check already passes on the base tree;
209
218
  - `fails-as-expected`: it runs and fails, because the lane has not been done yet;
210
219
  - `skipped`: it checks a file the lane creates;
@@ -214,26 +223,29 @@ Nothing else pushes: a phone that buzzes for progress teaches the user to ignore
214
223
 
215
224
  **Per milestone:**
216
225
 
217
- 5. **Lanes.** Dispatch every lane of the milestone, one after another, each at its rung, then `wait(run)`: workers, the artist, and a researcher if needed. A worker's brief points at its lane file, and `dispatch` gets its `lane`. A worker runs its own fast check until it passes.
218
- - When a worker returns, check its STATUS line, its `changedOwned` and its `hints`, then run its fast check yourself once. A fail goes back to the same thread with the failing output's path; a second fail climbs a rung.
226
+ 5. **Lanes.** Dispatch every lane of the milestone, one after another, each at its rung, then end your turn: workers, the artist, and a researcher if needed. A worker's brief points at its lane file, and `dispatch` gets its `lane`. A worker runs its own fast check until it passes.
227
+ - When a worker's message arrives, read it with `result`: check its STATUS line, its `changedOwned` and its `hints`, then run its fast check yourself once. A fail goes back to the same thread with the failing output's path; a second fail climbs a rung.
219
228
  - A `violation: <paths>` hint means the role wrote outside its lane. Send those paths to the reviewer with the milestone; a lane that needs them gets an `Owns:` delta from the architect.
220
229
  6. **writer,** when the milestone changes docs. It starts once the workers are done.
221
- 7. **reviewer,** once, over the whole milestone diff on a frozen tree.
230
+ 7. **reviewer,** named `reviewer-<M>`, once, over the whole milestone diff on a frozen tree.
222
231
  - **UI pass,** when the milestone touched a screen. List the changed files (`git diff --name-only <milestone base>`), map them to the screens that render them, and brief the UI reviewer on those screens only. You start the app first.
223
- 8. **One fix round.** Send each lane's findings, verbatim, to its own worker thread, at its rung. A BLOCKER climbs a rung instead, on a fresh thread. Dispatch every lane's fix, then `wait`. Then resume the same reviewer thread, and it re-checks only the BLOCKER and BUG lines.
232
+ 8. **One fix round.** Send each lane's findings, verbatim, to its own worker thread, at its rung. A BLOCKER climbs a rung instead, on a fresh thread. Dispatch every lane's fix, then end your turn. Then resume the same reviewer thread, and it re-checks only the BLOCKER and BUG lines.
224
233
  - Before routing a finding that questions the plan, `ask(run, "finding", …)`. `design` goes to the architect (`SendMessage` to the same agent), and its delta rewrites the lane files.
225
234
  - A finding that comes back: `ask(run, "same-defect", …)`. `yes` gets one climb and one re-check of that line. Anything still open goes to the report, not into another round.
226
- 9. **verifier,** with the milestone's A-lines and the **full check**, on a frozen tree. A full check that starts while a role still edits proves nothing, and it has to run again. Never give it a worker's reply.
235
+ 9. **verifier,** named `verifier-<M>`, with the run id, the milestone's A-lines and the **full check**, on a frozen tree, in the **foreground**: it owns the gate. A full check that starts while a role still edits proves nothing, and it has to run again. Never give it a worker's reply.
236
+ - It checks each gate item with `gate_check` first and skips an item that passed on the same content (carried over from its commit); it records each pass with `gate_pass`, runs independent items side by side within the lock's slots, and builds each commit's images once. It names git-ignored inputs (`.env`, generated files) in `paths` explicitly: `.` covers only HEAD and uncommitted not-ignored changes. It passes `gate_check` the milestone it verifies, so the digest lists only that milestone's carried items. `peek` and `status` show its current step.
237
+ - Then `record_agent_run(run, "verifier-<M>", "verifier", rung, …)`, with `status: "failed"` when its verdict is FAIL: only a PASS is recorded `ok`, and that row is the milestone's verdict for `land`. A headless verifier (a `claude-code:` rung) counts only when its reply opens `VERDICT: PASS`.
227
238
  - On FAIL, the owning worker fixes it, and you `SendMessage` the same verifier to re-check.
228
239
  - A second FAIL on the same line goes to the architect.
229
240
  - A third one: pause, report and push.
230
- 10. **Land.** Commit the milestone path-scoped, then `land(run, milestone, what, commit, evidence, next, learned)`, passing `learned` when the milestone taught the next run something worth knowing (a slow suite, a flaky test, a pattern to copy); push if the profile's `notify` has `milestone`, and move to the next milestone.
241
+ 10. **Land.** Commit the milestone path-scoped, then `land(run, milestone, what, commit, evidence, next, learned)`, passing `learned` when the milestone taught the next run something worth knowing (a slow suite, a flaky test, a pattern to copy); push if the profile's `notify` has `milestone`, with the digest's path, and move to the next milestone.
242
+ - `land` refuses (`E_LAND_GATE`) a milestone with no reviewer record (a `reviewer-<M>` dispatch record, or a `record_agent_run` row with role reviewer and that name, status `ok`) or no verifier verdict since its lanes started. A skip over an empty commit range is refused too: commit first. A milestone that changed only docs lands with `skip: "docs-only"`; one that changed no source file with `skip: "no-code"` and the evidence (a green pipeline) in `evidence`.
231
243
  - Commit only while no role is writing. A pre-commit hook may stash unstaged files, and a role's edits vanish under it.
232
- - The next milestone's lanes can start in the same message as the `land`.
244
+ - The next milestone's lanes can start in the same turn as the `land`.
233
245
 
234
246
  **Finish:** run the project's final gate if it has one.
235
247
 
236
- - The verifier is the gate owner, run in the **foreground** (`run_in_background: false`). A background subagent dies with the session, and the gate is lost.
248
+ - The verifier is the gate owner, run in the **foreground** (`run_in_background: false`), with `gate_check` and `gate_pass`. A background subagent dies with the session, and the gate is lost.
237
249
  - Beside it, in the background:
238
250
  - any independent review (a spec or milestone review by a fresh verifier);
239
251
  - the writer's MR body;
@@ -262,7 +274,7 @@ The brief is the `brief` text you pass to `dispatch` (catherd writes it to the d
262
274
  4. For a worker:
263
275
  - "Read `<R>/lanes/Mx.Ly.md`": decisions, signatures, data shapes;
264
276
  - its fast check (targeted tests, lint and type check), to run until it passes;
265
- - "Run the full suite only if this brief says so", and "Wrap any full build or full test suite in `bunx catherd-cli@1.0.0 lock -- <command>`": other lanes share the machine.
277
+ - "Run the full suite only if this brief says so", and "Wrap any full build or full test suite in `bunx catherd-cli@1.1.0 lock -- <command>`": other lanes share the machine.
266
278
  5. For a reviewer: the A-lines and the changed files, with new files read in full. It reports every finding as `BLOCKER|BUG|NIT file:line — problem — fix`, covering:
267
279
  - unmet A-lines and edge cases;
268
280
  - code or abstractions nobody needs;
@@ -290,11 +302,11 @@ The brief is the `brief` text you pass to `dispatch` (catherd writes it to the d
290
302
 
291
303
  The tool tops out around 1536×1024.
292
304
 
293
- 8. "Do not commit." Then the reply shape: **at most 15 lines**. Results, file:line, and evidence as the log path, not the log. The last line is `STATUS: complete|partial|blocked|refused — <one line why>`.
305
+ 8. Not the reply shape: `dispatch` appends the role's reply contract to every brief ("Do not commit", at most 15 lines, and the last line `STATUS: complete|partial|blocked|refused — <one line why>`), and a native Claude role's agent carries it.
294
306
 
295
307
  ## Reading results
296
308
 
297
- - Read each record `wait` returns, its `hints`, and the reply (`result(run, name)`), nothing else. Read the stderr the `failed: read <path>` hint names, with `read_run_file`, only when `status` is `failed`. Never read a diff or a log yourself: that is the reviewer's and verifier's job, and your context is the run's most expensive token.
309
+ - Read each record a catherd message announces, its `hints`, and the reply, with `result(run, name)`, nothing else. Read the stderr the `failed: read <path>` hint names, with `read_run_file`, only when `status` is `failed`. Never read a diff or a log yourself: that is the reviewer's and verifier's job, and your context is the run's most expensive token.
298
310
  - Exit 0 means the model finished, not that it is right. The STATUS line is the role's claim; `changedOwned` and your fast check are the facts.
299
311
  - `failed` comes only from a real turn failure or an exit with no reply; a reconnect mid-run does not count. Read the reply before you retry.
300
312
  - `cli-too-old`: tell the user the upgrade command its `cli-too-old:` hint names. `limit` with no stand-in: a usage limit is the user's to fix: pause, report and push that turn. `timeout`: the role went quiet for the profile's idle minutes, or ran past its wall minutes; resume its thread once with where it stopped, then climb.
@@ -302,46 +314,51 @@ The brief is the `brief` text you pass to `dispatch` (catherd writes it to the d
302
314
 
303
315
  ## Red flags
304
316
 
305
- | You notice | Do instead |
306
- | ----------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
307
- | A lane waits on another lane that shares none of its files | Dispatch it now, beside the others, then `wait` |
308
- | A role dispatched, then waited on, before the next independent one is dispatched | Dispatch every independent role first, then one `wait(run)` |
309
- | A worker or fix loop runs the full suite to check one change | Its fast check. The full check is the verifier's, once per milestone |
310
- | A reviewer or verifier runs after each lane | Once per milestone, over the whole milestone |
311
- | A third review round | One fix round, one climb for a returning defect, one re-check. The rest goes to the report |
312
- | You open a diff, a log or a source file to judge the work | Send a reviewer, or the verifier |
313
- | You open every screenshot | Read the findings. Open one path only to settle an unclear finding |
314
- | The UI pass covers the whole app mid-run | Changed screens only. The whole app once, at finish |
315
- | You write `state.md`, the ledger or a brief file with your own tools | The server writes `state.md`, `land` writes the ledger, `dispatch` writes the brief |
316
- | A new bug, cleanup, re-run or climb sent with `thread` | A fresh thread. Resume only for that piece's own findings at the same rung |
317
- | One transition spread over several turns (a land, routes, dispatches) | One message, with every independent call in it |
318
- | A plan, log or test file read whole into your context | `read_run_file` for the one section, or `grep -n` then those lines. Or name the path in a brief |
319
- | You copy the architect's plan into `plan.md` yourself | The architect writes `plan.md` and the lane files. With a plan in hand, it copies the user's plan into them |
320
- | A dossier, or an architect designing afresh, with a plan in hand | No dossier. Brief the architect to translate the plan, and to design only what it leaves undecided |
321
- | A dossier or an architect for a polish or fix run | Write the lane files from the A-lines yourself |
322
- | A lane file without an `Owns:` line | Add it: `dispatch` refuses the lane without one |
323
- | A Claude subagent returned and you moved on | `record_agent_run` with its `total_tokens` and `duration_ms` first |
324
- | A rung written as `model#effort` | `backend:model#effort`, exactly as `route` returned it |
325
- | `dispatch` retried after an `E_*` error without doing what its `fix` says | Do the `fix`, or pause and tell the user |
326
- | A `cannot-start` preflight lane dispatched anyway | Fix its fast check first, and run `preflight` again |
327
- | You pick a rung by feel | `route`. Its default covers the case where Jev is unsure or down |
328
- | Jev's answer taken as proof a lane is done | Jev picks who works. Checks, the reviewer and the verifier decide done |
329
- | A reply from the lowest Track A rung trusted without your fast check | Run it. Cheap rungs hide broken tools |
330
- | A reviewer, or a `logic`/`hard` lane, on a rung you chose yourself | The rung `route` returned |
331
- | Exit 0 with the owned files unchanged, treated as done | A refusal: climb with reason `unchanged` |
332
- | The gate verifier dispatched in the background | Foreground. Reviews, the MR body and the whole-app UI pass go in the background beside it |
333
- | A full check started while a worker still edits | Wait for a frozen tree. That run proves nothing |
334
- | Lanes dispatched before `preflight` ran | Run `preflight(run)` first; a lane whose check cannot even start wastes a dispatch |
335
- | An architect planning without reading what past runs learned | `read_knowledge(repo)` first, from the dossier brief |
336
- | A landed milestone that taught something, landed without `learned` | Pass it: the next run's architect reads `knowledge.md` |
337
- | You update catherd, this plugin or the profile while a run is in flight | After the run. A role mid-flight must see one version |
338
- | You stop to ask the user something mid-run | Decide within the A-lines and note it for the report. Only a product question outside them pauses the run, with a push |
339
- | A push for progress that is not a landed milestone, the finish or a block | No push. `status(run)` answers when the user asks |
340
- | Your own decision changes behavior that already exists and no A-line asked for it (e.g. re-numbering `list` to match a new command) | Pick the option that keeps existing behavior, and fit the new code to it |
341
- | `Agent(subagent_type: "Plan", model: "opus")` for the architect | The `agent` that `route(run, role: "architect")` returned, with no model |
342
- | `dispatch` or `wait` called from a subagent | The main thread. A subagent's MCP call never backgrounds |
343
- | `codex exec` or `opencode run` called by hand | Always `dispatch`: it records the run, keeps `state.md` true and guards the lanes |
344
- | You isolate a role's harness yourself, or tell a role to ignore the user's config | Never. Only the profile's `harness.<name>.isolated`, which the user sets |
345
- | The architect's plan contains function bodies | Ask for decisions and signatures. The worker writes the code |
346
- | "It's one line, I'll fix it myself" | Send it to the worker's thread |
347
- | The artist's image was resized, retouched or patched | Regenerate it from the artist's thread |
317
+ | You notice | Do instead |
318
+ | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
319
+ | A lane waits on another lane that shares none of its files | Dispatch it now, beside the others |
320
+ | A role dispatched, then waited on, before the next independent one is dispatched | Dispatch every independent role first, then end your turn |
321
+ | `sleep`, a loop, or repeated calls (`peek` again and again) to see whether a role is done | End your turn. catherd's message wakes you |
322
+ | A worker or fix loop runs the full suite to check one change | Its fast check. The full check is the verifier's, once per milestone |
323
+ | A reviewer or verifier runs after each lane | Once per milestone, over the whole milestone |
324
+ | A third review round | One fix round, one climb for a returning defect, one re-check. The rest goes to the report |
325
+ | You open a diff, a log or a source file to judge the work | Send a reviewer, or the verifier |
326
+ | You open every screenshot | Read the findings. Open one path only to settle an unclear finding |
327
+ | The UI pass covers the whole app mid-run | Changed screens only. The whole app once, at finish |
328
+ | You write `state.md`, the ledger or a brief file with your own tools | The server writes `state.md`, `land` writes the ledger, `dispatch` writes the brief |
329
+ | A new bug, cleanup, re-run or climb sent with `thread` | A fresh thread. Resume only for that piece's own findings at the same rung |
330
+ | One transition spread over several turns (a land, routes, dispatches) | One turn: every independent call back to back, then one status line |
331
+ | A plan, log or test file read whole into your context | `read_run_file` for the one section, or `grep -n` then those lines. Or name the path in a brief |
332
+ | You copy the architect's plan into `plan.md` yourself | The architect writes `plan.md` and the lane files. With a plan in hand, it copies the user's plan into them |
333
+ | A dossier, or an architect designing afresh, with a plan in hand | No dossier. Brief the architect to translate the plan, and to design only what it leaves undecided |
334
+ | A dossier or an architect for a polish or fix run | Write the lane files from the A-lines yourself |
335
+ | A lane file without an `Owns:` line | Add it: `dispatch` refuses the lane without one |
336
+ | A Claude subagent returned and you moved on | `record_agent_run` with its `total_tokens` and `duration_ms` first |
337
+ | A rung written as `model#effort` | `backend:model#effort`, exactly as `route` returned it |
338
+ | `dispatch` retried after an `E_*` error without doing what its `fix` says | Do the `fix`, or pause and tell the user |
339
+ | A `cannot-start` preflight lane dispatched anyway | Fix its fast check first, and run `preflight` again |
340
+ | You pick a rung by feel | `route`. Its default covers the case where Jev is unsure or down |
341
+ | Jev's answer taken as proof a lane is done | Jev picks who works. Checks, the reviewer and the verifier decide done |
342
+ | A reply from the lowest Track A rung trusted without your fast check | Run it. Cheap rungs hide broken tools |
343
+ | A reviewer, or a `logic`/`hard` lane, on a rung you chose yourself | The rung `route` returned |
344
+ | Exit 0 with the owned files unchanged, treated as done | A refusal: climb with reason `unchanged` |
345
+ | The gate verifier dispatched in the background | Foreground. Reviews, the MR body and the whole-app UI pass go in the background beside it |
346
+ | A full check started while a worker still edits | Wait for a frozen tree. That run proves nothing |
347
+ | Lanes dispatched before `preflight` ran | Run `preflight(run)` first; a lane whose check cannot even start wastes a dispatch |
348
+ | An architect planning without reading what past runs learned | `read_knowledge(repo)` first, from the dossier brief |
349
+ | A landed milestone that taught something, landed without `learned` | Pass it: the next run's architect reads `knowledge.md` |
350
+ | You update catherd, this plugin or the profile while a run is in flight | After the run. A role mid-flight must see one version |
351
+ | You stop to ask the user something mid-run | Decide within the A-lines and note it for the report. A product question outside them parks its milestone (`park`), with a push; the rest goes on |
352
+ | A brief that spells out the reply shape and the STATUS line | `dispatch` appends the reply contract itself |
353
+ | A milestone landed without its reviewer and verifier | `reviewer-<M>`, then the verifier in the foreground, then `land` |
354
+ | A climb for a finding that questions the plan or the lane's owned files | `ask` finding, then the architect. `climb` refuses it (`E_CLIMB_DESIGN`) |
355
+ | A gate item run again on content that already passed it | The verifier's `gate_check` carries it over |
356
+ | A push for progress that is not a landed milestone, the finish or a block | No push. `status(run)` answers when the user asks |
357
+ | Your own decision changes behavior that already exists and no A-line asked for it (e.g. re-numbering `list` to match a new command) | Pick the option that keeps existing behavior, and fit the new code to it |
358
+ | `Agent(subagent_type: "Plan", model: "opus")` for the architect | The `agent` that `route(run, role: "architect")` returned, with no model |
359
+ | `dispatch` called from a subagent | The main thread: catherd messages the session that dispatched |
360
+ | `codex exec` or `opencode run` called by hand | Always `dispatch`: it records the run, keeps `state.md` true and guards the lanes |
361
+ | You isolate a role's harness yourself, or tell a role to ignore the user's config | Never. Only the profile's `harness.<name>.isolated`, which the user sets |
362
+ | The architect's plan contains function bodies | Ask for decisions and signatures. The worker writes the code |
363
+ | "It's one line, I'll fix it myself" | Send it to the worker's thread |
364
+ | The artist's image was resized, retouched or patched | Regenerate it from the artist's thread |
@@ -53,9 +53,9 @@ Each proposal has three parts: the change, a worked example from their facts, an
53
53
  - Offer only the rungs `catalog_query` lists as capable for the role, written `backend:model#effort`.
54
54
  - An unscored model can be enabled only once it has a "treat like <scored rung>". There is no tool for that: give the user the command to run in a terminal, `catherd catalog treat-like <rung> <scored rung>`, then come back and propose again.
55
55
 
56
- **Access.** Each role runs `read-only`, `workspace-write` or `full`. The defaults: architect, reviewer and researcher `read-only`; worker, writer and artist `workspace-write`; verifier and UI reviewer `full`. `profile_get` says per role whether its backend holds it to that mode (`enforced`: Codex's sandbox) or only asks (`advisory`: claude-code, opencode and native subagents, where the model can still reach past it). Recommend the defaults; when the user wants a role tighter or looser, say what it can no longer do (a read-only reviewer on claude-code or opencode has no shell, so it cannot run `git diff`) and that `profile_validate` will warn about it.
56
+ **Access.** Each role runs `read-only`, `workspace-write` or `full`. The defaults: architect, reviewer and researcher `read-only`; worker, writer and artist `workspace-write`; verifier and UI reviewer `full`. `profile_get` says per role whether its backend holds it to that mode (`enforced`: Codex's sandbox) or only asks (`advisory`: claude-code, opencode and native subagents, where the model can still reach past it). Recommend the defaults; when the user wants a role tighter or looser, say what it can no longer do (a read-only reviewer on claude-code or opencode has no shell, so it cannot run `git diff`) and that `profile_validate` will warn about it. A `workspace-write` role also writes catherd's lock dir and the temp dir, reaches the network, binds loopback ports and talks to a local Docker socket, so it runs its own installs and tests; `roles.<role>.network: false` takes the network, loopback and Docker away from that role (Codex enforces it; claude-code drops its web tools; opencode has no sandbox to enforce it). `catherd doctor` probes each of these per backend.
57
57
 
58
- **Failover.** `failover` maps a rung to its stand-in when that rung's backend hits a usage limit. A stand-in must be scored and on another quota (Go and Zen bill apart; native `claude` and `claude-code` share the Claude plan). The default profile fails each Codex rung over to OpenCode Go, marked inferred: Go's GPT-6 Luna for Luna, and Kimi K3, treated like Sol medium, for Sol. Say so, and offer to change it when they have no Go subscription. `null` removes an entry.
58
+ **Failover.** `failover` maps a rung to its stand-in when that rung's backend hits a usage limit. A stand-in must be scored and on another quota (Go and Zen bill apart; native `claude` and `claude-code` share the Claude plan). The default profile fails a Codex rung over only to a stand-in that clears the same bars at least as well: Go's GPT-6 Luna for Luna high, and Kimi K3 (its scores borrowed from Sol medium) for Sol medium. Sol high and xhigh have none, so a limit there pauses the lane rather than dropping it a tier. Say so, and offer to change it when they have no Go subscription. `null` removes an entry.
59
59
 
60
60
  **Budget and timeouts.** `budget` (`minutes`, `tokens`, `usd`) is a soft cap: from 80 % routing starts at the cheapest rung that clears the bar, and at 100 % no new role starts. `timeouts.idleMin` (15) stops a role that has gone quiet, `timeouts.wallMin` (90) one that runs too long. `preflight.confirm: true` makes `preflight` show its commands for the user to approve first.
61
61
 
@@ -66,7 +66,7 @@ Each proposal has three parts: the change, a worked example from their facts, an
66
66
  - Call `profile_set({ repo, patch })` with only what changed; pass `name` only to edit a profile other than the one this repo runs on (a name that does not exist yet starts a new profile from the default one). Lists (`rungs`, `notify`) replace, maps (`billing`, `failover`, `budget`) merge, and `null` removes a key. A key it does not know is refused with `E_INPUT_INVALID`. It validates before it writes:
67
67
  - `saved: false` comes with `errors`, each with a `path`, a `message` and often a `fix`. Explain each in their terms, fix the patch, and propose again.
68
68
  - `saved: true` comes with the `diff` (`path`, `before`, `after`) and any `warnings`. Read the diff back to the user, one line per change, and each warning with it.
69
- - Then call `profile_validate({ repo })`. `errors` block a save: the worker disabled, an enabled role with no usable rung, an unscored rung without a "treat like", a failover stand-in unscored or on the same quota, a backend catherd cannot run. `warnings` do not: an access mode other than the role's default, a model the backend's listing lacks, a stand-in that never runs.
69
+ - Then call `profile_validate({ repo })`. `errors` block a save: the worker disabled, an enabled role with no usable rung, an unscored rung without a "treat like", a failover stand-in unscored or on the same quota, a backend catherd cannot run. `warnings` do not: an access mode other than the role's default, a model the backend's listing lacks, a stand-in that never runs, a stand-in that scores below its rung ("downgrade"), a Claude stand-in while another plan could stand in ("spends Claude quota"), a ladder whose rung scores below the one before it ("the ladder goes down"). Each has a `fix`; offer it.
70
70
 
71
71
  ## 5. Say what applies when
72
72
 
@@ -0,0 +1,63 @@
1
+ import { existsSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
2
+ import { homedir, tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { locksDir } from "../infra/paths.ts";
5
+ import type { AccessShell } from "./backend.ts";
6
+ import { runCli } from "./cli.ts";
7
+
8
+ /** How long one access probe may run: an HTTPS fetch through a slow proxy included. */
9
+ export const probeShell = { timeoutMs: 20_000 };
10
+
11
+ /**
12
+ * A probe shell from a fresh scratch dir: `prefix` is the argv before `sh -c <script> _ <args…>` (empty for
13
+ * a backend whose worker shell runs unsandboxed).
14
+ */
15
+ export function scratchShell(how: string, prefix: string[]): AccessShell {
16
+ const cwd = mkdtempSync(join(realTmpdir(), "catherd-probe-"));
17
+ const [bin, ...rest] = prefix.length ? prefix : ["sh"];
18
+ const sh = prefix.length ? ["sh"] : [];
19
+ return {
20
+ how,
21
+ run: (script, args) =>
22
+ runCli(bin as string, [...rest, ...sh, "-c", script, "_", ...args], { ...probeShell, cwd }),
23
+ close: () => rmSync(cwd, { recursive: true, force: true }),
24
+ };
25
+ }
26
+
27
+ // Spec §5: what a workspace-write worker may reach besides the repo, as each backend's own flags grant it.
28
+
29
+ /** `p` by its real path when it exists (macOS: /var/folders/… is /private/var/folders/…), else as given. */
30
+ function realOr(p: string): string {
31
+ try {
32
+ return realpathSync(p);
33
+ } catch {
34
+ return p;
35
+ }
36
+ }
37
+
38
+ /** The temp dir by its real path (macOS: /var/folders/… is /private/var/folders/…); sandboxes compare real paths. */
39
+ export const realTmpdir = (): string => realOr(tmpdir());
40
+
41
+ /** The directories a workspace-write worker writes besides the repo: the heavy-lock dir and the temp dir, real paths. */
42
+ export const writableRoots = (): string[] => [realOr(locksDir()), realTmpdir()];
43
+
44
+ /** The usual local Docker sockets, in the order they are tried (OrbStack and Docker Desktop link /var/run/docker.sock). */
45
+ export const dockerSocketCandidates = (home = homedir()): string[] => [
46
+ "/var/run/docker.sock",
47
+ join(home, ".orbstack", "run", "docker.sock"),
48
+ join(home, ".docker", "run", "docker.sock"),
49
+ join(home, ".colima", "default", "docker.sock"),
50
+ ];
51
+
52
+ /**
53
+ * The local Docker socket a worker may talk to, by its real path: the one `DOCKER_HOST` names when it is a
54
+ * unix socket; none when it names a remote daemon (tcp://, ssh://…), which the user's docker client uses
55
+ * instead of any local socket; else the first of `candidates` that exists.
56
+ */
57
+ export function dockerSocket(candidates = dockerSocketCandidates()): string | null {
58
+ const host = process.env.DOCKER_HOST;
59
+ if (host?.startsWith("unix://")) return realOr(host.slice("unix://".length));
60
+ if (host) return null;
61
+ const found = candidates.find((p) => existsSync(p));
62
+ return found ? realOr(found) : null;
63
+ }