@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
@@ -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
  }
@@ -1,52 +1,3 @@
1
- import type { PaneStyle, Role } from "./types.ts"
2
-
3
- /** Parse `herdr X.Y.Z` (or noisy multi-line) into a numeric tuple. */
4
- export function parseHerdrVersion(
5
- raw: string,
6
- ): [number, number, number] | null {
7
- const m = raw.match(/(\d+)\.(\d+)\.(\d+)/)
8
- if (!m) return null
9
- return [Number(m[1]), Number(m[2]), Number(m[3])]
10
- }
11
-
12
- export function versionGte(
13
- a: [number, number, number],
14
- b: [number, number, number],
15
- ): boolean {
16
- const [a0, a1, a2] = a
17
- const [b0, b1, b2] = b
18
- if (a0 !== b0) return a0 > b0
19
- if (a1 !== b1) return a1 > b1
20
- return a2 >= b2
21
- }
22
-
23
- /**
24
- * Floating popups need herdr ≥ 0.7.4. Fail closed on unparseable versions so an
25
- * unattended run never hangs on a CLI that rejects `--placement popup`.
26
- */
27
- export function supportsFloating(
28
- version: [number, number, number] | null,
29
- ): boolean {
30
- return version != null && versionGte(version, [0, 7, 4])
31
- }
32
-
33
- /**
34
- * Configured style vs effective style. Floating is only for planner/reviewer
35
- * (oneshot-eligible artifact producers); interactive roles always stay regular.
36
- */
37
- export function effectivePaneStyle(
38
- configured: PaneStyle,
39
- role: Role,
40
- ): { style: PaneStyle; effective: string } {
41
- if (configured === "regular") {
42
- return { style: "regular", effective: "regular" }
43
- }
44
- if (role === "planner" || role === "reviewer") {
45
- return { style: "floating", effective: "floating" }
46
- }
47
- return { style: "regular", effective: "regular (interactive role)" }
48
- }
49
-
50
1
  export function shellJoin(parts: string[]): string {
51
2
  return parts
52
3
  .map((p) => {
@@ -70,40 +21,3 @@ export function looksLikeShellOnly(names: string[]): boolean {
70
21
  )
71
22
  })
72
23
  }
73
-
74
- /** Parse a floating task's exit-file contents; null if not a finished exit code. */
75
- export function parseFloatingExit(text: string): number | null {
76
- const t = text.trim()
77
- const n = Number.parseInt(t, 10)
78
- return Number.isFinite(n) ? n : null
79
- }
80
-
81
- /**
82
- * Self-contained bash script body that cds to root, runs cmd + prompt as a
83
- * child (not exec — so EXIT trap still fires), and always records the exit
84
- * code for workflow_wait. Popups have no pane id; the exit file is the
85
- * liveness signal.
86
- */
87
- export function floatingTaskScriptBody(opts: {
88
- root: string
89
- resolvedCmd: string[]
90
- prompt: string
91
- exitFileAbs: string
92
- }): string {
93
- return [
94
- "#!/bin/bash",
95
- "set -uo pipefail",
96
- `EXIT_FILE=${shellJoin([opts.exitFileAbs])}`,
97
- "write_exit() {",
98
- " local st=$?",
99
- ` printf '%s\n' "$st" > "$EXIT_FILE" 2>/dev/null || true`,
100
- "}",
101
- "trap write_exit EXIT",
102
- "trap 'exit 129' HUP",
103
- "trap 'exit 130' INT",
104
- "trap 'exit 143' TERM",
105
- shellJoin(["cd", opts.root]),
106
- shellJoin([...opts.resolvedCmd, "--", opts.prompt]),
107
- "",
108
- ].join("\n")
109
- }
@@ -21,17 +21,8 @@ export function projectConfigPath(root = cwd()): string {
21
21
  return path.join(apneaRoot(root), "config.json")
22
22
  }
23
23
 
