@naxodev/apnea 0.2.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 (44) hide show
  1. package/README.md +18 -1
  2. package/SECURITY.md +32 -0
  3. package/briefs/orchestrator.md +4 -3
  4. package/dist/cli.js +8283 -15058
  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 +389 -242
  32. package/extension/services/operation-lock.ts +418 -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 +274 -57
  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
@@ -47,7 +47,7 @@ export const startWorkflow = (
47
47
  let pendingStatus: string = "none"
48
48
  if (pending) {
49
49
  const absPath = pending.startsWith("/") ? pending : `${root}/${pending}`
50
- const present = yield* fs.exists(absPath)
50
+ const present = yield* fs.projectPathExists(root, absPath)
51
51
  pendingStatus = present ? "artifact_exists" : "artifact_missing"
52
52
  }
53
53
  return ok(
@@ -59,7 +59,7 @@ export const startWorkflow = (
59
59
  pendingStatus === "artifact_exists"
60
60
  ? "call workflow_wait to ingest pending artifact"
61
61
  : pendingStatus === "artifact_missing"
62
- ? "offer re-dispatch same round via dispatch_role"
62
+ ? "offer same-round dispatch_role redeliver=true after proving the prior delivery is dead"
63
63
  : "inspect workflow_status and continue legal next step",
64
64
  },
65
65
  nextAfter(existing.step),
@@ -100,7 +100,7 @@ export const startWorkflow = (
100
100
  }
101
101
 
102
102
  const state: RunState = {
103
- version: 1,
103
+ version: 2,
104
104
  slug,
105
105
  step: "planning",
106
106
  phase_index: 1,
@@ -112,6 +112,7 @@ export const startWorkflow = (
112
112
  last_error: null,
113
113
  pending_artifact: null,
114
114
  pending_role: null,
115
+ pending_delivery: null,
115
116
  pending_pane_id: null,
116
117
  pending_pane_label: null,
117
118
  pending_started_at: null,
@@ -124,7 +125,8 @@ export const startWorkflow = (
124
125
  reviewer_tree_fingerprint: null,
125
126
  current_phase_package: null,
126
127
  current_code_review: null,
127
- phase_package_rework: false,
128
+ required_rework: null,
129
+ pending_commit: null,
128
130
  }
129
131
  // The literal above assigns the ladder's fields; this re-asserts them
130
132
  // through the shared helper so a rung added there cannot be missed here.
@@ -1,6 +1,6 @@
1
1
  import { Effect, Result } from "effect"
2
2
  import { LEGAL_TOOLS, nextAfter } from "../domain/state-machine.ts"
3
- import type { StateCorrupt } from "../errors.ts"
3
+ import type { AppError } from "../errors.ts"
4
4
  import { ok, type ToolResult } from "../result.ts"
5
5
  import { Config } from "../services/config.ts"
6
6
  import { RunStore } from "../services/run-store.ts"
@@ -12,7 +12,7 @@ import { Vcs } from "../services/vcs.ts"
12
12
  */
13
13
  export const statusWorkflow = (
14
14
  root: string,
15
- ): Effect.Effect<ToolResult, StateCorrupt, RunStore | Config | Vcs> =>
15
+ ): Effect.Effect<ToolResult, AppError, RunStore | Config | Vcs> =>
16
16
  Effect.gen(function* () {
17
17
  const store = yield* RunStore
18
18
  const config = yield* Config
@@ -1,7 +1,7 @@
1
1
  import { Clock, Effect, Exit, Option, Result } from "effect"
2
2
  import { resetRecoveryLadder } from "../domain/recovery.ts"
3
3
  import { inferKind } from "../domain/artifact-kind.ts"
4
- import { timeoutMsForKind } from "../domain/timeouts.ts"
4
+ import { deadlineAfter, timeoutMsForKind } from "../domain/timeouts.ts"
5
5
  import {
6
6
  asVerdict,
7
7
  isCompleteArtifact,
@@ -9,11 +9,7 @@ import {
9
9
  } from "../domain/frontmatter.ts"
10
10
  import { looksLikeShellOnly, shellJoin } from "../domain/herdr.ts"
11
11
  import { abs, rel } from "../domain/paths.ts"
12
- import {
13
- nextAfter,
14
- stepAfterArtifact,
15
- toolAllowed,
16
- } from "../domain/state-machine.ts"
12
+ import { nextAfter, toolAllowed } from "../domain/state-machine.ts"
17
13
  import {
18
14
  ArtifactInvalid,
19
15
  GateRefused,
@@ -24,12 +20,16 @@ import {
24
20
  } from "../errors.ts"
25
21
  import type { FrontMatter } from "../domain/types.ts"
26
22
  import { ok, type ToolResult } from "../result.ts"
27
- import { decodeFrontMatterResult } from "../schema/frontmatter.ts"
23
+ import {
24
+ validateArtifactCompletion,
25
+ type AcceptedArtifactCompletion,
26
+ } from "../schema/frontmatter.ts"
28
27
  import { Config } from "../services/config.ts"
29
28
  import { FileSystem } from "../services/file-system.ts"
30
29
  import { Herdr, paneReadRecentArgs } from "../services/herdr.ts"
31
30
  import { RunStore } from "../services/run-store.ts"
32
31
  import { Vcs } from "../services/vcs.ts"
32
+ import type { OperationHooks } from "../operation-hooks.ts"
33
33
 
34
34
  export type WaitParams = {
35
35
  poll_ms?: number
@@ -151,12 +151,7 @@ export const MAX_AUTO_POLL_MS =
151
151
  export const fitsHostShell = (pollMs: number): boolean =>
152
152
  defaultBudgetFor(pollMs) < HOST_SHELL_TIMEOUT_MS
153
153
 
154
- export type WaitHooks = {
155
- signal?: AbortSignal
156
- onUpdate?: (partial: {
157
- content: Array<{ type: "text"; text: string }>
158
- }) => void
159
- }
154
+ export type WaitHooks = OperationHooks
160
155
 
161
156
  /**
162
157
  * Async wait — polls via `Clock` + `Effect.sleep` so Pi stays responsive and
@@ -202,10 +197,10 @@ export const waitWorkflow = (
202
197
  const cfg = yield* config.load(root)
203
198
 
204
199
  const poll = params.poll_ms ?? 2000
205
- if (!Number.isFinite(poll) || poll < MIN_POLL_MS) {
200
+ if (!Number.isSafeInteger(poll) || poll < MIN_POLL_MS) {
206
201
  return yield* new GateRefused({
207
202
  gate: "poll_interval",
208
- message: `poll_ms must be a finite number >= ${MIN_POLL_MS}; got ${poll}. Every poll spawns two herdr subprocesses, so a tiny interval is a busy-spin, not a faster wait.`,
203
+ message: `poll_ms must be a safe integer >= ${MIN_POLL_MS}; got ${poll}. Every poll spawns two herdr subprocesses, so a tiny interval is a busy-spin, not a faster wait.`,
209
204
  })
210
205
  }
211
206
  // Refuse to PICK a budget that a default host shell would kill; still
@@ -232,10 +227,10 @@ export const waitWorkflow = (
232
227
  // NaN, and the call blocks until the role's deadline instead of
233
228
  // returning exit 3. The CLI is protected by `parseNumFlag`; a Pi tool
234
229
  // call reaches `run` through a raw cast with no runtime coercion.
235
- if (!Number.isFinite(budget)) {
230
+ if (!Number.isSafeInteger(budget) || budget <= 0) {
236
231
  return yield* new GateRefused({
237
232
  gate: "budget_floor",
238
- message: `budget_ms must be a finite number; got ${budget}`,
233
+ message: `budget_ms must be a positive safe integer; got ${budget}`,
239
234
  })
240
235
  }
241
236
  const floor = minBudgetFor(poll)
@@ -273,35 +268,33 @@ export const waitWorkflow = (
273
268
  const dispatchedAt = state.pending_started_at ?? startedMs
274
269
  if (state.pending_deadline_ms == null) {
275
270
  state.pending_started_at = dispatchedAt
276
- state.pending_deadline_ms = dispatchedAt + timeout
271
+ state.pending_deadline_ms = deadlineAfter(dispatchedAt, timeout)
277
272
  yield* store.save(state, root)
278
273
  }
279
274
  const requireVerdict = kind === "plan_review" || kind === "code_review"
280
275
 
281
- const readArtifact = (): Effect.Effect<FrontMatter | null> =>
276
+ const readArtifact = (): Effect.Effect<FrontMatter | null, AppError> =>
282
277
  Effect.gen(function* () {
283
- const present = yield* fs.exists(artifactAbs)
278
+ const present = yield* fs.projectPathExists(root, artifactAbs)
284
279
  if (!present) return null
285
- const text = yield* fs.readFile(artifactAbs)
280
+ const text = yield* fs.readProjectFile(root, artifactAbs).pipe(
281
+ Effect.mapError(
282
+ (error) =>
283
+ new ArtifactInvalid({
284
+ artifact: pendingArtifact,
285
+ message: error.message,
286
+ }),
287
+ ),
288
+ )
286
289
  return parseFrontMatter(text)
287
290
  })
288
291
 
289
292
  const advanceOnComplete = (
290
293
  fm: FrontMatter,
294
+ completion: AcceptedArtifactCompletion,
291
295
  msg = "artifact ready",
292
296
  ): Effect.Effect<ToolResult, AppError> =>
293
297
  Effect.gen(function* () {
294
- if (
295
- fm.rework !== undefined &&
296
- (kind !== "code_review" || fm.verdict !== "CHANGES_REQUIRED")
297
- ) {
298
- return yield* new ArtifactInvalid({
299
- artifact: pendingArtifact,
300
- message:
301
- "rework is valid only on a code_review artifact with verdict CHANGES_REQUIRED",
302
- })
303
- }
304
-
305
298
  if (
306
299
  state.pending_role === "reviewer" &&
307
300
  state.reviewer_tree_fingerprint != null
@@ -323,51 +316,26 @@ export const waitWorkflow = (
323
316
  }
324
317
  }
325
318
 
326
- // Post-completion Schema guard only isCompleteArtifact above is the
327
- // completeness test. A bad/absent verdict must keep waiting, not fail
328
- // here (that already happened before advanceOnComplete was called).
329
- //
330
- // Only review kinds carry a meaningful verdict. A stray `verdict:` on
331
- // a plan/code/package artifact is noise the old parser ignored;
332
- // validating it here would wedge the run permanently (state is not
333
- // saved on failure, so every retry fails identically).
334
- const decoded = decodeFrontMatterResult(
335
- {
336
- status: fm.status,
337
- ...(requireVerdict ? { verdict: fm.verdict } : {}),
338
- nits: fm.nits,
339
- ...(kind === "code_review" && fm.rework
340
- ? { rework: fm.rework }
341
- : {}),
342
- },
343
- pendingArtifact,
344
- )
345
- if (Result.isFailure(decoded)) {
346
- return yield* decoded.failure
347
- }
348
-
349
- const rework =
350
- kind === "code_review" ? decoded.success.rework : undefined
351
- const next = stepAfterArtifact(kind, fm.verdict, rework)
352
- if (typeof next === "object") {
353
- return yield* new ArtifactInvalid({
354
- artifact: pendingArtifact,
355
- message: next.error,
356
- })
357
- }
319
+ const { next, rework } = completion
358
320
 
359
321
  if (kind === "phase_package") {
360
322
  state.current_phase_package = pendingArtifact
361
323
  }
362
324
  if (kind === "code_review") {
363
325
  state.current_code_review = pendingArtifact
364
- state.phase_package_rework =
365
- fm.verdict === "CHANGES_REQUIRED" && rework === "phase_package"
366
326
  }
367
327
  const verdict = asVerdict(fm.verdict)
328
+ if (kind === "plan_review") {
329
+ state.required_rework = verdict === "CHANGES_REQUIRED" ? "plan" : null
330
+ }
331
+ if (kind === "code_review") {
332
+ state.required_rework =
333
+ verdict === "CHANGES_REQUIRED" ? (rework ?? "code") : null
334
+ }
368
335
  state.step = next
369
336
  state.pending_artifact = null
370
337
  state.pending_role = null
338
+ state.pending_delivery = null
371
339
  state.pending_pane_id = null
372
340
  state.pending_pane_label = null
373
341
  state.pending_started_at = null
@@ -422,7 +390,10 @@ export const waitWorkflow = (
422
390
  * without the second chance. `pending_final_grace` already makes the
423
391
  * deadline rung one-shot per run, so forcing it cannot spam a pane.
424
392
  */
425
- const tryNudge = (why: string, force = false): Effect.Effect<void> =>
393
+ const tryNudge = (
394
+ why: string,
395
+ force = false,
396
+ ): Effect.Effect<void, AppError> =>
426
397
  Effect.gen(function* () {
427
398
  if (
428
399
  (nudged && !force) ||
@@ -469,12 +440,16 @@ export const waitWorkflow = (
469
440
  const loop: Effect.Effect<ToolResult, AppError> = Effect.gen(function* () {
470
441
  while (true) {
471
442
  const now = yield* Clock.currentTimeMillis
472
- const deadline = state.pending_deadline_ms ?? startedMs + timeout
443
+ const deadline =
444
+ state.pending_deadline_ms ?? deadlineAfter(startedMs, timeout)
473
445
 
474
446
  const fm = yield* readArtifact()
475
- if (isCompleteArtifact(fm, { requireVerdict })) {
447
+ const completion = validateArtifactCompletion(kind, fm, pendingArtifact)
448
+ if (Result.isFailure(completion)) return yield* completion.failure
449
+ if (completion.success !== null) {
476
450
  return yield* advanceOnComplete(
477
451
  fm!,
452
+ completion.success,
478
453
  nudged ? "artifact ready after nudge" : "artifact ready",
479
454
  )
480
455
  }
@@ -490,7 +465,7 @@ export const waitWorkflow = (
490
465
  message: `role pane gone and artifact incomplete: ${pendingArtifact}`,
491
466
  details: {
492
467
  last_agent_status: lastStatus,
493
- hint: "re-dispatch same round after investigate",
468
+ hint: "after investigate, dispatch the same kind with redeliver=true",
494
469
  },
495
470
  })
496
471
  }
@@ -502,9 +477,18 @@ export const waitWorkflow = (
502
477
  // The artifact may have completed between the read above and this
503
478
  // terminal status, so fail the contract only after one fresh read.
504
479
  const artifactAfterDone = yield* readArtifact()
505
- if (isCompleteArtifact(artifactAfterDone, { requireVerdict })) {
480
+ const completionAfterDone = validateArtifactCompletion(
481
+ kind,
482
+ artifactAfterDone,
483
+ pendingArtifact,
484
+ )
485
+ if (Result.isFailure(completionAfterDone)) {
486
+ return yield* completionAfterDone.failure
487
+ }
488
+ if (completionAfterDone.success !== null) {
506
489
  return yield* advanceOnComplete(
507
490
  artifactAfterDone!,
491
+ completionAfterDone.success,
508
492
  "artifact ready as role exited",
509
493
  )
510
494
  }
@@ -568,7 +552,7 @@ export const waitWorkflow = (
568
552
  details: {
569
553
  last_agent_status: lastStatus,
570
554
  foreground: names,
571
- hint: "check pane transcript; re-dispatch same round",
555
+ hint: "check pane transcript; then dispatch the same kind with redeliver=true",
572
556
  },
573
557
  })
574
558
  }
@@ -605,7 +589,7 @@ export const waitWorkflow = (
605
589
  extendedOnce = true
606
590
  const extra = Math.max(Math.floor(timeout * 0.5), 120_000)
607
591
  state.pending_extended = true
608
- state.pending_deadline_ms = now + extra
592
+ state.pending_deadline_ms = deadlineAfter(now, extra)
609
593
  yield* store.save(state, root)
610
594
  hooks.onUpdate?.({
611
595
  content: [
@@ -639,7 +623,7 @@ export const waitWorkflow = (
639
623
  // grace is not.
640
624
  finalNudgeGrace = true
641
625
  state.pending_final_grace = true
642
- state.pending_deadline_ms = now + 180_000
626
+ state.pending_deadline_ms = deadlineAfter(now, 180_000)
643
627
  yield* store.save(state, root)
644
628
  yield* tryNudge("timeout idle", true)
645
629
  continue
@@ -681,7 +665,8 @@ export const waitWorkflow = (
681
665
  // was never the problem. `timeout` still sizes the extension,
682
666
  // because that has to come from config to stay stable across calls.
683
667
  const grantedMs =
684
- (state.pending_deadline_ms ?? dispatchedAt + timeout) - dispatchedAt
668
+ (state.pending_deadline_ms ?? deadlineAfter(dispatchedAt, timeout)) -
669
+ dispatchedAt
685
670
  return yield* new WaitTimeout({
686
671
  artifact: pendingArtifact,
687
672
  timeoutMs: grantedMs,
@@ -690,7 +675,7 @@ export const waitWorkflow = (
690
675
  nudged,
691
676
  extended_once: extendedOnce,
692
677
  configured_timeout_ms: timeout,
693
- hint: "inspect pane transcript; re-dispatch same round or nudge via herdr pane run",
678
+ hint: "inspect pane transcript; use redeliver=true for the same kind only after proving delivery is dead, or nudge via herdr pane run",
694
679
  },
695
680
  })
696
681
  })
@@ -707,7 +692,7 @@ export const waitWorkflow = (
707
692
  artifact,
708
693
  details: {
709
694
  last_agent_status: lastStatus,
710
- hint: "re-dispatch same round or wait again after investigate",
695
+ hint: "after investigate, use redeliver=true for the same kind or wait again",
711
696
  },
712
697
  }),
713
698
  ),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@naxodev/apnea",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Host-neutral multi-role workflow engine and standalone Bun CLI",
5
5
  "license": "MIT",
6
6
  "author": "Nacho Vazquez",
@@ -62,7 +62,7 @@
62
62
  "smoke:package": "bun scripts/package-smoke.ts"
63
63
  },
64
64
  "dependencies": {
65
- "effect": "4.0.0-beta.101",
65
+ "effect": "4.0.0-rc.111",
66
66
  "typebox": "^1.3.6"
67
67
  },
68
68
  "devDependencies": {
@@ -44,7 +44,11 @@
44
44
  "review_round_cap": { "type": "integer", "minimum": 1, "maximum": 20 },
45
45
  "timeouts_ms": {
46
46
  "type": "object",
47
- "additionalProperties": { "type": "integer", "minimum": 1000 }
47
+ "additionalProperties": {
48
+ "type": "integer",
49
+ "minimum": 1000,
50
+ "maximum": 9007199254740991
51
+ }
48
52
  }
49
53
  }
50
54
  }
@@ -5,8 +5,21 @@
5
5
  "type": "object",
6
6
  "additionalProperties": false,
7
7
  "required": ["version", "slug", "step", "phase_index", "rounds", "vcs"],
8
+ "allOf": [
9
+ {
10
+ "if": {
11
+ "properties": { "version": { "const": 2 } },
12
+ "required": ["version"]
13
+ },
14
+ "then": { "required": ["pending_commit"] },
15
+ "description": "Runtime enforcement is authoritative: decodeRunState applies the same rule plus consistency checks the JSON Schema cannot express."
16
+ }
17
+ ],
8
18
  "properties": {
9
- "version": { "const": 1 },
19
+ "version": {
20
+ "enum": [1, 2],
21
+ "description": "Version 1 is accepted INPUT for migration (decodes with pending_commit=null); version 2 is the OUTPUT every save writes and must carry pending_commit explicitly."
22
+ },
10
23
  "slug": { "type": "string", "minLength": 1 },
11
24
  "step": {
12
25
  "type": "string",
@@ -21,19 +34,39 @@
21
34
  "done"
22
35
  ]
23
36
  },
24
- "phase_index": { "type": "integer", "minimum": 0 },
25
- "phase_count_hint": { "type": ["integer", "null"], "minimum": 0 },
37
+ "phase_index": {
38
+ "type": "integer",
39
+ "minimum": 1,
40
+ "maximum": 9007199254740991
41
+ },
42
+ "phase_count_hint": {
43
+ "type": ["integer", "null"],
44
+ "minimum": 0,
45
+ "maximum": 9007199254740991
46
+ },
26
47
  "rounds": {
27
48
  "type": "object",
28
49
  "description": "Keys like phase-01/plan_review or phase-01/code_review",
29
- "additionalProperties": { "type": "integer", "minimum": 1 }
50
+ "additionalProperties": {
51
+ "type": "integer",
52
+ "minimum": 1,
53
+ "maximum": 9007199254740991
54
+ }
30
55
  },
31
56
  "vcs": { "type": "string", "enum": ["jj", "git"] },
32
57
  "allow_dirty": { "type": "boolean" },
33
58
  "goal": { "type": "string" },
34
59
  "last_error": { "type": ["string", "null"] },
35
- "pending_artifact": { "type": ["string", "null"] },
60
+ "pending_artifact": {
61
+ "type": ["string", "null"],
62
+ "pattern": "^\\.apnea\\/(?!.*\\u0000)(?!\\.(?:/|$)|\\.\\.(?:/|$))(?!.*\\/\\.(?:\\/|$))(?!.*\\/\\.\\.(?:\\/|$))(?!.*\\\\)[^/]+(?:/[^/]+)*$"
63
+ },
36
64
  "pending_role": { "type": ["string", "null"] },
65
+ "pending_delivery": {
66
+ "type": ["string", "null"],
67
+ "enum": ["manual", "interactive", null],
68
+ "description": "Known delivery boundary for pending ownership; null is idle or legacy-ambiguous"
69
+ },
37
70
  "pending_floating_exit": {
38
71
  "type": "null",
39
72
  "deprecated": true,
@@ -41,9 +74,21 @@
41
74
  },
42
75
  "pending_pane_id": { "type": ["string", "null"] },
43
76
  "pending_pane_label": { "type": ["string", "null"] },
44
- "pending_started_at": { "type": ["integer", "null"] },
45
- "pending_deadline_ms": { "type": ["integer", "null"] },
46
- "pending_nudged_at": { "type": ["integer", "null"] },
77
+ "pending_started_at": {
78
+ "type": ["integer", "null"],
79
+ "minimum": 0,
80
+ "maximum": 9007199254740991
81
+ },
82
+ "pending_deadline_ms": {
83
+ "type": ["integer", "null"],
84
+ "minimum": 0,
85
+ "maximum": 9007199254740991
86
+ },
87
+ "pending_nudged_at": {
88
+ "type": ["integer", "null"],
89
+ "minimum": 0,
90
+ "maximum": 9007199254740991
91
+ },
47
92
  "pending_final_grace": { "type": "boolean" },
48
93
  "pending_extended": { "type": "boolean" },
49
94
  "role_panes": {
@@ -60,8 +105,117 @@
60
105
  },
61
106
  "package_root": { "type": "string" },
62
107
  "reviewer_tree_fingerprint": { "type": ["string", "null"] },
63
- "current_phase_package": { "type": ["string", "null"] },
64
- "current_code_review": { "type": ["string", "null"] },
65
- "phase_package_rework": { "type": "boolean" }
108
+ "current_phase_package": {
109
+ "type": ["string", "null"],
110
+ "pattern": "^\\.apnea\\/(?!.*\\u0000)(?!\\.(?:/|$)|\\.\\.(?:/|$))(?!.*\\/\\.(?:\\/|$))(?!.*\\/\\.\\.(?:\\/|$))(?!.*\\\\)[^/]+(?:/[^/]+)*$"
111
+ },
112
+ "current_code_review": {
113
+ "type": ["string", "null"],
114
+ "pattern": "^\\.apnea\\/(?!.*\\u0000)(?!\\.(?:/|$)|\\.\\.(?:/|$))(?!.*\\/\\.(?:\\/|$))(?!.*\\/\\.\\.(?:\\/|$))(?!.*\\\\)[^/]+(?:/[^/]+)*$"
115
+ },
116
+ "required_rework": {
117
+ "type": ["string", "null"],
118
+ "enum": ["plan", "code", "phase_package", null],
119
+ "description": "Exact dispatch target that owns the next review round"
120
+ },
121
+ "pending_commit": {
122
+ "description": "Durable record of an in-flight commit transaction (crash-recoverable commit completion). Requires step=committing and phase_index equal to the run's phase_index; non-committing states must carry null. Version-1 files must not contain this key.",
123
+ "oneOf": [
124
+ {
125
+ "type": "null"
126
+ },
127
+ {
128
+ "type": "object",
129
+ "additionalProperties": false,
130
+ "required": [
131
+ "backend",
132
+ "id",
133
+ "phase_index",
134
+ "message",
135
+ "no_remaining_phases",
136
+ "verify_log",
137
+ "branch",
138
+ "parent_commit",
139
+ "tree_id"
140
+ ],
141
+ "properties": {
142
+ "backend": { "const": "git" },
143
+ "id": {
144
+ "type": "string",
145
+ "minLength": 1,
146
+ "description": "Transaction uuid; appears in the commit message as Apnea-Transaction: <id>"
147
+ },
148
+ "phase_index": { "type": "integer", "minimum": 1 },
149
+ "message": {
150
+ "type": "string",
151
+ "minLength": 1,
152
+ "description": "Full commit message including the Apnea-Transaction trailer line"
153
+ },
154
+ "no_remaining_phases": { "type": "boolean" },
155
+ "verify_log": { "type": "string", "minLength": 1 },
156
+ "branch": {
157
+ "type": "string",
158
+ "minLength": 1,
159
+ "description": "Full ref the commit must land on, e.g. refs/heads/apnea/<slug>"
160
+ },
161
+ "parent_commit": {
162
+ "type": "string",
163
+ "minLength": 1,
164
+ "description": "Expected HEAD at completion; also the created commit's parent"
165
+ },
166
+ "tree_id": {
167
+ "type": "string",
168
+ "minLength": 1,
169
+ "description": "Prepared tree id staged with .apnea excluded"
170
+ }
171
+ }
172
+ },
173
+ {
174
+ "type": "object",
175
+ "additionalProperties": false,
176
+ "required": [
177
+ "backend",
178
+ "id",
179
+ "phase_index",
180
+ "message",
181
+ "no_remaining_phases",
182
+ "verify_log",
183
+ "change_id",
184
+ "content_fingerprint"
185
+ ],
186
+ "properties": {
187
+ "backend": { "const": "jj" },
188
+ "id": {
189
+ "type": "string",
190
+ "minLength": 1,
191
+ "description": "Transaction uuid; appears in the change description as Apnea-Transaction: <id>"
192
+ },
193
+ "phase_index": { "type": "integer", "minimum": 1 },
194
+ "message": {
195
+ "type": "string",
196
+ "minLength": 1,
197
+ "description": "Full description including the Apnea-Transaction trailer line"
198
+ },
199
+ "no_remaining_phases": { "type": "boolean" },
200
+ "verify_log": { "type": "string", "minLength": 1 },
201
+ "change_id": {
202
+ "type": "string",
203
+ "minLength": 1,
204
+ "description": "Change id of the described working-copy change"
205
+ },
206
+ "content_fingerprint": {
207
+ "type": "string",
208
+ "minLength": 1,
209
+ "description": "Fingerprint of the change's non-.apnea diff at preparation time"
210
+ }
211
+ }
212
+ }
213
+ ]
214
+ },
215
+ "phase_package_rework": {
216
+ "type": "boolean",
217
+ "deprecated": true,
218
+ "description": "Deprecated migration-only field. True migrates to required_rework=phase_package."
219
+ }
66
220
  }
67
221
  }