@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,44 @@
1
+ import { toolToVerb } from "../registry.ts"
2
+ import type { ToolResult } from "../result.ts"
3
+
4
+ /** 0 ready · 1 refusal/error · 2 usage · 3 wait budget spent, call again. */
5
+ export const EXIT_OK = 0
6
+ export const EXIT_ERROR = 1
7
+ export const EXIT_USAGE = 2
8
+ export const EXIT_PENDING = 3
9
+
10
+ export function exitCodeFor(r: ToolResult): number {
11
+ if (!r.ok) return EXIT_ERROR
12
+ return r.data?.pending === true ? EXIT_PENDING : EXIT_OK
13
+ }
14
+
15
+ /**
16
+ * Canonical tool name → the command a shell caller actually runs, or `null`
17
+ * when `tool` isn't a runnable command: either it's already a human-readable
18
+ * hint (contains whitespace, e.g. "dispatch_role with allowed kind") that
19
+ * should render unchanged, or it's a bug — a tool-name-shaped string with no
20
+ * CLI verb behind it (e.g. a tool that was deleted from `LEGAL_TOOLS` but
21
+ * left in some error's `legal_next`). Returning `null` for the latter instead
22
+ * of the raw string keeps a dangling tool name from silently reaching
23
+ * rendered output.
24
+ */
25
+ function asCommand(tool: string): string | null {
26
+ const verb = toolToVerb(tool)
27
+ if (verb) return `apnea ${verb}`
28
+ return /\s/.test(tool) ? tool : null
29
+ }
30
+
31
+ export function renderHuman(r: ToolResult): string {
32
+ const mapped = (r.legal_next ?? [])
33
+ .map(asCommand)
34
+ .filter((c): c is string => c !== null)
35
+ const next = mapped.length ? `\nnext: ${mapped.join(" | ")}` : ""
36
+ const extra = r.data ? `\n${JSON.stringify(r.data, null, 2)}` : ""
37
+ return r.ok
38
+ ? `OK: ${r.message}${next}${extra}`
39
+ : `ERROR: ${r.error}${next}${extra}`
40
+ }
41
+
42
+ export function renderJson(r: ToolResult): string {
43
+ return JSON.stringify(r)
44
+ }
@@ -0,0 +1,44 @@
1
+ export type HumanGateDeps = {
2
+ isTty: () => boolean
3
+ prompt: (question: string) => Promise<string>
4
+ }
5
+
6
+ export const prodHumanGateDeps: HumanGateDeps = {
7
+ isTty: () => Boolean(process.stdin.isTTY && process.stdout.isTTY),
8
+ prompt: async (question) => {
9
+ process.stdout.write(question)
10
+ for await (const line of console) return line.trim()
11
+ return ""
12
+ },
13
+ }
14
+
15
+ /**
16
+ * Human-only confirmation for cap reset. An agent shelling out has captured
17
+ * pipes, so the TTY check fails closed. `--i-am-human` remains available for
18
+ * scripts and remote shells: the property this buys is auditability — the
19
+ * bypass is named in the transcript — not prevention.
20
+ */
21
+ export async function confirmHuman(
22
+ gate: string,
23
+ deps: HumanGateDeps,
24
+ override: boolean,
25
+ ): Promise<{ ok: true } | { ok: false; reason: string }> {
26
+ if (override) return { ok: true }
27
+ if (!deps.isTty()) {
28
+ return {
29
+ ok: false,
30
+ reason:
31
+ "reset-rounds is human-only and stdin/stdout are not a terminal. " +
32
+ "Run it yourself in a shell, or pass --i-am-human to override.",
33
+ }
34
+ }
35
+ const answer = await deps.prompt(
36
+ `Type the gate key to confirm reset (${gate}): `,
37
+ )
38
+ return answer === gate
39
+ ? { ok: true }
40
+ : {
41
+ ok: false,
42
+ reason: `confirmation did not match "${gate}"; nothing reset`,
43
+ }
44
+ }
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env bun
2
+ import { parseFlags, parseNumFlag } from "./parse.ts"
3
+ import {
4
+ EXIT_ERROR,
5
+ EXIT_USAGE,
6
+ exitCodeFor,
7
+ renderHuman,
8
+ renderJson,
9
+ } from "./format.ts"
10
+ import { confirmHuman, prodHumanGateDeps } from "./human-gate.ts"
11
+ import { OPERATIONS, executeOperation, findByVerb } from "../registry.ts"
12
+ import { DISPATCH_KINDS } from "../domain/state-machine.ts"
13
+ import type { ToolResult } from "../result.ts"
14
+
15
+ function usage(): string {
16
+ return [
17
+ "apnea — multi-role workflow driver",
18
+ "",
19
+ "Usage: apnea <command> [args] [--json]",
20
+ "",
21
+ ...OPERATIONS.map(
22
+ (o) => ` ${`${o.verb} ${o.usage ?? ""}`.trim().padEnd(38)} ${o.summary}`,
23
+ ),
24
+ " resume | abandon actions on an existing run",
25
+ "",
26
+ "apnea reset-rounds also accepts [--i-am-human] (CLI only — skips the TTY",
27
+ "confirmation prompt that only this surface has; see README.md).",
28
+ "",
29
+ `dispatch kinds: ${DISPATCH_KINDS.join(" | ")}`,
30
+ "",
31
+ "Exit codes: 0 ok · 1 refused/error · 2 usage · 3 still waiting (call again)",
32
+ ].join("\n")
33
+ }
34
+
35
+ /**
36
+ * Renders a usage failure through the same `renderJson`/`renderHuman` split
37
+ * as every other exit path, so a `--json` caller gets parseable output on
38
+ * exit 2 too instead of a plain-text message that breaks its parser. The
39
+ * full command listing is only useful to a human reading a terminal, so it's
40
+ * appended after the rendered error and only outside `--json` mode.
41
+ */
42
+ function printUsageError(message: string, json: boolean): void {
43
+ const result: ToolResult = { ok: false, error: message }
44
+ console.error(
45
+ json ? renderJson(result) : `${renderHuman(result)}\n\n${usage()}`,
46
+ )
47
+ }
48
+
49
+ export async function main(argv: string[]): Promise<number> {
50
+ const [verbRaw, ...rest] = argv
51
+ const { flags, values, rest: positional } = parseFlags(rest)
52
+ const json = flags.has("json")
53
+
54
+ if (!verbRaw) {
55
+ printUsageError("usage: apnea <command> [args] [--json]", json)
56
+ return EXIT_USAGE
57
+ }
58
+ if (verbRaw === "help" || verbRaw === "--help") {
59
+ console.log(usage())
60
+ return 0
61
+ }
62
+
63
+ // `resume` and `abandon` are actions on the start operation.
64
+ const isAction = verbRaw === "resume" || verbRaw === "abandon"
65
+ const op = findByVerb(isAction ? "start" : verbRaw)
66
+ if (!op) {
67
+ printUsageError(`unknown command: ${verbRaw}`, json)
68
+ return EXIT_USAGE
69
+ }
70
+
71
+ const built = buildParams(
72
+ op.verb,
73
+ isAction ? verbRaw : null,
74
+ flags,
75
+ values,
76
+ positional,
77
+ )
78
+ if (!built.ok) {
79
+ printUsageError(
80
+ built.message ?? `usage: apnea ${op.verb} ${op.usage}`.trim(),
81
+ json,
82
+ )
83
+ return EXIT_USAGE
84
+ }
85
+
86
+ if (op.humanOnly) {
87
+ const gate = String(built.params.gate ?? "")
88
+ const confirmed = await confirmHuman(
89
+ gate,
90
+ prodHumanGateDeps,
91
+ flags.has("i-am-human"),
92
+ )
93
+ if (!confirmed.ok) {
94
+ const result: ToolResult = { ok: false, error: confirmed.reason }
95
+ console.error(json ? renderJson(result) : renderHuman(result))
96
+ return EXIT_ERROR
97
+ }
98
+ }
99
+
100
+ const result = await executeOperation(op.verb, built.params)
101
+ const text = json ? renderJson(result) : renderHuman(result)
102
+ if (result.ok) console.log(text)
103
+ else console.error(text)
104
+ return exitCodeFor(result)
105
+ }
106
+
107
+ export type BuildParamsResult =
108
+ | { ok: true; params: Record<string, unknown> }
109
+ | { ok: false; message?: string }
110
+
111
+ export function buildParams(
112
+ verb: string,
113
+ action: string | null,
114
+ flags: Set<string>,
115
+ values: Map<string, string>,
116
+ positional: string[],
117
+ ): BuildParamsResult {
118
+ switch (verb) {
119
+ case "start": {
120
+ if (action) return { ok: true, params: { action } }
121
+ const goal = positional.join(" ").trim()
122
+ if (!goal) {
123
+ return {
124
+ ok: false,
125
+ message: "usage: apnea start <goal> [--allow-dirty] [--slug=name]",
126
+ }
127
+ }
128
+ return {
129
+ ok: true,
130
+ params: {
131
+ action: "start",
132
+ goal,
133
+ slug: values.get("slug"),
134
+ allow_dirty: flags.has("allow-dirty") || undefined,
135
+ },
136
+ }
137
+ }
138
+ case "dispatch": {
139
+ const kind = positional[0]
140
+ if (!kind || !DISPATCH_KINDS.includes(kind as never)) {
141
+ return {
142
+ ok: false,
143
+ message: `usage: apnea dispatch <${DISPATCH_KINDS.join("|")}> [--rework]`,
144
+ }
145
+ }
146
+ return {
147
+ ok: true,
148
+ params: { kind, rework: flags.has("rework") || undefined },
149
+ }
150
+ }
151
+ case "wait": {
152
+ // `--timeout` and `--budget` are the same knob: how long THIS call
153
+ // blocks. The role's deadline comes from config, stamped at dispatch.
154
+ const poll = parseNumFlag(values, "poll")
155
+ if (!poll.ok) {
156
+ return {
157
+ ok: false,
158
+ message: `usage: apnea wait [--poll=<ms>] (got --poll=${poll.raw})`,
159
+ }
160
+ }
161
+ const budget = parseNumFlag(values, "budget")
162
+ if (!budget.ok) {
163
+ return {
164
+ ok: false,
165
+ message: `usage: apnea wait [--budget=<ms>] (got --budget=${budget.raw})`,
166
+ }
167
+ }
168
+ const timeout = parseNumFlag(values, "timeout")
169
+ if (!timeout.ok) {
170
+ return {
171
+ ok: false,
172
+ message: `usage: apnea wait [--timeout=<ms>] (got --timeout=${timeout.raw})`,
173
+ }
174
+ }
175
+ return {
176
+ ok: true,
177
+ params: {
178
+ poll_ms: poll.value,
179
+ budget_ms: budget.value ?? timeout.value,
180
+ },
181
+ }
182
+ }
183
+ case "commit": {
184
+ const message = positional.join(" ").trim()
185
+ return {
186
+ ok: true,
187
+ params: {
188
+ message: message || undefined,
189
+ no_remaining_phases: flags.has("done") || undefined,
190
+ },
191
+ }
192
+ }
193
+ case "status":
194
+ return { ok: true, params: {} }
195
+ case "reset-rounds": {
196
+ const gate = positional[0]
197
+ if (!gate) {
198
+ return { ok: false, message: "usage: apnea reset-rounds <gate>" }
199
+ }
200
+ return { ok: true, params: { gate } }
201
+ }
202
+ case "setup":
203
+ return {
204
+ ok: true,
205
+ params: {
206
+ project: flags.has("project") || undefined,
207
+ force: flags.has("force") || undefined,
208
+ agents_md: flags.has("agents-md") || undefined,
209
+ },
210
+ }
211
+ default:
212
+ return { ok: false }
213
+ }
214
+ }
215
+
216
+ if (import.meta.main) {
217
+ process.exit(await main(process.argv.slice(2)))
218
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Split `--bare` switches from `--key=value` options; everything else is
3
+ * positional. `values` matters: `rest` never sees a `--`-prefixed token, so
4
+ * `--key=value` options are only reachable through the map.
5
+ */
6
+ export function parseFlags(tokens: string[]): {
7
+ flags: Set<string>
8
+ values: Map<string, string>
9
+ rest: string[]
10
+ } {
11
+ const flags = new Set<string>()
12
+ const values = new Map<string, string>()
13
+ const rest: string[] = []
14
+ for (const t of tokens) {
15
+ if (!t.startsWith("--")) {
16
+ rest.push(t)
17
+ continue
18
+ }
19
+ const body = t.slice(2)
20
+ const eq = body.indexOf("=")
21
+ if (eq > 0) values.set(body.slice(0, eq), body.slice(eq + 1))
22
+ else flags.add(body)
23
+ }
24
+ return { flags, values, rest }
25
+ }
26
+
27
+ /** Reading a `--key=value` numeric flag either yields the parsed number (or
28
+ * `undefined` when the caller didn't pass it) or the raw token that failed
29
+ * to parse, so the caller can name exactly what it received. */
30
+ export type NumFlag =
31
+ { ok: true; value: number | undefined } | { ok: false; raw: string }
32
+
33
+ /**
34
+ * Shared by `/apnea` and the CLI so a mistyped `--budget=abc` is refused the
35
+ * same way on both surfaces instead of silently falling back to a default —
36
+ * a scripting agent needs a signal, not a quietly-wrong value. `--key=`
37
+ * (empty string) counts as "not provided": a caller who writes it almost
38
+ * certainly forgot the value, not asked for zero.
39
+ */
40
+ export function parseNumFlag(
41
+ values: Map<string, string>,
42
+ key: string,
43
+ ): NumFlag {
44
+ const raw = values.get(key)
45
+ if (raw === undefined || raw === "") return { ok: true, value: undefined }
46
+ const n = Number(raw)
47
+ return Number.isFinite(n) ? { ok: true, value: n } : { ok: false, raw }
48
+ }
@@ -0,0 +1,26 @@
1
+ import { Result } from "effect"
2
+ import { ArtifactInvalid } from "../errors.ts"
3
+ import type { DispatchKind } from "./state-machine.ts"
4
+
5
+ /** Infer the dispatch kind an artifact path corresponds to. Ordering matters:
6
+ * plan-review must be checked before the bare plan.md suffix check. */
7
+ export function inferKind(
8
+ artifactRel: string,
9
+ ): Result.Result<DispatchKind, ArtifactInvalid> {
10
+ if (artifactRel.endsWith("plan.md") && !artifactRel.includes("plan-review"))
11
+ return Result.succeed("plan")
12
+ if (artifactRel.includes("plan-review")) return Result.succeed("plan_review")
13
+ if (artifactRel.endsWith("phase-package.md"))
14
+ return Result.succeed("phase_package")
15
+ if (artifactRel.endsWith("coder-result.md")) return Result.succeed("code")
16
+ if (artifactRel.endsWith("code-review.md"))
17
+ return Result.succeed("code_review")
18
+ if (artifactRel.endsWith("pr-description.md"))
19
+ return Result.succeed("pr_description")
20
+ return Result.fail(
21
+ new ArtifactInvalid({
22
+ artifact: artifactRel,
23
+ message: `cannot infer dispatch kind from ${artifactRel}`,
24
+ }),
25
+ )
26
+ }
@@ -0,0 +1,69 @@
1
+ import type { FrontMatter, Verdict } from "./types.ts"
2
+
3
+ const FM_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
4
+
5
+ export function parseFrontMatter(text: string): FrontMatter | null {
6
+ const m = text.match(FM_RE)
7
+ if (!m) return null
8
+ const raw = m[1] ?? ""
9
+ const body = m[2] ?? ""
10
+ const fields: Record<string, string> = {}
11
+ let currentKey: string | null = null
12
+ let currentVal: string[] = []
13
+
14
+ const flush = () => {
15
+ if (currentKey) {
16
+ fields[currentKey] = currentVal
17
+ .join("\n")
18
+ .replace(/^\s*\|\s*\n?/, "")
19
+ .trimEnd()
20
+ }
21
+ currentKey = null
22
+ currentVal = []
23
+ }
24
+
25
+ for (const line of raw.split(/\r?\n/)) {
26
+ const kv = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/)
27
+ if (kv) {
28
+ flush()
29
+ currentKey = kv[1]!
30
+ const rest = kv[2] ?? ""
31
+ if (rest === "|" || rest === ">") {
32
+ currentVal = []
33
+ } else {
34
+ currentVal = [rest]
35
+ }
36
+ } else if (
37
+ currentKey &&
38
+ (line.startsWith(" ") || line.startsWith("\t") || line === "")
39
+ ) {
40
+ currentVal.push(line.replace(/^\s{2}/, ""))
41
+ }
42
+ }
43
+ flush()
44
+
45
+ return {
46
+ status: fields.status,
47
+ verdict: fields.verdict,
48
+ nits: fields.nits,
49
+ rework: fields.rework,
50
+ raw,
51
+ body,
52
+ }
53
+ }
54
+
55
+ export function isCompleteArtifact(
56
+ fm: FrontMatter | null,
57
+ opts: { requireVerdict?: boolean } = {},
58
+ ): boolean {
59
+ if (!fm || fm.status !== "done") return false
60
+ if (opts.requireVerdict) {
61
+ return fm.verdict === "APPROVED" || fm.verdict === "CHANGES_REQUIRED"
62
+ }
63
+ return true
64
+ }
65
+
66
+ export function asVerdict(v: string | undefined): Verdict | null {
67
+ if (v === "APPROVED" || v === "CHANGES_REQUIRED") return v
68
+ return null
69
+ }
@@ -0,0 +1,109 @@
1
+ import type { PaneStyle, Role } from "./types.ts"
2
+
3
+ /** Parse `herdr X.Y.Z` (or noisy multi-line) into a numeric tuple. */
4
+ export function parseHerdrVersion(
5
+ raw: string,
6
+ ): [number, number, number] | null {
7
+ const m = raw.match(/(\d+)\.(\d+)\.(\d+)/)
8
+ if (!m) return null
9
+ return [Number(m[1]), Number(m[2]), Number(m[3])]
10
+ }
11
+
12
+ export function versionGte(
13
+ a: [number, number, number],
14
+ b: [number, number, number],
15
+ ): boolean {
16
+ const [a0, a1, a2] = a
17
+ const [b0, b1, b2] = b
18
+ if (a0 !== b0) return a0 > b0
19
+ if (a1 !== b1) return a1 > b1
20
+ return a2 >= b2
21
+ }
22
+
23
+ /**
24
+ * Floating popups need herdr ≥ 0.7.4. Fail closed on unparseable versions so an
25
+ * unattended run never hangs on a CLI that rejects `--placement popup`.
26
+ */
27
+ export function supportsFloating(
28
+ version: [number, number, number] | null,
29
+ ): boolean {
30
+ return version != null && versionGte(version, [0, 7, 4])
31
+ }
32
+
33
+ /**
34
+ * Configured style vs effective style. Floating is only for planner/reviewer
35
+ * (oneshot-eligible artifact producers); interactive roles always stay regular.
36
+ */
37
+ export function effectivePaneStyle(
38
+ configured: PaneStyle,
39
+ role: Role,
40
+ ): { style: PaneStyle; effective: string } {
41
+ if (configured === "regular") {
42
+ return { style: "regular", effective: "regular" }
43
+ }
44
+ if (role === "planner" || role === "reviewer") {
45
+ return { style: "floating", effective: "floating" }
46
+ }
47
+ return { style: "regular", effective: "regular (interactive role)" }
48
+ }
49
+
50
+ export function shellJoin(parts: string[]): string {
51
+ return parts
52
+ .map((p) => {
53
+ if (p === "&&" || p === "|" || p === "exec" || p === "env") return p
54
+ if (/^[A-Za-z0-9_./:=,@+-]+$/.test(p)) return p
55
+ return `'${p.replace(/'/g, `'\\''`)}'`
56
+ })
57
+ .join(" ")
58
+ }
59
+
60
+ /** True when every foreground process name looks like a bare shell prompt. */
61
+ export function looksLikeShellOnly(names: string[]): boolean {
62
+ if (names.length === 0) return false
63
+ return names.every((n) => {
64
+ const t = n.trim()
65
+ return (
66
+ /^(zsh|bash|sh|fish)$/i.test(t) ||
67
+ t === "-zsh" ||
68
+ t === "-bash" ||
69
+ t === "-sh"
70
+ )
71
+ })
72
+ }
73
+
74
+ /** Parse a floating task's exit-file contents; null if not a finished exit code. */
75
+ export function parseFloatingExit(text: string): number | null {
76
+ const t = text.trim()
77
+ const n = Number.parseInt(t, 10)
78
+ return Number.isFinite(n) ? n : null
79
+ }
80
+
81
+ /**
82
+ * Self-contained bash script body that cds to root, runs cmd + prompt as a
83
+ * child (not exec — so EXIT trap still fires), and always records the exit
84
+ * code for workflow_wait. Popups have no pane id; the exit file is the
85
+ * liveness signal.
86
+ */
87
+ export function floatingTaskScriptBody(opts: {
88
+ root: string
89
+ resolvedCmd: string[]
90
+ prompt: string
91
+ exitFileAbs: string
92
+ }): string {
93
+ return [
94
+ "#!/bin/bash",
95
+ "set -uo pipefail",
96
+ `EXIT_FILE=${shellJoin([opts.exitFileAbs])}`,
97
+ "write_exit() {",
98
+ " local st=$?",
99
+ ` printf '%s\n' "$st" > "$EXIT_FILE" 2>/dev/null || true`,
100
+ "}",
101
+ "trap write_exit EXIT",
102
+ "trap 'exit 129' HUP",
103
+ "trap 'exit 130' INT",
104
+ "trap 'exit 143' TERM",
105
+ shellJoin(["cd", opts.root]),
106
+ shellJoin([...opts.resolvedCmd, "--", opts.prompt]),
107
+ "",
108
+ ].join("\n")
109
+ }
@@ -0,0 +1,139 @@
1
+ import * as fs from "node:fs"
2
+ import * as os from "node:os"
3
+ import * as path from "node:path"
4
+ import { fileURLToPath } from "node:url"
5
+
6
+ export const APNEA_DIR = ".apnea"
7
+
8
+ export function cwd(): string {
9
+ return process.cwd()
10
+ }
11
+
12
+ export function apneaRoot(root = cwd()): string {
13
+ return path.join(root, APNEA_DIR)
14
+ }
15
+
16
+ export function statePath(root = cwd()): string {
17
+ return path.join(apneaRoot(root), "state.json")
18
+ }
19
+
20
+ export function projectConfigPath(root = cwd()): string {
21
+ return path.join(apneaRoot(root), "config.json")
22
+ }
23
+
24
+ function homedir(): string {
25
+ // Env first so an overridden HOME still wins (Bun's os.homedir() reads the
26
+ // passwd entry and ignores $HOME), then the passwd entry as the floor.
27
+ // Never "": an empty home makes globalConfigPath() cwd-relative, i.e. the
28
+ // *project* repo would be read as the trusted global config — the one place
29
+ // profiles/cmd_interactive are honoured. node:os is a pure read here.
30
+ return process.env.HOME || process.env.USERPROFILE || os.homedir()
31
+ }
32
+
33
+ export function globalConfigPath(): string {
34
+ return path.join(homedir(), ".config", "apnea", "config.json")
35
+ }
36
+
37
+ export function artifactsDir(root = cwd()): string {
38
+ return path.join(apneaRoot(root), "artifacts")
39
+ }
40
+
41
+ export function tasksDir(root = cwd()): string {
42
+ return path.join(apneaRoot(root), "tasks")
43
+ }
44
+
45
+ export function phaseDir(
46
+ phaseIndex: number,
47
+ round: number,
48
+ root = cwd(),
49
+ ): string {
50
+ const n = String(phaseIndex).padStart(2, "0")
51
+ return path.join(artifactsDir(root), `phase-${n}`, `round-${round}`)
52
+ }
53
+
54
+ export function planPath(root = cwd()): string {
55
+ return path.join(artifactsDir(root), "plan.md")
56
+ }
57
+
58
+ export function planReviewPath(round: number, root = cwd()): string {
59
+ return path.join(artifactsDir(root), "plan-review", `round-${round}.md`)
60
+ }
61
+
62
+ export function prDescriptionPath(root = cwd()): string {
63
+ return path.join(artifactsDir(root), "pr-description.md")
64
+ }
65
+
66
+ export function rel(p: string, root = cwd()): string {
67
+ return path.relative(root, p) || p
68
+ }
69
+
70
+ export function abs(p: string, root = cwd()): string {
71
+ return path.isAbsolute(p) ? p : path.join(root, p)
72
+ }
73
+
74
+ export const PACKAGE_NAME = "@naxodev/apnea"
75
+
76
+ /**
77
+ * The package-root search, with the starting directory as a parameter.
78
+ *
79
+ * Returns `null` when it cannot find us. An earlier version guessed two levels
80
+ * up instead — the exact wrong answer for the bundled and installed layouts
81
+ * this function exists to handle, handed back as if it were a real result.
82
+ * Callers can now tell "not found" from "found", and `dispatch` turns it into
83
+ * a refusal naming the paths it tried.
84
+ *
85
+ * Split out from `packageRoot()` so a test can supply the layout rather than
86
+ * inheriting whichever one the test runner happens to use. Testing
87
+ * `packageRoot()` directly only ever exercises the source tree, where the old
88
+ * two-levels-up rule was also correct — a test written that way passes against
89
+ * the bug.
90
+ */
91
+ export function findPackageRootFrom(startDir: string): string | null {
92
+ let dir = startDir
93
+ for (let up = 0; up < 10; up++) {
94
+ try {
95
+ const manifest = fs.readFileSync(path.join(dir, "package.json"), "utf8")
96
+ if ((JSON.parse(manifest) as { name?: string }).name === PACKAGE_NAME) {
97
+ return dir
98
+ }
99
+ } catch {
100
+ // No package.json here, or it is not readable JSON. Keep walking.
101
+ }
102
+ const parent = path.dirname(dir)
103
+ if (parent === dir) break
104
+ dir = parent
105
+ }
106
+ return null
107
+ }
108
+
109
+ /**
110
+ * Package root — the directory holding `briefs/`, `herdr-plugin/`, and our
111
+ * own `package.json`.
112
+ *
113
+ * Found by walking up and reading each `package.json`, NOT by counting
114
+ * directory levels. The level count differs for every way this code runs, and
115
+ * a wrong root is silent: `dispatch` still launches a pane, and the role sits
116
+ * there having been told to read a brief that does not exist.
117
+ *
118
+ * from source extension/domain/paths.ts -> 2 up
119
+ * from bundle dist/cli.js -> 1 up
120
+ * from npm node_modules/@naxodev/apnea/dist/cli.js -> 1 up
121
+ *
122
+ * The old two-up rule was right only for source. `bun build` emits one file at
123
+ * `dist/cli.js`, so the CLI resolved the repo's PARENT and every brief path it
124
+ * printed was wrong — found by running the CLI against this repo, not by any
125
+ * test, because the tests fake this function and the Pi extension happens to
126
+ * load from source.
127
+ *
128
+ * Matching on the package NAME rather than on the presence of `package.json`
129
+ * matters once this is installed as a dependency: the first `package.json`
130
+ * above `node_modules/...` belongs to the consumer, not to us.
131
+ *
132
+ * The source-layout guess remains only as a last resort for a fork or rename,
133
+ * where the name will never match. `dispatch` checks the brief exists before
134
+ * launching, so a wrong guess surfaces as a refusal rather than a stalled role.
135
+ */
136
+ export function packageRoot(): string {
137
+ const here = path.dirname(fileURLToPath(import.meta.url))
138
+ return findPackageRootFrom(here) ?? path.resolve(here, "..", "..")
139
+ }