@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.
- package/CONTEXT.md +61 -0
- package/CONTRIBUTING.md +21 -0
- package/LICENSE +21 -0
- package/README.md +163 -0
- package/SECURITY.md +35 -0
- package/briefs/coder.md +40 -0
- package/briefs/orchestrator.md +49 -0
- package/briefs/planner.md +54 -0
- package/briefs/reviewer.md +40 -0
- package/dist/cli.js +39397 -0
- package/docs/adr/0001-completion-signaling.md +3 -0
- package/docs/adr/0002-orchestrator-authority.md +3 -0
- package/docs/adr/0003-verify-at-gate.md +3 -0
- package/docs/adr/0004-artifact-layout-and-naming.md +3 -0
- package/docs/adr/0005-harness-profiles.md +5 -0
- package/docs/adr/0006-config-trust-model.md +3 -0
- package/docs/adr/0007-jj-first-commits.md +3 -0
- package/docs/adr/0008-effect-v4-internals.md +3 -0
- package/docs/adr/0009-cli-driver-split.md +9 -0
- package/docs/adr/0010-package-split.md +23 -0
- package/docs/protocol/artifacts.md +68 -0
- package/docs/protocol/config.md +186 -0
- package/docs/protocol/manual-gate.md +38 -0
- package/docs/protocol/overview.md +96 -0
- package/extension/adapters/commit.ts +15 -0
- package/extension/adapters/dispatch.ts +15 -0
- package/extension/adapters/setup.ts +34 -0
- package/extension/adapters/start.ts +16 -0
- package/extension/adapters/status.ts +24 -0
- package/extension/adapters/wait.ts +20 -0
- package/extension/api.ts +16 -0
- package/extension/cli/format.ts +44 -0
- package/extension/cli/human-gate.ts +44 -0
- package/extension/cli/main.ts +218 -0
- package/extension/cli/parse.ts +48 -0
- package/extension/domain/artifact-kind.ts +26 -0
- package/extension/domain/frontmatter.ts +69 -0
- package/extension/domain/herdr.ts +109 -0
- package/extension/domain/paths.ts +139 -0
- package/extension/domain/recovery.ts +25 -0
- package/extension/domain/rounds.ts +16 -0
- package/extension/domain/setup.ts +158 -0
- package/extension/domain/slug.ts +9 -0
- package/extension/domain/state-machine.ts +132 -0
- package/extension/domain/timeouts.ts +24 -0
- package/extension/domain/types.ts +145 -0
- package/extension/domain/verify-commands.ts +128 -0
- package/extension/errors.ts +247 -0
- package/extension/host-adapter.ts +8 -0
- package/extension/registry.ts +323 -0
- package/extension/result.ts +55 -0
- package/extension/run-tool.ts +43 -0
- package/extension/schema/config.ts +315 -0
- package/extension/schema/frontmatter.ts +34 -0
- package/extension/schema/state.ts +119 -0
- package/extension/services/app-live.ts +24 -0
- package/extension/services/config.ts +103 -0
- package/extension/services/file-system.ts +178 -0
- package/extension/services/herdr.ts +860 -0
- package/extension/services/run-store.ts +99 -0
- package/extension/services/vcs.ts +246 -0
- package/extension/workflows/commit.ts +148 -0
- package/extension/workflows/dispatch.ts +693 -0
- package/extension/workflows/reset.ts +26 -0
- package/extension/workflows/setup.ts +301 -0
- package/extension/workflows/start.ts +149 -0
- package/extension/workflows/status.ts +45 -0
- package/extension/workflows/wait.ts +793 -0
- package/herdr-plugin/herdr-plugin.toml +15 -0
- package/herdr-plugin/scripts/run-task.sh +8 -0
- package/package.json +75 -0
- package/schemas/artifact-frontmatter.md +38 -0
- package/schemas/config.schema.json +50 -0
- 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
|
+
}
|
package/extension/api.ts
ADDED
|
@@ -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"
|