pi-subagents 0.67.0 → 0.69.0

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 (122) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +1 -1
  3. package/docs/agents.md +41 -12
  4. package/docs/configuration.md +61 -19
  5. package/docs/extension-api.md +5 -1
  6. package/docs/missions.md +2 -2
  7. package/docs/models.md +11 -79
  8. package/docs/observability.md +18 -8
  9. package/docs/standalone-background.md +13 -3
  10. package/docs/tool-reference.md +38 -14
  11. package/docs/watchdog.md +10 -12
  12. package/docs/workflows.md +59 -1
  13. package/index.ts +5 -2
  14. package/package.json +4 -2
  15. package/runner-peer-loader.mjs +24 -0
  16. package/runner-peer-preload.mjs +25 -11
  17. package/skills/pi-subagents/SKILL.md +18 -21
  18. package/skills/pi-subagents/references/constraints-and-recipes.md +3 -2
  19. package/skills/pi-subagents/references/execution-controls.md +6 -4
  20. package/skills/pi-subagents/references/management-authoring-rpc.md +0 -1
  21. package/skills/pi-subagents/references/multi-lane-orchestration.md +1 -1
  22. package/skills/pi-subagents/references/prompting-and-roles.md +16 -12
  23. package/skills/pi-subagents/references/review-and-validation.md +3 -3
  24. package/src/agents/agent-management.ts +57 -58
  25. package/src/agents/agent-serializer.ts +4 -3
  26. package/src/agents/agents.ts +185 -72
  27. package/src/agents/chain-serializer.ts +5 -0
  28. package/src/agents/runtime-agent-registry.ts +7 -6
  29. package/src/agents/skills.ts +1 -1
  30. package/src/api/preflight.ts +20 -16
  31. package/src/api/required-child-extensions.ts +6 -0
  32. package/src/extension/config.ts +10 -37
  33. package/src/extension/fanout-child.ts +3 -0
  34. package/src/extension/herdr-pi-bridge.ts +160 -0
  35. package/src/extension/index.ts +42 -31
  36. package/src/extension/public-execution.ts +3 -3
  37. package/src/extension/schemas.ts +23 -6
  38. package/src/extension/tool-description.ts +8 -7
  39. package/src/inspectors/ghostty/plugin.ts +13 -1
  40. package/src/intercom/native-supervisor-channel.ts +22 -18
  41. package/src/policy/authority.ts +4 -0
  42. package/src/profiles/profiles.ts +12 -6
  43. package/src/runs/background/active-run-index.ts +17 -1
  44. package/src/runs/background/async-execution.ts +309 -126
  45. package/src/runs/background/async-job-tracker.ts +8 -6
  46. package/src/runs/background/async-resume.ts +13 -4
  47. package/src/runs/background/async-status.ts +15 -4
  48. package/src/runs/background/auto-drain.ts +20 -10
  49. package/src/runs/background/binary-bootstrap.ts +5 -0
  50. package/src/runs/background/chain-append.ts +1 -1
  51. package/src/runs/background/chain-root-attachment.ts +14 -33
  52. package/src/runs/background/notify.ts +74 -6
  53. package/src/runs/background/result-files.ts +8 -4
  54. package/src/runs/background/result-watcher.ts +19 -2
  55. package/src/runs/background/run-child-session.ts +20 -29
  56. package/src/runs/background/runner-aliases.ts +4 -33
  57. package/src/runs/background/runner-child-launch.ts +4 -1
  58. package/src/runs/background/runner-child-sessions.ts +2 -2
  59. package/src/runs/background/runner-http-dispatcher.ts +119 -0
  60. package/src/runs/background/scheduled-runs.ts +11 -5
  61. package/src/runs/background/stale-run-reconciler.ts +35 -11
  62. package/src/runs/background/subagent-runner.ts +413 -276
  63. package/src/runs/background/subagent-wait.ts +128 -23
  64. package/src/runs/background/wait-completions.ts +75 -27
  65. package/src/runs/background/wait-subscriptions.ts +9 -3
  66. package/src/runs/background/wait-tool.ts +4 -2
  67. package/src/runs/foreground/async-stop-action.ts +93 -3
  68. package/src/runs/foreground/execution.ts +115 -219
  69. package/src/runs/foreground/foreground-history.ts +2 -1
  70. package/src/runs/foreground/subagent-executor.ts +281 -80
  71. package/src/runs/shared/acceptance.ts +194 -37
  72. package/src/runs/shared/async-status-projection.ts +123 -33
  73. package/src/runs/shared/child-launch-plan.ts +15 -3
  74. package/src/runs/shared/child-launch.ts +19 -6
  75. package/src/runs/shared/child-runtime-config.ts +5 -0
  76. package/src/runs/shared/child-session.ts +94 -50
  77. package/src/runs/shared/child-tool-plan.ts +28 -16
  78. package/src/runs/shared/dynamic-fanout.ts +2 -2
  79. package/src/runs/shared/external-cli-contract.ts +11 -1
  80. package/src/runs/shared/external-cli-preflight.ts +6 -2
  81. package/src/runs/shared/herdr-connection.ts +134 -0
  82. package/src/runs/shared/herdr-external-adapters.ts +169 -0
  83. package/src/runs/shared/herdr-machine.ts +279 -0
  84. package/src/runs/shared/herdr-pi-protocol.ts +59 -0
  85. package/src/runs/shared/herdr-placed-run.ts +263 -0
  86. package/src/runs/shared/model-resolution-diagnostic.ts +76 -0
  87. package/src/runs/shared/{model-fallback.ts → model-resolution.ts} +22 -237
  88. package/src/runs/shared/model-scope.ts +1 -1
  89. package/src/runs/shared/nested-events.ts +11 -2
  90. package/src/runs/shared/parallel-utils.ts +7 -2
  91. package/src/runs/shared/pi-spawn.ts +1 -1
  92. package/src/runs/shared/subagent-prompt-runtime.ts +4 -2
  93. package/src/runs/shared/worktree-setup-command.ts +27 -4
  94. package/src/runs/shared/worktree.ts +30 -8
  95. package/src/shared/child-cache-retention.ts +43 -0
  96. package/src/shared/launch-contract.ts +6 -9
  97. package/src/shared/pruned-fork.ts +1 -1
  98. package/src/shared/required-child-extensions.ts +81 -0
  99. package/src/shared/settings.ts +5 -2
  100. package/src/shared/shortcuts.ts +0 -4
  101. package/src/shared/types.ts +81 -29
  102. package/src/slash/slash-commands.ts +0 -6
  103. package/src/slash/subagents-admin.ts +13 -9
  104. package/src/tui/render.ts +20 -10
  105. package/src/watchdog/child-status.ts +28 -36
  106. package/src/watchdog/lsp-diagnostics.ts +1 -1
  107. package/src/watchdog/model-selection.ts +1 -1
  108. package/src/watchdog/register-child.ts +10 -3
  109. package/src/watchdog/register-main.ts +20 -20
  110. package/src/watchdog/render.ts +1 -1
  111. package/src/watchdog/review.ts +14 -30
  112. package/src/watchdog/rules.ts +1 -1
  113. package/src/watchdog/runtime.ts +23 -12
  114. package/src/watchdog/settings.ts +3 -6
  115. package/src/watchdog/types.ts +3 -5
  116. package/src/watchdog/warning-format.ts +1 -1
  117. package/src/workflows/scripted-workflow.ts +68 -7
  118. package/src/workflows/workflow-receipt.ts +21 -3
  119. package/src/workflows/workflow-resources.ts +13 -2
  120. package/src/runs/shared/model-exclusions.ts +0 -374
  121. package/src/runs/shared/readonly-model-continuation.ts +0 -69
  122. package/src/runs/shared/readonly-session-evidence.ts +0 -307
