@naxodev/apnea 0.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 (74) hide show
  1. package/CONTEXT.md +61 -0
  2. package/CONTRIBUTING.md +21 -0
  3. package/LICENSE +21 -0
  4. package/README.md +163 -0
  5. package/SECURITY.md +35 -0
  6. package/briefs/coder.md +40 -0
  7. package/briefs/orchestrator.md +49 -0
  8. package/briefs/planner.md +54 -0
  9. package/briefs/reviewer.md +40 -0
  10. package/dist/cli.js +39397 -0
  11. package/docs/adr/0001-completion-signaling.md +3 -0
  12. package/docs/adr/0002-orchestrator-authority.md +3 -0
  13. package/docs/adr/0003-verify-at-gate.md +3 -0
  14. package/docs/adr/0004-artifact-layout-and-naming.md +3 -0
  15. package/docs/adr/0005-harness-profiles.md +5 -0
  16. package/docs/adr/0006-config-trust-model.md +3 -0
  17. package/docs/adr/0007-jj-first-commits.md +3 -0
  18. package/docs/adr/0008-effect-v4-internals.md +3 -0
  19. package/docs/adr/0009-cli-driver-split.md +9 -0
  20. package/docs/adr/0010-package-split.md +23 -0
  21. package/docs/protocol/artifacts.md +68 -0
  22. package/docs/protocol/config.md +186 -0
  23. package/docs/protocol/manual-gate.md +38 -0
  24. package/docs/protocol/overview.md +96 -0
  25. package/extension/adapters/commit.ts +15 -0
  26. package/extension/adapters/dispatch.ts +15 -0
  27. package/extension/adapters/setup.ts +34 -0
  28. package/extension/adapters/start.ts +16 -0
  29. package/extension/adapters/status.ts +24 -0
  30. package/extension/adapters/wait.ts +20 -0
  31. package/extension/api.ts +16 -0
  32. package/extension/cli/format.ts +44 -0
  33. package/extension/cli/human-gate.ts +44 -0
  34. package/extension/cli/main.ts +218 -0
  35. package/extension/cli/parse.ts +48 -0
  36. package/extension/domain/artifact-kind.ts +26 -0
  37. package/extension/domain/frontmatter.ts +69 -0
  38. package/extension/domain/herdr.ts +109 -0
  39. package/extension/domain/paths.ts +139 -0
  40. package/extension/domain/recovery.ts +25 -0
  41. package/extension/domain/rounds.ts +16 -0
  42. package/extension/domain/setup.ts +158 -0
  43. package/extension/domain/slug.ts +9 -0
  44. package/extension/domain/state-machine.ts +132 -0
  45. package/extension/domain/timeouts.ts +24 -0
  46. package/extension/domain/types.ts +145 -0
  47. package/extension/domain/verify-commands.ts +128 -0
  48. package/extension/errors.ts +247 -0
  49. package/extension/host-adapter.ts +8 -0
  50. package/extension/registry.ts +323 -0
  51. package/extension/result.ts +55 -0
  52. package/extension/run-tool.ts +43 -0
  53. package/extension/schema/config.ts +315 -0
  54. package/extension/schema/frontmatter.ts +34 -0
  55. package/extension/schema/state.ts +119 -0
  56. package/extension/services/app-live.ts +24 -0
  57. package/extension/services/config.ts +103 -0
  58. package/extension/services/file-system.ts +178 -0
  59. package/extension/services/herdr.ts +860 -0
  60. package/extension/services/run-store.ts +99 -0
  61. package/extension/services/vcs.ts +246 -0
  62. package/extension/workflows/commit.ts +148 -0
  63. package/extension/workflows/dispatch.ts +693 -0
  64. package/extension/workflows/reset.ts +26 -0
  65. package/extension/workflows/setup.ts +301 -0
  66. package/extension/workflows/start.ts +149 -0
  67. package/extension/workflows/status.ts +45 -0
  68. package/extension/workflows/wait.ts +793 -0
  69. package/herdr-plugin/herdr-plugin.toml +15 -0
  70. package/herdr-plugin/scripts/run-task.sh +8 -0
  71. package/package.json +75 -0
  72. package/schemas/artifact-frontmatter.md +38 -0
  73. package/schemas/config.schema.json +50 -0
  74. package/schemas/state.schema.json +63 -0
