@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
  import { Cause, Effect, Exit, Layer, Option, Result } from "effect"
2
- import { isAppError, toToolResult } from "./errors.ts"
2
+ import { isAppError, OperationAborted, toToolResult } from "./errors.ts"
3
3
  import type { ToolResult } from "./result.ts"
4
4
 
5
5
  /**
@@ -12,15 +12,32 @@ export async function runToolResult<E, R>(
12
12
  // without the layer would type-check and then die as a service-not-found
13
13
  // defect on every invocation. Pass `Layer.empty` explicitly when R is never.
14
14
  layer: Layer.Layer<R, never, never>,
15
+ hooks: {
16
+ signal?: AbortSignal
17
+ operation?: string
18
+ abortDetails?: Record<string, unknown>
19
+ } = {},
15
20
  ): Promise<ToolResult> {
16
21
  const provided = Effect.provide(effect, layer) as Effect.Effect<ToolResult, E>
17
- const exit = await Effect.runPromiseExit(provided)
22
+ const exit = await Effect.runPromiseExit(provided, { signal: hooks.signal })
18
23
 
19
24
  if (Exit.isSuccess(exit)) {
20
25
  return exit.value
21
26
  }
22
27
 
23
28
  const error = Exit.findErrorOption(exit)
29
+ if (
30
+ hooks.signal?.aborted ||
31
+ Result.isSuccess(Cause.findInterrupt(exit.cause))
32
+ ) {
33
+ return toToolResult(
34
+ new OperationAborted({
35
+ operation: hooks.operation ?? "operation",
36
+ details: hooks.abortDetails,
37
+ }),
38
+ )
39
+ }
40
+
24
41
  if (Option.isSome(error) && isAppError(error.value)) {
25
42
  return toToolResult(error.value)
26
43
  }
@@ -4,7 +4,6 @@ import {
4
4
  DEFAULT_TIMEOUTS,
5
5
  ROLE_MODE,
6
6
  type ApneaConfig,
7
- type PaneStyle,
8
7
  type Profile,
9
8
  type Role,
10
9
  type RoleMode,
@@ -21,21 +20,60 @@ const RoleBindingSchema = Schema.Struct({
21
20
  profile: Schema.String.check(Schema.isMinLength(1)),
22
21
  })
23
22
 
24
- export const PaneStyleSchema = Schema.Literals(["regular", "floating"] as const)
23
+ const ReviewRoundCapSchema = Schema.Int.check(
24
+ Schema.isBetween({ minimum: 1, maximum: 20 }),
25
+ )
26
+ const TimeoutMsSchema = Schema.Int.check(
27
+ Schema.isBetween({ minimum: 1_000, maximum: Number.MAX_SAFE_INTEGER }),
28
+ )
29
+
30
+ function validReviewRoundCap(value: number): boolean {
31
+ return Number.isSafeInteger(value) && value >= 1 && value <= 20
32
+ }
33
+
34
+ function validTimeout(value: number): boolean {
35
+ return Number.isSafeInteger(value) && value >= 1_000
36
+ }
37
+
38
+ function sanitizeConfigNumbers(
39
+ config: Record<string, unknown>,
40
+ ): Record<string, unknown> {
41
+ const sanitized = { ...config }
42
+ if (
43
+ typeof sanitized.review_round_cap === "number" &&
44
+ !validReviewRoundCap(sanitized.review_round_cap)
45
+ ) {
46
+ delete sanitized.review_round_cap
47
+ }
48
+ if (
49
+ sanitized.timeouts_ms &&
50
+ typeof sanitized.timeouts_ms === "object" &&
51
+ !Array.isArray(sanitized.timeouts_ms)
52
+ ) {
53
+ const timeouts = {
54
+ ...(sanitized.timeouts_ms as Record<string, unknown>),
55
+ }
56
+ for (const [key, value] of Object.entries(timeouts)) {
57
+ if (typeof value === "number" && !validTimeout(value)) {
58
+ delete timeouts[key]
59
+ }
60
+ }
61
+ sanitized.timeouts_ms = timeouts
62
+ }
63
+ return sanitized
64
+ }
25
65
 
26
66
  /**
27
67
  * Mirrors `schemas/config.schema.json` top-level keys.
28
68
  *
29
- * `review_round_cap` / `timeouts_ms` carry no range `check`: an out-of-range
30
- * number must fall back to the default (see below), not fail the decode. A
31
- * hard failure here bricks every tool until the file is hand-edited.
69
+ * Numeric fields are strict here. The decoders remove only invalid numeric
70
+ * values before decoding so published 0.2 per-field fallback remains intact.
32
71
  */
