@naxodev/apnea 0.1.0 → 0.2.1

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 (51) hide show
  1. package/README.md +25 -10
  2. package/SECURITY.md +32 -0
  3. package/briefs/orchestrator.md +4 -3
  4. package/dist/cli.js +8571 -15324
  5. package/docs/adr/0005-harness-profiles.md +1 -1
  6. package/docs/adr/0010-package-split.md +1 -1
  7. package/docs/protocol/artifacts.md +18 -2
  8. package/docs/protocol/config.md +22 -25
  9. package/docs/protocol/manual-gate.md +8 -8
  10. package/docs/protocol/overview.md +18 -5
  11. package/extension/adapters/commit.ts +5 -1
  12. package/extension/adapters/dispatch.ts +9 -1
  13. package/extension/adapters/setup.ts +15 -1
  14. package/extension/adapters/start.ts +5 -1
  15. package/extension/adapters/status.ts +17 -2
  16. package/extension/adapters/wait.ts +6 -1
  17. package/extension/api.ts +7 -1
  18. package/extension/cli/main.ts +67 -7
  19. package/extension/cli/parse.ts +172 -5
  20. package/extension/domain/herdr.ts +0 -86
  21. package/extension/domain/paths.ts +3 -13
  22. package/extension/domain/setup.ts +0 -20
  23. package/extension/domain/timeouts.ts +4 -0
  24. package/extension/domain/types.ts +64 -11
  25. package/extension/domain/verify-commands.ts +200 -108
  26. package/extension/errors.ts +51 -16
  27. package/extension/operation-hooks.ts +6 -0
  28. package/extension/registry.ts +29 -15
  29. package/extension/run-tool.ts +19 -2
  30. package/extension/schema/config.ts +86 -30
  31. package/extension/schema/frontmatter.ts +57 -0
  32. package/extension/schema/state.ts +226 -16
  33. package/extension/services/app-live.ts +2 -1
  34. package/extension/services/config.ts +6 -4
  35. package/extension/services/file-system.ts +346 -75
  36. package/extension/services/herdr.ts +393 -402
  37. package/extension/services/operation-lock.ts +418 -0
  38. package/extension/services/process.ts +477 -0
  39. package/extension/services/run-store.ts +38 -16
  40. package/extension/services/vcs.ts +1388 -86
  41. package/extension/workflows/commit.ts +222 -18
  42. package/extension/workflows/dispatch.ts +320 -220
  43. package/extension/workflows/setup.ts +61 -141
  44. package/extension/workflows/start.ts +6 -5
  45. package/extension/workflows/status.ts +2 -2
  46. package/extension/workflows/wait.ts +63 -134
  47. package/package.json +2 -3
  48. package/schemas/config.schema.json +11 -7
  49. package/schemas/state.schema.json +170 -12
  50. package/herdr-plugin/herdr-plugin.toml +0 -15
  51. package/herdr-plugin/scripts/run-task.sh +0 -8
@@ -1,5 +1,5 @@
1
1
  # Harness abstraction: profiles with mode variants
2
2
 
3
- Roles do not carry freeform model strings. Global **profiles** define `cmd_interactive` (required for dispatch) and optional `cmd_oneshot` (unused by regular Apnea dispatch). Every worker role opens the interactive harness TUI in a labeled Herdr pane so humans can watch the session. Binding a role to a profile missing `cmd_interactive` hard-errors at start. Project config may only rebind role→profile names.
3
+ Roles do not carry freeform model strings. Global **profiles** define `cmd_interactive` (required for dispatch) and optional `cmd_oneshot` (unused by Apnea dispatch). Every worker role opens the interactive harness TUI in a labeled Herdr pane so humans can watch the session. Binding a role to a profile missing `cmd_interactive` hard-errors at start. Project config may only rebind role→profile names.
4
4
 
5
5
  Each remembered role pane stores a deterministic fingerprint of the effective profile name and interactive command. Dispatch reuses the pane only when that fingerprint still matches resolved config. Legacy pane records have no fingerprint and are recreated once rather than risking an old harness.
@@ -16,7 +16,7 @@ Both adapters bind the operation registry exported by `@naxodev/apnea`. The Pi a
16
16
 
17
17
  The core exposes only the operation registry, result formatting, command parsing, resource-root lookup, and the `ApneaHostAdapter` launch hooks. Pi supplies those hooks when it creates its registry.
18
18
 
19
- `@naxodev/pi-apnea` depends on `@naxodev/apnea` with `^0.1.0`. Compatible core patch releases flow through that range. An incompatible host interface change requires coordinated minor releases of both packages.
19
+ The current incompatible `@naxodev/pi-apnea` line depends on `@naxodev/apnea` with `^0.2.0`. Compatible core patch releases flow through that range. For an incompatible line, the core manifest is pre-staged at the new minor so the workspace remains installable while Nx coordinates the adapter's minor release.
20
20
 
21
21
  ## Consequences
22
22
 
