pi-crew 0.10.2 → 0.10.3

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 (79) hide show
  1. package/CHANGELOG.md +249 -0
  2. package/dist/index.mjs +98 -307
  3. package/package.json +2 -1
  4. package/schema.json +11 -0
  5. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +6 -2
  6. package/skills/real-test-pi-crew/SKILL.md +278 -79
  7. package/src/config/config-merge.ts +11 -1
  8. package/src/config/config-validation.ts +40 -1
  9. package/src/config/config.ts +28 -6
  10. package/src/config/defaults.ts +35 -10
  11. package/src/config/env-vars.ts +27 -2
  12. package/src/config/types.ts +36 -0
  13. package/src/extension/registration/lifecycle-handlers.ts +40 -9
  14. package/src/extension/registration/team-tool.ts +53 -5
  15. package/src/extension/team-tool/doctor.ts +364 -7
  16. package/src/extension/team-tool/handle-settings.ts +19 -0
  17. package/src/extension/team-tool/inspect.ts +10 -2
  18. package/src/extension/team-tool/status.ts +7 -0
  19. package/src/extension/team-tool.ts +35 -2
  20. package/src/hooks/registry.ts +59 -56
  21. package/src/prompt/inbox-poll.ts +90 -0
  22. package/src/prompt/message-tool.ts +166 -0
  23. package/src/prompt/prompt-runtime.ts +201 -18
  24. package/src/prompt/surface-worker.ts +720 -0
  25. package/src/prompt/worker-events-channel.ts +49 -3
  26. package/src/runtime/async-runner.ts +29 -1
  27. package/src/runtime/background-runner.ts +13 -7
  28. package/src/runtime/broker/broker-issuer.ts +27 -2
  29. package/src/runtime/broker/crew-broker-tokens.ts +56 -4
  30. package/src/runtime/broker/crew-broker.ts +261 -41
  31. package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
  32. package/src/runtime/child-pi/child-pi-streams.ts +9 -1
  33. package/src/runtime/child-pi/child-pi.ts +353 -5
  34. package/src/runtime/crew-agent-records.ts +13 -1
  35. package/src/runtime/dispatch-batch.ts +12 -1
  36. package/src/runtime/event-log-tail-source.ts +374 -0
  37. package/src/runtime/finalize-run.ts +4 -0
  38. package/src/runtime/live-session/live-agent-manager.ts +34 -1
  39. package/src/runtime/live-session/live-control-realtime.ts +10 -0
  40. package/src/runtime/live-session/live-session-runtime.ts +47 -27
  41. package/src/runtime/manifest-cache.ts +128 -17
  42. package/src/runtime/model/pi-args.ts +54 -65
  43. package/src/runtime/output/sidechain-output.ts +61 -6
  44. package/src/runtime/process/proc-stat.ts +46 -0
  45. package/src/runtime/process/zombie-scanner.ts +32 -19
  46. package/src/runtime/spawn-policy.ts +27 -41
  47. package/src/runtime/surface/degrade.ts +776 -0
  48. package/src/runtime/surface/herdr-provider.ts +546 -0
  49. package/src/runtime/surface/launch-script.ts +172 -0
  50. package/src/runtime/surface/resolve-surface.ts +274 -0
  51. package/src/runtime/surface/surface-provider.ts +129 -0
  52. package/src/runtime/surface/surface-spawn.ts +475 -0
  53. package/src/runtime/surface/tmux-provider.ts +400 -0
  54. package/src/runtime/task-runner/child-executor.ts +47 -0
  55. package/src/runtime/task-runner/post-execution.ts +57 -2
  56. package/src/runtime/task-runner/prompt-builder.ts +1 -0
  57. package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
  58. package/src/runtime/task-runner/state-helpers.ts +54 -30
  59. package/src/runtime/task-runner.ts +4 -2
  60. package/src/runtime/team-runner.ts +101 -0
  61. package/src/schema/config-schema.ts +24 -0
  62. package/src/state/atomic-write.ts +219 -40
  63. package/src/state/coordination/locks.ts +7 -5
  64. package/src/state/coordination/mailbox.ts +56 -10
  65. package/src/state/event-log/cursor.ts +413 -23
  66. package/src/state/event-log/event-log.ts +120 -113
  67. package/src/state/event-log/sequence-cache.ts +21 -3
  68. package/src/state/stores/state-store.ts +98 -6
  69. package/src/state/types.ts +51 -0
  70. package/src/ui/inline-panel/agent-pane.ts +3 -0
  71. package/src/ui/render-diff.ts +16 -8
  72. package/src/ui/run-dashboard.ts +87 -42
  73. package/src/ui/run-event-bus.ts +10 -1
  74. package/src/ui/run-snapshot-cache.ts +83 -35
  75. package/src/ui/transcript-cache.ts +101 -13
  76. package/src/ui/transcript-viewer.ts +92 -24
  77. package/src/ui/widget/index.ts +32 -8
  78. package/src/utils/visual.ts +43 -0
  79. package/src/worktree/worktree-manager.ts +65 -4
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { logInternalError } from "../../utils/internal-error.ts";
3
4
  import { redactSecrets } from "../../utils/redaction.ts";
