@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.
- package/README.md +18 -1
- package/SECURITY.md +36 -0
- package/briefs/orchestrator.md +4 -3
- package/dist/cli.js +8375 -15093
- package/docs/protocol/artifacts.md +18 -2
- package/docs/protocol/config.md +15 -3
- package/docs/protocol/manual-gate.md +8 -8
- package/docs/protocol/overview.md +17 -4
- package/extension/adapters/commit.ts +5 -1
- package/extension/adapters/dispatch.ts +9 -1
- package/extension/adapters/setup.ts +15 -1
- package/extension/adapters/start.ts +5 -1
- package/extension/adapters/status.ts +17 -2
- package/extension/adapters/wait.ts +6 -1
- package/extension/api.ts +7 -1
- package/extension/cli/main.ts +67 -7
- package/extension/cli/parse.ts +172 -5
- package/extension/domain/paths.ts +2 -11
- package/extension/domain/timeouts.ts +4 -0
- package/extension/domain/types.ts +65 -3
- package/extension/errors.ts +51 -16
- package/extension/operation-hooks.ts +6 -0
- package/extension/registry.ts +29 -15
- package/extension/run-tool.ts +19 -2
- package/extension/schema/config.ts +58 -16
- package/extension/schema/frontmatter.ts +57 -0
- package/extension/schema/state.ts +210 -13
- package/extension/services/app-live.ts +2 -1
- package/extension/services/config.ts +6 -4
- package/extension/services/file-system.ts +346 -75
- package/extension/services/herdr.ts +466 -260
- package/extension/services/operation-lock.ts +452 -0
- package/extension/services/process.ts +477 -0
- package/extension/services/run-store.ts +38 -16
- package/extension/services/vcs.ts +1258 -328
- package/extension/workflows/commit.ts +214 -13
- package/extension/workflows/dispatch.ts +305 -67
- package/extension/workflows/setup.ts +59 -32
- package/extension/workflows/start.ts +6 -4
- package/extension/workflows/status.ts +2 -2
- package/extension/workflows/wait.ts +62 -77
- package/package.json +2 -2
- package/schemas/config.schema.json +5 -1
- 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
|
|
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
|
|
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
|
|
package/docs/protocol/config.md
CHANGED
|
@@ -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
|
|
117
|
-
interval is a busy-spin rather than a faster wait. `budget_ms` must be
|
|
118
|
-
|
|
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 :
|
|
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
|
|
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
|
-
-
|
|
77
|
-
-
|
|
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**
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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"
|
package/extension/cli/main.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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: {
|
|
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": {
|
package/extension/cli/parse.ts
CHANGED
|
@@ -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.
|
|
37
|
-
*
|
|
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
|
|
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.
|
|
212
|
+
return Number.isSafeInteger(n) && n > 0
|
|
213
|
+
? { ok: true, value: n }
|
|
214
|
+
: { ok: false, raw }
|
|
48
215
|
}
|