33
72
  export const GlobalConfigSchema = Schema.Struct({
34
73
  profiles: Schema.optional(Schema.Record(Schema.String, ProfileSchema)),
35
74
  roles: Schema.optional(Schema.Record(Schema.String, RoleBindingSchema)),
36
- review_round_cap: Schema.optional(Schema.Number),
37
- timeouts_ms: Schema.optional(Schema.Record(Schema.String, Schema.Number)),
38
- pane_style: Schema.optional(PaneStyleSchema),
75
+ review_round_cap: Schema.optional(ReviewRoundCapSchema),
76
+ timeouts_ms: Schema.optional(Schema.Record(Schema.String, TimeoutMsSchema)),
39
77
  })
40
78
 
41
79
  const PROJECT_KNOWN = new Set([
@@ -60,10 +98,9 @@ const PROJECT_FORBIDDEN = new Set([
60
98
  */
61
99
  export const ProjectConfigSchema = Schema.Struct({
62
100
  roles: Schema.optional(Schema.Record(Schema.String, RoleBindingSchema)),
63
- review_round_cap: Schema.optional(Schema.Number),
64
- timeouts_ms: Schema.optional(Schema.Record(Schema.String, Schema.Number)),
101
+ review_round_cap: Schema.optional(ReviewRoundCapSchema),
102
+ timeouts_ms: Schema.optional(Schema.Record(Schema.String, TimeoutMsSchema)),
65
103
  isolation: Schema.optional(Schema.Literal("shared_cwd")),
66
- pane_style: Schema.optional(PaneStyleSchema),
67
104
  })
68
105
 
69
106
  function configFail(
@@ -96,6 +133,16 @@ export function decodeGlobalConfig(
96
133
 
97
134
  const obj = objR.success
98
135
 
136
+ if (
137
+ "pane_style" in obj &&
138
+ obj.pane_style !== "regular" &&
139
+ obj.pane_style !== "floating"
140
+ ) {
141
+ return configFail(
142
+ `invalid legacy pane_style=${JSON.stringify(obj.pane_style)}; expected "regular" or "floating"`,
143
+ )
144
+ }
145
+
99
146
  if (
100
147
  "isolation" in obj &&
101
148
  obj.isolation !== undefined &&
@@ -119,7 +166,10 @@ export function decodeGlobalConfig(
119
166
  }
120
167
  }
121
168
 
122
- const decoded = Schema.decodeUnknownResult(GlobalConfigSchema)(obj)
169
+ const { pane_style: _legacy, ...globalConfig } = obj
170
+ const decoded = Schema.decodeUnknownResult(GlobalConfigSchema)(
171
+ sanitizeConfigNumbers(globalConfig),
172
+ )
123
173
  if (Result.isFailure(decoded)) {
124
174
  return configFail(decoded.failure.message)
125
175
  }
@@ -146,24 +196,15 @@ export function decodeGlobalConfig(
146
196
  const timeouts = { ...DEFAULT_TIMEOUTS }
147
197
  if (d.timeouts_ms) {
148
198
  for (const [k, v] of Object.entries(d.timeouts_ms)) {
149
- if (typeof v === "number" && v >= 1000) timeouts[k] = v
199
+ if (validTimeout(v)) timeouts[k] = v
150
200
  }
151
201
  }
152
202
 
153
- const pane_style: PaneStyle =
154
- d.pane_style === "regular" || d.pane_style === "floating"
155
- ? d.pane_style
156
- : "regular"
157
-
158
203
  return Result.succeed({
159
204
  profiles,
160
205
  roles,
161
- review_round_cap:
162
- typeof d.review_round_cap === "number" && d.review_round_cap >= 1
163
- ? d.review_round_cap
164
- : 3,
206
+ review_round_cap: d.review_round_cap ?? 3,
165
207
  timeouts_ms: timeouts,
166
- pane_style,
167
208
  })
168
209
  }
169
210
 
@@ -179,6 +220,18 @@ export function decodeProjectConfig(
179
220
 
180
221
  const obj = objR.success
181
222
 
223
+ // Legacy project configs may contain this retired preference. Validate it
224
+ // before stripping it so typos still fail instead of becoming silent no-ops.
225
+ if (
226
+ "pane_style" in obj &&
227
+ obj.pane_style !== "regular" &&
228
+ obj.pane_style !== "floating"
229
+ ) {
230
+ return configFail(
231
+ `invalid legacy pane_style=${JSON.stringify(obj.pane_style)}; expected "regular" or "floating"`,
232
+ )
233
+ }
234
+
182
235
  for (const key of Object.keys(obj)) {
183
236
  if (PROJECT_FORBIDDEN.has(key)) {
184
237
  return configFail(
@@ -214,9 +267,13 @@ export function decodeProjectConfig(
214
267
  }
215
268
  }
216
269
 
217
- const decoded = Schema.decodeUnknownResult(ProjectConfigSchema)(obj, {
218
- onExcessProperty: "error",
219
- })
270
+ const { pane_style: _legacy, ...projectConfig } = obj
271
+ const decoded = Schema.decodeUnknownResult(ProjectConfigSchema)(
272
+ sanitizeConfigNumbers(projectConfig),
273
+ {
274
+ onExcessProperty: "error",
275
+ },
276
+ )
220
277
  if (Result.isFailure(decoded)) {
221
278
  return configFail(decoded.failure.message)
222
279
  }
@@ -240,19 +297,18 @@ export function applyProjectConfig(
240
297
  const timeouts = { ...cfg.timeouts_ms }
241
298
  if (overlay.timeouts_ms) {
242
299
  for (const [k, v] of Object.entries(overlay.timeouts_ms)) {
243
- if (typeof v === "number" && v >= 1000) timeouts[k] = v
300
+ if (validTimeout(v)) timeouts[k] = v
244
301
  }
245
302
  }
246
303
  return {
247
304
  profiles: cfg.profiles,
248
305
  roles,
249
306
  review_round_cap:
250
- overlay.review_round_cap !== undefined && overlay.review_round_cap >= 1
307
+ overlay.review_round_cap !== undefined &&
308
+ validReviewRoundCap(overlay.review_round_cap)
251
309
  ? overlay.review_round_cap
252
310
  : cfg.review_round_cap,
253
311
  timeouts_ms: timeouts,
254
- pane_style:
255
- overlay.pane_style !== undefined ? overlay.pane_style : cfg.pane_style,
256
312
  }
257
313
  }
258
314
 
@@ -1,4 +1,10 @@
1
1
  import { Result, Schema } from "effect"
2
+ import { isCompleteArtifact } from "../domain/frontmatter.ts"
3
+ import {
4
+ stepAfterArtifact,
5
+ type DispatchKind,
6
+ } from "../domain/state-machine.ts"
7
+ import type { FrontMatter, ReworkTarget, Step } from "../domain/types.ts"
2
8
  import { ArtifactInvalid } from "../errors.ts"
3
9
 
4
10
  const VerdictSchema = Schema.Literals(["APPROVED", "CHANGES_REQUIRED"] as const)
@@ -17,6 +23,12 @@ export const FrontMatterResultSchema = Schema.Struct({
17
23
 
18
24
  export type FrontMatterResult = typeof FrontMatterResultSchema.Type
19
25
 
26
+ export type AcceptedArtifactCompletion = {
27
+ frontmatter: FrontMatterResult
28
+ next: Step
29
+ rework: ReworkTarget | undefined
30
+ }
31
+
20
32
  export function decodeFrontMatterResult(
21
33
  raw: unknown,
22
34
  artifact = "artifact",
@@ -32,3 +44,48 @@ export function decodeFrontMatterResult(
32
44
  }
33
45
  return Result.succeed(decoded.success)
34
46
  }
47
+
48
+ /** Shared acceptance boundary for wait advancement and redelivery refusal. */
49
+ export function validateArtifactCompletion(
50
+ kind: DispatchKind,
51
+ fm: FrontMatter | null,
52
+ artifact = "artifact",
53
+ ): Result.Result<AcceptedArtifactCompletion | null, ArtifactInvalid> {
54
+ const requireVerdict = kind === "plan_review" || kind === "code_review"
55
+ if (!isCompleteArtifact(fm, { requireVerdict })) return Result.succeed(null)
56
+
57
+ if (
58
+ fm!.rework !== undefined &&
59
+ (kind !== "code_review" || fm!.verdict !== "CHANGES_REQUIRED")
60
+ ) {
61
+ return Result.fail(
62
+ new ArtifactInvalid({
63
+ artifact,
64
+ message:
65
+ "rework is valid only on a code_review artifact with verdict CHANGES_REQUIRED",
66
+ }),
67
+ )
68
+ }
69
+
70
+ const decoded = decodeFrontMatterResult(
71
+ {
72
+ status: fm!.status,
73
+ ...(requireVerdict ? { verdict: fm!.verdict } : {}),
74
+ nits: fm!.nits,
75
+ ...(kind === "code_review" && fm!.rework ? { rework: fm!.rework } : {}),
76
+ },
77
+ artifact,
78
+ )
79
+ if (Result.isFailure(decoded)) return Result.fail(decoded.failure)
80
+
81
+ const rework = kind === "code_review" ? decoded.success.rework : undefined
82
+ const next = stepAfterArtifact(kind, fm!.verdict, rework)
83
+ if (typeof next === "object") {
84
+ return Result.fail(new ArtifactInvalid({ artifact, message: next.error }))
85
+ }
86
+ return Result.succeed({
87
+ frontmatter: decoded.success,
88
+ next,
89
+ rework,
90
+ })
91
+ }
@@ -1,6 +1,12 @@
1
1
  import { Result, Schema } from "effect"
2
2
  import { StateCorrupt } from "../errors.ts"
3
- import type { RunState, Step } from "../domain/types.ts"
3
+ import {
4
+ LEGACY_CODE_REWORK,
5
+ LEGACY_PLAN_REWORK,
6
+ type RequiredReworkTarget,
7
+ type RunState,
8
+ type Step,
9
+ } from "../domain/types.ts"
4
10
 
5
11
  export const StepSchema = Schema.Literals([
6
12
  "planning",
@@ -22,37 +28,94 @@ export const RoleSchema = Schema.Literals([
22
28
  "coder",
23
29
  ] as const)
24
30
 
31
+ export const RequiredReworkSchema = Schema.NullOr(
32
+ Schema.Literals(["plan", "code", "phase_package"] as const),
33
+ )
34
+
35
+ export const PendingDeliverySchema = Schema.NullOr(
36
+ Schema.Literals(["manual", "interactive"] as const),
37
+ )
38
+
25
39
  const PaneRefSchema = Schema.Struct({
26
40
  pane_id: Schema.String,
27
41
  label: Schema.String,
28
42
  profile_fingerprint: Schema.optionalKey(Schema.NullOr(Schema.String)),
29
43
  })
30
44
 
45
+ const PositiveSafeInteger = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))
46
+ const NonNegativeSafeInteger = Schema.Int.check(
47
+ Schema.isGreaterThanOrEqualTo(0),
48
+ )
49
+
50
+ const PendingCommitFields = {
51
+ id: Schema.String.check(Schema.isMinLength(1)),
52
+ phase_index: PositiveSafeInteger,
53
+ message: Schema.String.check(Schema.isMinLength(1)),
54
+ no_remaining_phases: Schema.Boolean,
55
+ verify_log: Schema.String.check(Schema.isMinLength(1)),
56
+ }
57
+
58
+ // Anchor fields flow into git/jj argv, so they get format checks beyond
59
+ // minLength. Exploiting a loose anchor requires state.json write access
60
+ // (game-over elsewhere), but validating costs nothing and removes the class.
61
+ export const GitPendingCommitSchema = Schema.Struct({
62
+ backend: Schema.Literal("git"),
63
+ ...PendingCommitFields,
64
+ branch: Schema.String.check(
65
+ Schema.isPattern(/^refs\/heads\/apnea\/[A-Za-z0-9._-]+$/),
66
+ ),
67
+ parent_commit: Schema.String.check(Schema.isPattern(/^[0-9a-f]{40,64}$/)),
68
+ tree_id: Schema.String.check(Schema.isPattern(/^[0-9a-f]{40,64}$/)),
69
+ })
70
+
71
+ export const JjPendingCommitSchema = Schema.Struct({
72
+ backend: Schema.Literal("jj"),
73
+ ...PendingCommitFields,
74
+ change_id: Schema.String.check(Schema.isPattern(/^[A-Za-z0-9]{1,32}$/)),
75
+ content_fingerprint: Schema.String.check(
76
+ Schema.isPattern(/^[0-9a-f]{8,64}$/),
77
+ ),
78
+ })
79
+
80
+ export const PendingCommitSchema = Schema.Union([
81
+ GitPendingCommitSchema,
82
+ JjPendingCommitSchema,
83
+ ])
84
+
31
85
  /**
32
- * Runtime codec for `state.json` (version 1).
33
- * Missing pane-tracking fields are filled in `decodeRunState` (legacy files).
86
+ * Runtime codec for `state.json`.
87
+ *
88
+ * Input accepts version 1 (files written by 0.2.x) and version 2. Version-1
89
+ * files must not carry `pending_commit` — that is enforced in
90
+ * `decodeRunState`, which migrates every decoded state to version 2 with
91
+ * `pending_commit: null`. Version-2 files must record `pending_commit`
92
+ * explicitly, so a truncated or hand-edited v2 file fails closed instead of
93
+ * silently losing a durable commit transaction.
34
94
  */
35
95
  export const RunStateSchema = Schema.Struct({
36
- version: Schema.Literal(1),
96
+ // v1 on Encoded so legacy files still decode; decodeRunState always
97
+ // outputs version 2.
98
+ version: Schema.Union([Schema.Literal(1), Schema.Literal(2)]),
37
99
  slug: Schema.String.check(Schema.isMinLength(1)),
38
100
  step: StepSchema,
39
- phase_index: Schema.Number,
40
- phase_count_hint: Schema.NullOr(Schema.Number),
41
- rounds: Schema.Record(Schema.String, Schema.Number),
101
+ phase_index: PositiveSafeInteger,
102
+ phase_count_hint: Schema.NullOr(NonNegativeSafeInteger),
103
+ rounds: Schema.Record(Schema.String, PositiveSafeInteger),
42
104
  vcs: VcsBackendSchema,
43
105
  allow_dirty: Schema.Boolean,
44
106
  goal: Schema.String,
45
107
  last_error: Schema.NullOr(Schema.String),
46
108
  pending_artifact: Schema.NullOr(Schema.String),
47
109
  pending_role: Schema.NullOr(RoleSchema),
110
+ pending_delivery: Schema.optionalKey(PendingDeliverySchema),
48
111
  // optional on Encoded so legacy fixtures without pane fields still decode
49
112
  pending_pane_id: Schema.optionalKey(Schema.NullOr(Schema.String)),
50
113
  pending_pane_label: Schema.optionalKey(Schema.NullOr(Schema.String)),
51
- /** Mirrored in `schemas/state.schema.json`; drift is caught by schema.test.ts. */
52
- pending_floating_exit: Schema.optionalKey(Schema.NullOr(Schema.String)),
53
- pending_started_at: Schema.optionalKey(Schema.NullOr(Schema.Number)),
54
- pending_deadline_ms: Schema.optionalKey(Schema.NullOr(Schema.Number)),
55
- pending_nudged_at: Schema.optionalKey(Schema.NullOr(Schema.Number)),
114
+ pending_started_at: Schema.optionalKey(Schema.NullOr(NonNegativeSafeInteger)),
115
+ pending_deadline_ms: Schema.optionalKey(
116
+ Schema.NullOr(NonNegativeSafeInteger),
117
+ ),
118
+ pending_nudged_at: Schema.optionalKey(Schema.NullOr(NonNegativeSafeInteger)),
56
119
  pending_final_grace: Schema.optionalKey(Schema.Boolean),
57
120
  pending_extended: Schema.optionalKey(Schema.Boolean),
58
121
  role_panes: Schema.optionalKey(Schema.Record(Schema.String, PaneRefSchema)),
@@ -60,15 +123,66 @@ export const RunStateSchema = Schema.Struct({
60
123
  reviewer_tree_fingerprint: Schema.NullOr(Schema.String),
61
124
  current_phase_package: Schema.NullOr(Schema.String),
62
125
  current_code_review: Schema.NullOr(Schema.String),
63
- phase_package_rework: Schema.optionalKey(Schema.Boolean),
126
+ required_rework: Schema.optionalKey(RequiredReworkSchema),
127
+ // optional on Encoded so version-1 files without the key still decode;
128
+ // presence rules per version are enforced in `decodeRunState`.
129
+ pending_commit: Schema.optionalKey(Schema.NullOr(PendingCommitSchema)),
64
130
  })
65
131
 
66
132
  export type DecodedRunState = typeof RunStateSchema.Type
67
133
 
134
+ function isPersistedArtifactPath(value: string): boolean {
135
+ if (
136
+ !value.startsWith(".apnea/") ||
137
+ value.includes("\\") ||
138
+ value.includes("\0")
139
+ ) {
140
+ return false
141
+ }
142
+ return value
143
+ .slice(".apnea/".length)
144
+ .split("/")
145
+ .every((part) => part !== "" && part !== "." && part !== "..")
146
+ }
147
+
148
+ function hasMatchingPendingCoderDispatch(d: DecodedRunState): boolean {
149
+ const phase = String(d.phase_index).padStart(2, "0")
150
+ const round = d.rounds[`phase-${phase}/code_review`] ?? 1
151
+ return (
152
+ d.pending_role === "coder" &&
153
+ d.pending_artifact ===
154
+ `.apnea/artifacts/phase-${phase}/round-${round}/coder-result.md`
155
+ )
156
+ }
157
+
68
158
  export function decodeRunState(
69
159
  json: unknown,
70
160
  path = "state.json",
71
161
  ): Result.Result<RunState, StateCorrupt> {
162
+ if (
163
+ json !== null &&
164
+ typeof json === "object" &&
165
+ !Array.isArray(json) &&
166
+ "pending_floating_exit" in json &&
167
+ json.pending_floating_exit !== null
168
+ ) {
169
+ return Result.fail(
170
+ new StateCorrupt({
171
+ path,
172
+ message:
173
+ 'this run has an active legacy floating dispatch, but floating dispatch was removed; dismiss or terminate the old popup first, then run `apnea abandon` and `apnea start "<goal>"`',
174
+ }),
175
+ )
176
+ }
177
+
178
+ const raw =
179
+ json !== null && typeof json === "object" && !Array.isArray(json)
180
+ ? (json as Record<string, unknown>)
181
+ : {}
182
+ const hasRequiredRework = raw.required_rework !== undefined
183
+ const hasPendingDelivery = raw.pending_delivery !== undefined
184
+ const hasPendingCommit =
185
+ "pending_commit" in raw && raw.pending_commit !== undefined
72
186
  const decoded = Schema.decodeUnknownResult(RunStateSchema)(json)
73
187
  if (Result.isFailure(decoded)) {
74
188
  return Result.fail(
@@ -79,9 +193,80 @@ export function decodeRunState(
79
193
  )
80
194
  }
81
195
  const d = decoded.success
196
+ // A version-1 writer never emits `pending_commit`; its presence means the
197
+ // file was mixed across versions. Fail closed instead of guessing.
198
+ if (d.version === 1 && hasPendingCommit) {
199
+ return Result.fail(
200
+ new StateCorrupt({
201
+ path,
202
+ message:
203
+ "pending_commit requires state version 2; refusing version-1 file that carries it",
204
+ }),
205
+ )
206
+ }
207
+ if (d.version === 2 && !hasPendingCommit) {
208
+ return Result.fail(
209
+ new StateCorrupt({
210
+ path,
211
+ message:
212
+ "version-2 state must record pending_commit explicitly; refusing file that omits it",
213
+ }),
214
+ )
215
+ }
216
+ if (
217
+ d.pending_commit !== null &&
218
+ d.pending_commit !== undefined &&
219
+ d.step !== "committing"
220
+ ) {
221
+ return Result.fail(
222
+ new StateCorrupt({
223
+ path,
224
+ message: `pending_commit requires step "committing", found "${d.step}"`,
225
+ }),
226
+ )
227
+ }
228
+ if (
229
+ d.pending_commit != null &&
230
+ d.pending_commit.phase_index !== d.phase_index
231
+ ) {
232
+ return Result.fail(
233
+ new StateCorrupt({
234
+ path,
235
+ message: `pending_commit targets phase ${d.pending_commit.phase_index} but state is at phase ${d.phase_index}`,
236
+ }),
237
+ )
238
+ }
239
+ if (
240
+ d.pending_commit != null &&
241
+ !isPersistedArtifactPath(d.pending_commit.verify_log)
242
+ ) {
243
+ return Result.fail(
244
+ new StateCorrupt({
245
+ path,
246
+ message:
247
+ "pending_commit.verify_log must be a repository-relative .apnea/ path",
248
+ }),
249
+ )
250
+ }
251
+ for (const [field, value] of [
252
+ ["pending_artifact", d.pending_artifact],
253
+ ["current_phase_package", d.current_phase_package],
254
+ ["current_code_review", d.current_code_review],
255
+ ] as const) {
256
+ if (value !== null && !isPersistedArtifactPath(value)) {
257
+ return Result.fail(
258
+ new StateCorrupt({
259
+ path,
260
+ message: `${field} must be a repository-relative .apnea/ path`,
261
+ }),
262
+ )
263
+ }
264
+ }
82
265
  // Backward-compat defaults for state.json files predating pane tracking.
266
+ // Every decoded state migrates to version 2; the rewrite to disk happens
267
+ // at the next normal save.
83
268
  const state: RunState = {
84
- version: 1,
269
+ version: 2,
85
270
  slug: d.slug,
86
271
  step: d.step as Step,
87
272
  phase_index: d.phase_index,
@@ -93,9 +278,13 @@ export function decodeRunState(
93
278
  last_error: d.last_error,
94
279
  pending_artifact: d.pending_artifact,
95
280
  pending_role: d.pending_role,
281
+ pending_delivery: hasPendingDelivery
282
+ ? (d.pending_delivery ?? null)
283
+ : d.pending_artifact !== null && d.pending_pane_id != null
284
+ ? "interactive"
285
+ : null,
96
286
  pending_pane_id: d.pending_pane_id ?? null,
97
287
  pending_pane_label: d.pending_pane_label ?? null,
98
- pending_floating_exit: d.pending_floating_exit ?? null,
99
288
  pending_started_at: d.pending_started_at ?? null,
100
289
  pending_deadline_ms: d.pending_deadline_ms ?? null,
101
290
  pending_nudged_at: d.pending_nudged_at ?? null,
@@ -111,7 +300,28 @@ export function decodeRunState(
111
300
  reviewer_tree_fingerprint: d.reviewer_tree_fingerprint,
112
301
  current_phase_package: d.current_phase_package,
113
302
  current_code_review: d.current_code_review,
114
- phase_package_rework: d.phase_package_rework ?? false,
303
+ required_rework: (hasRequiredRework
304
+ ? d.required_rework
305
+ : raw.phase_package_rework === true
306
+ ? "phase_package"
307
+ : null) as RequiredReworkTarget | null,
308
+ pending_commit: d.pending_commit ?? null,
309
+ }
310
+ if (
311
+ !hasRequiredRework &&
312
+ state.required_rework === null &&
313
+ d.step === "planning"
314
+ ) {
315
+ Object.defineProperty(state, LEGACY_PLAN_REWORK, { value: true })
316
+ }
317
+ if (
318
+ !hasRequiredRework &&
319
+ state.required_rework === null &&
320
+ d.step === "coding" &&
321
+ d.current_code_review !== null &&
322
+ !hasMatchingPendingCoderDispatch(d)
323
+ ) {
324
+ Object.defineProperty(state, LEGACY_CODE_REWORK, { value: true })
115
325
  }
116
326
  return Result.succeed(state)
117
327
  }
@@ -5,6 +5,7 @@ import { FileSystemLive } from "./file-system.ts"
5
5
  import { makeHerdrLive } from "./herdr.ts"
6
6
  import { RunStoreLive } from "./run-store.ts"
7
7
  import { VcsLive } from "./vcs.ts"
8
+ import { ProcessLive } from "./process.ts"
8
9
 
9
10
  /**
10
11
  * Blueprint layer for tool calls. Built freshly on every `Effect.provide`
@@ -18,7 +19,7 @@ export const makeAppLive = (hostAdapter: ApneaHostAdapter) =>
18
19
  VcsLive,
19
20
  makeHerdrLive(hostAdapter),
20
21
  ),
21
- FileSystemLive,
22
+ Layer.merge(FileSystemLive, ProcessLive),
22
23
  )
23
24
 
24
25
  export const AppLive = makeAppLive(neutralHostAdapter)
@@ -1,3 +1,4 @@
1
+ import * as os from "node:os"
1
2
  import { Context, Effect, Layer, Result } from "effect"
2
3
  import { globalConfigPath, projectConfigPath } from "../domain/paths.ts"
3
4
  import { ConfigError } from "../errors.ts"
@@ -50,7 +51,8 @@ export const ConfigLive = Layer.effect(
50
51
 
51
52
  const load = (root: string): Effect.Effect<ApneaConfig, ConfigError> =>
52
53
  Effect.gen(function* () {
53
- const gPath = globalConfigPath()
54
+ const trustedHome = os.homedir()
55
+ const gPath = globalConfigPath(trustedHome)
54
56
  const gPresent = yield* fs.exists(gPath)
55
57
  if (!gPresent) {
56
58
  return yield* new ConfigError({
@@ -58,7 +60,7 @@ export const ConfigLive = Layer.effect(
58
60
  path: gPath,
59
61
  })
60
62
  }
61
- const gText = yield* fs.readFile(gPath)
63
+ const gText = yield* fs.readTrustedGlobalFile(trustedHome, gPath)
62
64
  const gRaw = yield* parseJson(gText, gPath)
63
65
  const gDecoded = decodeGlobalConfig(gRaw)
64
66
  if (Result.isFailure(gDecoded)) {
@@ -67,9 +69,9 @@ export const ConfigLive = Layer.effect(
67
69
  let cfg = gDecoded.success
68
70
 
69
71
  const pPath = projectConfigPath(root)
70
- const pPresent = yield* fs.exists(pPath)
72
+ const pPresent = yield* fs.projectPathExists(root, pPath)
71
73
  if (pPresent) {
72
- const pText = yield* fs.readFile(pPath)
74
+ const pText = yield* fs.readProjectFile(root, pPath)
73
75
  const pRaw = yield* parseJson(pText, pPath)
74
76
  const pDecoded = decodeProjectConfig(pRaw)
75
77
  if (Result.isFailure(pDecoded)) {