@naxodev/apnea 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +18 -1
  2. package/SECURITY.md +36 -0
  3. package/briefs/orchestrator.md +4 -3
  4. package/dist/cli.js +8375 -15093
  5. package/docs/protocol/artifacts.md +18 -2
  6. package/docs/protocol/config.md +15 -3
  7. package/docs/protocol/manual-gate.md +8 -8
  8. package/docs/protocol/overview.md +17 -4
  9. package/extension/adapters/commit.ts +5 -1
  10. package/extension/adapters/dispatch.ts +9 -1
  11. package/extension/adapters/setup.ts +15 -1
  12. package/extension/adapters/start.ts +5 -1
  13. package/extension/adapters/status.ts +17 -2
  14. package/extension/adapters/wait.ts +6 -1
  15. package/extension/api.ts +7 -1
  16. package/extension/cli/main.ts +67 -7
  17. package/extension/cli/parse.ts +172 -5
  18. package/extension/domain/paths.ts +2 -11
  19. package/extension/domain/timeouts.ts +4 -0
  20. package/extension/domain/types.ts +65 -3
  21. package/extension/errors.ts +51 -16
  22. package/extension/operation-hooks.ts +6 -0
  23. package/extension/registry.ts +29 -15
  24. package/extension/run-tool.ts +19 -2
  25. package/extension/schema/config.ts +58 -16
  26. package/extension/schema/frontmatter.ts +57 -0
  27. package/extension/schema/state.ts +210 -13
  28. package/extension/services/app-live.ts +2 -1
  29. package/extension/services/config.ts +6 -4
  30. package/extension/services/file-system.ts +346 -75
  31. package/extension/services/herdr.ts +466 -260
  32. package/extension/services/operation-lock.ts +452 -0
  33. package/extension/services/process.ts +477 -0
  34. package/extension/services/run-store.ts +38 -16
  35. package/extension/services/vcs.ts +1258 -328
  36. package/extension/workflows/commit.ts +214 -13
  37. package/extension/workflows/dispatch.ts +305 -67
  38. package/extension/workflows/setup.ts +59 -32
  39. package/extension/workflows/start.ts +6 -4
  40. package/extension/workflows/status.ts +2 -2
  41. package/extension/workflows/wait.ts +62 -77
  42. package/package.json +2 -2
  43. package/schemas/config.schema.json +5 -1
  44. package/schemas/state.schema.json +165 -11
@@ -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,35 +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
- pending_started_at: Schema.optionalKey(Schema.NullOr(Schema.Number)),
52
- pending_deadline_ms: Schema.optionalKey(Schema.NullOr(Schema.Number)),
53
- 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)),
54
119
  pending_final_grace: Schema.optionalKey(Schema.Boolean),
55
120
  pending_extended: Schema.optionalKey(Schema.Boolean),
56
121
  role_panes: Schema.optionalKey(Schema.Record(Schema.String, PaneRefSchema)),
@@ -58,11 +123,38 @@ export const RunStateSchema = Schema.Struct({
58
123
  reviewer_tree_fingerprint: Schema.NullOr(Schema.String),
59
124
  current_phase_package: Schema.NullOr(Schema.String),
60
125
  current_code_review: Schema.NullOr(Schema.String),
61
- 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)),
62
130
  })
63
131
 
64
132
  export type DecodedRunState = typeof RunStateSchema.Type
65
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
+
66
158
  export function decodeRunState(
67
159
  json: unknown,
68
160
  path = "state.json",
@@ -83,6 +175,14 @@ export function decodeRunState(
83
175
  )
84
176
  }
85
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
86
186
  const decoded = Schema.decodeUnknownResult(RunStateSchema)(json)
87
187
  if (Result.isFailure(decoded)) {
88
188
  return Result.fail(
@@ -93,9 +193,80 @@ export function decodeRunState(
93
193
  )
94
194
  }
95
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
+ }
96
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.
97
268
  const state: RunState = {
98
- version: 1,
269
+ version: 2,
99
270
  slug: d.slug,
100
271
  step: d.step as Step,
101
272
  phase_index: d.phase_index,
@@ -107,6 +278,11 @@ export function decodeRunState(
107
278
  last_error: d.last_error,
108
279
  pending_artifact: d.pending_artifact,
109
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,
110
286
  pending_pane_id: d.pending_pane_id ?? null,
111
287
  pending_pane_label: d.pending_pane_label ?? null,
112
288
  pending_started_at: d.pending_started_at ?? null,
@@ -124,7 +300,28 @@ export function decodeRunState(
124
300
  reviewer_tree_fingerprint: d.reviewer_tree_fingerprint,
125
301
  current_phase_package: d.current_phase_package,
126
302
  current_code_review: d.current_code_review,
127
- 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 })
128
325
  }
129
326
  return Result.succeed(state)
130
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)) {