@naxodev/apnea 0.2.0 → 0.2.2

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 (44) hide show
  1. package/README.md +18 -1
  2. package/SECURITY.md +36 -0
  3. package/briefs/orchestrator.md +4 -3
  4. package/dist/cli.js +8375 -15093
  5. package/docs/protocol/artifacts.md +18 -2
  6. package/docs/protocol/config.md +15 -3
  7. package/docs/protocol/manual-gate.md +8 -8
  8. package/docs/protocol/overview.md +17 -4
  9. package/extension/adapters/commit.ts +5 -1
  10. package/extension/adapters/dispatch.ts +9 -1
  11. package/extension/adapters/setup.ts +15 -1
  12. package/extension/adapters/start.ts +5 -1
  13. package/extension/adapters/status.ts +17 -2
  14. package/extension/adapters/wait.ts +6 -1
  15. package/extension/api.ts +7 -1
  16. package/extension/cli/main.ts +67 -7
  17. package/extension/cli/parse.ts +172 -5
  18. package/extension/domain/paths.ts +2 -11
  19. package/extension/domain/timeouts.ts +4 -0
  20. package/extension/domain/types.ts +65 -3
  21. package/extension/errors.ts +51 -16
  22. package/extension/operation-hooks.ts +6 -0
  23. package/extension/registry.ts +29 -15
  24. package/extension/run-tool.ts +19 -2
  25. package/extension/schema/config.ts +58 -16
  26. package/extension/schema/frontmatter.ts +57 -0
  27. package/extension/schema/state.ts +210 -13
  28. package/extension/services/app-live.ts +2 -1
  29. package/extension/services/config.ts +6 -4
  30. package/extension/services/file-system.ts +346 -75
  31. package/extension/services/herdr.ts +466 -260
  32. package/extension/services/operation-lock.ts +452 -0
  33. package/extension/services/process.ts +477 -0
  34. package/extension/services/run-store.ts +38 -16
  35. package/extension/services/vcs.ts +1258 -328
  36. package/extension/workflows/commit.ts +214 -13
  37. package/extension/workflows/dispatch.ts +305 -67
  38. package/extension/workflows/setup.ts +59 -32
  39. package/extension/workflows/start.ts +6 -4
  40. package/extension/workflows/status.ts +2 -2
  41. package/extension/workflows/wait.ts +62 -77
  42. package/package.json +2 -2
  43. package/schemas/config.schema.json +5 -1
  44. package/schemas/state.schema.json +165 -11