@@ -0,0 +1,3 @@
1
+ # Completion signaling: artifact front-matter only
2
+
3
+ A sequential loop has at most one outstanding dispatch. Multiple completion channels (result JSON, pane markers, agent-status parsing) create competing parsers and fail open on the weakest one. We use a single machine channel: the role writes one artifact with YAML front-matter (`status`, optional `verdict`/`nits`). Markers are human-only; Herdr agent-status is liveness only. Absence of the target path (clear-before-dispatch / per-round paths) replaces dispatch-id correlation.
@@ -0,0 +1,3 @@
1
+ # Orchestrator authority: tools are the state machine
2
+
3
+ The loop is deterministic; the default orchestrator model is not. Every mutating tool validates `state.json.step` and refuses illegal transitions with a corrective error. `workflow_status` is read-only. Cap reset is not a model-facing tool at all: `reset-rounds` exists only on the CLI and as `/apnea reset-rounds`, and the CLI refuses it unless stdin/stdout are a terminal and the human retypes the gate key. `--i-am-human` overrides for scripts — the guarantee is auditability, not prevention. Hand-editing `state.json` voids the warranty.
@@ -0,0 +1,3 @@
1
+ # Verify commands run at commit, not by trust
2
+
3
+ Phase packages include verify commands, but a readonly-in-spirit reviewer cannot be the hard gate. `workflow_commit_phase` executes those commands, writes `artifacts/phase-N/round-M/verify.log`, and refuses on non-zero exit in addition to requiring APPROVED. Planner-authored commands are arbitrary code in the project cwd — accepted as the same trust domain as the coder writing sources. Plan review must sanity-read verify commands before approving a plan. No sandbox in v1.
@@ -0,0 +1,3 @@
1
+ # `.apnea/` layout and per-round paths
2
+
3
+ Runtime state lives under `.apnea/` (package name, not “pi-herdr”) and is VCS-ignored. Artifacts use `phase-N/round-M/` paths so rework never overwrites audit history. Glossary terms (Run, Phase, Phase package, Dispatch, Round, Verdict, Brief, Artifact, Gate, Profile, Role) are mandatory in briefs and tool errors so foreign harnesses pattern-match one vocabulary.
@@ -0,0 +1,5 @@
1
+ # Harness abstraction: profiles with mode variants
2
+
3
+ Roles do not carry freeform model strings. Global **profiles** define `cmd_interactive` (required for dispatch) and optional `cmd_oneshot` (unused by regular Apnea dispatch). Every worker role opens the interactive harness TUI in a labeled Herdr pane so humans can watch the session. Binding a role to a profile missing `cmd_interactive` hard-errors at start. Project config may only rebind role→profile names.
4
+
5
+ Each remembered role pane stores a deterministic fingerprint of the effective profile name and interactive command. Dispatch reuses the pane only when that fingerprint still matches resolved config. Legacy pane records have no fingerprint and are recreated once rather than risking an old harness.
@@ -0,0 +1,3 @@
1
+ # Config trust: binaries only in global config
2
+
3
+ Project-local argv is RCE on `workflow_start`. Profiles and all `cmd_*` arrays resolve only from `~/.config/apnea/config.json` (plus package defaults). `.apnea/config.json` may rebind roles to existing profile names and set caps/timeouts. Unknown keys and unimplemented values hard-error. Repo text (tasks, plans, verify commands) remains an accepted prompt-injection surface and is disclosed, not denied.
@@ -0,0 +1,3 @@
1
+ # jj-first commit semantics
2
+
3
+ If `.jj/` exists, use jj only for mutations (`describe` + `new` after each approved phase). Never run mutating git commands in a jj repo. Git backend: branch `apnea/<slug>` at start, one commit per phase. jj bookmark `apnea/<slug>` is created at **terminus** (when writing/finishing PR description), not at start — bookmarks do not follow `@`. “Clean tree” for start means empty meaningful file diff (jj: empty `@` changes the user cares about; not “op log quiet”). Neither VCS → refuse auto-commit. Coder commits are a protocol violation and must escalate.
@@ -0,0 +1,3 @@
1
+ # Effect v4 for extension internals
2
+
3
+ Apnea’s Pi extension mixed `ToolResult` returns with thrown errors, hand-rolled JSON validation, and hard-to-test `fs`/herdr/vcs side effects inside tool functions. We adopt **Effect v4** (pinned beta) for workflow programs and IO services only: pure domain (state machine, paths, front-matter line parse, round keys) stays plain TypeScript; Pi tool params stay TypeBox; adapters map Effect exits to `ToolResult`. Services are custom and thin (FileSystem, RunStore, Config, Vcs, Herdr, Clock) — not `@effect/platform-node`. Each tool call runs with a fresh `AppLive` provide (no process-global runtime). Config/state decode via Effect Schema as runtime source of truth; repo `schemas/*.json` remain human protocol docs, kept honest with fixture tests. Protocol, artifact layout, and config trust are unchanged (ADRs 0001–0007).
@@ -0,0 +1,9 @@
1
+ # CLI driver: one registry, two front-ends
2
+
3
+ The loop's enforcement lives in `workflows/` + `domain/state-machine.ts`, not in Pi.
4
+ `extension/registry.ts` is the single definition of every operation; `@naxodev/pi-apnea`
5
+ binds it to Pi tools and `extension/cli/` binds it to argv, so the two front-ends cannot
6
+ desync. Any harness that can run a shell command can hold the orchestrator seat. Pi keeps
7
+ its exclusives — streaming `onUpdate` during wait, `sendUserMessage` kick, `/` autocomplete.
8
+ `workflow_wait` is bounded and resumable because host shell timeouts are shorter than role
9
+ timeouts; the authoritative deadline lives in `state.json`, not in process memory.
@@ -0,0 +1,23 @@
1
+ # ADR 0010: Split the engine and Pi adapter
2
+
3
+ ## Status
4
+
5
+ Accepted.
6
+
7
+ ## Context
8
+
9
+ The original package combined a standalone CLI, workflow engine, protocol resources, and Pi integration. This made the host-neutral CLI depend on Pi-specific launch behavior and prevented consumers from installing the engine alone.
10
+
11
+ ## Decision
12
+
13
+ Publish the workflow engine and CLI as `@naxodev/apnea`. Publish Pi registration, Pi resources, and Pi-specific role launch behavior as `@naxodev/pi-apnea`.
14
+
15
+ Both adapters bind the operation registry exported by `@naxodev/apnea`. The Pi adapter calls registry operations in process. It does not invoke the CLI as a subprocess.
16
+
17
+ The core exposes only the operation registry, result formatting, command parsing, resource-root lookup, and the `ApneaHostAdapter` launch hooks. Pi supplies those hooks when it creates its registry.
18
+
19
+ `@naxodev/pi-apnea` depends on `@naxodev/apnea` with `^0.1.0`. Compatible core patch releases flow through that range. An incompatible host interface change requires coordinated minor releases of both packages.
20
+
21
+ ## Consequences
22
+
23
+ The CLI package installs and runs without Pi. Installing the Pi adapter installs the core transitively and registers Pi extensions, skills, and prompts. Protocol documentation, schemas, briefs, and Herdr resources have one owner in the core package.
@@ -0,0 +1,68 @@
1
+ # Artifacts
2
+
3
+ ## Layout
4
+
5
+ ```text
6
+ .apnea/ # runtime; ignore in VCS
7
+ config.json # optional project role→profile bindings only
8
+ state.json # step, phase index, rounds per (phase, gate), slug
9
+ tasks/
10
+ <dispatch-id>.md # human/agent-readable task payload
11
+ artifacts/
12
+ plan.md # or plan-round-N.md on plan rework
13
+ plan-review/
14
+ round-1.md
15
+ phase-01/
16
+ round-1/
17
+ phase-package.md
18
+ coder-result.md
19
+ code-review.md
20
+ verify.log # written by workflow_commit_phase
21
+ phase-02/
22
+ ...
23
+ pr-description.md
24
+ ```
25
+
26
+ Package-owned briefs live in the installed package (`briefs/`), not under `.apnea/`.
27
+
28
+ ## Front-matter schema
29
+
30
+ Required on every role-produced artifact:
31
+
32
+ | Field | Values |
33
+ | -------- | ----------------------- |
34
+ | `status` | `done` (v1; no partial) |
35
+
36
+ Review artifacts also require:
37
+
38
+ | Field | Values |
39
+ | --------- | -------------------------------- |
40
+ | `verdict` | `APPROVED` \| `CHANGES_REQUIRED` |
41
+
42
+ Code-review artifacts with `CHANGES_REQUIRED` may also declare:
43
+
44
+ | Field | Values |
45
+ | -------- | ------------------------- |
46
+ | `rework` | `code` \| `phase_package` |
47
+
48
+ Missing `rework` means `code`, preserving existing review artifacts. `phase_package` returns the run to phase packaging. The revised package is written under the next `round-N` directory, so the rejected package and review remain available.
49
+
50
+ Optional:
51
+
52
+ | Field | Values |
53
+ | ------ | ------------------------------------------------ |
54
+ | `nits` | freeform markdown string; ignored by commit gate |
55
+
56
+ Non-review artifacts (plan, phase package, coder result, pr-description): `status: done` only; no `verdict`.
57
+
58
+ ## Clear-before-dispatch
59
+
60
+ For a given target path, `dispatch_role` deletes or renames away any existing file at that path before sending the pointer so absence means “not done.”
61
+
62
+ On rework after CHANGES_REQUIRED, the **round number increases** and the path changes (`round-2/...`), so history is preserved.
63
+
64
+ On crash re-dispatch (same round), the same path is cleared and reused.
65
+
66
+ ## Human markers (optional)
67
+
68
+ Roles may print `PLAN_READY`, `VERDICT: APPROVED`, etc. for humans. Tools must not depend on them.
@@ -0,0 +1,186 @@
1
+ # Config
2
+
3
+ ## Layers (merge order)
4
+
5
+ 1. Package defaults
6
+ 2. `~/.config/apnea/config.json` — **only place profiles/`cmd` may appear**
7
+ 3. `.apnea/config.json` — role → profile bindings, caps, timeouts, slug defaults
8
+ 4. Per-run `workflow_start` args — slug, allow-dirty, temporary role rebinds
9
+
10
+ Unknown keys and unimplemented values (`isolation: "worktree"`) **hard-error** at start.
11
+
12
+ ## Global profiles
13
+
14
+ ```jsonc
15
+ // ~/.config/apnea/config.json
16
+ {
17
+ "profiles": {
18
+ "pi-grok": {
19
+ "cmd_interactive": [
20
+ "pi",
21
+ "--provider",
22
+ "grok-cli",
23
+ "--model",
24
+ "grok-4.5",
25
+ ],
26
+ "cmd_oneshot": [
27
+ "pi",
28
+ "--print",
29
+ "--provider",
30
+ "grok-cli",
31
+ "--model",
32
+ "grok-4.5",
33
+ ],
34
+ },
35
+ "claude-fable": {
36
+ // oneshot needs Write (or equivalent) so the role can emit artifacts
37
+ "cmd_oneshot": [
38
+ "claude",
39
+ "-p",
40
+ "--model",
41
+ "claude-fable-5",
42
+ "--allowedTools",
43
+ "Read,Write,Edit,Glob,Grep",
44
+ ],
45
+ "cmd_interactive": ["claude", "--model", "claude-fable-5"],
46
+ },
47
+ },
48
+ "roles": {
49
+ "orchestrator": { "profile": "pi-grok" },
50
+ "planner": { "profile": "claude-fable" },
51
+ "reviewer": { "profile": "claude-fable" },
52
+ "coder": { "profile": "pi-grok" },
53
+ },
54
+ "review_round_cap": 3,
55
+ // "regular" (default) or "floating"; omit the key to keep the default
56
+ "pane_style": "regular",
57
+ "timeouts_ms": {
58
+ "planning": 1500000,
59
+ "plan_review": 900000,
60
+ "phase_packaging": 900000,
61
+ "coding": 2700000,
62
+ "code_review": 900000,
63
+ "verify": 900000,
64
+ },
65
+ }
66
+ ```
67
+
68
+ ## Role timeout vs. `--timeout`
69
+
70
+ `timeouts_ms` above sets each role's real deadline. `dispatch_role` (`apnea dispatch`) reads it
71
+ and stamps the deadline into `state.json` at dispatch time, so it survives across process
72
+ restarts and repeated `wait` calls.
73
+
74
+ `apnea wait --timeout=<ms>` and `/apnea wait --timeout=<ms>` are a different knob: `--timeout`
75
+ is an alias for `--budget`, and both bound how long **that one call** blocks before returning —
76
+ not the role's deadline. When the call's budget runs out before the role's deadline, it exits
77
+ `3` ("still waiting") and the caller must call `wait` again; this does not extend or shorten the
78
+ role's timeout.
79
+
80
+ The **CLI's** default budget is 90000ms. Agent shell tools commonly default to a 120000ms
81
+ timeout, and a budget above that is killed before it can return the exit `3` the resume protocol
82
+ depends on — the caller sees a killed command instead of an instruction to call again.
83
+
84
+ The `workflow_wait` **Pi tool** has no default budget: it blocks until the role finishes. Pi
85
+ streams progress and can interrupt the call, so there is no host shell timeout to fit inside,
86
+ and chunking it would only add tool round trips. Everything below about budgets and the poll
87
+ ceiling therefore describes the CLI, or a Pi call that passes `budget_ms` explicitly.
88
+
89
+ The floor is `12000 + max(60000, 4 x poll_ms)` — 72000ms at the default 2000ms poll. A call
90
+ shorter than that is refused.
91
+
92
+ The floor exists because two rungs measure a duration by polling: the idle nudge needs 60000ms
93
+ of unbroken idleness, and the dead-harness check needs four consecutive shell-only polls. Both
94
+ start after a 12000ms grace. A duration is only meaningful over an interval something actually
95
+ watched, and nothing watches the role between `wait` calls, so each of those rungs has to
96
+ complete inside a single call.
97
+
98
+ An earlier version tried to carry those counters across calls in `state.json`. That cannot be
99
+ made correct. The counter has to assume something about the gap it did not observe: assume
100
+ "still idle" and a role that was working gets nudged; assume "not idle" and the evidence is
101
+ discarded so the rung never fires. Both failures were reproduced.
102
+
103
+ Facts do survive a gap, and stay in `state.json`: the role deadline, whether the one-time
104
+ extension was consumed, whether the final grace was taken, and whether a nudge was sent.
105
+
106
+ Raising `--poll` therefore raises the floor. When you do not pass `--budget`, the budget is
107
+ raised to the floor for you, so `apnea wait --poll=20000` works — the call simply runs longer
108
+ than the 90000ms default.
109
+
110
+ That auto-raise stops above `--poll=26999`, the largest poll whose budget stays strictly under
111
+ the 120000ms an agent shell commonly allows. The bound is strict because a call that runs
112
+ exactly 120000ms is killed at the instant it would have returned exit `3`. Above it there is no
113
+ budget that both clears the floor and returns in time, so `wait` refuses rather than picking one
114
+ that gets killed. Passing `--budget` explicitly still works at any poll: it is a deliberate
115
+ statement that your shell allows a longer call. An explicit budget under the floor is still
116
+ refused.
117
+
118
+ `poll_ms` must be at least 250ms. Every poll spawns two herdr subprocesses, so a smaller
119
+ interval is a busy-spin rather than a faster wait. `budget_ms` must be finite. The sleep between
120
+ polls is clamped to the remaining budget, so a large `poll_ms` cannot make a call outlive it.
121
+
122
+ The idle nudge and the final grace are independent rungs. A role nudged early for going idle
123
+ still receives its 180000ms grace at the deadline; it just is not prompted twice.
124
+
125
+ The 60000ms idle threshold is a trade, not a tuned value. It is short enough that the rung fits
126
+ a 90000ms call, and short enough to sometimes prompt a role that is working — a coder blocked on
127
+ a slow test run can read `idle` for a minute. The nudge is one-shot, so a false one is spent. A
128
+ 90000ms threshold would raise the floor to 102000ms and force the default budget to about
129
+ 105000ms, leaving roughly 15000ms of margin under a 120000ms shell. Raise one and you must raise
130
+ the other.
131
+
132
+ ## Pane style
133
+
134
+ `pane_style` controls how role dispatches appear in Herdr:
135
+
136
+ - Values: `"regular"` | `"floating"`. Default is `"regular"` — **omitting the key changes nothing**.
137
+ - Allowed in global config and overridable per project.
138
+ - `"floating"` applies to **planner/reviewer oneshot dispatches only**. It requires herdr ≥ 0.7.4 **and** the linked `apnea` plugin (provisioned by `/apnea setup`). The popup is **session-modal**: it takes keyboard input until the worker exits, and dismissing it early kills the dispatch.
139
+ - Floating oneshot scripts resolve `cmd_oneshot[0]` to an **absolute path** in the orchestrator environment and pass an augmented `PATH` into the popup. Bare names must be findable via the orchestrator's `PATH` (e.g. `~/.local/bin`); otherwise dispatch fails immediately instead of the popup exiting 127. Prefer absolute `cmd_oneshot` if your orchestrator process has a stripped PATH.
140
+ - **Lifecycle (single popup):** herdr allows only one popup. Dispatch refuses while a prior floating oneshot is still live. The worker writes an exit-status file; `workflow_wait` fails closed when the popup dies without a complete artifact (instead of hanging until timeout). A second open while a popup is up surfaces `popup already open` immediately.
141
+ - Interactive roles (orchestrator, coder) always use regular panes; the dispatch result reports `pane_style_effective: "regular (interactive role)"`.
142
+ - Misconfiguration (old herdr, missing plugin, missing `cmd_oneshot`, unresolved oneshot binary) fails fast at dispatch with an actionable error.
143
+ - Non-goal: per-role `pane_style` is future work. Setup never writes or flips this key — it only preserves a valid existing value on re-run.
144
+
145
+ ## Role modes (fixed)
146
+
147
+ By default every worker role launches the **interactive** harness TUI so you can watch it in Herdr. With `pane_style: "floating"`, planner and reviewer instead run their profile's **`cmd_oneshot`** inside the popup (those profiles therefore need `cmd_oneshot` for floating — dispatch preflight errors otherwise). Interactive roles always stay on `cmd_interactive`.
148
+
149
+ | Role | Required profile capability |
150
+ | ------------ | ------------------------------------------------------------------- |
151
+ | orchestrator | `cmd_interactive` |
152
+ | planner | `cmd_interactive` (and `cmd_oneshot` when `pane_style: "floating"`) |
153
+ | reviewer | `cmd_interactive` (and `cmd_oneshot` when `pane_style: "floating"`) |
154
+ | coder | `cmd_interactive` |
155
+
156
+ Binding a role to a profile missing the required capability → **hard-error**.
157
+
158
+ ## Project config (no binaries)
159
+
160
+ ```jsonc
161
+ // .apnea/config.json
162
+ {
163
+ "roles": {
164
+ "coder": { "profile": "pi-grok" },
165
+ },
166
+ "review_round_cap": 3,
167
+ }
168
+ ```
169
+
170
+ Project entries that include `cmd`, `cmd_oneshot`, `cmd_interactive`, or `bin` → **hard-error**.
171
+
172
+ ## Trust model
173
+
174
+ - Repo-controlled **text** (tasks, plans, verify commands, briefs if vendored) is an accepted prompt-injection surface — disclosed, not “solved.”
175
+ - Repo-controlled **argv** is not allowed.
176
+ - Planner-authored verify commands run at commit gate in the project cwd (same trust domain as the coder writing source). Output → `verify.log`.
177
+
178
+ ## Setup skill
179
+
180
+ `apnea-setup` / `/apnea-init`:
181
+
182
+ - Detect available binaries (`pi`, `claude`, `codex`, `herdr`, `jj`, `git`)
183
+ - Write a safe global starter config
184
+ - Optionally write project role bindings
185
+ - Never write `cmd` into project config
186
+ - Point at the manual gate before claiming readiness
@@ -0,0 +1,38 @@
1
+ # Manual gate (before any extension code)
2
+
3
+ ## Goal
4
+
5
+ Prove the paper protocol works with mixed harnesses and file handoffs **without** Apnea tools. Human acts as orchestrator.
6
+
7
+ ## Setup
8
+
9
+ - Herdr session in a throwaway toy repo (jj or git)
10
+ - Global profiles already defined (hand-written or future `/apnea-init`)
11
+ - Briefs from this package mounted or copied read-only for agents to read
12
+ - Layout under `.apnea/` created by hand as needed
13
+
14
+ ## Toy feature
15
+
16
+ Two phases, e.g. “CLI that greets by name + `--json` flag,” small enough to finish in one sitting.
17
+
18
+ ## Required paths (must all pass)
19
+
20
+ | # | Path | Pass criteria |
21
+ | --- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
22
+ | 1 | Happy path | Two phases committed/described; `pr-description.md` exists |
23
+ | 2 | Plan CHANGES_REQUIRED | At least one plan rework; new plan-review round artifact; **no** manual verdict transcription |
24
+ | 3 | Code CHANGES_REQUIRED | Live follow-up into existing coder pane; new `round-N` code-review; then APPROVED |
25
+ | 4 | APPROVED + nits | One review with `nits` field; commit still proceeds |
26
+ | 5 | Kill-and-resume | Kill coder pane mid-phase; human reconciles per protocol (artifact present? ingest : re-dispatch same round); continue to success |
27
+ | 6 | Mixed harness | Planner/reviewer Claude oneshot; coder Pi interactive |
28
+
29
+ ## Hard rules during the gate
30
+
31
+ - Every verdict read from artifact front-matter only
32
+ - No tool code, no “helper scripts” that parse markers as truth
33
+ - Verify commands run by the human-orchestrator before each phase “commit” (simulating `workflow_commit_phase`)
34
+ - Reviewer must not leave a dirty file-content tree; if they do, treat as escalate
35
+
36
+ ## Exit
37
+
38
+ When the table passes, record a short note in `examples/toy/GATE-RESULTS.md` and only then implement extension tools.
@@ -0,0 +1,96 @@
1
+ # Protocol overview
2
+
3
+ ## Roles
4
+
5
+ | Role | Mode | Default job |
6
+ | ---------------- | ---------------------------- | ------------------------------------------ |
7
+ | **orchestrator** | interactive (Pi; tools only) | Drive the loop; no product code |
8
+ | **planner** | interactive TUI | Plan, phase packages, final PR description |
9
+ | **reviewer** | interactive TUI | Plan review + code review |
10
+ | **coder** | interactive TUI | Implement current phase package only |
11
+
12
+ All worker roles open a **live harness TUI** in a Herdr pane (Claude / Pi / Codex / …). Dispatch never uses oneshot (`-p` / print) — that dumps shell output and is not watchable. Profiles must provide `cmd_interactive` for every role.
13
+
14
+ ## State machine (`state.json.step`)
15
+
16
+ ```text
17
+ start
18
+ → planning
19
+ → plan_review
20
+ → phase_packaging
21
+ → coding
22
+ → code_review
23
+ → committing
24
+ → (phase_packaging | finishing)
25
+ → finishing # planner writes pr-description
26
+ → done
27
+ ```
28
+
29
+ Illegal tool calls refuse with the legal next call named in the error.
30
+
31
+ ## Tools
32
+
33
+ | Tool | Purpose |
34
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------- |
35
+ | `workflow_start` | Resolve config; clean-tree check (unless allow-dirty / resume); create git branch or prepare jj; label panes; write state |
36
+ | `dispatch_role` | Write task file; open live harness TUI in pane; wait idle; submit short pointer; rework-only round increment |
37
+ | `workflow_wait` | Wait for artifact front-matter; treat agent-status as liveness only |
38
+ | `workflow_commit_phase` | Require APPROVED + verify commands (log to `verify.log`) + VCS backend; advance phase |
39
+ | `workflow_status` | **Read-only** snapshot |
40
+
41
+ Cap reset (`reset-rounds`) is not a Pi tool at all — it exists only as `apnea reset-rounds`
42
+ (CLI) and `/apnea reset-rounds` (slash command), both human-only. See [ADR 0002](../adr/0002-orchestrator-authority.md).
43
+
44
+ ## Dispatch pointer (all harnesses)
45
+
46
+ Short message only, e.g.:
47
+
48
+ ```text
49
+ You are the reviewer.
50
+ Read brief: <package>/briefs/reviewer.md
51
+ Read task: .apnea/tasks/<id>.md
52
+ Write artifact exactly at: .apnea/artifacts/phase-03/round-2/code-review.md
53
+ Follow the brief. Do not invent paths.
54
+ ```
55
+
56
+ ## Completion
57
+
58
+ Machine channel: **artifact front-matter only**.
59
+
60
+ ```yaml
61
+ ---
62
+ status: done
63
+ verdict: APPROVED # or CHANGES_REQUIRED; omit for non-review artifacts
64
+ rework: code # optional on code-review CHANGES_REQUIRED; code or phase_package
65
+ nits: | # optional; never blocks alone
66
+ Consider renaming foo later.
67
+ ---
68
+ ```
69
+
70
+ Pane markers are human decoration. Herdr `agent_status` is liveness (dead pane / hung oneshot without artifact → escalate).
71
+
72
+ ## Rework
73
+
74
+ - **All roles:** live follow-up on the same pane when the harness is still idle; if pane missing or busy → new pane + cold TUI launch, then pointer.
75
+ - Pointer always includes prior artifact paths for context.
76
+ - Round increments **only** after CHANGES_REQUIRED on the same (phase, gate).
77
+ - Crash / timeout / resume: **same round number**, clear-before-dispatch on that path.
78
+
79
+ ## Resume
80
+
81
+ 1. Re-resolve panes by role label (respawn if needed).
82
+ 2. If expected artifact has valid front-matter → ingest and advance.
83
+ 3. Else report state; **offer** re-dispatch — never auto-dispatch.
84
+ 4. Skip clean-tree check on resume.
85
+
86
+ ## Commit
87
+
88
+ - Orchestrator only.
89
+ - `workflow_commit_phase` runs phase package verify commands; non-zero → refuse.
90
+ - jj: `jj describe` + `jj new` after APPROVED; **bookmark `apnea/<slug>` at terminus**, not start.
91
+ - git: branch `apnea/<slug>` at start; one commit per phase.
92
+ - No push / remote PR in v1. Terminus artifact: `pr-description.md` (planner).
93
+
94
+ ## Concurrency
95
+
96
+ One run per repo. Existing `state.json` → resume or abandon only.
@@ -0,0 +1,15 @@
1
+ import type { ToolResult } from "../result.ts"
2
+ import { runToolResult } from "../run-tool.ts"
3
+ import { makeAppLive } from "../services/app-live.ts"
4
+ import { neutralHostAdapter, type ApneaHostAdapter } from "../host-adapter.ts"
5
+ import { commitWorkflow, type CommitParams } from "../workflows/commit.ts"
6
+
7
+ export async function workflowCommitPhase(
8
+ params: CommitParams,
9
+ hostAdapter: ApneaHostAdapter = neutralHostAdapter,
10
+ ): Promise<ToolResult> {
11
+ return runToolResult(
12
+ commitWorkflow(params, process.cwd()),
13
+ makeAppLive(hostAdapter),
14
+ )
15
+ }
@@ -0,0 +1,15 @@
1
+ import type { ToolResult } from "../result.ts"
2
+ import { runToolResult } from "../run-tool.ts"
3
+ import { makeAppLive } from "../services/app-live.ts"
4
+ import { neutralHostAdapter, type ApneaHostAdapter } from "../host-adapter.ts"
5
+ import { dispatchWorkflow, type DispatchParams } from "../workflows/dispatch.ts"
6
+
7
+ export async function workflowDispatch(
8
+ params: DispatchParams,
9
+ hostAdapter: ApneaHostAdapter = neutralHostAdapter,
10
+ ): Promise<ToolResult> {
11
+ return runToolResult(
12
+ dispatchWorkflow(params, process.cwd()),
13
+ makeAppLive(hostAdapter),
14
+ )
15
+ }
@@ -0,0 +1,34 @@
1
+ import type { ToolResult } from "../result.ts"
2
+ import { runToolResult } from "../run-tool.ts"
3
+ import { makeAppLive } from "../services/app-live.ts"
4
+ import { resolveExecutable } from "../services/herdr.ts"
5
+ import { neutralHostAdapter, type ApneaHostAdapter } from "../host-adapter.ts"
6
+ import {
7
+ setupWorkflow,
8
+ type SetupDeps,
9
+ type SetupParams,
10
+ } from "../workflows/setup.ts"
11
+
12
+ /**
13
+ * Walk PATH directly rather than spawning `which` once per binary — `which`
14
+ * itself is absent from minimal environments, which would report every
15
+ * harness as missing.
16
+ */
17
+ function onPath(bin: string): boolean {
18
+ return resolveExecutable(bin) !== null
19
+ }
20
+
21
+ export async function apneaSetup(
22
+ params: SetupParams,
23
+ hostAdapter: ApneaHostAdapter = neutralHostAdapter,
24
+ ): Promise<ToolResult> {
25
+ const prodDeps: SetupDeps = {
26
+ onPath,
27
+ materializeRoleAgentDir: () =>
28
+ hostAdapter.materializeRoleAgentDir?.() ?? null,
29
+ }
30
+ return runToolResult(
31
+ setupWorkflow(params, process.cwd(), prodDeps),
32
+ makeAppLive(hostAdapter),
33
+ )
34
+ }
@@ -0,0 +1,16 @@
1
+ import { makeAppLive } from "../services/app-live.ts"
2
+ import type { ApneaHostAdapter } from "../host-adapter.ts"
3
+ import { neutralHostAdapter } from "../host-adapter.ts"
4
+ import type { ToolResult } from "../result.ts"
5
+ import { runToolResult } from "../run-tool.ts"
6
+ import { startWorkflow, type StartParams } from "../workflows/start.ts"
7
+
8
+ export async function workflowStart(
9
+ params: StartParams,
10
+ hostAdapter: ApneaHostAdapter = neutralHostAdapter,
11
+ ): Promise<ToolResult> {
12
+ return runToolResult(
13
+ startWorkflow(params, process.cwd()),
14
+ makeAppLive(hostAdapter),
15
+ )
16
+ }
@@ -0,0 +1,24 @@
1
+ import { makeAppLive } from "../services/app-live.ts"
2
+ import { neutralHostAdapter, type ApneaHostAdapter } from "../host-adapter.ts"
3
+ import type { ToolResult } from "../result.ts"
4
+ import { runToolResult } from "../run-tool.ts"
5
+ import { resetRoundsWorkflow } from "../workflows/reset.ts"
6
+ import { statusWorkflow } from "../workflows/status.ts"
7
+
8
+ export async function workflowStatus(
9
+ hostAdapter: ApneaHostAdapter = neutralHostAdapter,
10
+ ): Promise<ToolResult> {
11
+ return runToolResult(statusWorkflow(process.cwd()), makeAppLive(hostAdapter))
12
+ }
13
+
14
+ export async function workflowResetRounds(
15
+ params: {
16
+ gate: string
17
+ },
18
+ hostAdapter: ApneaHostAdapter = neutralHostAdapter,
19
+ ): Promise<ToolResult> {
20
+ return runToolResult(
21
+ resetRoundsWorkflow(params, process.cwd()),
22
+ makeAppLive(hostAdapter),
23
+ )
24
+ }
@@ -0,0 +1,20 @@
1
+ import type { ToolResult } from "../result.ts"
2
+ import { runToolResult } from "../run-tool.ts"
3
+ import { makeAppLive } from "../services/app-live.ts"
4
+ import { neutralHostAdapter, type ApneaHostAdapter } from "../host-adapter.ts"
5
+ import {
6
+ waitWorkflow,
7
+ type WaitHooks,
8
+ type WaitParams,
9
+ } from "../workflows/wait.ts"
10
+
11
+ export async function workflowWait(
12
+ params: WaitParams,
13
+ hostAdapter: ApneaHostAdapter = neutralHostAdapter,
14
+ hooks: WaitHooks = {},
15
+ ): Promise<ToolResult> {
16
+ return runToolResult(
17
+ waitWorkflow(params, process.cwd(), hooks),
18
+ makeAppLive(hostAdapter),
19
+ )
20
+ }
@@ -0,0 +1,16 @@
1
+ export {
2
+ createOperations,
3
+ createExecutor,
4
+ executeOperation,
5
+ findByTool,
6
+ findByVerb,
7
+ OPERATIONS,
8
+ toolToVerb,
9
+ type Operation,
10
+ type ExecuteOperation,
11
+ } from "./registry.ts"
12
+ export { parseFlags, parseNumFlag } from "./cli/parse.ts"
13
+ export { DISPATCH_KINDS, type DispatchKind } from "./domain/state-machine.ts"
14
+ export { packageRoot } from "./domain/paths.ts"
15
+ export { formatResult, toolContent, type ToolResult } from "./result.ts"
16
+ export type { ApneaHostAdapter } from "./host-adapter.ts"