4
5
  import { isSafePathId } from "../../utils/safe-paths.ts";
5
6
 
@@ -12,15 +13,69 @@ export interface SidechainEntry {
12
13
  cwd: string;
13
14
  }
14
15
 
16
+ // ── Batched JSONL writer (Task 26, 2026-08-24) ──────────────────────────
17
+ // PERF (2026-08-24): the old writeSidechainEntry did one mkdirSync +
18
+ // appendFileSync + full redaction clone PER STREAMING EVENT (message_update
19
+ // fires per chunk). Batch per path on a 50ms unref'd timer like
20
+ // child-pi-transcript; redaction + serialization still run per event at
21
+ // queue time, and the flush does ONE mkdir + append per path per window.
22
+ interface PendingJsonlBatch {
23
+ lines: string[];
24
+ timer: NodeJS.Timeout;
25
+ }
26
+ const pendingJsonlBatches = new Map<string, PendingJsonlBatch>();
27
+ const JSONL_FLUSH_MS = 50;
28
+
29
+ function queueJsonlLine(filePath: string, line: string): void {
30
+ const pending = pendingJsonlBatches.get(filePath);
31
+ if (pending) {
32
+ pending.lines.push(line);
33
+ return;
34
+ }
35
+ const timer = setTimeout(() => flushJsonlBatch(filePath), JSONL_FLUSH_MS);
36
+ timer.unref();
37
+ pendingJsonlBatches.set(filePath, { lines: [line], timer });
38
+ }
39
+
40
+ function flushJsonlBatch(filePath: string): void {
41
+ const pending = pendingJsonlBatches.get(filePath);
42
+ if (!pending) return;
43
+ pendingJsonlBatches.delete(filePath);
44
+ clearTimeout(pending.timer);
45
+ try {
46
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
47
+ fs.appendFileSync(filePath, pending.lines.join(""), "utf-8");
48
+ } catch (error) {
49
+ logInternalError("sidechain-output.flush", error as Error, `path=${filePath}`);
50
+ }
51
+ }
52
+
15
53
  export function writeSidechainEntry(filePath: string, entry: Omit<SidechainEntry, "isSidechain" | "timestamp">): void {
16
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
17
- fs.appendFileSync(
18
- filePath,
19
- `${JSON.stringify(redactSecrets({ isSidechain: true, timestamp: new Date().toISOString(), ...entry }))}\n`,
20
- "utf-8",
21
- );
54
+ queueJsonlLine(filePath, `${JSON.stringify(redactSecrets({ isSidechain: true, timestamp: new Date().toISOString(), ...entry }))}\n`);
22
55
  }
23
56
 
