@naxodev/pi-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/LICENSE +21 -0
- package/README.md +52 -0
- package/extension/commands.ts +333 -0
- package/extension/index.ts +78 -0
- package/extension/pi-role-agent.ts +171 -0
- package/extension/runtime.ts +20 -0
- package/package.json +75 -0
- package/prompts/apnea-init.md +20 -0
- package/skills/apnea-orchestrator/SKILL.md +85 -0
- package/skills/apnea-setup/SKILL.md +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nacho Vazquez
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# @naxodev/pi-apnea
|
|
2
|
+
|
|
3
|
+
Pi adapter for the [Apnea workflow engine](../apnea/README.md). It registers Apnea tools, slash commands, skills, and prompts while `@naxodev/apnea` provides the shared workflow and standalone CLI.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Node.js 22.19 or later
|
|
8
|
+
- Pi 0.83.x or 0.84.x
|
|
9
|
+
- Bun 1.3.7 or later for the transitive `apnea` executable
|
|
10
|
+
- Herdr and at least one supported agent CLI, as documented by [`@naxodev/apnea`](../apnea/README.md#requirements)
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
pi install npm:@naxodev/pi-apnea
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The normal `@naxodev/apnea` dependency installs transitively, including the `apnea` executable. Pi loads this package's `extension`, `skills`, and `prompts` resources.
|
|
19
|
+
|
|
20
|
+
Pi role panes use a dedicated `PI_CODING_AGENT_DIR` without `pi-vimmode`. The user's orchestrator session remains unchanged.
|
|
21
|
+
|
|
22
|
+
## Quickstart
|
|
23
|
+
|
|
24
|
+
Run these commands inside Pi:
|
|
25
|
+
|
|
26
|
+
```text
|
|
27
|
+
/apnea setup
|
|
28
|
+
/apnea start describe the implementation goal
|
|
29
|
+
/apnea status
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`setup` creates global profiles in `~/.config/apnea/config.json` and links the Herdr plugin when Herdr is available. `start` begins one workflow against the current working copy. `status` reports the current step and next legal operation.
|
|
33
|
+
|
|
34
|
+
The shorter `/apnea-start` and `/apnea-status` aliases are also available. See the [Apnea CLI and operation reference](../apnea/README.md#cli-reference) for the shared command surface and exit behavior.
|
|
35
|
+
|
|
36
|
+
## Verify
|
|
37
|
+
|
|
38
|
+
Run `/apnea status` before starting a workflow. A clean installation reports no active run and identifies `workflow_start` as a legal next operation. If Pi does not register the commands, run `/reload` and inspect Pi's package-loading output.
|
|
39
|
+
|
|
40
|
+
Apnea executes repository-controlled text through configured agent CLIs and can execute planner-authored verification commands. Read the [trust model](../apnea/SECURITY.md) before using it with an untrusted repository.
|
|
41
|
+
|
|
42
|
+
## Versioning
|
|
43
|
+
|
|
44
|
+
Compatible core patch releases flow through the `^0.1.0` dependency range. Incompatible host interface changes require coordinated minor releases of both packages.
|
|
45
|
+
|
|
46
|
+
## Contributing
|
|
47
|
+
|
|
48
|
+
Use the workspace [contribution guide](../../CONTRIBUTING.md) for setup, checks, and release policy. Use [GitHub Discussions](https://github.com/naxodev/ai/discussions) for usage questions and the workspace [security policy](../../SECURITY.md) for private vulnerability reports.
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
|
|
52
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-facing slash commands with `/` autocomplete.
|
|
3
|
+
* These call the same functions as the LLM tools — no workflow_* typing required.
|
|
4
|
+
*/
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
6
|
+
import {
|
|
7
|
+
DISPATCH_KINDS,
|
|
8
|
+
formatResult,
|
|
9
|
+
parseFlags,
|
|
10
|
+
parseNumFlag,
|
|
11
|
+
type DispatchKind,
|
|
12
|
+
type ExecuteOperation,
|
|
13
|
+
type Operation,
|
|
14
|
+
type ToolResult,
|
|
15
|
+
} from "@naxodev/apnea"
|
|
16
|
+
import { executePiOperation, PI_OPERATIONS } from "./runtime.ts"
|
|
17
|
+
|
|
18
|
+
export const SUBS = [
|
|
19
|
+
...PI_OPERATIONS.map((o) => o.verb),
|
|
20
|
+
"resume",
|
|
21
|
+
"abandon",
|
|
22
|
+
"help",
|
|
23
|
+
] as const satisfies readonly string[]
|
|
24
|
+
|
|
25
|
+
function notify(
|
|
26
|
+
ctx: {
|
|
27
|
+
ui: { notify: (m: string, l?: "info" | "error" | "warning") => void }
|
|
28
|
+
},
|
|
29
|
+
r: ToolResult,
|
|
30
|
+
) {
|
|
31
|
+
const text = formatResult(r)
|
|
32
|
+
ctx.ui.notify(text, r.ok ? "info" : "error")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function orchestratorKickMessage(
|
|
36
|
+
kind: "start" | "resume",
|
|
37
|
+
goal?: string,
|
|
38
|
+
): string {
|
|
39
|
+
if (kind === "start") {
|
|
40
|
+
return [
|
|
41
|
+
"You are the Apnea orchestrator for this run. Schedule only — no product code.",
|
|
42
|
+
goal ? `Goal: ${goal}` : null,
|
|
43
|
+
"State is step=planning with no pending dispatch.",
|
|
44
|
+
'Immediately call dispatch_role with kind="plan", then workflow_wait.',
|
|
45
|
+
"Then continue the loop: plan_review → phase_package → code → code_review → workflow_commit_phase → … → pr_description.",
|
|
46
|
+
"Use workflow_status if unsure. Never call workflow_reset_rounds. Escalate timeouts/caps/dirty-reviewer to the human.",
|
|
47
|
+
]
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.join("\n")
|
|
50
|
+
}
|
|
51
|
+
return [
|
|
52
|
+
"You are the Apnea orchestrator resuming this run. Schedule only — no product code.",
|
|
53
|
+
"Call workflow_status (and workflow_start action=resume if needed).",
|
|
54
|
+
"If a pending artifact exists, workflow_wait. Else dispatch the legal kind for the current step.",
|
|
55
|
+
"Never auto-invent steps; never workflow_reset_rounds; never product code.",
|
|
56
|
+
].join("\n")
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function helpText(operations: readonly Operation[]): string {
|
|
60
|
+
// verb + usage on one line (what to type), summary indented below (what
|
|
61
|
+
// it does) — usage is what regressed when helpText() first went generated:
|
|
62
|
+
// it rendered only the LLM-facing summary and dropped the flag syntax.
|
|
63
|
+
const lines = operations.map((o) => {
|
|
64
|
+
const invocation = o.usage ? `${o.verb} ${o.usage}` : o.verb
|
|
65
|
+
return ` /apnea ${invocation}\n ${o.summary}`
|
|
66
|
+
})
|
|
67
|
+
return [
|
|
68
|
+
"Apnea commands (tools remain for the model; you use /apnea):",
|
|
69
|
+
...lines,
|
|
70
|
+
" /apnea resume | abandon # actions on an existing run",
|
|
71
|
+
` dispatch kinds: ${DISPATCH_KINDS.join(" | ")}`,
|
|
72
|
+
" /apnea help",
|
|
73
|
+
].join("\n")
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function registerApneaCommands(
|
|
77
|
+
pi: ExtensionAPI,
|
|
78
|
+
operations: readonly Operation[] = PI_OPERATIONS,
|
|
79
|
+
execute: ExecuteOperation = executePiOperation,
|
|
80
|
+
): void {
|
|
81
|
+
const run = (verb: string, params: Record<string, unknown>) => {
|
|
82
|
+
const operation = operations.find((candidate) => candidate.verb === verb)
|
|
83
|
+
if (!operation) throw new Error(`Missing Apnea operation: ${verb}`)
|
|
84
|
+
return execute(operation.verb, params)
|
|
85
|
+
}
|
|
86
|
+
const kick = (kind: "start" | "resume", goal?: string) => {
|
|
87
|
+
pi.sendUserMessage(orchestratorKickMessage(kind, goal))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
pi.registerCommand("apnea", {
|
|
91
|
+
description: "Apnea workflow: start, status, dispatch, wait, commit, …",
|
|
92
|
+
getArgumentCompletions: (prefix: string) => {
|
|
93
|
+
const parts = prefix.trimStart().split(/\s+/)
|
|
94
|
+
// completing first token (subcommand)
|
|
95
|
+
if (parts.length <= 1) {
|
|
96
|
+
const p = parts[0] ?? ""
|
|
97
|
+
const hits = SUBS.filter((s) => s.startsWith(p)).map((s) => ({
|
|
98
|
+
value: s,
|
|
99
|
+
label: s,
|
|
100
|
+
}))
|
|
101
|
+
return hits.length ? hits : null
|
|
102
|
+
}
|
|
103
|
+
const sub = parts[0]
|
|
104
|
+
if (sub === "dispatch") {
|
|
105
|
+
const p = parts[1] ?? ""
|
|
106
|
+
// Complete the kind token only — Pi replaces the last token.
|
|
107
|
+
const hits = DISPATCH_KINDS.filter((k) => k.startsWith(p)).map((k) => ({
|
|
108
|
+
value: k,
|
|
109
|
+
label: k,
|
|
110
|
+
}))
|
|
111
|
+
return hits.length ? hits : null
|
|
112
|
+
}
|
|
113
|
+
if (sub === "setup") {
|
|
114
|
+
const p = parts[1] ?? ""
|
|
115
|
+
const flags = ["--project", "--force", "--agents-md"]
|
|
116
|
+
const hits = flags
|
|
117
|
+
.filter((f) => f.startsWith(p) || p === "")
|
|
118
|
+
.map((f) => ({ value: f, label: f }))
|
|
119
|
+
return hits.length ? hits : null
|
|
120
|
+
}
|
|
121
|
+
if (sub === "start" && parts.length === 2 && parts[1]?.startsWith("--")) {
|
|
122
|
+
const flags = ["--allow-dirty", "--slug="]
|
|
123
|
+
const p = parts[1] ?? ""
|
|
124
|
+
const hits = flags
|
|
125
|
+
.filter((f) => f.startsWith(p))
|
|
126
|
+
.map((f) => ({ value: f, label: f }))
|
|
127
|
+
return hits.length ? hits : null
|
|
128
|
+
}
|
|
129
|
+
if (sub === "commit") {
|
|
130
|
+
const p = parts[1] ?? ""
|
|
131
|
+
if (p.startsWith("-") || p === "") {
|
|
132
|
+
const hits = ["--done"]
|
|
133
|
+
.filter((f) => f.startsWith(p) || p === "")
|
|
134
|
+
.map((f) => ({ value: f, label: f }))
|
|
135
|
+
return hits.length ? hits : null
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (sub === "reset-rounds") {
|
|
139
|
+
const gates = [
|
|
140
|
+
"plan_review",
|
|
141
|
+
"phase-01/code_review",
|
|
142
|
+
"phase-02/code_review",
|
|
143
|
+
]
|
|
144
|
+
const p = parts[1] ?? ""
|
|
145
|
+
const hits = gates
|
|
146
|
+
.filter((g) => g.startsWith(p))
|
|
147
|
+
.map((g) => ({ value: g, label: g }))
|
|
148
|
+
return hits.length ? hits : null
|
|
149
|
+
}
|
|
150
|
+
return null
|
|
151
|
+
},
|
|
152
|
+
handler: async (args, ctx) => {
|
|
153
|
+
const raw = args.trim()
|
|
154
|
+
if (!raw || raw === "help") {
|
|
155
|
+
ctx.ui.notify(helpText(operations), "info")
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const tokens = raw.split(/\s+/).filter(Boolean)
|
|
160
|
+
const sub = tokens[0]!
|
|
161
|
+
const { flags, values, rest } = parseFlags(tokens.slice(1))
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
switch (sub) {
|
|
165
|
+
case "help":
|
|
166
|
+
ctx.ui.notify(helpText(operations), "info")
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
case "setup":
|
|
170
|
+
notify(
|
|
171
|
+
ctx,
|
|
172
|
+
await run("setup", {
|
|
173
|
+
project: flags.has("project"),
|
|
174
|
+
force: flags.has("force"),
|
|
175
|
+
agents_md: flags.has("agents-md"),
|
|
176
|
+
}),
|
|
177
|
+
)
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
case "start": {
|
|
181
|
+
// goal is everything not a flag; support --slug=x
|
|
182
|
+
const slug = values.get("slug")
|
|
183
|
+
const goal = rest.join(" ").trim()
|
|
184
|
+
if (!goal) {
|
|
185
|
+
ctx.ui.notify(
|
|
186
|
+
"Usage: /apnea start <goal> [--allow-dirty] [--slug=name]",
|
|
187
|
+
"error",
|
|
188
|
+
)
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
const r = await run("start", {
|
|
192
|
+
goal,
|
|
193
|
+
slug,
|
|
194
|
+
allow_dirty: flags.has("allow-dirty"),
|
|
195
|
+
action: "start",
|
|
196
|
+
})
|
|
197
|
+
notify(ctx, r)
|
|
198
|
+
if (r.ok) kick("start", goal)
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
case "resume": {
|
|
203
|
+
const r = await run("start", { goal: "", action: "resume" })
|
|
204
|
+
notify(ctx, r)
|
|
205
|
+
if (r.ok) kick("resume")
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
case "abandon":
|
|
210
|
+
notify(ctx, await run("start", { goal: "", action: "abandon" }))
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
case "status":
|
|
214
|
+
notify(ctx, await run("status", {}))
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
case "wait": {
|
|
218
|
+
// `--timeout` and `--budget` are the same knob: how long THIS
|
|
219
|
+
// call blocks. The role's deadline comes from config, stamped
|
|
220
|
+
// at dispatch. Same flags as `apnea wait`; only the default
|
|
221
|
+
// differs, because this surface has no host shell to time out.
|
|
222
|
+
const poll = parseNumFlag(values, "poll")
|
|
223
|
+
if (!poll.ok) {
|
|
224
|
+
ctx.ui.notify(
|
|
225
|
+
`Usage: /apnea wait [--poll=<ms>] (got --poll=${poll.raw})`,
|
|
226
|
+
"error",
|
|
227
|
+
)
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
const budget = parseNumFlag(values, "budget")
|
|
231
|
+
if (!budget.ok) {
|
|
232
|
+
ctx.ui.notify(
|
|
233
|
+
`Usage: /apnea wait [--budget=<ms>] (got --budget=${budget.raw})`,
|
|
234
|
+
"error",
|
|
235
|
+
)
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
const timeout = parseNumFlag(values, "timeout")
|
|
239
|
+
if (!timeout.ok) {
|
|
240
|
+
ctx.ui.notify(
|
|
241
|
+
`Usage: /apnea wait [--timeout=<ms>] (got --timeout=${timeout.raw})`,
|
|
242
|
+
"error",
|
|
243
|
+
)
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
const r = await run("wait", {
|
|
247
|
+
poll_ms: poll.value,
|
|
248
|
+
// Unbounded by default, like the Pi tool in `index.ts`:
|
|
249
|
+
// `/apnea` runs inside Pi, which has no shell timeout, so
|
|
250
|
+
// the CLI's chunking default would end the wait with a
|
|
251
|
+
// green "still waiting" toast and nothing left polling.
|
|
252
|
+
budget_ms:
|
|
253
|
+
budget.value ?? timeout.value ?? Number.MAX_SAFE_INTEGER,
|
|
254
|
+
})
|
|
255
|
+
notify(ctx, r)
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
case "dispatch": {
|
|
260
|
+
const kind = rest[0] as DispatchKind | undefined
|
|
261
|
+
if (!kind || !DISPATCH_KINDS.includes(kind)) {
|
|
262
|
+
ctx.ui.notify(
|
|
263
|
+
`Usage: /apnea dispatch <${DISPATCH_KINDS.join("|")}> [--rework]`,
|
|
264
|
+
"error",
|
|
265
|
+
)
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
notify(
|
|
269
|
+
ctx,
|
|
270
|
+
await run("dispatch", {
|
|
271
|
+
kind,
|
|
272
|
+
rework: flags.has("rework"),
|
|
273
|
+
}),
|
|
274
|
+
)
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
case "commit": {
|
|
279
|
+
// `parseFlags` already strips `--` tokens into `flags`/`values`;
|
|
280
|
+
// `rest` never contains them, so no filter is needed here.
|
|
281
|
+
const message = rest.join(" ").trim() || undefined
|
|
282
|
+
notify(
|
|
283
|
+
ctx,
|
|
284
|
+
await run("commit", {
|
|
285
|
+
message,
|
|
286
|
+
no_remaining_phases: flags.has("done"),
|
|
287
|
+
}),
|
|
288
|
+
)
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
case "reset-rounds": {
|
|
293
|
+
const gate = rest[0]
|
|
294
|
+
if (!gate) {
|
|
295
|
+
ctx.ui.notify("Usage: /apnea reset-rounds <gate>", "error")
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
notify(ctx, await run("reset-rounds", { gate }))
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
default:
|
|
303
|
+
ctx.ui.notify(
|
|
304
|
+
`Unknown subcommand: ${sub}\n${helpText(operations)}`,
|
|
305
|
+
"error",
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
} catch (e) {
|
|
309
|
+
ctx.ui.notify(e instanceof Error ? e.message : String(e), "error")
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
// Short aliases that also show in `/` autocomplete
|
|
315
|
+
pi.registerCommand("apnea-status", {
|
|
316
|
+
description: "Apnea: read-only run status (alias of /apnea status)",
|
|
317
|
+
handler: async (_args, ctx) => notify(ctx, await run("status", {})),
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
pi.registerCommand("apnea-start", {
|
|
321
|
+
description: "Apnea: start a run — /apnea-start <goal>",
|
|
322
|
+
handler: async (args, ctx) => {
|
|
323
|
+
const goal = args.trim()
|
|
324
|
+
if (!goal) {
|
|
325
|
+
ctx.ui.notify("Usage: /apnea-start <goal>", "error")
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
const r = await run("start", { goal, action: "start" })
|
|
329
|
+
notify(ctx, r)
|
|
330
|
+
if (r.ok) kick("start", goal)
|
|
331
|
+
},
|
|
332
|
+
})
|
|
333
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @naxodev/pi-apnea — Pi adapter for the Apnea workflow.
|
|
3
|
+
*
|
|
4
|
+
* Definitions come from @naxodev/apnea; this file only binds them to Pi.
|
|
5
|
+
* The standalone CLI binds the same registry to argv.
|
|
6
|
+
*/
|
|
7
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
8
|
+
import { toolContent } from "@naxodev/apnea"
|
|
9
|
+
import { registerApneaCommands } from "./commands.ts"
|
|
10
|
+
import { executePiOperation, PI_OPERATIONS } from "./runtime.ts"
|
|
11
|
+
|
|
12
|
+
export default function (pi: ExtensionAPI) {
|
|
13
|
+
// `/apnea …` for humans (autocomplete); tools remain for the model
|
|
14
|
+
registerApneaCommands(pi, PI_OPERATIONS, executePiOperation)
|
|
15
|
+
|
|
16
|
+
for (const op of PI_OPERATIONS) {
|
|
17
|
+
if (op.tool === null) continue
|
|
18
|
+
|
|
19
|
+
// wait is the one operation with streaming + abort; Pi's exclusive.
|
|
20
|
+
if (op.tool === "workflow_wait") {
|
|
21
|
+
pi.registerTool({
|
|
22
|
+
name: op.tool,
|
|
23
|
+
label: "Apnea wait",
|
|
24
|
+
description: [op.summary, op.guidance].filter(Boolean).join(" "),
|
|
25
|
+
parameters: op.params,
|
|
26
|
+
async execute(
|
|
27
|
+
_id: string,
|
|
28
|
+
params: { poll_ms?: number; budget_ms?: number },
|
|
29
|
+
signal: AbortSignal | undefined,
|
|
30
|
+
onUpdate:
|
|
31
|
+
| ((partial: {
|
|
32
|
+
content: Array<{ type: "text"; text: string }>
|
|
33
|
+
details: unknown
|
|
34
|
+
}) => void)
|
|
35
|
+
| undefined,
|
|
36
|
+
) {
|
|
37
|
+
return toolContent(
|
|
38
|
+
// Pi blocks in one chunk by design: it streams progress and
|
|
39
|
+
// can be interrupted, so it has no host shell timeout to fit
|
|
40
|
+
// inside. The registry handler no longer injects this — only
|
|
41
|
+
// the CLI reaches that, and it must stay bounded.
|
|
42
|
+
await executePiOperation(
|
|
43
|
+
op.verb,
|
|
44
|
+
{
|
|
45
|
+
...params,
|
|
46
|
+
budget_ms: params.budget_ms ?? Number.MAX_SAFE_INTEGER,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
signal,
|
|
50
|
+
onUpdate: onUpdate
|
|
51
|
+
? (partial) =>
|
|
52
|
+
onUpdate({
|
|
53
|
+
content: partial.content,
|
|
54
|
+
details: {
|
|
55
|
+
ok: true,
|
|
56
|
+
message: partial.content[0]?.text ?? "",
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
: undefined,
|
|
60
|
+
},
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
pi.registerTool({
|
|
69
|
+
name: op.tool,
|
|
70
|
+
label: `Apnea ${op.verb}`,
|
|
71
|
+
description: [op.summary, op.guidance].filter(Boolean).join(" "),
|
|
72
|
+
parameters: op.params,
|
|
73
|
+
async execute(_id: string, params: Record<string, unknown>) {
|
|
74
|
+
return toolContent(await executePiOperation(op.verb, params))
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Role-pane pi launches must not load pi-vimmode. Modal vim intercepts
|
|
3
|
+
* herdr `pane run` pastes and leaves the pointer sitting in INSERT — the
|
|
4
|
+
* single biggest cause of idle-without-artifact stalls for the coder.
|
|
5
|
+
*
|
|
6
|
+
* Strategy: materialize a dedicated PI_CODING_AGENT_DIR that reuses the
|
|
7
|
+
* user's auth/npm/skills but filters pi-vimmode out of packages, then wrap
|
|
8
|
+
* interactive `pi` launches with that env. Reused panes also get a
|
|
9
|
+
* best-effort `/vimmode off` slash command.
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from "node:fs"
|
|
12
|
+
import * as os from "node:os"
|
|
13
|
+
import * as path from "node:path"
|
|
14
|
+
|
|
15
|
+
const PI_VIMMODE_MARKERS = ["pi-vimmode", "pekochan069/pi-vimmode"]
|
|
16
|
+
|
|
17
|
+
export function isPiCmd(cmd: string[] | undefined | null): boolean {
|
|
18
|
+
if (!cmd?.length) return false
|
|
19
|
+
const bin = path.basename(cmd[0]!)
|
|
20
|
+
return bin === "pi"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function packageSource(entry: unknown): string | null {
|
|
24
|
+
if (typeof entry === "string") return entry
|
|
25
|
+
if (entry && typeof entry === "object" && !Array.isArray(entry)) {
|
|
26
|
+
const src = (entry as Record<string, unknown>).source
|
|
27
|
+
return typeof src === "string" ? src : null
|
|
28
|
+
}
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** True when a packages[] entry is (or wraps) pi-vimmode. */
|
|
33
|
+
export function isPiVimModePackage(entry: unknown): boolean {
|
|
34
|
+
const src = packageSource(entry)
|
|
35
|
+
if (!src) return false
|
|
36
|
+
const lower = src.toLowerCase()
|
|
37
|
+
return PI_VIMMODE_MARKERS.some((m) => lower.includes(m))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Drop pi-vimmode from a packages list. Leaves every other entry intact
|
|
42
|
+
* (string form and object form with filters).
|
|
43
|
+
*/
|
|
44
|
+
export function filterPackagesNoVim(packages: unknown): unknown[] {
|
|
45
|
+
if (!Array.isArray(packages)) return []
|
|
46
|
+
return packages.filter((p) => !isPiVimModePackage(p))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function defaultSourceAgentDir(): string {
|
|
50
|
+
return (
|
|
51
|
+
process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent")
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function defaultRoleAgentDir(): string {
|
|
56
|
+
const home = process.env.HOME || process.env.USERPROFILE || os.homedir()
|
|
57
|
+
return path.join(home, ".config", "apnea", "pi-role-agent")
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function symlinkOrCopy(src: string, dest: string): void {
|
|
61
|
+
if (!fs.existsSync(src)) return
|
|
62
|
+
if (path.resolve(src) === path.resolve(dest)) return
|
|
63
|
+
try {
|
|
64
|
+
if (fs.existsSync(dest) || fs.lstatSync(dest).isSymbolicLink()) {
|
|
65
|
+
fs.rmSync(dest, { recursive: true, force: true })
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
try {
|
|
69
|
+
fs.rmSync(dest, { recursive: true, force: true })
|
|
70
|
+
} catch {
|
|
71
|
+
/* ignore */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
fs.symlinkSync(src, dest)
|
|
76
|
+
} catch {
|
|
77
|
+
// Windows or no-symlink FS: copy
|
|
78
|
+
const st = fs.statSync(src)
|
|
79
|
+
if (st.isDirectory()) {
|
|
80
|
+
fs.cpSync(src, dest, { recursive: true, force: true })
|
|
81
|
+
} else {
|
|
82
|
+
fs.copyFileSync(src, dest)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Build (or refresh) a PI_CODING_AGENT_DIR for Apnea role panes.
|
|
89
|
+
* - settings.json: user's packages minus pi-vimmode; piVimMode key stripped
|
|
90
|
+
* - auth/npm/skills/extensions/themes/models: linked from the real agent dir
|
|
91
|
+
*
|
|
92
|
+
* Idempotent. Safe to call on every dispatch.
|
|
93
|
+
*/
|
|
94
|
+
export function materializePiRoleAgentDir(opts?: {
|
|
95
|
+
sourceAgentDir?: string
|
|
96
|
+
destDir?: string
|
|
97
|
+
}): string {
|
|
98
|
+
const source = opts?.sourceAgentDir ?? defaultSourceAgentDir()
|
|
99
|
+
const dest = opts?.destDir ?? defaultRoleAgentDir()
|
|
100
|
+
fs.mkdirSync(dest, { recursive: true })
|
|
101
|
+
|
|
102
|
+
const srcSettingsPath = path.join(source, "settings.json")
|
|
103
|
+
let settings: Record<string, unknown> = {}
|
|
104
|
+
if (fs.existsSync(srcSettingsPath)) {
|
|
105
|
+
try {
|
|
106
|
+
const raw = JSON.parse(fs.readFileSync(srcSettingsPath, "utf8"))
|
|
107
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
108
|
+
settings = { ...(raw as Record<string, unknown>) }
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
settings = {}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
settings.packages = filterPackagesNoVim(settings.packages)
|
|
116
|
+
delete settings.piVimMode
|
|
117
|
+
|
|
118
|
+
fs.writeFileSync(
|
|
119
|
+
path.join(dest, "settings.json"),
|
|
120
|
+
`${JSON.stringify(settings, null, 2)}\n`,
|
|
121
|
+
"utf8",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
// Reuse identity + installed packages; keep sessions local to role dir.
|
|
125
|
+
for (const name of [
|
|
126
|
+
"auth.json",
|
|
127
|
+
"npm",
|
|
128
|
+
"skills",
|
|
129
|
+
"extensions",
|
|
130
|
+
"themes",
|
|
131
|
+
"models.json",
|
|
132
|
+
"bin",
|
|
133
|
+
"tools",
|
|
134
|
+
]) {
|
|
135
|
+
symlinkOrCopy(path.join(source, name), path.join(dest, name))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Optional: pi-vimmode.config.js must not apply either
|
|
139
|
+
const vimCfg = path.join(dest, "pi-vimmode.config.js")
|
|
140
|
+
if (fs.existsSync(vimCfg) || safeIsSymlink(vimCfg)) {
|
|
141
|
+
try {
|
|
142
|
+
fs.rmSync(vimCfg, { force: true })
|
|
143
|
+
} catch {
|
|
144
|
+
/* ignore */
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return dest
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function safeIsSymlink(p: string): boolean {
|
|
152
|
+
try {
|
|
153
|
+
return fs.lstatSync(p).isSymbolicLink()
|
|
154
|
+
} catch {
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Prefix a pi interactive command with env PI_CODING_AGENT_DIR=... so the
|
|
161
|
+
* role pane never loads pi-vimmode. Non-pi cmds pass through unchanged.
|
|
162
|
+
* `opts` is for tests; production callers omit it.
|
|
163
|
+
*/
|
|
164
|
+
export function wrapInteractiveCmdNoVim(
|
|
165
|
+
cmd: string[],
|
|
166
|
+
opts?: { sourceAgentDir?: string; destDir?: string },
|
|
167
|
+
): string[] {
|
|
168
|
+
if (!isPiCmd(cmd)) return cmd
|
|
169
|
+
const agentDir = materializePiRoleAgentDir(opts)
|
|
170
|
+
return ["env", `PI_CODING_AGENT_DIR=${agentDir}`, ...cmd]
|
|
171
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createExecutor,
|
|
3
|
+
createOperations,
|
|
4
|
+
type ApneaHostAdapter,
|
|
5
|
+
} from "@naxodev/apnea"
|
|
6
|
+
import {
|
|
7
|
+
isPiCmd,
|
|
8
|
+
materializePiRoleAgentDir,
|
|
9
|
+
wrapInteractiveCmdNoVim,
|
|
10
|
+
} from "./pi-role-agent.ts"
|
|
11
|
+
|
|
12
|
+
export const piHostAdapter: ApneaHostAdapter = {
|
|
13
|
+
materializeRoleAgentDir: materializePiRoleAgentDir,
|
|
14
|
+
prepareInteractiveCommand: wrapInteractiveCmdNoVim,
|
|
15
|
+
beforeInteractivePrompt: (command) =>
|
|
16
|
+
isPiCmd(command) ? "/vimmode off" : null,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const PI_OPERATIONS = createOperations(piHostAdapter)
|
|
20
|
+
export const executePiOperation = createExecutor(piHostAdapter)
|
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@naxodev/pi-apnea",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi adapter for the Apnea multi-role workflow",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Nacho Vazquez",
|
|
7
|
+
"funding": "https://github.com/sponsors/NachoVazquez",
|
|
8
|
+
"homepage": "https://github.com/naxodev/ai/tree/main/packages/pi-apnea#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/naxodev/ai.git",
|
|
12
|
+
"directory": "packages/pi-apnea"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/naxodev/ai/issues"
|
|
16
|
+
},
|
|
17
|
+
"type": "module",
|
|
18
|
+
"keywords": [
|
|
19
|
+
"pi-package",
|
|
20
|
+
"pi",
|
|
21
|
+
"apnea",
|
|
22
|
+
"herdr",
|
|
23
|
+
"workflow"
|
|
24
|
+
],
|
|
25
|
+
"pi": {
|
|
26
|
+
"skills": [
|
|
27
|
+
"./skills"
|
|
28
|
+
],
|
|
29
|
+
"prompts": [
|
|
30
|
+
"./prompts"
|
|
31
|
+
],
|
|
32
|
+
"extensions": [
|
|
33
|
+
"./extension"
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"LICENSE",
|
|
38
|
+
"README.md",
|
|
39
|
+
"extension",
|
|
40
|
+
"prompts",
|
|
41
|
+
"skills",
|
|
42
|
+
"!extension/**/*.test.ts"
|
|
43
|
+
],
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public",
|
|
46
|
+
"provenance": true
|
|
47
|
+
},
|
|
48
|
+
"packageManager": "bun@1.3.7",
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=22.19.0"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"check": "bun run typecheck && bun run test && bun run format:check && bun run pack:check && bun run smoke:package",
|
|
54
|
+
"typecheck": "tsc --noEmit",
|
|
55
|
+
"test": "bun test extension scripts",
|
|
56
|
+
"format": "prettier --write .",
|
|
57
|
+
"format:check": "prettier --check .",
|
|
58
|
+
"pack:check": "bun scripts/verify-pack.ts",
|
|
59
|
+
"prepublish:core": "bun scripts/check-core-published.ts",
|
|
60
|
+
"smoke:package": "bun scripts/package-smoke.ts"
|
|
61
|
+
},
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"@naxodev/apnea": "^0.1.0"
|
|
64
|
+
},
|
|
65
|
+
"peerDependencies": {
|
|
66
|
+
"@earendil-works/pi-coding-agent": ">=0.83.0 <0.85.0"
|
|
67
|
+
},
|
|
68
|
+
"devDependencies": {
|
|
69
|
+
"@earendil-works/pi-coding-agent": "0.84.0",
|
|
70
|
+
"@types/bun": "^1.3.14",
|
|
71
|
+
"@types/node": "^26.1.2",
|
|
72
|
+
"prettier": "^3.9.0",
|
|
73
|
+
"typescript": "^7.0.2"
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Initialize Apnea config (prefer /apnea setup)
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Prefer the slash command (no model needed):
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
/apnea setup
|
|
9
|
+
/apnea setup --project
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
If tools/commands are unavailable, run the apnea-setup skill steps:
|
|
13
|
+
|
|
14
|
+
1. Detect `pi`, `claude`, `codex`, `herdr`, `jj`, `git` on PATH.
|
|
15
|
+
2. Create or update `~/.config/apnea/config.json` with **profiles** only (no project cmds).
|
|
16
|
+
3. Optional: `.apnea/config.json` with role→profile names only.
|
|
17
|
+
4. Point at `@naxodev/apnea/docs/protocol/config.md`.
|
|
18
|
+
5. Note `pane_style` (`regular` default / `floating` opt-in, needs herdr ≥ 0.7.4) — setup never writes it.
|
|
19
|
+
|
|
20
|
+
Do not invent binaries. Do not write `cmd` into project config.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: apnea-orchestrator
|
|
3
|
+
description: Drive an Apnea run as hybrid orchestrator (schedule only). Use when starting or resuming a multi-role plan→review→code loop in Herdr.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# apnea-orchestrator
|
|
7
|
+
|
|
8
|
+
## Goal
|
|
9
|
+
|
|
10
|
+
Drive one **Run** to `pr-description.md` without writing product code.
|
|
11
|
+
|
|
12
|
+
## Critical: start is not the loop
|
|
13
|
+
|
|
14
|
+
`workflow_start` / `/apnea start` only writes `.apnea/state.json` with `step=planning`.
|
|
15
|
+
**It does not launch any role.** Stopping after start is a failed orchestration.
|
|
16
|
+
|
|
17
|
+
Immediately after a successful start:
|
|
18
|
+
|
|
19
|
+
1. `dispatch_role` with `kind: "plan"`
|
|
20
|
+
2. `workflow_wait`
|
|
21
|
+
3. Continue the loop until `done`
|
|
22
|
+
|
|
23
|
+
## When tools exist
|
|
24
|
+
|
|
25
|
+
Use only these; the CLI column is the same operation for a harness with no Apnea Pi plugin. See `@naxodev/apnea/docs/adr/0009-cli-driver-split.md`.
|
|
26
|
+
|
|
27
|
+
| Tool | CLI |
|
|
28
|
+
| ----------------------- | ----------------------- |
|
|
29
|
+
| `workflow_start` | `apnea start <goal>` |
|
|
30
|
+
| `dispatch_role` | `apnea dispatch <kind>` |
|
|
31
|
+
| `workflow_wait` | `apnea wait` |
|
|
32
|
+
| `workflow_commit_phase` | `apnea commit` |
|
|
33
|
+
| `workflow_status` | `apnea status` |
|
|
34
|
+
|
|
35
|
+
`apnea wait` returns exit `3` when its own budget runs out but the role still has time. That is not a failure. Call `apnea wait` again, as many times as it takes. The default budget is 90s, so the call returns before a typical 120s shell timeout kills it.
|
|
36
|
+
|
|
37
|
+
Do not pass `--budget` below 72s. A call must be long enough to contain the 60s idle nudge and the four polls that detect a dead harness; a shorter call is refused, with the required floor in the message. Raising `--poll` raises that floor, and if you omit `--budget` it is raised to match, so the call runs longer rather than failing. Keep `--poll` between 250ms and 26999ms, or state `--budget` yourself — above that the floor outgrows a typical shell timeout. Whether the role was already nudged, already took its one-time extension, and its deadline all persist across calls — only these two duration checks need one call to complete.
|
|
38
|
+
|
|
39
|
+
Never: `apnea reset-rounds` / `/apnea reset-rounds` (human only - it is not a model-facing tool; see `@naxodev/apnea/docs/adr/0002-orchestrator-authority.md`), never edit `.apnea/state.json` by hand, never implement product code.
|
|
40
|
+
|
|
41
|
+
### Loop (do not skip steps)
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
start
|
|
45
|
+
→ dispatch plan → wait
|
|
46
|
+
→ dispatch plan_review → wait
|
|
47
|
+
CHANGES_REQUIRED → dispatch plan (rework=true) → wait → plan_review …
|
|
48
|
+
APPROVED → dispatch phase_package → wait
|
|
49
|
+
→ dispatch code → wait
|
|
50
|
+
→ dispatch code_review → wait
|
|
51
|
+
CHANGES_REQUIRED → dispatch code (rework=true) → wait → code_review …
|
|
52
|
+
APPROVED → workflow_commit_phase
|
|
53
|
+
→ more phases? → phase_package …
|
|
54
|
+
→ else → dispatch pr_description → wait → done
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Follow `@naxodev/apnea/briefs/orchestrator.md` and `@naxodev/apnea/docs/protocol/overview.md`.
|
|
58
|
+
|
|
59
|
+
## When Pi tools are absent
|
|
60
|
+
|
|
61
|
+
Run the `apnea` CLI instead — same loop, same refusals (see the table above). Any shell that
|
|
62
|
+
can run `apnea` can hold the orchestrator seat.
|
|
63
|
+
|
|
64
|
+
## Paper mode (no shell at all)
|
|
65
|
+
|
|
66
|
+
If you cannot run shell commands either, you may still help the **human** orchestrate:
|
|
67
|
+
|
|
68
|
+
- Propose exact task file contents and pointer messages
|
|
69
|
+
- Name exact artifact paths for the current step
|
|
70
|
+
- Parse front-matter when asked
|
|
71
|
+
- Remind verify-before-commit
|
|
72
|
+
|
|
73
|
+
Do not pretend tools exist.
|
|
74
|
+
|
|
75
|
+
## Active recovery before escalate
|
|
76
|
+
|
|
77
|
+
Do **not** stop at the first timeout. Investigate and fix:
|
|
78
|
+
|
|
79
|
+
1. `herdr pane get` / `pane read` the pending role pane.
|
|
80
|
+
2. Prompt stuck in input → `send-keys Enter` or re-`pane run` the pointer.
|
|
81
|
+
3. Idle without artifact → nudge with exact artifact path.
|
|
82
|
+
4. Still working / API retry → `workflow_wait` again.
|
|
83
|
+
5. Pane dead → `dispatch_role` same kind (not rework).
|
|
84
|
+
|
|
85
|
+
Escalate only after two failed recovery attempts, or on round cap / dirty reviewer tree / illegal step / VCS confusion.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: apnea-setup
|
|
3
|
+
description: Create global Apnea profiles and optional project role bindings. Prefer /apnea setup when the extension is loaded. Use this skill when installing Apnea without commands, switching providers, or fixing config trust errors.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# apnea-setup
|
|
7
|
+
|
|
8
|
+
## Prefer
|
|
9
|
+
|
|
10
|
+
If the Apnea extension is loaded:
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
/apnea setup
|
|
14
|
+
/apnea setup --project # also write .apnea/config.json bindings
|
|
15
|
+
/apnea setup --force # replace global profiles instead of merge
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
That path is deterministic (no LLM). Use this skill only as a fallback.
|
|
19
|
+
|
|
20
|
+
## Goal
|
|
21
|
+
|
|
22
|
+
Leave the user with a valid **global** profile config and optional **project** role bindings. Never put binaries in project config.
|
|
23
|
+
|
|
24
|
+
## Steps (fallback)
|
|
25
|
+
|
|
26
|
+
1. Read `@naxodev/apnea/docs/protocol/config.md` if available.
|
|
27
|
+
2. Check PATH for: `pi`, `claude`, `codex`, `herdr`, `jj`, `git`.
|
|
28
|
+
3. Ensure `~/.config/apnea/` exists.
|
|
29
|
+
4. Write or merge `~/.config/apnea/config.json`:
|
|
30
|
+
- Define profiles only for binaries that exist.
|
|
31
|
+
- Include both `cmd_oneshot` and `cmd_interactive` where the harness supports them.
|
|
32
|
+
- Bind default roles: orchestrator+coder → pi profile; planner+reviewer → claude or codex if present, else pi.
|
|
33
|
+
- Preserve an existing `pane_style` (`regular`|`floating`) if present; **never write/flip `pane_style`** — it is user opt-in only.
|
|
34
|
+
5. When herdr is on PATH, provision the herdr `apnea` plugin: copy `herdr-plugin/` from the installed `@naxodev/apnea` package to `~/.config/apnea/herdr-plugin/` (refresh on every run) and, if herdr ≥ 0.7.4 and the plugin is not already linked, run `herdr plugin link ~/.config/apnea/herdr-plugin`. On herdr < 0.7.4, skip link and note the upgrade requirement.
|
|
35
|
+
6. Optionally write `.apnea/config.json` with **only** `{ "roles": { "...": { "profile": "..." } } }`.
|
|
36
|
+
7. Validate against trust rules: no project `cmd_*`, no unknown isolation modes.
|
|
37
|
+
8. Next: `/apnea start <goal>` inside Herdr.
|
|
38
|
+
|
|
39
|
+
## Refuse
|
|
40
|
+
|
|
41
|
+
- Writing `cmd`, `cmd_oneshot`, `cmd_interactive`, or `bin` under the project.
|