@@ -5,7 +5,7 @@
5
5
  ```text
6
6
  .apnea/ # runtime; ignore in VCS
7
7
  config.json # optional project role→profile bindings only
8
- state.json # step, phase index, rounds per (phase, gate), slug
8
+ state.json # step, phase index, rounds, required rework target, slug
9
9
  tasks/
10
10
  <dispatch-id>.md # human/agent-readable task payload
11
11
  artifacts/
@@ -25,6 +25,10 @@
25
25
 
26
26
  Package-owned briefs live in the installed package (`briefs/`), not under `.apnea/`.
27
27
 
28
+ Persisted artifact references in `state.json` must be repository-relative paths below `.apnea/`.
29
+ Absolute paths and paths containing NULs, empty, `.`, `..`, or backslash components are corrupt
30
+ state. State and artifacts must be regular UTF-8 files no larger than 1 MiB.
31
+
28
32
  ## Front-matter schema
29
33
 
30
34
  Required on every role-produced artifact:
@@ -61,7 +65,19 @@ For a given target path, `dispatch_role` deletes or renames away any existing fi
61
65
 
62
66
  On rework after CHANGES_REQUIRED, the **round number increases** and the path changes (`round-2/...`), so history is preserved.
63
67
 
64
- On crash re-dispatch (same round), the same path is cleared and reused.
68
+ On crash redelivery, `dispatch_role` requires `redeliver=true`, matching pending ownership, and proof that any recorded pane is dead. It clears and reuses the same path without advancing the round. Manual/no-Herdr ownership requires an explicit operator request because no pane can provide liveness evidence.
69
+
70
+ `state.json.pending_delivery` records whether pending ownership crossed a `manual` or `interactive` delivery boundary. Dispatch persists `interactive` before calling `runInteractivePrompt`. The Herdr adapter then requires a successful ownership save with the acquired pane identity before starting the harness or sending the task prompt. Interruption retains that ownership for recovery. Submission and acceptance-query failures preserve the task and pending Artifact because delivery may have occurred. Proven pre-delivery failures restore the prior state. Legacy or interrupted acquisition can still leave a null pane identity; redelivery refuses that ambiguous state. Completion clears the mode with every other pending field, and rollback restores the prior mode.
71
+
72
+ Version-1 state lacks this mode. A recorded pending pane safely migrates to `interactive`; a null pane cannot distinguish manual work from an accepted interactive prompt whose final save failed. It migrates to null and redelivery fails closed instead of guessing.
73
+
74
+ Before checking pane liveness or renaming the artifact, redelivery reads the pending path and applies the complete `workflow_wait` acceptance boundary: completeness, rework legality, frontmatter schema decoding, and state transition. An accepted artifact refuses redelivery with an explicit `workflow_wait` next step and remains unchanged. Review `status: done` without a valid verdict is incomplete. Illegal or unknown rework metadata is malformed. Both proceed to normal redelivery validation.
75
+
76
+ ## Persisted rework ownership
77
+
78
+ After a review rejects work, `state.json.required_rework` names the exact next target: `plan`, `code`, or `phase_package`. Dispatch consumes this marker only when it saves pending ownership. Refusals and launch rollback preserve it. A recovered same-round dispatch sees a cleared marker and does not advance the round again.
79
+
80
+ Version-1 state without this field migrates package rework only from `phase_package_rework: true`. Planning state and coding state with `current_code_review` are ambiguous: either may be initial work after a completed prior stage. Only an explicit, matching deprecated assertion (`kind=plan` or `kind=code` with `rework=true`) converts that legacy ambiguity into one authoritative rework dispatch. Omitted assertions remain ordinary work. A matching coder dispatch already pending is not ambiguous and never re-arms code rework.
65
81
 
66
82
  ## Human markers (optional)
67
83
 
@@ -9,6 +9,10 @@
9
9
 
10
10
  Unknown keys and unimplemented values (`isolation: "worktree"`) **hard-error** at start.
11
11
 
12
+ `review_round_cap` is a safe integer from 1 through 20. Every `timeouts_ms` value is a safe
13
+ integer from 1000 through 9007199254740991. For 0.2 compatibility, a numeric value outside these
14
+ ranges falls back per field instead of rejecting the whole config. Wrong types still hard-error.
15
+
12
16
  ## Global profiles
13
17
 
14
18
  ```jsonc
@@ -113,9 +117,12 @@ that gets killed. Passing `--budget` explicitly still works at any poll: it is a
113
117
  statement that your shell allows a longer call. An explicit budget under the floor is still
114
118
  refused.
115
119
 
116
- `poll_ms` must be at least 250ms. Every poll spawns two herdr subprocesses, so a smaller
117
- interval is a busy-spin rather than a faster wait. `budget_ms` must be finite. The sleep between
118
- polls is clamped to the remaining budget, so a large `poll_ms` cannot make a call outlive it.
120
+ `poll_ms` must be a safe integer of at least 250ms. Every poll spawns two herdr subprocesses, so a
121
+ smaller interval is a busy-spin rather than a faster wait. `budget_ms` must be a positive safe
122
+ integer. Human flags use JavaScript numeric syntax: hexadecimal and scientific forms are accepted
123
+ when they evaluate to an exact safe integer, while fractional values are refused. The sleep
124
+ between polls is clamped to the remaining budget, so a large `poll_ms` cannot make a call outlive
125
+ it.
119
126
 
120
127
  The idle nudge and the final grace are independent rungs. A role nudged early for going idle
121
128
  still receives its 180000ms grace at the deadline; it just is not prompted twice.
@@ -169,3 +176,8 @@ Project entries that include `cmd`, `cmd_oneshot`, `cmd_interactive`, or `bin`
169
176
  - Optionally write project role bindings
170
177
  - Never write `cmd` into project config
171
178
  - Point at the manual gate before claiming readiness
179
+
180
+ Existing global config is merged only after bounded JSON parsing. Malformed global JSON is left
181
+ unchanged unless the human passes `--force`; forced replacement is atomic and reported. Existing
182
+ project config is validated before setup overwrites role bindings and malformed content fails
183
+ closed.
@@ -17,14 +17,14 @@ Two phases, e.g. “CLI that greets by name + `--json` flag,” small enough to
17
17
 