24
- function homedir(): string {
25
- // Env first so an overridden HOME still wins (Bun's os.homedir() reads the
26
- // passwd entry and ignores $HOME), then the passwd entry as the floor.
27
- // Never "": an empty home makes globalConfigPath() cwd-relative, i.e. the
28
- // *project* repo would be read as the trusted global config — the one place
29
- // profiles/cmd_interactive are honoured. node:os is a pure read here.
30
- return process.env.HOME || process.env.USERPROFILE || os.homedir()
31
- }
32
-
33
- export function globalConfigPath(): string {
34
- return path.join(homedir(), ".config", "apnea", "config.json")
24
+ export function globalConfigPath(home = os.homedir()): string {
25
+ return path.join(home, ".config", "apnea", "config.json")
35
26
  }
36
27
 
37
28
  export function artifactsDir(root = cwd()): string {
@@ -107,8 +98,7 @@ export function findPackageRootFrom(startDir: string): string | null {
107
98
  }
108
99
 
109
100
  /**
110
- * Package root — the directory holding `briefs/`, `herdr-plugin/`, and our
111
- * own `package.json`.
101
+ * Package root — the directory holding `briefs/` and our own `package.json`.
112
102
  *
113
103
  * Found by walking up and reading each `package.json`, NOT by counting
114
104
  * directory levels. The level count differs for every way this code runs, and
@@ -21,19 +21,6 @@ export function deepMergeProfiles(
21
21
  return out
22
22
  }
23
23
 
24
- /**
25
- * Carry a valid user pane_style preference forward. Setup never writes the
26
- * key when absent, and never invents values — only preserves exact "regular"
27
- * or "floating". Invalid prev values are dropped.
28
- */
29
- export function preservePaneStyle(
30
- prev: Record<string, unknown>,
31
- ): "regular" | "floating" | undefined {
32
- const v = prev.pane_style
33
- if (v === "regular" || v === "floating") return v
34
- return undefined
35
- }
36
-
37
24
  export function buildProfiles(has: Detected): Record<string, unknown> {
38
25
  const profiles: Record<string, unknown> = {}
39
26
 
@@ -119,8 +106,6 @@ export function buildGlobalConfig(opts: {
119
106
  )
120
107
  }
121
108
 
122
- const preservedPaneStyle = preservePaneStyle(prev)
123
-
124
109
  const globalConfig: Record<string, unknown> = {
125
110
  profiles: nextProfiles,
126
111
  roles: force || !prev.roles ? roles : prev.roles,
@@ -133,11 +118,6 @@ export function buildGlobalConfig(opts: {
133
118
  // values wait/commit actually use cannot drift apart.
134
119
  { ...DEFAULT_TIMEOUTS },
135
120
  }
136
- // Preserve user opt-in only — never introduce pane_style when absent.
137
- if (preservedPaneStyle !== undefined) {
138
- globalConfig.pane_style = preservedPaneStyle
139
- }
140
-
141
121
  return globalConfig
142
122
  }
143
123
 
@@ -16,6 +16,10 @@ const STEP_KEY: Record<DispatchKind, string> = {
16
16
 
17
17
  export const DEFAULT_TIMEOUT_MS = 900_000
18
18
 
19
+ export function deadlineAfter(start: number, duration: number): number {
20
+ return Math.min(Number.MAX_SAFE_INTEGER, start + duration)
21
+ }
22
+
19
23
  export function timeoutMsForKind(
20
24
  kind: DispatchKind,
21
25
  timeouts: Record<string, number>,
@@ -12,12 +12,59 @@ export type Role = "orchestrator" | "planner" | "reviewer" | "coder"
12
12
 
13
13
  export type RoleMode = "oneshot" | "interactive"
14
14
 
15
- export type PaneStyle = "regular" | "floating"
15
+ export type PendingDelivery = "manual" | "interactive"
16
16
 
17
17
  export type Verdict = "APPROVED" | "CHANGES_REQUIRED"
18
18
 
19
19
  export type ReworkTarget = "code" | "phase_package"
20
20
 
21
+ export type RequiredReworkTarget = "plan" | ReworkTarget
22
+
23
+ /** Fields every pending commit carries regardless of backend. */
24
+ export interface PendingCommitCore {
25
+ /** Transaction id; appears in the commit message as `Apnea-Transaction: <id>`. */
26
+ id: string
27
+ /** Phase whose completion this transaction commits. */
28
+ phase_index: number
29
+ /** Full commit message including the `Apnea-Transaction:` trailer line. */
30
+ message: string
31
+ /** Whether completion advances to finishing instead of the next phase. */
32
+ no_remaining_phases: boolean
33
+ /** Repo-relative `.apnea/` path of the verify log for this phase. */
34
+ verify_log: string
35
+ }
36
+
37
+ /** Git anchor captured before any ref moves: the exact commit to create. */
38
+ export interface GitPendingCommit extends PendingCommitCore {
39
+ backend: "git"
40
+ /** Full ref (e.g. `refs/heads/apnea/slug`) the commit must land on. */
41
+ branch: string
42
+ /** Expected HEAD when completion starts; also the created commit's parent. */
43
+ parent_commit: string
44
+ /** Prepared tree id (staged with `.apnea` excluded). */
45
+ tree_id: string
46
+ }
47
+
48
+ /** jj anchor captured right after describing `@`. */
49
+ export interface JjPendingCommit extends PendingCommitCore {
50
+ backend: "jj"
51
+ /** Change id of the described working-copy change. */
52
+ change_id: string
53
+ /** Fingerprint of the change's non-.apnea diff at preparation time. */
54
+ content_fingerprint: string
55
+ }
56
+
57
+ /**
58
+ * Durable record of an in-flight commit. Written after VCS preparation and
59
+ * before completion, so a crash anywhere afterwards can recognize and finish
60
+ * the transaction exactly once.
61
+ */
62
+ export type PendingCommit = GitPendingCommit | JjPendingCommit
63
+
64
+ /** Internal decode marker for ambiguous version-1 planning state. */
65
+ export const LEGACY_PLAN_REWORK = Symbol("apnea.legacy-plan-rework")
66
+ export const LEGACY_CODE_REWORK = Symbol("apnea.legacy-code-rework")
67
+
21
68
  export type VcsBackend = "jj" | "git"
22
69
 
23
70
  export interface Profile {
@@ -34,11 +81,10 @@ export interface ApneaConfig {
34
81
  roles: Record<string, RoleBinding>
35
82
  review_round_cap: number
36
83
  timeouts_ms: Record<string, number>
37
- pane_style: PaneStyle
38
84
  }
39
85
 
40
86
  export interface RunState {
41
- version: 1
87
+ version: 2
42
88
  slug: string
43
89
  step: Step
44
90
  phase_index: number
@@ -53,16 +99,12 @@ export interface RunState {
53
99
  pending_artifact: string | null
54
100
  /** Role for pending dispatch */
55
101
  pending_role: Role | null
102
+ /** Delivery boundary crossed for the pending dispatch, if known. */
103
+ pending_delivery: PendingDelivery | null
56
104
  /** Herdr pane id for the in-flight dispatch */
57
105
  pending_pane_id: string | null
58
106
  /** Label of that pane (apnea:role:unique) */
59
107
  pending_pane_label: string | null
60
- /**
61
- * Relative path to the floating oneshot exit-status file (written when the
62
- * popup worker process ends). Null for regular panes. Herdr popups have no
63
- * pane id — this is how wait detects death without hanging until timeout.
64
- */
65
- pending_floating_exit: string | null
66
108
  /**
67
109
  * Epoch ms when the in-flight dispatch was launched. Null when idle.
68
110
  * Persisted so a chunked `workflow_wait` measures elapsed time from the
@@ -108,8 +150,19 @@ export interface RunState {
108
150
  current_phase_package: string | null
109
151
  /** Last code-review path for commit gate */
110
152
  current_code_review: string | null
111
- /** The next phase-package dispatch must consume planner-owned rework. */
112
- phase_package_rework: boolean
153
+ /** The exact dispatch that owns the next review round, if any. */
154
+ required_rework: RequiredReworkTarget | null
155
+ /**
156
+ * In-flight commit transaction, if any. Set after VCS preparation and
157
+ * cleared only after completion + bookmark succeed; a crash leaves it
158
+ * durable so the next `workflow_commit_phase` call resumes that
159
+ * transaction instead of re-running gates and verification.
160
+ */
161
+ pending_commit: PendingCommit | null
162
+ /** Never serialized. An old planning state needs an explicit assertion. */
163
+ [LEGACY_PLAN_REWORK]?: true
164
+ /** Never serialized. Ambiguous old coding state needs an explicit assertion. */
165
+ [LEGACY_CODE_REWORK]?: true
113
166
  }
114
167
 
115
168
  export interface FrontMatter {