57
+ /**
58
+ * Append one pre-serialized JSONL line through the shared batched writer.
59
+ * Task 26: the live-session transcript append routes through the same
60
+ * per-path 50ms batching as sidechain output. child-pi-transcript's batched
61
+ * writer was NOT reused — it takes a ChildPiRunInput and enforces
62
+ * artifactsRoot containment + a second redaction pass (redactJsonLine over
63
+ * the already-redacted line), so it is not generic over plain paths.
64
+ */
65
+ export function appendBatchedJsonlLine(filePath: string, line: string): void {
66
+ queueJsonlLine(filePath, line);
67
+ }
68
+
69
+ /** @internal — flush on session teardown / process exit so files are complete. */
70
+ export function flushPendingSidechainWrites(): void {
71
+ for (const filePath of [...pendingJsonlBatches.keys()]) flushJsonlBatch(filePath);
72
+ }
73
+
74
+ // The batch timers are unref'd, so a naturally-draining event loop can exit
75
+ // with unflushed telemetry. Same exit-hook pattern as atomic-write.ts and
76
+ // crew-agent-records.ts (sync-only work, safe inside "exit").
77
+ process.on("exit", () => flushPendingSidechainWrites());
78
+
24
79
  export function sidechainOutputPath(stateRoot: string, taskId: string): string {
25
80
  if (!isSafePathId(taskId)) throw new Error(`Invalid taskId: ${taskId}`);
26
81
  return path.join(stateRoot, "agents", taskId, "sidechain.output.jsonl");
@@ -0,0 +1,46 @@
1
+ /**
2
+ * proc-stat.ts — THE single `/proc/<pid>/stat` field parser.
3
+ *
4
+ * Three consumers previously hand-rolled the same paren-aware split (the
5
+ * surface spawn's parent starttime capture, the worker-side parent-guard, and
6
+ * the zombie scanner's ppid/starttime read). A divergence between them is not
7
+ * cosmetic: the spawn writes `PI_CREW_PARENT_START_TIME` from field 22, and
8
+ * the worker kills itself when that value stops matching — so both sides MUST
9
+ * index identically. comm (field 2) may contain spaces AND ')' characters,
10
+ * hence everything up to the LAST ")" is skipped, never a naive whitespace
11
+ * split.
12
+ *
13
+ * Layout after the comm field: rest[0] = state, rest[1] = ppid, …
14
+ * rest[STARTTIME_INDEX] = starttime (field 22, clock ticks since boot).
15
+ */
16
+
17
+ /** Field 22 (starttime) minus the two consumed by the comm cut = index 19. */
18
+ export const PROC_STAT_STARTTIME_INDEX = 19;
19
+ /** Field 3 (state) → first entry after the comm cut. */
20
+ export const PROC_STAT_STATE_INDEX = 0;
21
+ /** Field 4 (ppid) → second entry after the comm cut. */
22
+ export const PROC_STAT_PPID_INDEX = 1;
23
+
24
+ /**
25
+ * Fields of a `/proc/<pid>/stat` line AFTER the comm column (paren-aware).
26
+ * Returns undefined when the buffer has no closing paren at all.
27
+ */
28
+ export function fieldsAfterComm(stat: string): string[] | undefined {
29
+ const lastParen = stat.lastIndexOf(")");
30
+ if (lastParen === -1) return undefined;
31
+ return stat
32
+ .slice(lastParen + 1)
33
+ .trim()
34
+ .split(/\s+/);
35
+ }
36
+
37
+ /**
38
+ * Raw starttime ticks (field 22) as a string, for byte-exact comparison with
39
+ * the value the spawner recorded. undefined when absent/unparseable.
40
+ */
41
+ export function procStartTimeTicks(stat: string): string | undefined {
42
+ const fields = fieldsAfterComm(stat);
43
+ if (!fields) return undefined;
44
+ const raw = fields[PROC_STAT_STARTTIME_INDEX];
45
+ return raw && Number.isFinite(Number(raw)) ? raw : undefined;
46
+ }
@@ -23,6 +23,7 @@
23
23
  */
24
24
 
25
25
  import * as fs from "node:fs";
26
+ import { fieldsAfterComm, PROC_STAT_PPID_INDEX, PROC_STAT_STARTTIME_INDEX } from "./proc-stat.ts";
26
27
 
27
28
  export interface ZombieSubagent {
28
29
  pid: number;
@@ -32,6 +33,15 @@ export interface ZombieSubagent {
32
33
  /** Whether the recorded crew parent PID is still alive. */
33
34
  parentAlive: boolean;
34
35
  role: string | undefined;
36
+ /**
37
+ * Mux kind from PI_CREW_SURFACE ("tmux"|"herdr") when the worker booted in
38
+ * a surface pane (T12). Surface workers have NO heartbeat (T9 handoff) —
39
+ * env markers are the only identity signal, so the scanner never gates a
40
+ * surface entry on liveness telemetry.
41
+ */
42
+ surface?: "tmux" | "herdr";
43
+ /** Pane id from PI_CREW_SURFACE_PANE — doctor closes orphan panes by it. */
44
+ surfacePaneId?: string;
35
45
  rssKb: number;
36
46
  elapsedSec: number | undefined;
37
47
  cmd: string;
@@ -66,17 +76,12 @@ function readProcStat(pid: number): { ppid: number; elapsedSec: number | undefin
66
76
  try {
67
77
  const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf-8");
68
78
  // stat format: pid (comm) state ppid ... starttime ...
69
- // comm may contain spaces/parens, so parse from the LAST ')' backwards.
70
- const closeParen = stat.lastIndexOf(")");
71
- if (closeParen < 0) return undefined;
72
- const rest = stat
73
- .slice(closeParen + 2)
74
- .trim()
75
- .split(/\s+/);
76
- // rest[0] = state, rest[1] = ppid
77
- const ppid = Number.parseInt(rest[1] ?? "", 10);
78
- // starttime (clock ticks since boot) is field 22 in the full stat → index 19 in `rest`
79
- const starttimeTicksRaw = Number.parseInt(rest[19] ?? "", 10);
79
+ // Shared paren-aware cut (proc-stat.ts) — MUST index identically with
80
+ // surface-spawn's PI_CREW_PARENT_START_TIME capture.
81
+ const rest = fieldsAfterComm(stat);
82
+ if (!rest) return undefined;
83
+ const ppid = Number.parseInt(rest[PROC_STAT_PPID_INDEX] ?? "", 10);
84
+ const starttimeTicksRaw = Number.parseInt(rest[PROC_STAT_STARTTIME_INDEX] ?? "", 10);
80
85
  const starttimeTicks = Number.isFinite(starttimeTicksRaw) ? starttimeTicksRaw : undefined;
81
86
  const elapsedSec = computeElapsedSec(starttimeTicks);
82
87
  return { ppid: Number.isFinite(ppid) ? ppid : 0, elapsedSec };
@@ -184,12 +189,17 @@ export function scanZombieSubagents(): ZombieScanResult {
184
189
 
185
190
  const crewParentPid = Number.parseInt(environ.PI_CREW_PARENT_PID ?? "", 10);
186
191
  const stat = readProcStat(pid);
192
+ // Surface identity (T12): strict allowlist — an unrelated PI_CREW_SURFACE
193
+ // value must not half-populate the fields doctor relies on for pane close.
194
+ const surface = environ.PI_CREW_SURFACE === "tmux" || environ.PI_CREW_SURFACE === "herdr" ? environ.PI_CREW_SURFACE : undefined;
187
195
  const entry: ZombieSubagent = {
188
196
  pid,
189
197
  ppid: stat?.ppid ?? 0,
190
198
  crewParentPid: Number.isFinite(crewParentPid) ? crewParentPid : 0,
191
199
  parentAlive: Number.isFinite(crewParentPid) && isPidAlive(crewParentPid),
192
200
  role: environ.PI_CREW_ROLE,
201
+ surface,
202
+ surfacePaneId: environ.PI_CREW_SURFACE_PANE || undefined,
193
203
  rssKb: readProcRssKb(pid),
194
204
  elapsedSec: stat?.elapsedSec,
195
205
  cmd: readProcCmdline(pid),
@@ -256,11 +266,9 @@ export function formatZombieReport(scan: ZombieScanResult): string {
256
266
  lines.push(`### Zombies — parent dead (${scan.zombies.length})`);
257
267
  lines.push("These sub-agents are orphaned. Safe to kill after review:");
258
268
  lines.push("");
259
- lines.push(" PID PARENT RSS ROLE CMD");
269
+ lines.push(" PID PARENT RSS ROLE SURFACE CMD");
260
270
  for (const z of scan.zombies) {
261
- lines.push(
262
- ` ${String(z.pid).padEnd(9)}${String(z.crewParentPid).padEnd(8)}${formatRss(z.rssKb).padEnd(10)}${(z.role ?? "?").padEnd(14)}${z.cmd.slice(0, 60)}`,
263
- );
271
+ lines.push(formatZombieRow(z));
264
272
  }
265
273
  lines.push("");
266
274
  }
@@ -269,11 +277,9 @@ export function formatZombieReport(scan: ZombieScanResult): string {
269
277
  lines.push(`### Live — parent still running (${scan.live.length})`);
270
278
  lines.push("NOT zombies. Do not kill (parent PID is alive and may still reap them).");
271
279
  lines.push("");
272
- lines.push(" PID PARENT RSS ROLE CMD");
280
+ lines.push(" PID PARENT RSS ROLE SURFACE CMD");
273
281
  for (const l of scan.live) {
274
- lines.push(
275
- ` ${String(l.pid).padEnd(9)}${String(l.crewParentPid).padEnd(8)}${formatRss(l.rssKb).padEnd(10)}${(l.role ?? "?").padEnd(14)}${l.cmd.slice(0, 60)}`,
276
- );
282
+ lines.push(formatZombieRow(l));
277
283
  }
278
284
  lines.push("");
279
285
  }
@@ -288,6 +294,13 @@ export function formatZombieReport(scan: ZombieScanResult): string {
288
294
  return lines.join("\n");
289
295
  }
290
296
 
297
+ /** One table row — shared by the zombie + live sections so columns stay aligned. */
298
+ function formatZombieRow(z: ZombieSubagent): string {
299
+ // SURFACE = "kind:paneId" (T12) — the pane doctor closes when orphaned.
300
+ const surface = z.surface ? `${z.surface}:${z.surfacePaneId ?? "?"}` : "-";
301
+ return ` ${String(z.pid).padEnd(9)}${String(z.crewParentPid).padEnd(8)}${formatRss(z.rssKb).padEnd(10)}${(z.role ?? "?").padEnd(14)}${surface.padEnd(16)}${z.cmd.slice(0, 44)}`;
302
+ }
303
+
291
304
  function formatRss(kb: number): string {
292
305
  if (kb >= 1024 * 1024) return `${(kb / 1024 / 1024).toFixed(1)}G`;
293
306
  if (kb >= 1024) return `${(kb / 1024).toFixed(0)}M`;
@@ -8,26 +8,26 @@
8
8
  *
9
9
  * Gate dimensions (each with a distinct machine-readable reason + fail-fast
10
10
  * message; evaluated in this order — cheapest/most-global first):
11
- * 1. nesting-disabled — `nesting.enabled` is false (DEFAULT; fail-closed)
12
- * 2. role-denied — the PARENT task's role is not executor-class
13
- * (read-only roles cannot raise spawn privileges),
14
- * or the requested grandchild role is not one of
15
- * the delegate-tool's allowed roles
16
- * 3. trust-denied — untrusted escalation context (manual-only trust
11
+ * 1. trust-denied — untrusted escalation context (manual-only trust
17
12
  * gate; the handler resolves trust, policy enforces)
18
- * 4. depth-exceeded — child depth (parent.depth + 1) exceeds the
19
- * resolved maxDepth (default 2). Depth comes from
13
+ * 2. role-denied — the REQUESTED grandchild role is not one of the
14
+ * delegate-tool's allowed roles (the parent role is
15
+ * NO LONGER gated — D8: every role may delegate;
16
+ * the spawn-side checkCrewDepth cap is the boundary)
17
+ * 3. depth-exceeded — child depth (parent.depth + 1) exceeds the
18
+ * resolved maxDepth (default 4). Depth comes from
20
19
  * the parent task RECORD, never worker env
21
20
  * (design §7 rev-2 P0-2)
22
- * 5. slots-exhausted — nested-slot budget exhausted ("N/M in flight");
21
+ * 4. slots-exhausted — nested-slot budget exhausted ("N/M in flight");
23
22
  * fail-fast, never queue
24
- * 6. budget-insufficient— requested budgetTokens exceed the parent task's
23
+ * 5. budget-insufficient— requested budgetTokens exceed the parent task's
25
24
  * remaining allocation (tokensGranted - tokensSpent)
26
- * 7. model-invalid — requested model is not in the resolved model
25
+ * 6. model-invalid — requested model is not in the resolved model
27
26
  * catalog (the unvalidated provider/model
28
27
  * pass-through — model-fallback.ts:282, the
29
28
  * 429-cascade root — must not be reachable here)
30
- * 8. timeout-invalid — timeoutSec outside 1..MAX (default 900)
29
+ * 7. timeout-invalid — timeoutSec outside 1..MAX (default 900)
30
+ * 8. workspace-conflict — write-capable grandchild narrowing cwd overlap
31
31
  */
32
32
 
33
33
  import { modelRefToString } from "./model/model-fallback.ts";
@@ -48,7 +48,6 @@ export const DEFAULT_DELEGATE_TIMEOUT_SEC = 900;
48
48
  export const MAX_DELEGATE_TIMEOUT_SEC = 86_400;
49
49
 
50
50
  export type SpawnPolicyDenyReason =
51
- | "nesting-disabled"
52
51
  | "role-denied"
53
52
  | "trust-denied"
54
53
  | "depth-exceeded"
@@ -59,14 +58,14 @@ export type SpawnPolicyDenyReason =
59
58
  | "workspace-conflict";
60
59
 
61
60
  export interface DelegateAdmissionInput {
62
- /** Resolved `nesting.enabled` config (DEFAULT false — fail-closed, ADR-5 §10). */
63
- nestingEnabled: boolean;
64
- /** Resolved max depth (nesting.maxDepth default 2; env clamp mirrors PI_CREW_MAX_DEPTH 1..10). */
61
+ /** Resolved max depth (nesting.maxDepth default 4; env clamp mirrors PI_CREW_MAX_DEPTH 1..10). */
65
62
  maxDepth: number;
66
63
  /** Parent task record fields the policy is allowed to trust. */
67
64
  parentTask: {
68
65
  taskId: string;
69
- role: string;
66
+ /** Role for workspace-capability evaluation (policy no longer gates the
67
+ * parent role — D8 opens delegation to every role). */
68
+ role?: string;
70
69
  /** Absent on pre-v2 records — treated as depth 1 (a worker). */
71
70
  depth?: number;
72
71
  allocation?: { tokensGranted?: number; tokensSpent?: number };
@@ -122,26 +121,13 @@ function deny(reason: SpawnPolicyDenyReason, message: string): SpawnPolicyDecisi
122
121
  }
123
122
 
124
123
  export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnPolicyDecision {
125
- const { nestingEnabled, maxDepth, parentTask, requested } = input;
124
+ const { maxDepth, parentTask, requested } = input;
126
125
  const parentDepth = parentTask.depth ?? 1; // pre-v2 records: workers are depth 1
127
126
  const childDepth = parentDepth + 1;
128
127
 
129
- // 1. Flag gate — fail-closed default (ADR-5 §10).
130
- if (!nestingEnabled) {
131
- return deny(
132
- "nesting-disabled",
133
- "delegate rejected: governed nesting is disabled (nesting.enabled=false, the fail-closed default; enable it in USER config)",
134
- );
135
- }
136
-
137
- // 2. Role gate — parent must be executor-class; requested role must be in
138
- // the tool's allowed set.
139
- if (!isExecutorClassRole(parentTask.role)) {
140
- return deny(
141
- "role-denied",
142
- `delegate rejected: role '${parentTask.role}' is not executor-class (${EXECUTOR_CLASS_ROLES.join("/")}) — read-only roles cannot delegate`,
143
- );
144
- }
128
+ // 1. Role gate — the REQUESTED grandchild role must be in the tool's allowed
129
+ // set (the PARENT role is no longer gated — D8: every role may delegate;
130
+ // the spawn-side checkCrewDepth cap + slot budget below are the boundary).
145
131
  const requestedRole = requested?.role;
146
132
  if (requestedRole !== undefined && !(DELEGATE_ALLOWED_ROLES as readonly string[]).includes(requestedRole)) {
147
133
  return deny(
@@ -150,12 +136,12 @@ export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnP
150
136
  );
151
137
  }
152
138
 
153
- // 3. Trust gate — untrusted escalation context never spawns.
139
+ // 2. Trust gate — untrusted escalation context never spawns.
154
140
  if (input.untrusted) {
155
141
  return deny("trust-denied", "delegate rejected: untrusted escalation context (trust gate is manual-only)");
156
142
  }
157
143
 
158
- // 4. Depth gate — computed from the parent RECORD (never worker env).
144
+ // 3. Depth gate — computed from the parent RECORD (never worker env).
159
145
  if (childDepth > maxDepth) {
160
146
  return deny(
161
147
  "depth-exceeded",
@@ -163,7 +149,7 @@ export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnP
163
149
  );
164
150
  }
165
151
 
166
- // 5. Nested-slot budget — fail-fast, never queue.
152
+ // 4. Nested-slot budget — fail-fast, never queue.
167
153
  if (input.slots.used >= input.slots.max) {
168
154
  return deny(
169
155
  "slots-exhausted",
@@ -171,7 +157,7 @@ export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnP
171
157
  );
172
158
  }
173
159
 
174
- // 6. Parent allocation sufficiency — reserve up-front or reject.
160
+ // 5. Parent allocation sufficiency — reserve up-front or reject.
175
161
  if (requested?.budgetTokens !== undefined) {
176
162
  const granted = parentTask.allocation?.tokensGranted ?? 0;
177
163
  const spent = parentTask.allocation?.tokensSpent ?? 0;
@@ -184,7 +170,7 @@ export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnP
184
170
  }
185
171
  }
186
172
 
187
- // 7. Model validation against the resolved catalog.
173
+ // 6. Model validation against the resolved catalog.
188
174
  const normalizedModel = requested?.model !== undefined ? modelRefToString(requested.model) : undefined;
189
175
  if (normalizedModel !== undefined && input.modelCatalog !== undefined) {
190
176
  if (!input.modelCatalog.includes(normalizedModel)) {
@@ -192,7 +178,7 @@ export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnP
192
178
  }
193
179
  }
194
180
 
195
- // 8. Timeout validation (mandatory default 900).
181
+ // 7. Timeout validation (mandatory default 900).
196
182
  const timeoutSec = requested?.timeoutSec ?? DEFAULT_DELEGATE_TIMEOUT_SEC;
197
183
  if (!Number.isFinite(timeoutSec) || timeoutSec < 1 || timeoutSec > MAX_DELEGATE_TIMEOUT_SEC) {
198
184
  return deny(
@@ -201,7 +187,7 @@ export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnP
201
187
  );
202
188
  }
203
189
 
204
- // 9. Workspace interaction (ADR-5 §9): a write-capable grandchild sharing
190
+ // 8. Workspace interaction (ADR-5 §9): a write-capable grandchild sharing
205
191
  // the parent's cwd with ANOTHER in-flight executor is only admitted when
206
192
  // serialization is established (limits.serializeOnPathOverlap) — otherwise
207
193
  // reject; read-only grandchild roles (explorer/analyst) never conflict.