@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,23 +1,15 @@
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,
8
8
  parseFrontMatter,
9
9
  } from "../domain/frontmatter.ts"
10
- import {
11
- looksLikeShellOnly,
12
- parseFloatingExit,
13
- shellJoin,
14
- } from "../domain/herdr.ts"
10
+ import { looksLikeShellOnly, shellJoin } from "../domain/herdr.ts"
15
11
  import { abs, rel } from "../domain/paths.ts"
16
- import {
17
- nextAfter,
18
- stepAfterArtifact,
19
- toolAllowed,
20
- } from "../domain/state-machine.ts"
12
+ import { nextAfter, toolAllowed } from "../domain/state-machine.ts"
21
13
  import {
22
14
  ArtifactInvalid,
23
15
  GateRefused,
@@ -28,12 +20,16 @@ import {
28
20
  } from "../errors.ts"
29
21
  import type { FrontMatter } from "../domain/types.ts"
30
22
  import { ok, type ToolResult } from "../result.ts"
31
- import { decodeFrontMatterResult } from "../schema/frontmatter.ts"
23
+ import {
24
+ validateArtifactCompletion,
25
+ type AcceptedArtifactCompletion,
26
+ } from "../schema/frontmatter.ts"
32
27
  import { Config } from "../services/config.ts"
33
28
  import { FileSystem } from "../services/file-system.ts"
34
29
  import { Herdr, paneReadRecentArgs } from "../services/herdr.ts"
35
30
  import { RunStore } from "../services/run-store.ts"
36
31
  import { Vcs } from "../services/vcs.ts"
32
+ import type { OperationHooks } from "../operation-hooks.ts"
37
33
 
38
34
  export type WaitParams = {
39
35
  poll_ms?: number
@@ -155,12 +151,7 @@ export const MAX_AUTO_POLL_MS =
155
151
  export const fitsHostShell = (pollMs: number): boolean =>
156
152
  defaultBudgetFor(pollMs) < HOST_SHELL_TIMEOUT_MS
157
153
 
158
- export type WaitHooks = {
159
- signal?: AbortSignal
160
- onUpdate?: (partial: {
161
- content: Array<{ type: "text"; text: string }>
162
- }) => void
163
- }
154
+ export type WaitHooks = OperationHooks
164
155
 
165
156
  /**
166
157
  * Async wait — polls via `Clock` + `Effect.sleep` so Pi stays responsive and
@@ -206,10 +197,10 @@ export const waitWorkflow = (
206
197
  const cfg = yield* config.load(root)
207
198
 
208
199
  const poll = params.poll_ms ?? 2000
209
- if (!Number.isFinite(poll) || poll < MIN_POLL_MS) {
200
+ if (!Number.isSafeInteger(poll) || poll < MIN_POLL_MS) {
210
201
  return yield* new GateRefused({
211
202
  gate: "poll_interval",
212
- 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.`,
213
204
  })
214
205
  }
215
206
  // Refuse to PICK a budget that a default host shell would kill; still
@@ -236,10 +227,10 @@ export const waitWorkflow = (
236
227
  // NaN, and the call blocks until the role's deadline instead of
237
228
  // returning exit 3. The CLI is protected by `parseNumFlag`; a Pi tool
238
229
  // call reaches `run` through a raw cast with no runtime coercion.
239
- if (!Number.isFinite(budget)) {
230
+ if (!Number.isSafeInteger(budget) || budget <= 0) {
240
231
  return yield* new GateRefused({
241
232
  gate: "budget_floor",
242
- message: `budget_ms must be a finite number; got ${budget}`,
233
+ message: `budget_ms must be a positive safe integer; got ${budget}`,
243
234
  })
244
235
  }
245
236
  const floor = minBudgetFor(poll)
@@ -277,45 +268,33 @@ export const waitWorkflow = (
277
268
  const dispatchedAt = state.pending_started_at ?? startedMs
278
269
  if (state.pending_deadline_ms == null) {
279
270
  state.pending_started_at = dispatchedAt
280
- state.pending_deadline_ms = dispatchedAt + timeout
271
+ state.pending_deadline_ms = deadlineAfter(dispatchedAt, timeout)
281
272
  yield* store.save(state, root)
282
273
  }
283
274
  const requireVerdict = kind === "plan_review" || kind === "code_review"
284
275
 
285
- const readArtifact = (): Effect.Effect<FrontMatter | null> =>
276
+ const readArtifact = (): Effect.Effect<FrontMatter | null, AppError> =>
286
277
  Effect.gen(function* () {
287
- const present = yield* fs.exists(artifactAbs)
278
+ const present = yield* fs.projectPathExists(root, artifactAbs)
288
279
  if (!present) return null
289
- 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
+ )
290
289
  return parseFrontMatter(text)
291
290
  })
292
291
 
293
- const readFloatingExitCode = (
294
- exitAbs: string,
295
- ): Effect.Effect<number | null> =>
296
- Effect.gen(function* () {
297
- const present = yield* fs.exists(exitAbs)
298
- if (!present) return null
299
- const text = yield* fs.readFile(exitAbs)
300
- return parseFloatingExit(text)
301
- })
302
-
303
292
  const advanceOnComplete = (
304
293
  fm: FrontMatter,
294
+ completion: AcceptedArtifactCompletion,
305
295
  msg = "artifact ready",
306
296
  ): Effect.Effect<ToolResult, AppError> =>
307
297
  Effect.gen(function* () {
308
- if (
309
- fm.rework !== undefined &&
310
- (kind !== "code_review" || fm.verdict !== "CHANGES_REQUIRED")
311
- ) {
312
- return yield* new ArtifactInvalid({
313
- artifact: pendingArtifact,
314
- message:
315
- "rework is valid only on a code_review artifact with verdict CHANGES_REQUIRED",
316
- })
317
- }
318
-
319
298
  if (
320
299
  state.pending_role === "reviewer" &&
321
300
  state.reviewer_tree_fingerprint != null
@@ -337,54 +316,28 @@ export const waitWorkflow = (
337
316
  }
338
317
  }
339
318
 
340
- // Post-completion Schema guard only isCompleteArtifact above is the
341
- // completeness test. A bad/absent verdict must keep waiting, not fail
342
- // here (that already happened before advanceOnComplete was called).
343
- //
344
- // Only review kinds carry a meaningful verdict. A stray `verdict:` on
345
- // a plan/code/package artifact is noise the old parser ignored;
346
- // validating it here would wedge the run permanently (state is not
347
- // saved on failure, so every retry fails identically).
348
- const decoded = decodeFrontMatterResult(
349
- {
350
- status: fm.status,
351
- ...(requireVerdict ? { verdict: fm.verdict } : {}),
352
- nits: fm.nits,
353
- ...(kind === "code_review" && fm.rework
354
- ? { rework: fm.rework }
355
- : {}),
356
- },
357
- pendingArtifact,
358
- )
359
- if (Result.isFailure(decoded)) {
360
- return yield* decoded.failure
361
- }
362
-
363
- const rework =
364
- kind === "code_review" ? decoded.success.rework : undefined
365
- const next = stepAfterArtifact(kind, fm.verdict, rework)
366
- if (typeof next === "object") {
367
- return yield* new ArtifactInvalid({
368
- artifact: pendingArtifact,
369
- message: next.error,
370
- })
371
- }
319
+ const { next, rework } = completion
372
320
 
373
321
  if (kind === "phase_package") {
374
322
  state.current_phase_package = pendingArtifact
375
323
  }
376
324
  if (kind === "code_review") {
377
325
  state.current_code_review = pendingArtifact
378
- state.phase_package_rework =
379
- fm.verdict === "CHANGES_REQUIRED" && rework === "phase_package"
380
326
  }
381
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
+ }
382
335
  state.step = next
383
336
  state.pending_artifact = null
384
337
  state.pending_role = null
338
+ state.pending_delivery = null
385
339
  state.pending_pane_id = null
386
340
  state.pending_pane_label = null
387
- state.pending_floating_exit = null
388
341
  state.pending_started_at = null
389
342
  state.pending_deadline_ms = null
390
343
  resetRecoveryLadder(state)
@@ -406,8 +359,6 @@ export const waitWorkflow = (
406
359
  )
407
360
  })
408
361
 
409
- const floatingFlushMs = 2_000
410
-
411
362
  let lastStatus = "waiting"
412
363
  /**
413
364
  * Both counters below are per-call on purpose. They measure a duration
@@ -418,7 +369,6 @@ export const waitWorkflow = (
418
369
  */
419
370
  let shellOnlyPolls = 0
420
371
  let idleSince: number | null = null
421
- let floatingExitSeenAt: number | null = null
422
372
  let nudged = state.pending_nudged_at != null
423
373
  let extendedOnce = state.pending_extended
424
374
  let finalNudgeGrace = state.pending_final_grace
@@ -440,7 +390,10 @@ export const waitWorkflow = (
440
390
  * without the second chance. `pending_final_grace` already makes the
441
391
  * deadline rung one-shot per run, so forcing it cannot spam a pane.
442
392
  */
443
- const tryNudge = (why: string, force = false): Effect.Effect<void> =>
393
+ const tryNudge = (
394
+ why: string,
395
+ force = false,
396
+ ): Effect.Effect<void, AppError> =>
444
397
  Effect.gen(function* () {
445
398
  if (
446
399
  (nudged && !force) ||
@@ -487,54 +440,20 @@ export const waitWorkflow = (
487
440
  const loop: Effect.Effect<ToolResult, AppError> = Effect.gen(function* () {
488
441
  while (true) {
489
442
  const now = yield* Clock.currentTimeMillis
490
- const deadline = state.pending_deadline_ms ?? startedMs + timeout
443
+ const deadline =
444
+ state.pending_deadline_ms ?? deadlineAfter(startedMs, timeout)
491
445
 
492
446
  const fm = yield* readArtifact()
493
- 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) {
494
450
  return yield* advanceOnComplete(
495
451
  fm!,
452
+ completion.success,
496
453
  nudged ? "artifact ready after nudge" : "artifact ready",
497
454
  )
498
455
  }
499
456
 
500
- // Floating oneshot: no pane id — exit file is liveness. Fail closed
501
- // when the popup dies without a complete artifact instead of
502
- // hanging until timeout.
503
- if (state.pending_floating_exit) {
504
- const exitAbs = abs(state.pending_floating_exit, root)
505
- const code = yield* readFloatingExitCode(exitAbs)
506
- if (code != null) {
507
- lastStatus = `floating_exit_${code}`
508
- floatingExitSeenAt ??= now
509
- // Short flush window: oneshot may finish writing as the process exits.
510
- if (now - floatingExitSeenAt >= floatingFlushMs) {
511
- const again = yield* readArtifact()
512
- if (isCompleteArtifact(again, { requireVerdict })) {
513
- return yield* advanceOnComplete(
514
- again!,
515
- "artifact ready (floating oneshot exited)",
516
- )
517
- }
518
- state.pending_floating_exit = null
519
- state.last_error = `floating oneshot exited ${code} without ${pendingArtifact}`
520
- yield* store.save(state, root)
521
- return yield* new HerdrError({
522
- message: `floating ${state.pending_role} exited (code ${code}) without writing ${pendingArtifact}`,
523
- details: {
524
- exit_code: code,
525
- last_agent_status: lastStatus,
526
- hint:
527
- code === 129
528
- ? "popup received Hangup (dismiss/focus steal) — re-dispatch same round; keep focus on the popup"
529
- : "inspect oneshot output; re-dispatch same round or set pane_style=regular",
530
- },
531
- })
532
- }
533
- } else {
534
- lastStatus = "floating_running"
535
- }
536
- }
537
-
538
457
  if ((yield* herdr.enabled) && state.pending_pane_id) {
539
458
  const info = yield* herdr.paneGet(state.pending_pane_id)
540
459
  if (!info.ok) {
@@ -546,7 +465,7 @@ export const waitWorkflow = (
546
465
  message: `role pane gone and artifact incomplete: ${pendingArtifact}`,
547
466
  details: {
548
467
  last_agent_status: lastStatus,
549
- hint: "re-dispatch same round after investigate",
468
+ hint: "after investigate, dispatch the same kind with redeliver=true",
550
469
  },
551
470
  })
552
471
  }
@@ -558,9 +477,18 @@ export const waitWorkflow = (
558
477
  // The artifact may have completed between the read above and this
559
478
  // terminal status, so fail the contract only after one fresh read.
560
479
  const artifactAfterDone = yield* readArtifact()
561
- 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) {
562
489
  return yield* advanceOnComplete(
563
490
  artifactAfterDone!,
491
+ completionAfterDone.success,
564
492
  "artifact ready as role exited",
565
493
  )
566
494
  }
@@ -624,7 +552,7 @@ export const waitWorkflow = (
624
552
  details: {
625
553
  last_agent_status: lastStatus,
626
554
  foreground: names,
627
- hint: "check pane transcript; re-dispatch same round",
555
+ hint: "check pane transcript; then dispatch the same kind with redeliver=true",
628
556
  },
629
557
  })
630
558
  }
@@ -661,7 +589,7 @@ export const waitWorkflow = (
661
589
  extendedOnce = true
662
590
  const extra = Math.max(Math.floor(timeout * 0.5), 120_000)
663
591
  state.pending_extended = true
664
- state.pending_deadline_ms = now + extra
592
+ state.pending_deadline_ms = deadlineAfter(now, extra)
665
593
  yield* store.save(state, root)
666
594
  hooks.onUpdate?.({
667
595
  content: [
@@ -695,7 +623,7 @@ export const waitWorkflow = (
695
623
  // grace is not.
696
624
  finalNudgeGrace = true
697
625
  state.pending_final_grace = true
698
- state.pending_deadline_ms = now + 180_000
626
+ state.pending_deadline_ms = deadlineAfter(now, 180_000)
699
627
  yield* store.save(state, root)
700
628
  yield* tryNudge("timeout idle", true)
701
629
  continue
@@ -737,7 +665,8 @@ export const waitWorkflow = (
737
665
  // was never the problem. `timeout` still sizes the extension,
738
666
  // because that has to come from config to stay stable across calls.
739
667
  const grantedMs =
740
- (state.pending_deadline_ms ?? dispatchedAt + timeout) - dispatchedAt
668
+ (state.pending_deadline_ms ?? deadlineAfter(dispatchedAt, timeout)) -
669
+ dispatchedAt
741
670
  return yield* new WaitTimeout({
742
671
  artifact: pendingArtifact,
743
672
  timeoutMs: grantedMs,
@@ -746,7 +675,7 @@ export const waitWorkflow = (
746
675
  nudged,
747
676
  extended_once: extendedOnce,
748
677
  configured_timeout_ms: timeout,
749
- 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",
750
679
  },
751
680
  })
752
681
  })
@@ -763,7 +692,7 @@ export const waitWorkflow = (
763
692
  artifact,
764
693
  details: {
765
694
  last_agent_status: lastStatus,
766
- hint: "re-dispatch same round or wait again after investigate",
695
+ hint: "after investigate, use redeliver=true for the same kind or wait again",
767
696
  },
768
697
  }),
769
698
  ),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@naxodev/apnea",
3
- "version": "0.1.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",
@@ -39,7 +39,6 @@
39
39
  "docs/adr",
40
40
  "docs/protocol",
41
41
  "extension",
42
- "herdr-plugin",
43
42
  "schemas",
44
43
  "!extension/**/*.test.ts",
45
44
  "!extension/test"
@@ -63,7 +62,7 @@
63
62
  "smoke:package": "bun scripts/package-smoke.ts"
64
63
  },
65
64
  "dependencies": {
66
- "effect": "4.0.0-beta.101",
65
+ "effect": "4.0.0-rc.111",
67
66
  "typebox": "^1.3.6"
68
67
  },
69
68
  "devDependencies": {
@@ -35,16 +35,20 @@
35
35
  }
36
36
  }
37
37
  },
38
- "review_round_cap": { "type": "integer", "minimum": 1, "maximum": 20 },
39
- "timeouts_ms": {
40
- "type": "object",
41
- "additionalProperties": { "type": "integer", "minimum": 1000 }
42
- },
43
38
  "pane_style": {
44
39
  "type": "string",
45
40
  "enum": ["regular", "floating"],
46
- "default": "regular",
47
- "description": "Herdr pane style for role dispatches. \"floating\" applies to oneshot roles only and requires herdr >= 0.7.4; interactive roles always use regular panes. Default: regular."
41
+ "deprecated": true,
42
+ "description": "Deprecated migration-only setting. The runtime accepts and ignores it during migration."
43
+ },
44
+ "review_round_cap": { "type": "integer", "minimum": 1, "maximum": 20 },
45
+ "timeouts_ms": {
46
+ "type": "object",
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,25 +34,61 @@
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
+ },
70
+ "pending_floating_exit": {
71
+ "type": "null",
72
+ "deprecated": true,
73
+ "description": "Deprecated migration-only field. Missing or null values migrate; active non-null legacy floating runs must be abandoned."
74
+ },
37
75
  "pending_pane_id": { "type": ["string", "null"] },
38
76
  "pending_pane_label": { "type": ["string", "null"] },
39
- "pending_floating_exit": { "type": ["string", "null"] },
40
- "pending_started_at": { "type": ["integer", "null"] },
41
- "pending_deadline_ms": { "type": ["integer", "null"] },
42
- "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
+ },
43
92
  "pending_final_grace": { "type": "boolean" },
44
93
  "pending_extended": { "type": "boolean" },
45
94
  "role_panes": {
@@ -56,8 +105,117 @@
56
105
  },
57
106
  "package_root": { "type": "string" },
58
107
  "reviewer_tree_fingerprint": { "type": ["string", "null"] },
59
- "current_phase_package": { "type": ["string", "null"] },
60
- "current_code_review": { "type": ["string", "null"] },
61
- "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
+ }
62
220
  }
63
221
  }
@@ -1,15 +0,0 @@
1
- id = "apnea"
2
- name = "Apnea"
3
- version = "0.1.0"
4
- description = "Apnea floating-pane worker for oneshot role dispatches."
5
- min_herdr_version = "0.7.4"
6
- platforms = ["linux", "macos"]
7
-
8
- [[panes]]
9
- id = "worker"
10
- title = "Apnea worker"
11
- placement = "overlay"
12
- # Absolute bash + plugin-relative script. Bare `bash` fails on stripped plugin
13
- # PATH (exit 127). Do not pass a foreign --cwd to plugin pane open — relative
14
- # script paths resolve against that cwd, not plugin_root.
15
- command = ["/bin/bash", "scripts/run-task.sh"]
@@ -1,8 +0,0 @@
1
- #!/bin/bash
2
- set -euo pipefail
3
- if [[ -z "${APNEA_TASK_SCRIPT:-}" ]]; then
4
- echo "apnea: APNEA_TASK_SCRIPT not set (open this pane via apnea dispatch)" >&2
5
- exit 64
6
- fi
7
- # Absolute bash: popup PATH may not resolve bare `bash` (exit 127).
8
- exec /bin/bash "$APNEA_TASK_SCRIPT"