18
18
  ## Required paths (must all pass)
19
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 interactive; coder Pi interactive |
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 : dispatch same kind with `redeliver=true`); continue to success |
27
+ | 6 | Mixed harness | Planner/reviewer Claude interactive; coder Pi interactive |
28
28
 
29
29
  ## Hard rules during the gate
30
30
 
@@ -33,7 +33,7 @@ Illegal tool calls refuse with the legal next call named in the error.
33
33
  | Tool | Purpose |
34
34
  | ----------------------- | ------------------------------------------------------------------------------------------------------------------------- |
35
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 |
36
+ | `dispatch_role` | Write task file; claim pending ownership; consume persisted rework; open live harness TUI and submit a short pointer |
37
37
  | `workflow_wait` | Wait for artifact front-matter; treat agent-status as liveness only |
38
38
  | `workflow_commit_phase` | Require APPROVED + verify commands (log to `verify.log`) + VCS backend; advance phase |
39
39
  | `workflow_status` | **Read-only** snapshot |
@@ -73,20 +73,33 @@ Pane markers are human decoration. Herdr `agent_status` is liveness (dead pane w
73
73
 
74
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
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.
76
+ - `workflow_wait` records the exact required target (`plan`, `code`, or `phase_package`) after `CHANGES_REQUIRED`.
77
+ - `dispatch_role` derives round advancement only from that persisted target and consumes it when pending ownership is saved.
78
+ - The deprecated `rework` dispatch parameter remains an assertion through 0.2.x. It grants authority only when migrating ambiguous version-1 planning or coding state.
79
+ - Round increments **only** when the required target is dispatched on the same (phase, gate).
80
+ - Crash / timeout / resume: after proving the prior pane is dead, explicitly dispatch the same kind with `redeliver=true`. It validates pending kind, role, phase, and round, then clears the same artifact without advancing the round. A live or ambiguous pane refuses. Manual/no-Herdr work has no pane proof, so the operator must explicitly request redelivery.
81
+ - Pending ownership records `pending_delivery: manual | interactive`. The prepared save writes this before crossing `runInteractivePrompt`. The Herdr adapter saves the acquired pane identity before starting the harness or sending the task prompt. Interruption and uncertain delivery preserve ownership for recovery. Interactive null-pane ownership still refuses redelivery as ambiguous. Version-1 ownership without this field migrates to interactive only when a pane id is already recorded; legacy null-pane ownership remains ambiguous and refuses.
82
+ - Before liveness checks or clear-before-dispatch, redelivery parses the pending artifact with the same acceptance rule as `workflow_wait`. `status: done` completes non-review artifacts; review artifacts also require `verdict: APPROVED | CHANGES_REQUIRED`, legal rework placement, schema-valid rework values, and a valid state transition. Accepted artifacts remain byte-identical and refuse with guidance to call `workflow_wait`. Malformed or incomplete artifacts continue to liveness validation.
78
83
 
79
84
  ## Resume
80
85
 
81
86
  1. Re-resolve panes by role label (respawn if needed).
82
87
  2. If expected artifact has valid front-matter → ingest and advance.
83
- 3. Else report state; **offer** re-dispatch — never auto-dispatch.
88
+ 3. Else report state; **offer** explicit `redeliver=true` after liveness checks — never auto-dispatch.
84
89
  4. Skip clean-tree check on resume.
85
90
 
86
91
  ## Commit
87
92
 
88
93
  - Orchestrator only.
89
94
  - `workflow_commit_phase` runs phase package verify commands; non-zero → refuse.
95
+ - Commits are crash-recoverable transactions. After verification, the run
96
+ prepares the commit and persists a durable `pending_commit` anchor in
97
+ `state.json` (version 2) before completing it; every commit message body
98
+ carries an `Apnea-Transaction: <uuid>` trailer line. A later
99
+ `workflow_commit_phase` call that finds a pending transaction skips gates
100
+ and verification, completes or recognizes the exact commit once (drift is
101
+ refused), then advances. Cancellation after the anchor is saved does not
102
+ undo the transaction.
90
103
  - jj: `jj describe` + `jj new` after APPROVED; **bookmark `apnea/<slug>` at terminus**, not start.
91
104
  - git: branch `apnea/<slug>` at start; one commit per phase.
92
105
  - No push / remote PR in v1. Terminus artifact: `pr-description.md` (planner).
@@ -3,13 +3,17 @@ import { runToolResult } from "../run-tool.ts"
3
3
  import { makeAppLive } from "../services/app-live.ts"
4
4
  import { neutralHostAdapter, type ApneaHostAdapter } from "../host-adapter.ts"
5
5
  import { commitWorkflow, type CommitParams } from "../workflows/commit.ts"
6
+ import { withRepositoryLock } from "../services/operation-lock.ts"
7
+ import type { OperationHooks } from "../operation-hooks.ts"
6
8
 
7
9
  export async function workflowCommitPhase(
8
10
  params: CommitParams,
9
11
  hostAdapter: ApneaHostAdapter = neutralHostAdapter,
12
+ hooks: OperationHooks = {},
10
13
  ): Promise<ToolResult> {
11
14
  return runToolResult(
12
- commitWorkflow(params, process.cwd()),
15
+ withRepositoryLock(process.cwd(), commitWorkflow(params, process.cwd())),
13
16
  makeAppLive(hostAdapter),
17
+ { signal: hooks.signal, operation: "workflow_commit_phase" },
14
18
  )
15
19
  }
@@ -3,13 +3,21 @@ import { runToolResult } from "../run-tool.ts"
3
3
  import { makeAppLive } from "../services/app-live.ts"
4
4
  import { neutralHostAdapter, type ApneaHostAdapter } from "../host-adapter.ts"
5
5
  import { dispatchWorkflow, type DispatchParams } from "../workflows/dispatch.ts"
6
+ import { withRepositoryLock } from "../services/operation-lock.ts"
7
+ import type { OperationHooks } from "../operation-hooks.ts"
6
8
 
7
9
  export async function workflowDispatch(
8
10
  params: DispatchParams,
9
11
  hostAdapter: ApneaHostAdapter = neutralHostAdapter,
12
+ hooks: OperationHooks = {},
10
13
  ): Promise<ToolResult> {
11
14
  return runToolResult(
12
- dispatchWorkflow(params, process.cwd()),
15
+ withRepositoryLock(process.cwd(), dispatchWorkflow(params, process.cwd())),
13
16
  makeAppLive(hostAdapter),
17
+ {
18
+ signal: hooks.signal,
19
+ operation: "dispatch_role",
20
+ abortDetails: { delivery: "unknown" },
21
+ },
14
22
  )
15
23
  }
@@ -1,4 +1,5 @@
1
1
  import type { ToolResult } from "../result.ts"
2
+ import * as os from "node:os"
2
3
  import { runToolResult } from "../run-tool.ts"
3
4
  import { makeAppLive } from "../services/app-live.ts"
4
5
  import { resolveExecutable } from "../services/herdr.ts"
@@ -8,6 +9,8 @@ import {
8
9
  type SetupDeps,
9
10
  type SetupParams,
10
11
  } from "../workflows/setup.ts"
12
+ import { withSetupLocks } from "../services/operation-lock.ts"
13
+ import type { OperationHooks } from "../operation-hooks.ts"
11
14
 
12
15
  /**
13
16
  * Walk PATH directly rather than spawning `which` once per binary — `which`
@@ -21,14 +24,25 @@ function onPath(bin: string): boolean {
21
24
  export async function apneaSetup(
22
25
  params: SetupParams,
23
26
  hostAdapter: ApneaHostAdapter = neutralHostAdapter,
27
+ hooks: OperationHooks = {},
24
28
  ): Promise<ToolResult> {
29
+ const trustedHome = os.homedir()
25
30
  const prodDeps: SetupDeps = {
26
31
  onPath,
32
+ trustedHome: () => trustedHome,
27
33
  materializeRoleAgentDir: () =>
28
34
  hostAdapter.materializeRoleAgentDir?.() ?? null,
29
35
  }
36
+ const root = process.cwd()
37
+ const workflow = setupWorkflow(params, root, prodDeps)
30
38
  return runToolResult(
31
- setupWorkflow(params, process.cwd(), prodDeps),
39
+ withSetupLocks(
40
+ trustedHome,
41
+ root,
42
+ params.project === true || params.agents_md === true,
43
+ workflow,
44
+ ),
32
45
  makeAppLive(hostAdapter),
46
+ { signal: hooks.signal, operation: "setup" },
33
47
  )
34
48
  }
@@ -4,13 +4,17 @@ import { neutralHostAdapter } from "../host-adapter.ts"
4
4
  import type { ToolResult } from "../result.ts"
5
5
  import { runToolResult } from "../run-tool.ts"
6
6
  import { startWorkflow, type StartParams } from "../workflows/start.ts"
7
+ import { withRepositoryLock } from "../services/operation-lock.ts"
8
+ import type { OperationHooks } from "../operation-hooks.ts"
7
9
 
8
10
  export async function workflowStart(
9
11
  params: StartParams,
10
12
  hostAdapter: ApneaHostAdapter = neutralHostAdapter,
13
+ hooks: OperationHooks = {},
11
14
  ): Promise<ToolResult> {
12
15
  return runToolResult(
13
- startWorkflow(params, process.cwd()),
16
+ withRepositoryLock(process.cwd(), startWorkflow(params, process.cwd())),
14
17
  makeAppLive(hostAdapter),
18
+ { signal: hooks.signal, operation: "workflow_start" },
15
19
  )
16
20
  }
@@ -4,11 +4,21 @@ import type { ToolResult } from "../result.ts"
4
4
  import { runToolResult } from "../run-tool.ts"
5
5
  import { resetRoundsWorkflow } from "../workflows/reset.ts"
6
6
  import { statusWorkflow } from "../workflows/status.ts"
7
+ import { withRepositoryLock } from "../services/operation-lock.ts"
8
+ import type { OperationHooks } from "../operation-hooks.ts"
7
9
 
8
10
  export async function workflowStatus(
9
11
  hostAdapter: ApneaHostAdapter = neutralHostAdapter,
12
+ hooks: OperationHooks = {},
10
13
  ): Promise<ToolResult> {
11
- return runToolResult(statusWorkflow(process.cwd()), makeAppLive(hostAdapter))
14
+ return runToolResult(
15
+ statusWorkflow(process.cwd()),
16
+ makeAppLive(hostAdapter),
17
+ {
18
+ signal: hooks.signal,
19
+ operation: "workflow_status",
20
+ },
21
+ )
12
22
  }
13
23
 
14
24
  export async function workflowResetRounds(
@@ -16,9 +26,14 @@ export async function workflowResetRounds(
16
26
  gate: string
17
27
  },
18
28
  hostAdapter: ApneaHostAdapter = neutralHostAdapter,
29
+ hooks: OperationHooks = {},
19
30
  ): Promise<ToolResult> {
20
31
  return runToolResult(
21
- resetRoundsWorkflow(params, process.cwd()),
32
+ withRepositoryLock(
33
+ process.cwd(),
34
+ resetRoundsWorkflow(params, process.cwd()),
35
+ ),
22
36
  makeAppLive(hostAdapter),
37
+ { signal: hooks.signal, operation: "workflow_reset_rounds" },
23
38
  )
24
39
  }
@@ -7,6 +7,7 @@ import {
7
7
  type WaitHooks,
8
8
  type WaitParams,
9
9
  } from "../workflows/wait.ts"
10
+ import { withRepositoryLock } from "../services/operation-lock.ts"
10
11
 
11
12
  export async function workflowWait(
12
13
  params: WaitParams,
@@ -14,7 +15,11 @@ export async function workflowWait(
14
15
  hooks: WaitHooks = {},
15
16
  ): Promise<ToolResult> {
16
17
  return runToolResult(
17
- waitWorkflow(params, process.cwd(), hooks),
18
+ withRepositoryLock(
19
+ process.cwd(),
20
+ waitWorkflow(params, process.cwd(), hooks),
21
+ ),
18
22
  makeAppLive(hostAdapter),
23
+ // waitWorkflow owns signal cancellation so it can preserve WaitAborted.
19
24
  )
20
25
  }
package/extension/api.ts CHANGED
@@ -9,7 +9,13 @@ export {
9
9
  type Operation,
10
10
  type ExecuteOperation,
11
11
  } from "./registry.ts"
12
- export { parseFlags, parseNumFlag } from "./cli/parse.ts"
12
+ export type { OperationHooks } from "./operation-hooks.ts"
13
+ export {
14
+ parseFlags,
15
+ parseNumFlag,
16
+ parseOperationArgs,
17
+ type ParsedOperationArgs,
18
+ } from "./cli/parse.ts"
13
19
  export { DISPATCH_KINDS, type DispatchKind } from "./domain/state-machine.ts"
14
20
  export { packageRoot } from "./domain/paths.ts"
15
21
  export { formatResult, toolContent, type ToolResult } from "./result.ts"
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- import { parseFlags, parseNumFlag } from "./parse.ts"
2
+ import { parseFlags, parseNumFlag, parseOperationArgs } from "./parse.ts"
3
3
  import {
4
4
  EXIT_ERROR,
5
5
  EXIT_USAGE,
@@ -8,7 +8,12 @@ import {
8
8
  renderJson,
9
9
  } from "./format.ts"
10
10
  import { confirmHuman, prodHumanGateDeps } from "./human-gate.ts"
11
- import { OPERATIONS, executeOperation, findByVerb } from "../registry.ts"
11
+ import {
12
+ OPERATIONS,
13
+ executeOperation,
14
+ findByVerb,
15
+ type ExecuteOperation,
16
+ } from "../registry.ts"
12
17
  import { DISPATCH_KINDS } from "../domain/state-machine.ts"
13
18
  import type { ToolResult } from "../result.ts"
14
19
 
@@ -48,14 +53,18 @@ function printUsageError(message: string, json: boolean): void {
48
53
 
49
54
  export async function main(argv: string[]): Promise<number> {
50
55
  const [verbRaw, ...rest] = argv
51
- const { flags, values, rest: positional } = parseFlags(rest)
52
- const json = flags.has("json")
56
+ const json = parseFlags(rest).flags.has("json")
53
57
 
54
58
  if (!verbRaw) {
55
59
  printUsageError("usage: apnea <command> [args] [--json]", json)
56
60
  return EXIT_USAGE
57
61
  }
58
62
  if (verbRaw === "help" || verbRaw === "--help") {
63
+ const parsed = parseOperationArgs("help", rest, { surface: "cli" })
64
+ if (!parsed.ok) {
65
+ printUsageError(parsed.message, json)
66
+ return EXIT_USAGE
67
+ }
59
68
  console.log(usage())
60
69
  return 0
61
70
  }
@@ -68,6 +77,13 @@ export async function main(argv: string[]): Promise<number> {
68
77
  return EXIT_USAGE
69
78
  }
70
79
 
80
+ const parsed = parseOperationArgs(verbRaw, rest, { surface: "cli" })
81
+ if (!parsed.ok) {
82
+ printUsageError(parsed.message, json)
83
+ return EXIT_USAGE
84
+ }
85
+ const { flags, values, positional } = parsed
86
+
71
87
  const built = buildParams(
72
88
  op.verb,
73
89
  isAction ? verbRaw : null,
@@ -97,13 +113,53 @@ export async function main(argv: string[]): Promise<number> {
97
113
  }
98
114
  }
99
115
 
100
- const result = await executeOperation(op.verb, built.params)
116
+ const result = await executeWithSignals(
117
+ executeOperation,
118
+ op.verb,
119
+ built.params,
120
+ )
101
121
  const text = json ? renderJson(result) : renderHuman(result)
102
122
  if (result.ok) console.log(text)
103
123
  else console.error(text)
104
124
  return exitCodeFor(result)
105
125
  }
106
126
 
127
+ export type SignalTarget = {
128
+ on: (signal: "SIGINT" | "SIGTERM", listener: () => void) => unknown
129
+ off: (signal: "SIGINT" | "SIGTERM", listener: () => void) => unknown
130
+ }
131
+
132
+ export async function executeWithSignals(
133
+ execute: ExecuteOperation,
134
+ verb: string,
135
+ params: Record<string, unknown>,
136
+ target: SignalTarget = process,
137
+ exit: (code: number) => void = (code) => process.exit(code),
138
+ ): Promise<ToolResult> {
139
+ const controller = new AbortController()
140
+ let interrupts = 0
141
+ const abort = () => controller.abort()
142
+ const onInterrupt = () => {
143
+ interrupts += 1
144
+ if (interrupts >= 2) {
145
+ // A second Ctrl+C must always terminate, even if an operation stopped
146
+ // observing the abort signal.
147
+ controller.abort()
148
+ exit(130)
149
+ return
150
+ }
151
+ abort()
152
+ }
153
+ target.on("SIGINT", onInterrupt)
154
+ target.on("SIGTERM", abort)
155
+ try {
156
+ return await execute(verb, params, { signal: controller.signal })
157
+ } finally {
158
+ target.off("SIGINT", onInterrupt)
159
+ target.off("SIGTERM", abort)
160
+ }
161
+ }
162
+
107
163
  export type BuildParamsResult =
108
164
  | { ok: true; params: Record<string, unknown> }
109
165
  | { ok: false; message?: string }
@@ -140,12 +196,16 @@ export function buildParams(
140
196
  if (!kind || !DISPATCH_KINDS.includes(kind as never)) {
141
197
  return {
142
198
  ok: false,
143
- message: `usage: apnea dispatch <${DISPATCH_KINDS.join("|")}> [--rework]`,
199
+ message: `usage: apnea dispatch <${DISPATCH_KINDS.join("|")}> [--rework] [--redeliver]`,
144
200
  }
145
201
  }
146
202
  return {
147
203
  ok: true,
148
- params: { kind, rework: flags.has("rework") || undefined },
204
+ params: {
205
+ kind,
206
+ rework: flags.has("rework") || undefined,
207
+ redeliver: flags.has("redeliver") || undefined,
208
+ },
149
209
  }
150
210
  }
151
211
  case "wait": {
@@ -11,7 +11,16 @@ export function parseFlags(tokens: string[]): {
11
11
  const flags = new Set<string>()
12
12
  const values = new Map<string, string>()
13
13
  const rest: string[] = []
14
+ let positionalOnly = false
14
15
  for (const t of tokens) {
16
+ if (positionalOnly) {
17
+ rest.push(t)
18
+ continue
19
+ }
20
+ if (t === "--") {
21
+ positionalOnly = true
22
+ continue
23
+ }
15
24
  if (!t.startsWith("--")) {
16
25
  rest.push(t)
17
26
  continue
@@ -24,6 +33,162 @@ export function parseFlags(tokens: string[]): {
24
33
  return { flags, values, rest }
25
34
  }
26
35
 
36
+ type OperationArgSpec = {
37
+ switches: readonly string[]
38
+ values: readonly string[]
39
+ minPositionals: number
40
+ maxPositionals: number
41
+ }
42
+
43
+ const OPERATION_ARGS: Readonly<Record<string, OperationArgSpec>> = {
44
+ setup: {
45
+ switches: ["project", "force", "agents-md"],
46
+ values: [],
47
+ minPositionals: 0,
48
+ maxPositionals: 0,
49
+ },
50
+ start: {
51
+ switches: ["allow-dirty"],
52
+ values: ["slug"],
53
+ minPositionals: 1,
54
+ maxPositionals: Number.POSITIVE_INFINITY,
55
+ },
56
+ resume: { switches: [], values: [], minPositionals: 0, maxPositionals: 0 },
57
+ abandon: { switches: [], values: [], minPositionals: 0, maxPositionals: 0 },
58
+ help: { switches: [], values: [], minPositionals: 0, maxPositionals: 0 },
59
+ status: { switches: [], values: [], minPositionals: 0, maxPositionals: 0 },
60
+ wait: {
61
+ switches: [],
62
+ values: ["poll", "budget", "timeout"],
63
+ minPositionals: 0,
64
+ maxPositionals: 0,
65
+ },
66
+ dispatch: {
67
+ switches: ["rework", "redeliver"],
68
+ values: [],
69
+ minPositionals: 1,
70
+ maxPositionals: 1,
71
+ },
72
+ commit: {
73
+ switches: ["done"],
74
+ values: [],
75
+ minPositionals: 0,
76
+ maxPositionals: Number.POSITIVE_INFINITY,
77
+ },
78
+ "reset-rounds": {
79
+ switches: [],
80
+ values: [],
81
+ minPositionals: 1,
82
+ maxPositionals: 1,
83
+ },
84
+ }
85
+
86
+ export type ParsedOperationArgs =
87
+ | {
88
+ ok: true
89
+ flags: Set<string>
90
+ values: Map<string, string>
91
+ positional: string[]
92
+ }
93
+ | { ok: false; message: string }
94
+
95
+ /**
96
+ * Parse and validate one human-facing Apnea invocation. This is the shared
97
+ * strict boundary for the standalone CLI and Pi's `/apnea` command.
98
+ */
99
+ export function parseOperationArgs(
100
+ verb: string,
101
+ tokens: string[],
102
+ options: { surface?: "cli" | "slash" } = {},
103
+ ): ParsedOperationArgs {
104
+ const spec = OPERATION_ARGS[verb]
105
+ if (!spec) return { ok: false, message: `unknown command: ${verb}` }
106
+
107
+ const allowedSwitches = new Set(spec.switches)
108
+ if (options.surface === "cli") {
109
+ allowedSwitches.add("json")
110
+ if (verb === "reset-rounds") allowedSwitches.add("i-am-human")
111
+ }
112
+ const allowedValues = new Set(spec.values)
113
+ const numericValues = new Set(["poll", "budget", "timeout"])
114
+ const flags = new Set<string>()
115
+ const values = new Map<string, string>()
116
+ const positional: string[] = []
117
+ let positionalOnly = false
118
+
119
+ for (const token of tokens) {
120
+ if (positionalOnly) {
121
+ positional.push(token)
122
+ continue
123
+ }
124
+ if (token === "--") {
125
+ positionalOnly = true
126
+ continue
127
+ }
128
+ if (!token.startsWith("--")) {
129
+ positional.push(token)
130
+ continue
131
+ }
132
+
133
+ const body = token.slice(2)
134
+ const equals = body.indexOf("=")
135
+ const key = equals >= 0 ? body.slice(0, equals) : body
136
+ const value = equals >= 0 ? body.slice(equals + 1) : undefined
137
+
138
+ if (allowedSwitches.has(key)) {
139
+ if (value !== undefined)
140
+ return { ok: false, message: `option --${key} does not take a value` }
141
+ if (flags.has(key))
142
+ return {
143
+ ok: false,
144
+ message: `option --${key} was provided more than once`,
145
+ }
146
+ flags.add(key)
147
+ continue
148
+ }
149
+ if (allowedValues.has(key)) {
150
+ if (value === undefined)
151
+ return { ok: false, message: `option --${key} requires =<value>` }
152
+ if (numericValues.has(key)) {
153
+ const numeric = Number(value)
154
+ if (
155
+ value === "" ||
156
+ value.trim() !== value ||
157
+ !Number.isSafeInteger(numeric) ||
158
+ numeric <= 0
159
+ ) {
160
+ return {
161
+ ok: false,
162
+ message: `invalid numeric option --${key}=${value}`,
163
+ }
164
+ }
165
+ }
166
+ if (values.has(key))
167
+ return {
168
+ ok: false,
169
+ message: `option --${key} was provided more than once`,
170
+ }
171
+ values.set(key, value)
172
+ continue
173
+ }
174
+ return { ok: false, message: `unknown option --${key} for ${verb}` }
175
+ }
176
+
177
+ if (
178
+ positional.length < spec.minPositionals ||
179
+ positional.length > spec.maxPositionals
180
+ ) {
181
+ return { ok: false, message: `invalid positional arguments for ${verb}` }
182
+ }
183
+ if (values.has("budget") && values.has("timeout")) {
184
+ return {
185
+ ok: false,
186
+ message: "--budget and --timeout are aliases; provide only one",
187
+ }
188
+ }
189
+ return { ok: true, flags, values, positional }
190
+ }
191
+
27
192
  /** Reading a `--key=value` numeric flag either yields the parsed number (or
28
193
  * `undefined` when the caller didn't pass it) or the raw token that failed
29
194
  * to parse, so the caller can name exactly what it received. */
@@ -33,16 +198,18 @@ export type NumFlag =
33
198
  /**
34
199
  * Shared by `/apnea` and the CLI so a mistyped `--budget=abc` is refused the
35
200
  * 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.
201
+ * a scripting agent needs a signal, not a quietly-wrong value. This exported
202
+ * boundary rejects empty and padded values even when called directly.
39
203
  */
40
204
  export function parseNumFlag(
41
205
  values: Map<string, string>,
42
206
  key: string,
43
207
  ): NumFlag {
44
208
  const raw = values.get(key)
45
- if (raw === undefined || raw === "") return { ok: true, value: undefined }
209
+ if (raw === undefined) return { ok: true, value: undefined }
210
+ if (raw === "" || raw.trim() !== raw) return { ok: false, raw }
46
211
  const n = Number(raw)
47
- return Number.isFinite(n) ? { ok: true, value: n } : { ok: false, raw }
212
+ return Number.isSafeInteger(n) && n > 0
213
+ ? { ok: true, value: n }
214
+ : { ok: false, raw }
48
215
  }