@@ -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`, even though `pending_pane_id` is still null. This closes the crash window where the prompt may be accepted but the pane id cannot be saved: 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
@@ -52,8 +56,6 @@ Unknown keys and unimplemented values (`isolation: "worktree"`) **hard-error** a
52
56
  "coder": { "profile": "pi-grok" },
53
57
  },
54
58
  "review_round_cap": 3,
55
- // "regular" (default) or "floating"; omit the key to keep the default
56
- "pane_style": "regular",
57
59
  "timeouts_ms": {
58
60
  "planning": 1500000,
59
61
  "plan_review": 900000,
@@ -115,9 +117,12 @@ that gets killed. Passing `--budget` explicitly still works at any poll: it is a
115
117
  statement that your shell allows a longer call. An explicit budget under the floor is still
116
118
  refused.
117
119
 
118
- `poll_ms` must be at least 250ms. Every poll spawns two herdr subprocesses, so a smaller
119
- interval is a busy-spin rather than a faster wait. `budget_ms` must be finite. The sleep between
120
- polls is clamped to the remaining budget, so a large `poll_ms` cannot make a call outlive it.
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.
121
126
 
122
127
  The idle nudge and the final grace are independent rungs. A role nudged early for going idle
123
128
  still receives its 180000ms grace at the deadline; it just is not prompted twice.
@@ -129,29 +134,16 @@ a slow test run can read `idle` for a minute. The nudge is one-shot, so a false
129
134
  105000ms, leaving roughly 15000ms of margin under a 120000ms shell. Raise one and you must raise
130
135
  the other.
131
136
 
132
- ## Pane style
133
-
134
- `pane_style` controls how role dispatches appear in Herdr:
135
-
136
- - Values: `"regular"` | `"floating"`. Default is `"regular"` — **omitting the key changes nothing**.
137
- - Allowed in global config and overridable per project.
138
- - `"floating"` applies to **planner/reviewer oneshot dispatches only**. It requires herdr ≥ 0.7.4 **and** the linked `apnea` plugin (provisioned by `/apnea setup`). The popup is **session-modal**: it takes keyboard input until the worker exits, and dismissing it early kills the dispatch.
139
- - Floating oneshot scripts resolve `cmd_oneshot[0]` to an **absolute path** in the orchestrator environment and pass an augmented `PATH` into the popup. Bare names must be findable via the orchestrator's `PATH` (e.g. `~/.local/bin`); otherwise dispatch fails immediately instead of the popup exiting 127. Prefer absolute `cmd_oneshot` if your orchestrator process has a stripped PATH.
140
- - **Lifecycle (single popup):** herdr allows only one popup. Dispatch refuses while a prior floating oneshot is still live. The worker writes an exit-status file; `workflow_wait` fails closed when the popup dies without a complete artifact (instead of hanging until timeout). A second open while a popup is up surfaces `popup already open` immediately.
141
- - Interactive roles (orchestrator, coder) always use regular panes; the dispatch result reports `pane_style_effective: "regular (interactive role)"`.
142
- - Misconfiguration (old herdr, missing plugin, missing `cmd_oneshot`, unresolved oneshot binary) fails fast at dispatch with an actionable error.
143
- - Non-goal: per-role `pane_style` is future work. Setup never writes or flips this key — it only preserves a valid existing value on re-run.
144
-
145
137
  ## Role modes (fixed)
146
138
 
147
- By default every worker role launches the **interactive** harness TUI so you can watch it in Herdr. With `pane_style: "floating"`, planner and reviewer instead run their profile's **`cmd_oneshot`** inside the popup (those profiles therefore need `cmd_oneshot` for floating dispatch preflight errors otherwise). Interactive roles always stay on `cmd_interactive`.
139
+ Every worker role launches the **interactive** harness TUI in a reusable Herdr pane. Apnea dispatch always resolves `cmd_interactive`; a profile that only has `cmd_oneshot` cannot satisfy any role. `cmd_oneshot` remains part of the profile contract for manual and external workflows.
148
140
 
149
- | Role | Required profile capability |
150
- | ------------ | ------------------------------------------------------------------- |
151
- | orchestrator | `cmd_interactive` |
152
- | planner | `cmd_interactive` (and `cmd_oneshot` when `pane_style: "floating"`) |
153
- | reviewer | `cmd_interactive` (and `cmd_oneshot` when `pane_style: "floating"`) |
154
- | coder | `cmd_interactive` |
141
+ | Role | Required profile capability |
142
+ | ------------ | --------------------------- |
143
+ | orchestrator | `cmd_interactive` |
144
+ | planner | `cmd_interactive` |
145
+ | reviewer | `cmd_interactive` |
146
+ | coder | `cmd_interactive` |
155
147
 
156
148
  Binding a role to a profile missing the required capability → **hard-error**.
157
149
 
@@ -184,3 +176,8 @@ Project entries that include `cmd`, `cmd_oneshot`, `cmd_interactive`, or `bin`
184
176
  - Optionally write project role bindings
185
177
  - Never write `cmd` into project config
186
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 oneshot; 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 |
@@ -67,26 +67,39 @@ nits: | # optional; never blocks alone
67
67
  ---
68
68
  ```
69
69
 
70
- Pane markers are human decoration. Herdr `agent_status` is liveness (dead pane / hung oneshot without artifact → escalate).
70
+ Pane markers are human decoration. Herdr `agent_status` is liveness (dead pane without artifact → escalate).
71
71
 
72
72
  ## Rework
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`, while the pane id may still be null. Interactive null-pane ownership refuses redelivery because prompt acceptance may have happened before the final save. 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": {