@@ -11,12 +11,14 @@ import type {
11
11
  AcceptanceLedger,
12
12
  AcceptanceLevel,
13
13
  AcceptanceReport,
14
+ AcceptanceReviewGate,
14
15
  AcceptanceRole,
15
16
  AcceptanceRuntimeCheck,
16
17
  AcceptanceRuntimeCheckStatus,
17
18
  AcceptanceReviewResult,
18
19
  AcceptanceVerifyCommand,
19
20
  AcceptanceVerifyResult,
21
+ JsonSchemaObject,
20
22
  ResolvedAcceptanceConfig,
21
23
  ResolvedAcceptanceGate,
22
24
  SingleResult,
@@ -25,6 +27,7 @@ import type {
25
27
  } from "../../shared/types.ts";
26
28
  import { unresolvedChildWatchdogBlockers } from "../../watchdog/child-status.ts";
27
29
  import { isAgentContract } from "./agent-contract.ts";
30
+ import { validateStructuredOutputValue } from "./structured-output.ts";
28
31
  import { classifyTaskMutationIntent, stripSeverityCompounds, taskMayMutate } from "./task-intent.ts";
29
32
 
30
33
  const LEVEL_RANK: Record<Exclude<AcceptanceLevel, "auto">, number> = {
@@ -49,9 +52,9 @@ const VALID_EVIDENCE_KINDS: AcceptanceEvidenceKind[] = [
49
52
  const VALID_EVIDENCE = new Set<AcceptanceEvidenceKind>(VALID_EVIDENCE_KINDS);
50
53
  const ACCEPTANCE_EVIDENCE_HELP = `Supported evidence kinds: ${VALID_EVIDENCE_KINDS.join(", ")}. Example: { level: "checked", evidence: ["commands-run", "changed-files"] }.`;
51
54
  const ACCEPTANCE_OBJECT_EXAMPLE = "Example: { level: \"checked\", evidence: [\"commands-run\", \"changed-files\"] }.";
52
- const ACCEPTANCE_CONFIG_KEYS = new Set(["level", "report", "criteria", "evidence", "verify", "review", "stopRules", "reason"]);
55
+ const ACCEPTANCE_CONFIG_KEYS = new Set(["level", "report", "preserveStagedIndex", "criteria", "evidence", "verify", "review", "stopRules", "reason"]);
53
56
  const ACCEPTANCE_GATE_KEYS = new Set(["id", "must", "evidence", "severity"]);
54
- const ACCEPTANCE_VERIFY_KEYS = new Set(["id", "command", "timeoutMs", "cwd", "env", "allowFailure"]);
57
+ const ACCEPTANCE_VERIFY_KEYS = new Set(["id", "command", "timeoutMs", "cwd", "env", "allowFailure", "output", "schema"]);
55
58
  const ACCEPTANCE_REVIEW_KEYS = new Set(["agent", "focus", "required"]);
56
59
  const EXPLICIT_REVIEWED_UNAVAILABLE = "is an achieved status, not a requestable acceptance level. For a read-only reviewer call, omit acceptance. To require independent review of a writer result, use acceptance.review.required and orchestrate the reviewer separately.";
57
60
 
@@ -106,8 +109,9 @@ function inferLevel(input: {
106
109
  const roleResolvesReadOnly = input.acceptanceRole !== undefined && inferredReadOnly;
107
110
  const dynamicResolvesReadOnly = inferredReadOnly && !writeTask;
108
111
  const riskyKeywordPattern = /\b(?:release|migration|migrate|security|data[- ]loss|destructive|post-review|fix pass)\b/;
112
+ // Topic keywords cannot override classified read-only intent; unknown tasks keep their risk gate.
109
113
  const keywordRiskReadOnly = input.acceptanceRole === undefined
110
- ? intent.kind === "read-only" && !riskyKeywordPattern.test(task)
114
+ ? intent.kind === "read-only"
111
115
  : inferredReadOnly;
112
116
  const risky = Boolean(input.async && writeTask)
113
117
  || (Boolean(input.dynamic) && !roleResolvesReadOnly && !dynamicResolvesReadOnly)
@@ -198,15 +202,59 @@ type GateAcceptanceNormalizationResult =
198
202
  | { ok: true; acceptance?: AcceptanceInput }
199
203
  | { ok: false; error: string };
200
204
 
205
+ const GATE_OBJECT_KEYS = new Set(["command", "output", "schema", "timeoutMs"]);
206
+ export const GATE_INPUT_ERROR = "gate must be a non-empty command string or { command, output?: \"json\", schema?, timeoutMs? }.";
207
+
208
+ export interface GateObjectInput {
209
+ command: string;
210
+ output?: "json";
211
+ schema?: JsonSchemaObject;
212
+ timeoutMs?: number;
213
+ }
214
+
215
+ /** Validates the two accepted gate shapes without normalizing them. Shared by preflight and the workflow sandbox mirror. */
216
+ export function parseGateInput(gate: unknown): { ok: true; gate: GateObjectInput } | { ok: false; error: string } {
217
+ if (typeof gate === "string") {
218
+ return gate.trim() ? { ok: true, gate: { command: gate.trim() } } : { ok: false, error: GATE_INPUT_ERROR };
219
+ }
220
+ if (!gate || typeof gate !== "object" || Array.isArray(gate)) return { ok: false, error: GATE_INPUT_ERROR };
221
+ const value = gate as Record<string, unknown>;
222
+ for (const key of Object.keys(value)) {
223
+ if (!GATE_OBJECT_KEYS.has(key)) return { ok: false, error: `gate.${key} is not supported.` };
224
+ }
225
+ if (typeof value.command !== "string" || !value.command.trim()) return { ok: false, error: GATE_INPUT_ERROR };
226
+ if (value.output !== undefined && value.output !== "json") return { ok: false, error: "gate.output must be \"json\" when present." };
227
+ if (value.schema !== undefined) {
228
+ if (value.output !== "json") return { ok: false, error: "gate.schema requires gate.output: \"json\"." };
229
+ if (!value.schema || typeof value.schema !== "object" || Array.isArray(value.schema)) return { ok: false, error: "gate.schema must be a JSON Schema object." };
230
+ }
231
+ if (value.timeoutMs !== undefined && (!Number.isInteger(value.timeoutMs) || (value.timeoutMs as number) < 1)) return { ok: false, error: "gate.timeoutMs must be an integer >= 1." };
232
+ const parsed: GateObjectInput = { command: value.command.trim() };
233
+ if (value.output === "json") parsed.output = "json";
234
+ // SAFETY: schema was checked above to be a non-array object; timeoutMs to be an integer >= 1.
235
+ if (value.schema !== undefined) parsed.schema = value.schema as JsonSchemaObject;
236
+ if (value.timeoutMs !== undefined) parsed.timeoutMs = value.timeoutMs as number;
237
+ return { ok: true, gate: parsed };
238
+ }
239
+
240
+ /** True when an acceptance policy declares at least one `output: "json"` verify command, from either the gate shorthand or an explicit verify list. */
241
+ export function acceptanceHasTypedVerify(acceptance: AcceptanceInput | undefined): boolean {
242
+ if (!acceptance || typeof acceptance !== "object") return false;
243
+ return Array.isArray(acceptance.verify) && acceptance.verify.some((command) => command && typeof command === "object" && command.output === "json");
244
+ }
245
+
246
+ export const TYPED_VERIFY_OUTPUT_SCHEMA_CONFLICT = "a typed verify command (output: \"json\") cannot be combined with outputSchema; the child would have two structured-output sources.";
247
+
201
248
  export function normalizeGateAcceptance(gate: unknown, acceptance: AcceptanceInput | undefined): GateAcceptanceNormalizationResult {
202
249
  if (gate === undefined) {
203
250
  if (acceptance === undefined) return { ok: true };
204
251
  const normalized = normalizeAcceptanceValue(acceptance);
205
252
  return normalized.error ? { ok: false, error: normalized.error } : { ok: true, acceptance: normalized.value as AcceptanceInput };
206
253
  }
207
- if (typeof gate !== "string" || !gate.trim()) return { ok: false, error: "gate must be a non-empty command string." };
254
+ const parsed = parseGateInput(gate);
255
+ if (!parsed.ok) return { ok: false, error: parsed.error };
208
256
  if (acceptance !== undefined && acceptance !== false) return { ok: false, error: "gate cannot be combined with acceptance; use one gate command or acceptance.verify." + describeGateAcceptanceConflict(gate, acceptance) };
209
- return { ok: true, acceptance: { level: "verified", verify: [{ id: "gate", command: gate.trim() }] } };
257
+ return { ok: true, acceptance: { level: "verified", verify: [{ id: "gate", ...parsed.gate }] } };
210
258
  }
211
259
 
212
260
  export function describeGateAcceptanceConflict(gate: unknown, acceptance: unknown): string {
@@ -265,6 +313,12 @@ export function validateAcceptanceInput(input: unknown, pathLabel = "acceptance"
265
313
  if (value.report !== undefined && value.report !== "on" && value.report !== "off") {
266
314
  errors.push(`${pathLabel}.report must be on or off.`);
267
315
  }
316
+ if (value.preserveStagedIndex !== undefined && value.preserveStagedIndex !== true) {
317
+ errors.push(`${pathLabel}.preserveStagedIndex must be true when provided.`);
318
+ }
319
+ if (value.preserveStagedIndex === true && value.level !== "checked" && value.level !== "verified") {
320
+ errors.push(`${pathLabel}.preserveStagedIndex requires level checked or verified.`);
321
+ }
268
322
  if (value.level === "none" && (typeof value.reason !== "string" || !value.reason.trim())) {
269
323
  errors.push(`${pathLabel}.reason is required when level is none.`);
270
324
  }
@@ -346,6 +400,11 @@ export function validateAcceptanceInput(input: unknown, pathLabel = "acceptance"
346
400
  if (cmd.allowFailure !== undefined && typeof cmd.allowFailure !== "boolean") {
347
401
  errors.push(`${pathLabel}.verify[${index}].allowFailure must be a boolean.`);
348
402
  }
403
+ if (cmd.output !== undefined && cmd.output !== "json") errors.push(`${pathLabel}.verify[${index}].output must be "json" when present.`);
404
+ if (cmd.schema !== undefined) {
405
+ if (cmd.output !== "json") errors.push(`${pathLabel}.verify[${index}].schema requires output: "json".`);
406
+ if (!cmd.schema || typeof cmd.schema !== "object" || Array.isArray(cmd.schema)) errors.push(`${pathLabel}.verify[${index}].schema must be a JSON Schema object.`);
407
+ }
349
408
  }
350
409
  }
351
410
  if (value.review !== undefined && value.review !== false) {
@@ -370,7 +429,7 @@ export function validateAcceptanceInput(input: unknown, pathLabel = "acceptance"
370
429
  return errors;
371
430
  }
372
431
 
373
- export function validateExecutionAcceptance(input: {
432
+ type ExecutionAcceptanceInput = {
374
433
  acceptance?: unknown;
375
434
  outputSchema?: unknown;
376
435
  tasks?: Array<{ acceptance?: unknown; outputSchema?: unknown }>;
@@ -379,23 +438,39 @@ export function validateExecutionAcceptance(input: {
379
438
  outputSchema?: unknown;
380
439
  parallel?: Array<{ acceptance?: unknown; outputSchema?: unknown }> | { acceptance?: unknown; outputSchema?: unknown };
381
440
  }>;
382
- }): string[] {
441
+ };
442
+
443
+ export function validateExecutionAcceptancePolicy(input: ExecutionAcceptanceInput): string[] {
383
444
  const errors = validateAcceptanceInput(input.acceptance, "acceptance");
384
- errors.push(...validateAcceptanceReportMode(input.acceptance, input.outputSchema, "acceptance"));
385
445
  for (const [index, task] of (input.tasks ?? []).entries()) {
386
446
  errors.push(...validateAcceptanceInput(task.acceptance, `tasks[${index}].acceptance`));
387
- errors.push(...validateAcceptanceReportMode(task.acceptance, task.outputSchema, `tasks[${index}].acceptance`));
388
447
  }
389
448
  for (const [stepIndex, step] of (input.chain ?? []).entries()) {
390
449
  errors.push(...validateAcceptanceInput(step.acceptance, `chain[${stepIndex}].acceptance`));
391
- errors.push(...validateAcceptanceReportMode(step.acceptance, step.outputSchema, `chain[${stepIndex}].acceptance`));
392
450
  if (Array.isArray(step.parallel)) {
393
451
  for (const [taskIndex, task] of step.parallel.entries()) {
394
452
  errors.push(...validateAcceptanceInput(task.acceptance, `chain[${stepIndex}].parallel[${taskIndex}].acceptance`));
395
- errors.push(...validateAcceptanceReportMode(task.acceptance, task.outputSchema, `chain[${stepIndex}].parallel[${taskIndex}].acceptance`));
396
453
  }
397
454
  } else if (step.parallel) {
398
455
  errors.push(...validateAcceptanceInput(step.parallel.acceptance, `chain[${stepIndex}].parallel.acceptance`));
456
+ }
457
+ }
458
+ return errors;
459
+ }
460
+
461
+ export function validateExecutionAcceptance(input: ExecutionAcceptanceInput): string[] {
462
+ const errors = validateExecutionAcceptancePolicy(input);
463
+ errors.push(...validateAcceptanceReportMode(input.acceptance, input.outputSchema, "acceptance"));
464
+ for (const [index, task] of (input.tasks ?? []).entries()) {
465
+ errors.push(...validateAcceptanceReportMode(task.acceptance, task.outputSchema, `tasks[${index}].acceptance`));
466
+ }
467
+ for (const [stepIndex, step] of (input.chain ?? []).entries()) {
468
+ errors.push(...validateAcceptanceReportMode(step.acceptance, step.outputSchema, `chain[${stepIndex}].acceptance`));
469
+ if (Array.isArray(step.parallel)) {
470
+ for (const [taskIndex, task] of step.parallel.entries()) {
471
+ errors.push(...validateAcceptanceReportMode(task.acceptance, task.outputSchema, `chain[${stepIndex}].parallel[${taskIndex}].acceptance`));
472
+ }
473
+ } else if (step.parallel) {
399
474
  errors.push(...validateAcceptanceReportMode(step.parallel.acceptance, step.parallel.outputSchema, `chain[${stepIndex}].parallel.acceptance`));
400
475
  }
401
476
  }
@@ -408,7 +483,7 @@ function validateAcceptanceReportMode(acceptance: unknown, outputSchema: unknown
408
483
  acceptance = normalized.value;
409
484
  if (!acceptance || typeof acceptance !== "object" || Array.isArray(acceptance)) return [];
410
485
  if (!("report" in acceptance)) return [];
411
- return outputSchema === undefined ? [`${pathLabel}.report requires outputSchema.`] : [];
486
+ return outputSchema === undefined || outputSchema === false ? [`${pathLabel}.report requires outputSchema.`] : [];
412
487
  }
413
488
 
414
489
  function normalizeCriteria(criteria: Array<string | { id?: string; must?: string; evidence?: AcceptanceEvidenceKind[]; severity?: "required" | "recommended" }> | undefined, evidence: AcceptanceEvidenceKind[]): ResolvedAcceptanceGate[] {
@@ -453,6 +528,7 @@ export function resolveEffectiveAcceptance(input: {
453
528
  inferredReason: [],
454
529
  criteria,
455
530
  evidence,
531
+ preserveStagedIndex: explicit.preserveStagedIndex,
456
532
  verify: explicit.verify ?? [],
457
533
  review: explicit.review,
458
534
  stopRules: explicit.stopRules ?? [],
@@ -478,6 +554,7 @@ export function resolveEffectiveAcceptance(input: {
478
554
  inferredReason: inferred.reasons,
479
555
  criteria: level === "none" ? [] : criteria,
480
556
  evidence: level === "none" ? [] : evidence,
557
+ preserveStagedIndex: explicit.preserveStagedIndex,
481
558
  verify: explicit.verify ?? [],
482
559
  review,
483
560
  stopRules: explicit.stopRules ?? [],
@@ -489,6 +566,12 @@ function acceptanceRequiresChildReport(acceptance: ResolvedAcceptanceConfig): bo
489
566
  return acceptance.criteria.length > 0 || acceptance.evidence.length > 0;
490
567
  }
491
568
 
569
+ /** Label a declared review gate the same way in prompts and capability summaries. */
570
+ export function formatReviewGateLabel(review: AcceptanceReviewGate): string {
571
+ const status = review.required === false ? "optional" : "required";
572
+ return review.agent ? `${status} by ${review.agent}` : status;
573
+ }
574
+
492
575
  export function formatAcceptancePrompt(acceptance: ResolvedAcceptanceConfig, options: { reportOptional?: boolean; structuredOutput?: boolean } = {}): string {
493
576
  if (acceptance.level === "none") return "";
494
577
  if (options.reportOptional && !acceptanceRequiresChildReport(acceptance)) return "";
@@ -503,12 +586,15 @@ export function formatAcceptancePrompt(acceptance: ResolvedAcceptanceConfig, opt
503
586
  "",
504
587
  `Required evidence: ${acceptance.evidence.join(", ") || "none"}`,
505
588
  ];
589
+ if (acceptance.preserveStagedIndex) {
590
+ lines.push("The host will verify that the staged index is unchanged from launch; report noStagedFiles truthfully even when the preserved index is non-empty.");
591
+ }
506
592
  if (acceptance.verify.length > 0) {
507
593
  lines.push("", "Runtime verification commands configured by parent:");
508
594
  for (const command of acceptance.verify) lines.push(`- ${command.id}: ${command.command}`);
509
595
  }
510
596
  if (acceptance.review) {
511
- lines.push("", `Review gate: ${acceptance.review.required === false ? "optional" : "required"}${acceptance.review.agent ? ` by ${acceptance.review.agent}` : ""}.`);
597
+ lines.push("", `Review gate: ${formatReviewGateLabel(acceptance.review)}.`);
512
598
  if (acceptance.review.focus) lines.push(`Review focus: ${acceptance.review.focus}`);
513
599
  }
514
600
  if (acceptance.stopRules.length > 0) {
@@ -1055,9 +1141,32 @@ function checkNoStagedFiles(cwd: string): AcceptanceRuntimeCheck {
1055
1141
  : { id: "no-staged-files", status: "failed", message: `Staged files present: ${staged.join(", ")}` };
1056
1142
  }
1057
1143
 
1144
+ /** Capture the repository-wide index tree. Throws when Git cannot provide trustworthy evidence. */
1145
+ export function captureStagedIndexBaseline(cwd: string): string {
1146
+ const result = spawnSync("git", ["write-tree"], { cwd, encoding: "utf-8", windowsHide: true });
1147
+ const oid = result.stdout.trim();
1148
+ if (result.status !== 0 || !oid) {
1149
+ const detail = result.stderr.trim();
1150
+ throw new Error(`Unable to capture staged index baseline${detail ? `: ${detail}` : "."}`);
1151
+ }
1152
+ return oid;
1153
+ }
1154
+
1155
+ function checkStagedIndexUnchanged(cwd: string, baseline: string): AcceptanceRuntimeCheck {
1156
+ try {
1157
+ const terminal = captureStagedIndexBaseline(cwd);
1158
+ return terminal === baseline
1159
+ ? { id: "staged-index-unchanged", status: "passed", message: "Staged index matches the launch baseline." }
1160
+ : { id: "staged-index-unchanged", status: "failed", message: "Staged index changed after launch." };
1161
+ } catch (error) {
1162
+ return { id: "staged-index-unchanged", status: "failed", message: error instanceof Error ? error.message : String(error) };
1163
+ }
1164
+ }
1165
+
1058
1166
  function runStructuralChecks(acceptance: ResolvedAcceptanceConfig, report: AcceptanceReport, cwd: string): AcceptanceRuntimeCheck[] {
1059
1167
  const checks: AcceptanceRuntimeCheck[] = [];
1060
1168
  for (const kind of acceptance.evidence) {
1169
+ if (kind === "no-staged-files" && acceptance.preserveStagedIndex) continue;
1061
1170
  if (kind === "no-staged-files" && report.noStagedFiles === undefined) continue;
1062
1171
  const status = reportEvidenceStatus(report, kind);
1063
1172
  checks.push({
@@ -1070,7 +1179,7 @@ function runStructuralChecks(acceptance: ResolvedAcceptanceConfig, report: Accep
1070
1179
  : `${kind} evidence missing from child report.`,
1071
1180
  });
1072
1181
  }
1073
- if (acceptance.evidence.includes("no-staged-files")) checks.push(checkNoStagedFiles(cwd));
1182
+ if (!acceptance.preserveStagedIndex && acceptance.evidence.includes("no-staged-files")) checks.push(checkNoStagedFiles(cwd));
1074
1183
  return checks;
1075
1184
  }
1076
1185
 
@@ -1178,6 +1287,43 @@ function isCachedVerifyResult(value: unknown): value is AcceptanceVerifyResult {
1178
1287
  && typeof result.durationMs === "number";
1179
1288
  }
1180
1289
 
1290
+ const TYPED_VERIFY_OUTPUT_MAX_BYTES = 12_000;
1291
+
1292
+ /**
1293
+ * A passing `output: "json"` command must print one JSON document on stdout.
1294
+ * The parsed value becomes the run's structured output; anything else turns
1295
+ * the passing run into a failed one so acceptance rejects rather than
1296
+ * silently dropping the verdict. Stdout is already bounded by trimOutput, so a
1297
+ * truncated document is detected by its marker instead of a parse guess.
1298
+ */
1299
+ async function applyTypedVerifyOutput(command: AcceptanceVerifyCommand, run: AcceptanceVerifyResult): Promise<void> {
1300
+ if (run.status !== "passed") return;
1301
+ const stdout = run.stdout ?? "";
1302
+ const fail = (message: string): void => {
1303
+ run.status = "failed";
1304
+ run.structuredOutputError = message;
1305
+ };
1306
+ if (!stdout.trim()) return fail("output: \"json\" command printed nothing on stdout.");
1307
+ if (stdout.endsWith("...[truncated]")) return fail(`output: "json" stdout exceeded ${TYPED_VERIFY_OUTPUT_MAX_BYTES} characters and was truncated.`);
1308
+ let value: unknown;
1309
+ try {
1310
+ value = JSON.parse(stdout);
1311
+ } catch (error) {
1312
+ return fail(`output: "json" stdout is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
1313
+ }
1314
+ if (command.schema) {
1315
+ const validation = await validateStructuredOutputValue(command.schema, value);
1316
+ if (validation.status === "invalid") return fail(`output: "json" stdout does not match gate.schema: ${validation.message}`);
1317
+ }
1318
+ run.structuredOutput = value;
1319
+ }
1320
+
1321
+ /** The first typed verify result on a ledger, if any command produced one. */
1322
+ export function typedVerifyOutput(ledger: AcceptanceLedger | undefined): { value: unknown } | undefined {
1323
+ const run = ledger?.verifyRuns?.find((entry) => entry.structuredOutput !== undefined);
1324
+ return run ? { value: run.structuredOutput } : undefined;
1325
+ }
1326
+
1181
1327
  async function runMemoizedVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string, options: {
1182
1328
  signal?: AbortSignal;
1183
1329
  abortMessage?: string;
@@ -1191,7 +1337,9 @@ async function runMemoizedVerifyCommand(command: AcceptanceVerifyCommand, defaul
1191
1337
  } catch {
1192
1338
  workspaceState = undefined;
1193
1339
  }
1194
- if (!workspaceState || !options.artifactsDir || !options.runId) {
1340
+ // Typed gates read inputs (a report file, a log) that can change without
1341
+ // the tracked tree changing, so their verdicts are never reused.
1342
+ if (!workspaceState || !options.artifactsDir || !options.runId || command.output === "json") {
1195
1343
  return runVerifyCommand(command, defaultCwd, options);
1196
1344
  }
1197
1345
  const envKeys = Object.keys(command.env ?? {}).sort();
@@ -1363,6 +1511,8 @@ export async function evaluateAcceptance(input: {
1363
1511
  acceptance: ResolvedAcceptanceConfig;
1364
1512
  output: string;
1365
1513
  cwd: string;
1514
+ /** Host-captured launch index tree; required by preserveStagedIndex. */
1515
+ stagedIndexBaseline?: string;
1366
1516
  /**
1367
1517
  * Content the child sent to its configured output file (from its own write
1368
1518
  * tool calls, not from disk, so a concurrent writer to the same path cannot
@@ -1397,7 +1547,7 @@ export async function evaluateAcceptance(input: {
1397
1547
  if (input.watchdog) {
1398
1548
  const unresolved = unresolvedChildWatchdogBlockers(input.watchdog);
1399
1549
  ledger.runtimeChecks.push(unresolved.length
1400
- ? { id: "watchdog-blocker", status: "failed", message: `Unresolved watchdog blocker: ${unresolved[0]!.summary}` }
1550
+ ? { id: "watchdog-blocker", status: "failed", message: "Unresolved watchdog blocker (details are available in child watchdog status)." }
1401
1551
  : { id: "watchdog-blocker", status: "passed", message: "No unresolved watchdog blockers." });
1402
1552
  }
1403
1553
 
@@ -1458,30 +1608,36 @@ export async function evaluateAcceptance(input: {
1458
1608
  ledger.runtimeChecks.push({ id: "verification-config", status: "failed", message: "verified acceptance requires runtime verify commands." });
1459
1609
  ledger.status = "rejected";
1460
1610
  ledger.evidenceStatus = "rejected";
1461
- return ledger;
1462
- }
1463
- ledger.verifyRuns = [];
1464
- for (const command of acceptance.verify) {
1465
- ledger.verifyRuns.push(await runMemoizedVerifyCommand(command, input.cwd, {
1466
- signal: input.signal,
1467
- abortMessage: input.abortMessage,
1468
- artifactsDir: input.artifactsDir,
1469
- runId: input.runId,
1470
- }));
1471
- if (input.signal?.aborted) break;
1472
- }
1473
- if (ledger.verifyRuns.some((run) => run.status === "failed" || run.status === "timed-out")) {
1474
- ledger.status = "rejected";
1475
- ledger.evidenceStatus = "rejected";
1476
- return ledger;
1477
- }
1478
- if (!ledger.runtimeChecks.some((check) => check.status === "failed")) {
1479
- ledger.status = "verified";
1480
- ledger.evidenceStatus = "verified";
1611
+ } else {
1612
+ ledger.verifyRuns = [];
1613
+ for (const command of acceptance.verify) {
1614
+ const run = await runMemoizedVerifyCommand(command, input.cwd, {
1615
+ signal: input.signal,
1616
+ abortMessage: input.abortMessage,
1617
+ artifactsDir: input.artifactsDir,
1618
+ runId: input.runId,
1619
+ });
1620
+ if (command.output === "json") await applyTypedVerifyOutput(command, run);
1621
+ ledger.verifyRuns.push(run);
1622
+ if (input.signal?.aborted) break;
1623
+ }
1624
+ if (ledger.verifyRuns.some((run) => run.status === "failed" || run.status === "timed-out")) {
1625
+ ledger.status = "rejected";
1626
+ ledger.evidenceStatus = "rejected";
1627
+ } else if (!ledger.runtimeChecks.some((check) => check.status === "failed")) {
1628
+ ledger.status = "verified";
1629
+ ledger.evidenceStatus = "verified";
1630
+ }
1481
1631
  }
1482
1632
  }
1483
1633
 
1484
- if (ledger.runtimeChecks.some((check) => check.status === "failed")) {
1634
+ if (acceptance.preserveStagedIndex) {
1635
+ ledger.runtimeChecks.push(input.stagedIndexBaseline
1636
+ ? checkStagedIndexUnchanged(input.cwd, input.stagedIndexBaseline)
1637
+ : { id: "staged-index-unchanged", status: "failed", message: "Staged index launch baseline is unavailable." });
1638
+ }
1639
+
1640
+ if (ledger.status === "rejected" || ledger.runtimeChecks.some((check) => check.status === "failed")) {
1485
1641
  ledger.status = "rejected";
1486
1642
  ledger.evidenceStatus = "rejected";
1487
1643
  return ledger;
@@ -1535,6 +1691,7 @@ export function acceptanceFailureMessage(ledger: AcceptanceLedger): string | und
1535
1691
  const failedCheck = ledger.runtimeChecks.find((check) => check.status === "failed");
1536
1692
  if (failedCheck) return `Acceptance rejected: ${failedCheck.message}`;
1537
1693
  const failedVerify = ledger.verifyRuns.find((run) => run.status === "failed" || run.status === "timed-out");
1694
+ if (failedVerify?.structuredOutputError) return `Acceptance verification '${failedVerify.id}' failed: ${failedVerify.structuredOutputError}`;
1538
1695
  if (failedVerify) return `Acceptance verification '${failedVerify.id}' ${failedVerify.status}.`;
1539
1696
  if (ledger.reviewResult?.status === "blockers") return "Acceptance review found blockers.";
1540
1697
  return "Acceptance rejected.";
@@ -224,16 +224,25 @@ function appendBoundedChildren(children: AsyncStatusSnapshotNode[], source: read
224
224
  ctx.omitted.children += Math.max(0, source.length - remaining);
225
225
  }
226
226
 
227
+ function stepIdentity(step: AsyncJobStep | NestedStepSummary, index: number, ctx: ProjectionContext) {
228
+ return {
229
+ id: publicText("workflowKey" in step && step.workflowKey ? step.workflowKey : "runId" in step && step.runId ? step.runId : `step:${index}`, `step:${index}`, ctx.caps.maxStringLength),
230
+ label: publicText("label" in step && step.label ? step.label : step.agent, "step", ctx.caps.maxStringLength),
231
+ };
232
+ }
233
+
227
234
  function projectStep(step: AsyncJobStep | NestedStepSummary, index: number, depth: number, ctx: ProjectionContext): AsyncStatusSnapshotNode {
228
235
  const state = normalizeState(step.status);
229
236
  const startedAt = publicTime(step.startedAt);
230
- const endedAt = publicTime(step.endedAt);
237
+ const durationMs = "durationMs" in step ? publicTime(step.durationMs) : undefined;
238
+ const endedAt = publicTime(step.endedAt) ?? (terminalState(state) && startedAt !== undefined && durationMs !== undefined ? publicTime(startedAt + durationMs) : undefined);
231
239
  const updatedAt = endedAt ?? publicTime(step.lastActivityAt) ?? startedAt;
232
240
  const activity = activityFor(step, ctx);
241
+ const { id, label } = stepIdentity(step, index, ctx);
233
242
  const node: AsyncStatusSnapshotNode = {
234
- id: publicText("workflowKey" in step && step.workflowKey ? step.workflowKey : "runId" in step && step.runId ? step.runId : `step:${index}`, `step:${index}`, ctx.caps.maxStringLength),
243
+ id,
235
244
  kind: "step",
236
- label: publicText("label" in step && step.label ? step.label : step.agent, "step", ctx.caps.maxStringLength),
245
+ label,
237
246
  state,
238
247
  ...(startedAt !== undefined ? { startedAt } : {}),
239
248
  ...(updatedAt !== undefined ? { updatedAt } : {}),
@@ -251,16 +260,6 @@ function projectStep(step: AsyncJobStep | NestedStepSummary, index: number, dept
251
260
  return node;
252
261
  }
253
262
 
254
- function projectWorkflowGraphNode(node: WorkflowGraphSnapshot["nodes"][number], index: number, depth: number, ctx: ProjectionContext): AsyncStatusSnapshotNode {
255
- const step: AsyncJobStep = {
256
- agent: node.agent ?? node.label,
257
- status: workflowGraphStepStatus(node.status),
258
- workflowKey: node.id,
259
- label: node.label,
260
- };
261
- return projectStep(step, node.flatIndex ?? index, depth, ctx);
262
- }
263
-
264
263
  function projectNestedRun(child: NestedRunSummary, index: number, depth: number, ctx: ProjectionContext): AsyncStatusSnapshotNode {
265
264
  const state = normalizeState(child.state);
266
265
  const startedAt = publicTime(child.startedAt);
@@ -324,7 +323,86 @@ function projectHostStep(hostStep: HostStepNode, ctx: ProjectionContext): AsyncS
324
323
  };
325
324
  }
326
325
 
327
- function projectRun(job: AsyncJobState, ctx: ProjectionContext): AsyncStatusSnapshotNode {
326
+ type MaterializedChildren = Map<string, AsyncJobState[]>;
327
+
328
+ function asyncJobOrder(left: AsyncJobState, right: AsyncJobState): number {
329
+ const leftRank = left.status === "running" ? 0 : left.status === "queued" ? 1 : 2;
330
+ const rightRank = right.status === "running" ? 0 : right.status === "queued" ? 1 : 2;
331
+ const leftTime = left.updatedAt ?? left.startedAt ?? 0;
332
+ const rightTime = right.updatedAt ?? right.startedAt ?? 0;
333
+ return leftRank - rightRank || rightTime - leftTime || left.asyncId.localeCompare(right.asyncId);
334
+ }
335
+
336
+ function hasCyclicWorkflowParent(job: AsyncJobState, parents: ReadonlyMap<string, AsyncJobState>): boolean {
337
+ const seen = new Set([job.asyncId]);
338
+ let parentId = job.parentWorkflowRunId;
339
+ while (parentId) {
340
+ if (seen.has(parentId)) return true;
341
+ seen.add(parentId);
342
+ parentId = parents.get(parentId)?.parentWorkflowRunId;
343
+ }
344
+ return false;
345
+ }
346
+
347
+ function groupMaterializedChildren(jobs: readonly AsyncJobState[]) {
348
+ const parents = new Map(jobs.filter((job) => job.mode === "workflow").map((job) => [job.asyncId, job]));
349
+ const childrenByParent: MaterializedChildren = new Map();
350
+ const liveRoots = new Set<AsyncJobState>();
351
+ const roots: AsyncJobState[] = [];
352
+ for (const job of jobs) {
353
+ const parent = job.parentWorkflowRunId ? parents.get(job.parentWorkflowRunId) : undefined;
354
+ const keepLiveRoot = (job.status === "running" || job.status === "queued") && parent?.status !== "running";
355
+ if (parent && !hasCyclicWorkflowParent(job, parents)) {
356
+ const siblings = childrenByParent.get(parent.asyncId) ?? [];
357
+ siblings.push(job);
358
+ childrenByParent.set(parent.asyncId, siblings);
359
+ if (keepLiveRoot) {
360
+ liveRoots.add(job);
361
+ roots.push(job);
362
+ }
363
+ } else {
364
+ roots.push(job);
365
+ }
366
+ }
367
+ for (const children of childrenByParent.values()) children.sort(asyncJobOrder);
368
+ return { roots, childrenByParent, liveRoots };
369
+ }
370
+
371
+ function assignMaterializedChildren(steps: readonly AsyncJobStep[], children: readonly AsyncJobState[]): Map<AsyncJobStep, AsyncJobState> {
372
+ const assigned = new Map<AsyncJobStep, AsyncJobState>();
373
+ const claimed = new Set<AsyncJobState>();
374
+ for (const step of steps) {
375
+ if (!step.runId) continue;
376
+ const child = children.find((candidate) => !claimed.has(candidate) && candidate.asyncId === step.runId);
377
+ if (child) {
378
+ assigned.set(step, child);
379
+ claimed.add(child);
380
+ }
381
+ }
382
+ for (const step of steps) {
383
+ if (assigned.has(step) || !step.workflowKey) continue;
384
+ const child = children.find((candidate) => !claimed.has(candidate) && candidate.workflowKey === step.workflowKey);
385
+ if (child) {
386
+ assigned.set(step, child);
387
+ claimed.add(child);
388
+ }
389
+ }
390
+ return assigned;
391
+ }
392
+
393
+ function projectMaterializedStep(step: AsyncJobStep, index: number, child: AsyncJobState, depth: number, ctx: ProjectionContext, childrenByParent: MaterializedChildren, liveRoots: ReadonlySet<AsyncJobState>): AsyncStatusSnapshotNode {
394
+ const { id, label } = stepIdentity(step, index, ctx);
395
+ const selfStep = child.mode === undefined || child.mode === "single" ? child.steps?.[0] : undefined;
396
+ const live = projectRun(child, ctx, depth, childrenByParent, liveRoots, selfStep !== undefined);
397
+ const laneActivity = activityFor(step, ctx);
398
+ const selfActivity = selfStep ? activityFor(selfStep, ctx) : undefined;
399
+ const activity = laneActivity || selfActivity || live.activity ? { ...laneActivity, ...selfActivity, ...live.activity } : undefined;
400
+ const node: AsyncStatusSnapshotNode = { ...live, id, kind: "step", label };
401
+ if (activity) node.activity = activity;
402
+ return node;
403
+ }
404
+
405
+ function projectRun(job: AsyncJobState, ctx: ProjectionContext, depth: number, childrenByParent: MaterializedChildren, liveRoots: ReadonlySet<AsyncJobState>, omitSteps = false): AsyncStatusSnapshotNode {
328
406
  const state = normalizeState(job.status);
329
407
  const startedAt = publicTime(job.startedAt);
330
408
  const updatedAt = publicTime(job.updatedAt) ?? startedAt;
@@ -339,16 +417,32 @@ function projectRun(job: AsyncJobState, ctx: ProjectionContext): AsyncStatusSnap
339
417
  ...(terminalState(state) && updatedAt !== undefined ? { endedAt: updatedAt } : {}),
340
418
  ...(activity ? { activity } : {}),
341
419
  };
342
- if (ctx.caps.maxDepth > 0) {
343
- const stepChildren = job.steps?.map((step, index) => projectStep(step, step.index ?? index, 1, ctx)) ?? [];
344
- const loadedKeys = new Set(job.steps?.flatMap((step) => step.workflowKey ? [step.workflowKey] : []) ?? []);
345
- const graphStages = job.mode === "workflow" ? workflowGraphStageNodes(job.workflowGraph) : [];
346
- const graphChildren = graphStages
347
- .filter((graphNode) => !loadedKeys.has(graphNode.id))
348
- .map((graphNode, index) => projectWorkflowGraphNode(graphNode, index, 1, ctx));
349
- const nestedChildren = job.nestedChildren?.map((child, index) => projectNestedRun(child, index, 1, ctx)) ?? [];
420
+ const steps = omitSteps ? [] : job.steps ?? [];
421
+ const materialized = childrenByParent.get(job.asyncId) ?? [];
422
+ const nestedSummaries = (job.nestedChildren ?? []).filter((child) => !materialized.some((live) => live.asyncId === child.id));
423
+ const loadedKeys = new Set(steps.flatMap((step) => step.workflowKey ? [step.workflowKey] : []));
424
+ const graphStages = job.mode === "workflow" ? workflowGraphStageNodes(job.workflowGraph).filter((graphNode) => !loadedKeys.has(graphNode.id)) : [];
425
+ const graphSteps = graphStages.map((node, index) => ({
426
+ step: { agent: node.agent ?? node.label, status: workflowGraphStepStatus(node.status), workflowKey: node.id, label: node.label },
427
+ index: node.flatIndex ?? index,
428
+ }));
429
+ const lanes = [...steps, ...graphSteps.map(({ step }) => step)];
430
+ const assigned = assignMaterializedChildren(lanes, materialized);
431
+ const claimed = new Set(assigned.values());
432
+ if (depth < ctx.caps.maxDepth) {
433
+ const childDepth = depth + 1;
434
+ const projectLane = (step: AsyncJobStep, index: number): AsyncStatusSnapshotNode | undefined => {
435
+ const child = assigned.get(step);
436
+ if (!child) return projectStep(step, index, childDepth, ctx);
437
+ if (liveRoots.has(child)) return undefined;
438
+ return projectMaterializedStep(step, index, child, childDepth, ctx, childrenByParent, liveRoots);
439
+ };
440
+ const stepChildren = steps.map((step, index) => projectLane(step, step.index ?? index)).filter((child): child is AsyncStatusSnapshotNode => child !== undefined);
441
+ const graphChildren = graphSteps.map(({ step, index }) => projectLane(step, index)).filter((child): child is AsyncStatusSnapshotNode => child !== undefined);
442
+ const nestedChildren = nestedSummaries.map((child, index) => projectNestedRun(child, index, childDepth, ctx));
443
+ const unclaimedChildren = materialized.filter((child) => !claimed.has(child) && !liveRoots.has(child)).map((child) => projectRun(child, ctx, childDepth, childrenByParent, liveRoots));
350
444
  const hostStepChildren = validHostStepList(job.hostSteps).map((hostStep) => projectHostStep(hostStep, ctx));
351
- const ordinaryChildren = [...stepChildren, ...graphChildren, ...nestedChildren];
445
+ const ordinaryChildren = [...stepChildren, ...graphChildren, ...nestedChildren, ...unclaimedChildren];
352
446
  const retainedHostSteps = hostStepChildren.slice(0, ctx.caps.maxChildrenPerNode);
353
447
  const retainedOrdinaryChildren = ordinaryChildren.slice(0, ctx.caps.maxChildrenPerNode - retainedHostSteps.length);
354
448
  const bounded: AsyncStatusSnapshotNode[] = [];
@@ -356,10 +450,9 @@ function projectRun(job: AsyncJobState, ctx: ProjectionContext): AsyncStatusSnap
356
450
  ctx.omitted.children += ordinaryChildren.length - retainedOrdinaryChildren.length + hostStepChildren.length - retainedHostSteps.length;
357
451
  if (bounded.length) node.children = bounded;
358
452
  } else {
359
- const loadedKeys = new Set(job.steps?.flatMap((step) => step.workflowKey ? [step.workflowKey] : []) ?? []);
360
- const graphStages = job.mode === "workflow" ? workflowGraphStageNodes(job.workflowGraph) : [];
361
- const graphCount = graphStages.filter((graphNode) => !loadedKeys.has(graphNode.id)).length;
362
- ctx.omitted.children += (job.steps?.length ?? 0) + graphCount + (job.nestedChildren?.length ?? 0) + validHostStepList(job.hostSteps).length;
453
+ const rootAssignments = [...assigned.values()].filter((child) => liveRoots.has(child)).length;
454
+ const unclaimedChildren = materialized.filter((child) => !claimed.has(child) && !liveRoots.has(child)).length;
455
+ ctx.omitted.children += lanes.length - rootAssignments + nestedSummaries.length + unclaimedChildren + validHostStepList(job.hostSteps).length;
363
456
  }
364
457
  return node;
365
458
  }
@@ -550,11 +643,8 @@ function projectLoadedWorkflowRow(step: AsyncJobStep, index: number, preflight?:
550
643
  export function projectAsyncStatusSnapshot(jobs: Iterable<AsyncJobState>, options: AsyncStatusSnapshotOptions = {}): AsyncStatusSnapshot {
551
644
  const caps = resolveCaps(options);
552
645
  const ctx: ProjectionContext = { caps, omitted: { runs: 0, children: 0, byteLimitExceeded: false } };
553
- const sorted = [...jobs].sort((left, right) => {
554
- const leftUpdated = left.updatedAt ?? left.startedAt ?? 0;
555
- const rightUpdated = right.updatedAt ?? right.startedAt ?? 0;
556
- return rightUpdated - leftUpdated || left.asyncId.localeCompare(right.asyncId);
557
- });
646
+ const { roots, childrenByParent, liveRoots } = groupMaterializedChildren([...jobs]);
647
+ const sorted = roots.sort(asyncJobOrder);
558
648
  ctx.omitted.runs += Math.max(0, sorted.length - caps.maxRuns);
559
649
  const snapshot: AsyncStatusSnapshot = {
560
650
  kind: ASYNC_STATUS_SNAPSHOT_KIND,
@@ -562,7 +652,7 @@ export function projectAsyncStatusSnapshot(jobs: Iterable<AsyncJobState>, option
562
652
  generatedAt: options.generatedAt ?? Date.now(),
563
653
  caps,
564
654
  omitted: ctx.omitted,
565
- runs: sorted.slice(0, caps.maxRuns).map((job) => projectRun(job, ctx)),
655
+ runs: sorted.slice(0, caps.maxRuns).map((job) => projectRun(job, ctx, 0, childrenByParent, liveRoots)),
566
656
  };
567
657
  enforceByteLimit(snapshot);
568
658
  return snapshot;