pi-crew 0.9.68 → 0.10.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 (217) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/agents/analyst.md +1 -1
  3. package/agents/cold-verifier.md +3 -1
  4. package/agents/critic.md +1 -1
  5. package/agents/executor.md +1 -1
  6. package/agents/explorer.md +1 -1
  7. package/agents/planner.md +1 -1
  8. package/agents/reviewer.md +1 -1
  9. package/agents/security-reviewer.md +1 -1
  10. package/agents/test-engineer.md +1 -1
  11. package/agents/verifier.md +1 -1
  12. package/agents/writer.md +1 -1
  13. package/dist/index.mjs +65838 -60331
  14. package/docs/README.md +2 -0
  15. package/docs/actions-reference.md +31 -0
  16. package/docs/commands-reference.md +17 -6
  17. package/docs/resource-formats.md +13 -0
  18. package/package.json +4 -2
  19. package/schema.json +15 -1
  20. package/scripts/resource-sampler.mjs +36 -2
  21. package/skills/requirements-to-task-packet/SKILL.md +26 -0
  22. package/skills/widget-rendering/SKILL.md +7 -7
  23. package/src/agents/agent-config.ts +2 -1
  24. package/src/agents/discover-agents.ts +23 -14
  25. package/src/config/config-merge.ts +183 -0
  26. package/src/config/config-validation.ts +681 -0
  27. package/src/config/config.ts +22 -864
  28. package/src/config/defaults.ts +35 -1
  29. package/src/config/drift-detector.ts +1 -1
  30. package/src/config/env-vars.ts +691 -0
  31. package/src/config/role-tools.ts +8 -8
  32. package/src/config/sanitize-project-config.ts +172 -0
  33. package/src/config/types.ts +30 -0
  34. package/src/extension/async-notifier.ts +25 -2
  35. package/src/extension/crew-vibes/config.ts +2 -1
  36. package/src/extension/plan-orchestrate.ts +132 -0
  37. package/src/extension/registration/commands/dashboard.ts +158 -0
  38. package/src/extension/registration/commands/index.ts +35 -0
  39. package/src/extension/registration/commands/manage.ts +303 -0
  40. package/src/extension/registration/commands/run.ts +214 -0
  41. package/src/extension/registration/commands/shared.ts +631 -0
  42. package/src/extension/registration/commands/status.ts +60 -0
  43. package/src/extension/registration/commands.ts +13 -1224
  44. package/src/extension/registration/lifecycle-handlers.ts +126 -15
  45. package/src/extension/registration/subagent-tools.ts +218 -9
  46. package/src/extension/rpc-hmac.ts +5 -3
  47. package/src/extension/team-tool/api/heartbeat.ts +47 -10
  48. package/src/extension/team-tool/api/plan-approval.ts +9 -0
  49. package/src/extension/team-tool/api/task-claims.ts +109 -40
  50. package/src/extension/team-tool/cancel.ts +84 -50
  51. package/src/extension/team-tool/dispatch/index.ts +1 -0
  52. package/src/extension/team-tool/dispatch/run.ts +4 -1
  53. package/src/extension/team-tool/doctor.ts +103 -1
  54. package/src/extension/team-tool/orchestrate.ts +66 -1
  55. package/src/extension/team-tool/plans.ts +192 -0
  56. package/src/extension/team-tool/respond.ts +197 -65
  57. package/src/extension/team-tool/run-deadline.ts +16 -1
  58. package/src/extension/team-tool/run-intent.ts +63 -0
  59. package/src/extension/team-tool/run.ts +48 -18
  60. package/src/extension/team-tool/status.ts +84 -26
  61. package/src/extension/team-tool.ts +11 -2
  62. package/src/hooks/registry.ts +1 -6
  63. package/src/i18n.ts +9 -0
  64. package/src/prompt/prompt-runtime.ts +521 -2
  65. package/src/prompt/worker-events-channel.ts +173 -0
  66. package/src/runtime/README.md +8 -8
  67. package/src/runtime/async-runner.ts +7 -3
  68. package/src/runtime/background-runner.ts +42 -14
  69. package/src/runtime/broker/broker-issuer.ts +9 -2
  70. package/src/runtime/broker/crew-broker-tokens.ts +43 -6
  71. package/src/runtime/broker/crew-broker.ts +838 -10
  72. package/src/runtime/broker/wait-status-cache.ts +157 -0
  73. package/src/runtime/budget-enforcement.ts +281 -0
  74. package/src/runtime/child-pi/child-pi-spawn.ts +60 -14
  75. package/src/runtime/child-pi/child-pi-timers.ts +324 -0
  76. package/src/runtime/child-pi/child-pi.ts +97 -201
  77. package/src/runtime/child-pi/mock-fixtures.ts +16 -2
  78. package/src/runtime/crew-agent-records.ts +259 -14
  79. package/src/runtime/delegate-spawn.ts +148 -0
  80. package/src/runtime/deterministic-ast.ts +2 -1
  81. package/src/runtime/dispatch-batch.ts +945 -0
  82. package/src/runtime/finalize-run.ts +557 -0
  83. package/src/runtime/goal-workflow/adaptive-plan.ts +87 -13
  84. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -2
  85. package/src/runtime/goal-workflow/goal-state-store.ts +1 -1
  86. package/src/runtime/group-join.ts +11 -125
  87. package/src/runtime/live-session/live-session-runtime.ts +26 -1
  88. package/src/runtime/merge-gate.ts +7 -1
  89. package/src/runtime/merge-loop.ts +130 -0
  90. package/src/runtime/model/model-budget-summary.ts +53 -0
  91. package/src/runtime/model/model-fallback.ts +32 -2
  92. package/src/runtime/model/pi-args.ts +10 -0
  93. package/src/runtime/model/provider-extensions.ts +10 -0
  94. package/src/runtime/orphan-worker-registry.ts +1 -1
  95. package/src/runtime/output/output-validator.ts +45 -0
  96. package/src/runtime/parent-guard.ts +3 -1
  97. package/src/runtime/peer-dep.ts +2 -1
  98. package/src/runtime/per-write-validator.ts +0 -5
  99. package/src/runtime/pi-spawn.ts +61 -15
  100. package/src/runtime/plan-approval.ts +125 -0
  101. package/src/runtime/plan-replan.ts +151 -0
  102. package/src/runtime/recovery/checkpoint.ts +0 -18
  103. package/src/runtime/recovery/crash-recovery.ts +95 -46
  104. package/src/runtime/scheduler-context.ts +98 -0
  105. package/src/runtime/scheduling/coalesce-tasks.ts +5 -0
  106. package/src/runtime/scheduling/global-worker-cap.ts +2 -1
  107. package/src/runtime/scheduling/nested-slots.ts +70 -0
  108. package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -13
  109. package/src/runtime/scheduling/task-graph-scheduler.ts +0 -10
  110. package/src/runtime/settings-store.ts +219 -0
  111. package/src/runtime/spawn-policy.ts +217 -0
  112. package/src/runtime/stale-reconciler.ts +87 -6
  113. package/src/runtime/subagent-manager.ts +25 -1
  114. package/src/runtime/task-output-context.ts +230 -9
  115. package/src/runtime/task-packet.ts +23 -1
  116. package/src/runtime/task-runner/child-executor.ts +106 -7
  117. package/src/runtime/task-runner/post-execution.ts +125 -1
  118. package/src/runtime/task-runner/pre-execution.ts +39 -1
  119. package/src/runtime/task-runner/prompt-builder.ts +51 -1
  120. package/src/runtime/task-runner/retrieval-orchestrator.ts +72 -18
  121. package/src/runtime/task-runner/spec-evidence.ts +403 -0
  122. package/src/runtime/task-runner/state-helpers.ts +26 -24
  123. package/src/runtime/task-runner.ts +11 -0
  124. package/src/runtime/team-runner.ts +129 -1671
  125. package/src/runtime/verification/spec-sandbox.ts +255 -0
  126. package/src/runtime/verification/verification-gates.ts +3 -2
  127. package/src/runtime/verification/verification-worktree.ts +2 -1
  128. package/src/runtime/workflow-phase-advance.ts +100 -0
  129. package/src/runtime/workspace-tree.ts +9 -0
  130. package/src/schema/config-schema.ts +65 -25
  131. package/src/schema/sensitive-config-paths.ts +64 -0
  132. package/src/schema/team-tool-schema.ts +13 -3
  133. package/src/state/README.md +4 -10
  134. package/src/state/atomic-write.ts +20 -3
  135. package/src/state/contracts.ts +38 -0
  136. package/src/state/coordination/mailbox.ts +12 -2
  137. package/src/state/event-log/cursor.ts +223 -0
  138. package/src/state/event-log/event-log-rotation.ts +12 -4
  139. package/src/state/event-log/event-log.ts +152 -369
  140. package/src/state/event-log/sequence-cache.ts +373 -0
  141. package/src/state/event-log/worker-atomic-writer.ts +2 -1
  142. package/src/state/stores/active-run-registry.ts +3 -2
  143. package/src/state/stores/manifest-io.ts +237 -0
  144. package/src/state/stores/ownership-map.ts +162 -0
  145. package/src/state/stores/plan-store.ts +241 -0
  146. package/src/state/stores/run-cache.ts +0 -90
  147. package/src/state/stores/spec-store.ts +189 -0
  148. package/src/state/stores/state-store.ts +66 -229
  149. package/src/state/types.ts +197 -0
  150. package/src/ui/dashboard-panes/plan-pane.ts +136 -0
  151. package/src/ui/dashboard-panes/progress-pane.ts +6 -0
  152. package/src/ui/dashboard-panes/transcript-pane.ts +31 -0
  153. package/src/ui/heartbeat-aggregator.ts +9 -1
  154. package/src/ui/keybinding-map.ts +54 -13
  155. package/src/ui/powerbar-publisher.ts +52 -1
  156. package/src/ui/run-dashboard.ts +31 -5
  157. package/src/ui/run-snapshot-cache.ts +57 -30
  158. package/src/ui/snapshot-types.ts +6 -1
  159. package/src/ui/widget/widget-renderer.ts +9 -1
  160. package/src/utils/file-coalescer.ts +0 -4
  161. package/src/utils/fs-errno.ts +66 -0
  162. package/src/utils/fs-watch.ts +1 -1
  163. package/src/utils/internal-error.ts +3 -1
  164. package/src/utils/paths.ts +11 -3
  165. package/src/utils/task-name-generator.ts +1 -8
  166. package/src/workflows/discover-workflows.ts +19 -2
  167. package/src/workflows/validate-workflow.ts +7 -1
  168. package/src/workflows/workflow-config.ts +10 -0
  169. package/src/workflows/workflow-serializer.ts +3 -0
  170. package/src/worktree/worktree-manager.ts +22 -0
  171. package/src/agents/agent-search.ts +0 -98
  172. package/src/benchmark/benchmark-runner.ts +0 -313
  173. package/src/benchmark/feedback-loop.ts +0 -73
  174. package/src/config/resilient-parser.ts +0 -117
  175. package/src/extension/crew-vibes/cat-frames.ts +0 -18
  176. package/src/extension/result-watcher.ts +0 -139
  177. package/src/observability/exporters/prometheus-exporter.ts +0 -54
  178. package/src/observability/metric-retention.ts +0 -64
  179. package/src/runtime/compaction/compaction-summary.ts +0 -278
  180. package/src/runtime/errors/crew-errors.ts +0 -162
  181. package/src/runtime/live-session/intercom-bridge.ts +0 -187
  182. package/src/runtime/loop-gates.ts +0 -128
  183. package/src/runtime/metric-parser.ts +0 -36
  184. package/src/runtime/output/stream-preview.ts +0 -184
  185. package/src/runtime/output/tool-progress.ts +0 -278
  186. package/src/runtime/phase-tracker.ts +0 -385
  187. package/src/runtime/pipeline-runner.ts +0 -523
  188. package/src/runtime/process/process-lifecycle.ts +0 -491
  189. package/src/runtime/recovery/retry-runner.ts +0 -330
  190. package/src/runtime/run-drift.ts +0 -219
  191. package/src/runtime/task-quality.ts +0 -199
  192. package/src/runtime/task-runner/run-projection.ts +0 -128
  193. package/src/runtime/verification/post-checks.ts +0 -142
  194. package/src/state/coordination/schedule.ts +0 -166
  195. package/src/state/event-log/jsonl-writer.ts +0 -115
  196. package/src/state/hook-instinct-bridge.ts +0 -94
  197. package/src/state/hook-integrations.ts +0 -51
  198. package/src/state/session-state-map.ts +0 -51
  199. package/src/state/stores/blob-store.ts +0 -308
  200. package/src/state/stores/instinct-store.ts +0 -275
  201. package/src/state/stores/observation-store.ts +0 -176
  202. package/src/state/tiered-eval.ts +0 -480
  203. package/src/state/types-eval.ts +0 -58
  204. package/src/tools/safe-bash-extension.ts +0 -54
  205. package/src/tools/safe-bash.ts +0 -505
  206. package/src/ui/agent-management-overlay.ts +0 -160
  207. package/src/ui/crew-footer.ts +0 -102
  208. package/src/ui/crew-select-list.ts +0 -114
  209. package/src/ui/dashboard-panes/capability-pane.ts +0 -77
  210. package/src/ui/transcript-entries.ts +0 -256
  211. package/src/utils/conflict-detect.ts +0 -721
  212. package/src/utils/fingerprint.ts +0 -180
  213. package/src/utils/gh-protocol.ts +0 -556
  214. package/src/utils/project-detector.ts +0 -160
  215. package/src/utils/sse-parser.ts +0 -131
  216. package/src/workflows/cost-estimator.ts +0 -34
  217. package/src/workflows/intermediate-store.ts +0 -166
@@ -3,8 +3,10 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { errors } from "../errors.ts";
5
5
  import { atomicWriteFile, atomicWriteJson } from "../state/atomic-write.ts";
6
+ import { getCurrentPlanRecord } from "../state/stores/plan-store.ts";
6
7
  import { loadManifestWithRecovery, loadTasksWithRecovery, saveRunManifest } from "../state/stores/state-store.ts";
7
8
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
9
+ import { logInternalError } from "../utils/internal-error.ts";
8
10
  import { recordFromTask, upsertCrewAgent } from "./crew-agent-records.ts";
9
11
  import { checkProcessLiveness } from "./process-status.ts";
10
12
 
@@ -23,7 +25,7 @@ const ORPHAN_TEMP_SCAN_BATCH_SIZE = 50;
23
25
  export interface ReconcileResult {
24
26
  runId: string;
25
27
  /** What was found and what action was taken */
26
- verdict: "healthy" | "blocked_awaiting_approval" | "result_exists" | "pid_dead" | "pid_alive_stale" | "no_status";
28
+ verdict: "healthy" | "blocked_awaiting_approval" | "waiting_answer" | "result_exists" | "pid_dead" | "pid_alive_stale" | "no_status";
27
29
  /** Whether repair was applied */
28
30
  repaired: boolean;
29
31
  /** Human-readable detail */
@@ -45,6 +47,50 @@ export function isPlanApprovalPending(manifest: TeamRunManifest): boolean {
45
47
  return manifest.status === "blocked" && manifest.planApproval?.required === true && manifest.planApproval.status === "pending";
46
48
  }
47
49
 
50
+ /** T2/R4 (ADR-4 §2 reader migration): plan-record-first variant — the current
51
+ * PlanRecord's approval (when present) is authoritative; pre-v2 runs fall
52
+ * back to the manifest gate above. The `blocked + pending → protected`
53
+ * invariant is preserved on BOTH paths. */
54
+ export function isPlanApprovalPendingEffective(manifest: TeamRunManifest): boolean {
55
+ if (isPlanApprovalPending(manifest)) return true;
56
+ if (manifest.status !== "blocked") return false;
57
+ return getCurrentPlanRecord(manifest)?.approval?.status === "pending";
58
+ }
59
+
60
+ /** WP-2/R2 (ADR-0 item 9, docs/decisions/2026-08-17-waiting-producer-ask.md):
61
+ * waiting TTL — a manifest.waitState.askedAt younger than this marks an
62
+ * intentional ask-wait; older marks a leaked park marker (worker died without
63
+ * resolving the ask) and the run loses reconciler protection. Leak guard. */
64
+ const WAITING_TTL_MS = 24 * 60 * 60 * 1000;
65
+
66
+ /**
67
+ * Is this run parked in the ask tool awaiting a human answer (WP-2/R2)?
68
+ *
69
+ * True when waitState is set and its askedAt is within waitingTtl. The parked
70
+ * worker is blocked polling the mailbox for a response — not crashed — and it
71
+ * typically stops heartbeating while parked, so the run must be protected at
72
+ * run level (before the no-PID/heartbeat staleness phases) exactly like
73
+ * plan-approval runs. askedAt exactly at the TTL boundary still counts as
74
+ * pending (<=, fail-safe toward preservation); a future askedAt (clock skew)
75
+ * also passes. TTL-EXPIRED waitState returns false — the marker leaked and the
76
+ * normal staleness repair paths apply (leak guard).
77
+ */
78
+ export function isWaitAnswerPending(manifest: TeamRunManifest, now = Date.now()): boolean {
79
+ const askedAtMs = manifest.waitState?.askedAt ? new Date(manifest.waitState.askedAt).getTime() : Number.NaN;
80
+ return Number.isFinite(askedAtMs) && now - askedAtMs <= WAITING_TTL_MS;
81
+ }
82
+
83
+ /**
84
+ * Generalized intentional-wait predicate (ADR-0 item 9, extended by ADR-4 §2):
85
+ * the run is blocked on a human decision — plan approval (record-first with
86
+ * manifest fallback — pre-v2 semantics preserved on the fallback path) or a
87
+ * pending ask answer within waitingTtl. Such runs are not crashes and must not
88
+ * be stale-repaired or cancelled by reconciliation.
89
+ */
90
+ export function isIntentionalWait(manifest: TeamRunManifest, now = Date.now()): boolean {
91
+ return isPlanApprovalPendingEffective(manifest) || isWaitAnswerPending(manifest, now);
92
+ }
93
+
48
94
  const STALE_ALIVE_PID_MS = 24 * 60 * 60 * 1000; // 24 hours
49
95
  const ACTIVE_EVIDENCE_TTL_MS = 5 * 60 * 1000;
50
96
  /** For no-PID runs, repair when ALL running tasks have heartbeat stale beyond this threshold. */
@@ -315,7 +361,9 @@ export function reconcileStaleRun(manifest: TeamRunManifest, tasks: TeamTaskStat
315
361
  // Preserve runs intentionally blocked on human plan approval. These are not
316
362
  // crashes even if the owning PID is gone — they are waiting for a decision.
317
363
  // Must short-circuit before Phase 1 (result check) and Phase 2 (PID liveness).
318
- if (isPlanApprovalPending(manifest)) {
364
+ // ADR-4 §2: record-first predicate — a PlanRecord approval is authoritative,
365
+ // the manifest field remains the pre-v2 fallback (never dropped).
366
+ if (isPlanApprovalPendingEffective(manifest)) {
319
367
  return {
320
368
  runId,
321
369
  verdict: "blocked_awaiting_approval",
@@ -324,6 +372,22 @@ export function reconcileStaleRun(manifest: TeamRunManifest, tasks: TeamTaskStat
324
372
  };
325
373
  }
326
374
 
375
+ // WP-2/R2 (ADR-0 item 9): a run parked in the ask tool with a fresh
376
+ // waitState.askedAt is intentionally waiting on a human answer — not stale.
377
+ // Short-circuits before Phase 1/2 like plan-approval above: a parked worker
378
+ // does not heartbeat, so the no-PID/heartbeat staleness phases would
379
+ // otherwise cancel it long before its (server-clamped <=1h) ask deadline.
380
+ // TTL-expired waitState (>24h) falls through — the park marker leaked
381
+ // (worker died without resolving) and normal staleness repair applies.
382
+ if (isWaitAnswerPending(manifest, now)) {
383
+ return {
384
+ runId,
385
+ verdict: "waiting_answer",
386
+ repaired: false,
387
+ detail: "Ask answer pending (manifest.waitState.askedAt within waiting TTL); run is intentionally waiting and must not be stale-repaired",
388
+ };
389
+ }
390
+
327
391
  // Phase 1: Check if results already exist
328
392
  const phase1 = checkResultFile(manifest, tasks);
329
393
  if (phase1.found) {
@@ -538,7 +602,14 @@ export function reconcileOrphanedTempWorkspaces(
538
602
  }
539
603
  }
540
604
  // If still running after reconciliation attempt, mark for dir-preserving
541
- if (result.verdict === "healthy" || (result.verdict === "no_status" && !result.repaired)) {
605
+ if (
606
+ result.verdict === "healthy" ||
607
+ // WP-2/R2: a run intentionally waiting on an ask answer keeps the
608
+ // temp workspace alive (its stateRoot + mailbox must survive for
609
+ // the parked worker to receive the answer).
610
+ result.verdict === "waiting_answer" ||
611
+ (result.verdict === "no_status" && !result.repaired)
612
+ ) {
542
613
  hasRunning = true;
543
614
  }
544
615
  } catch (err) {
@@ -546,14 +617,19 @@ export function reconcileOrphanedTempWorkspaces(
546
617
  // Note: manifestPath here refers to the variable defined in the
547
618
  // for loop at line 442 (outer scope of this catch block).
548
619
  const scanManifestPath = manifestPath;
549
- console.warn(`[stale-reconciler] Skipping manifest due to parse error: ${scanManifestPath}: ${err}`);
620
+ logInternalError(
621
+ "stale-reconciler",
622
+ new Error(`Skipping manifest due to parse error: ${scanManifestPath}: ${err}`),
623
+ undefined,
624
+ "warn",
625
+ );
550
626
  }
551
627
  }
552
628
  } catch (err) {
553
629
  // Cannot determine running state — treat as if running to prevent
554
630
  // premature cleanup of a potentially active workspace.
555
631
  hasRunning = true;
556
- console.warn(`[stale-reconciler] Skipping unreadable runs dir: ${stateRunsDir}: ${err}`);
632
+ logInternalError("stale-reconciler", new Error(`Skipping unreadable runs dir: ${stateRunsDir}: ${err}`), undefined, "warn");
557
633
  }
558
634
 
559
635
  // Post-loop: check if this workspace dir can be cleaned up.
@@ -617,7 +693,12 @@ export function reconcileOrphanedTempWorkspaces(
617
693
  }
618
694
  }
619
695
  } catch (err) {
620
- console.warn(`[stale-reconciler] Skipping unreadable runs dir: ${stateRunsDir}: ${err}`);
696
+ logInternalError(
697
+ "stale-reconciler",
698
+ new Error(`Skipping unreadable runs dir: ${stateRunsDir}: ${err}`),
699
+ undefined,
700
+ "warn",
701
+ );
621
702
  }
622
703
  }
623
704
  }
@@ -25,11 +25,26 @@ export interface SubagentSpawnOptions {
25
25
  /** Optional batch grouping id (Rule 1). Agents sharing a batchId coalesce
26
26
  * completion notifications into one. undefined => individual (default). */
27
27
  batchId?: string;
28
+ /** WP-1/R1 (H6): owning team-run task id. Set by the one-shot Agent-tool
29
+ * route once the run manifest resolves (taskId is only knowable after the
30
+ * run is dispatched). undefined for unlinked/legacy spawns. */
31
+ taskId?: string;
32
+ /** WP-1/R1 (H6): ownership depth in the task tree (0 for a root one-shot).
33
+ * Optional for back-compat; defaults to 0 at spawn. WP-5 owns depth
34
+ * semantics for nested runs. */
35
+ depth?: number;
28
36
  }
29
37
 
30
38
  export interface SubagentRecord {
31
39
  id: string;
32
40
  runId?: string;
41
+ /** WP-1/R1 (H6): owning team-run task id (task ⇄ subagentId link).
42
+ * Optional for back-compat — records without it render as today and steer
43
+ * returns the existing "not linked" message (no throw). */
44
+ taskId?: string;
45
+ /** WP-1/R1 (H6): ownership depth (0 for root one-shot). Optional for
46
+ * back-compat — legacy records without it render as today. */
47
+ depth?: number;
33
48
  type: string;
34
49
  description: string;
35
50
  prompt: string;
@@ -144,6 +159,7 @@ const ALLOWED_RECORD_FIELDS = new Set([
144
159
  "runId",
145
160
  "cwd",
146
161
  "taskId",
162
+ "depth",
147
163
  "result",
148
164
  "error",
149
165
  "resultConsumed",
@@ -185,7 +201,13 @@ export function readPersistedSubagentRecord(cwd: string, id: string): SubagentRe
185
201
  try {
186
202
  const raw = JSON.parse(fs.readFileSync(persistedSubagentPath(cwd, id), "utf-8"));
187
203
  return sanitizePersistedRecord(raw);
188
- } catch {
204
+ } catch (error) {
205
+ // R17-B5: parse/read failures were fully silent, hiding lost records
206
+ // (see NEW-R3 note in savePersistedSubagentRecord). ENOENT stays quiet —
207
+ // a legitimately removed record (cancel) is not an error.
208
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
209
+ logInternalError("subagent-manager.read-persisted", error, `id=${id}`, "warn");
210
+ }
189
211
  return undefined;
190
212
  }
191
213
  }
@@ -254,6 +276,8 @@ export class SubagentManager {
254
276
  ownerSessionGeneration: options.ownerSessionGeneration,
255
277
  ownerSessionId: options.ownerSessionId,
256
278
  batchId: options.batchId,
279
+ taskId: options.taskId,
280
+ depth: options.depth ?? 0,
257
281
  };
258
282
  this.records.set(record.id, record);
259
283
  this.cwdByRecord.set(record.id, options.cwd);
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { DEFAULT_OUTPUT_CONTEXT } from "../config/defaults.ts";
4
+ import { getCrewEnv } from "../config/env-vars.ts";
4
5
  import { atomicWriteFile } from "../state/atomic-write.ts";
5
6
  import { writeArtifact } from "../state/stores/artifact-store.ts";
6
7
  import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../state/types.ts";
@@ -124,6 +125,13 @@ export interface ReadIfSmallTeeResult {
124
125
  content: string;
125
126
  /** Set only when tee was actually written (file size > 2× threshold + write succeeded). */
126
127
  fullOutputPath?: string;
128
+ /** R10-1 (dep-context cache tee-safety): character length of the RAW file
129
+ * content BEFORE the truncation pipeline ran. Populated on every
130
+ * successful read; lets callers tell whether a cached no-tee read
131
+ * (`readIfSmall`) would have teed
132
+ * (length > TEE_THRESHOLD_MULTIPLIER × MAX_RESULT_INLINE_BYTES) without
133
+ * re-reading the file — no heuristics on the truncated body. */
134
+ originalLength?: number;
127
135
  }
128
136
 
129
137
  /**
@@ -211,9 +219,11 @@ export function readIfSmallWithTee(
211
219
  },
212
220
  }),
213
221
  ]);
214
- return fullOutputPath ? { content: result.text, fullOutputPath } : { content: result.text };
222
+ return fullOutputPath
223
+ ? { content: result.text, fullOutputPath, originalLength: content.length }
224
+ : { content: result.text, originalLength: content.length };
215
225
  }
216
- return { content };
226
+ return { content, originalLength: content.length };
217
227
  } catch {
218
228
  return undefined;
219
229
  }
@@ -401,6 +411,12 @@ export function collectDependencyOutputContext(
401
411
  tasks: TeamTaskState[],
402
412
  task: TeamTaskState,
403
413
  step: WorkflowStep,
414
+ /** R10-1 residual: per-run result-artifact read cache (same instance the
415
+ * closeout aggregation uses — created once in executeTeamRunCore, threaded
416
+ * SchedulerContext → baseInput → TaskRunnerInput). Optional: undefined
417
+ * keeps the previous uncached per-dep readIfSmallWithTee behavior
418
+ * byte-for-byte. NEVER pass a per-call cache instance. */
419
+ cache?: ResultArtifactReadCache,
404
420
  ): DependencyOutputContext {
405
421
  const byStep = new Map(tasks.map((item) => [item.stepId, item]).filter((entry): entry is [string, TeamTaskState] => Boolean(entry[0])));
406
422
  const byId = new Map(tasks.map((item) => [item.id, item]));
@@ -424,11 +440,19 @@ export function collectDependencyOutputContext(
424
440
  .filter((item): item is TeamTaskState => Boolean(item))
425
441
  .map((item) => {
426
442
  const fullOutputPath = item.resultArtifact ? teePathForArtifact(manifest.artifactsRoot, task.id, item.id) : undefined;
443
+ // R10-1 residual: route the dep result read through the same per-run
444
+ // cache the closeout aggregation populated (readTaskResultArtifactWithTee).
445
+ // Without a cache this issues the identical readIfSmallWithTee call as
446
+ // before (byte-identical output); with one, a non-tee-band hit returns
447
+ // the memoized body while tee-band truncations still do the real read
448
+ // (tee file + fullOutputPath preserved per consumer).
427
449
  const teeResult = item.resultArtifact
428
- ? readIfSmallWithTee(item.resultArtifact.path, {
429
- baseDir: manifest.artifactsRoot,
430
- ...(fullOutputPath ? { tee: { fullOutputPath } } : {}),
431
- })
450
+ ? readTaskResultArtifactWithTee(
451
+ item.resultArtifact,
452
+ manifest.artifactsRoot,
453
+ fullOutputPath ? { fullOutputPath } : undefined,
454
+ cache,
455
+ )
432
456
  : undefined;
433
457
  const resultText = teeResult?.content;
434
458
  const inlineBytes = resultText?.length ?? 0;
@@ -570,12 +594,209 @@ export function writeTaskInputsArtifact(
570
594
  });
571
595
  }
572
596
 
573
- export function aggregateTaskOutputs(tasks: TeamTaskState[], manifest?: TeamRunManifest): string {
597
+ /**
598
+ * R10-1: outcome of one aggregated result-artifact read (the pair of disk
599
+ * ops {@link aggregateTaskOutputs} performs per task: readIfSmall +
600
+ * containedExists). Cached as a unit so a cache hit replaces BOTH ops.
601
+ */
602
+ export interface ResultArtifactReadOutcome {
603
+ /** Truncated body from readIfSmall; undefined when the read failed/missing. */
604
+ body: string | undefined;
605
+ /** containedExists() result for the same artifact path. */
606
+ exists: boolean;
607
+ /** R10-1 (dep-context tee-safety): character length of the raw file content
608
+ * at miss time. Present whenever the read succeeded; used by the
609
+ * dependency-context seam to decide whether a cache hit is byte-identical
610
+ * (no tee would have been written) or must fall through to a real
611
+ * readIfSmallWithTee. Undefined when the read failed. */
612
+ originalLength?: number;
613
+ }
614
+
615
+ /**
616
+ * R10-1: per-run memoization for result-artifact reads inside
617
+ * {@link aggregateTaskOutputs} (batch summary + group-join both aggregate the
618
+ * SAME settled batch every closeout — the second aggregation re-reads each
619
+ * `results/<taskId>.txt` from disk for no benefit).
620
+ *
621
+ * Cache key = artifact path + descriptor identity (`sizeBytes|contentHash`).
622
+ * writeArtifact computes both on every write (STATE-9: in-memory,
623
+ * post-redaction), so a retry that rewrites `results/<taskId>.txt` produces a
624
+ * NEW descriptor → new key → automatic miss. Descriptors missing BOTH fields
625
+ * bypass the cache entirely (cannot distinguish a rewrite from unchanged
626
+ * content). "Missing" outcomes are cached too (result artifacts use the
627
+ * default `retention: "run"` and are not pruned mid-run, so a miss is stable
628
+ * within a run). Path is used as-is (producer/taskId-keyed, stable absolute
629
+ * path from the manifest — no per-lookup realpath needed).
630
+ *
631
+ * Lifetime: one run (closure-level in executeTeamRunCore) — no cross-run
632
+ * leakage, no invalidation beyond the descriptor-identity key.
633
+ *
634
+ * Env bypass: `PI_CREW_DISABLE_RESULT_READ_CACHE=1` (read ONCE at creation)
635
+ * returns a cache whose lookups always miss — control mode for benches/tests
636
+ * that need the uncached behavior while still routing through the counting
637
+ * wrapper below.
638
+ */
639
+ export interface ResultArtifactReadCache {
640
+ /** Cached outcome for the artifact identity, or undefined on miss/bypass. */
641
+ lookup(descriptor: ArtifactDescriptor): ResultArtifactReadOutcome | undefined;
642
+ /** Store an outcome. No-op when the descriptor lacks identity fields. */
643
+ store(descriptor: ArtifactDescriptor, outcome: ResultArtifactReadOutcome): void;
644
+ }
645
+
646
+ /**
647
+ * R10-1 test/bench counters for the result-artifact read path. Track disk ops
648
+ * ACTUALLY ISSUED through {@link readTaskResultArtifact} (incremented in the
649
+ * miss branch right before the real readIfSmall/containedExists calls) plus
650
+ * cache hits/misses. The disabled-control mode still routes through the
651
+ * counting wrapper (map simply not consulted) so cached-vs-uncached
652
+ * comparisons are honest.
653
+ */
654
+ export interface ResultArtifactReadStats {
655
+ readFile: number;
656
+ exists: number;
657
+ hits: number;
658
+ misses: number;
659
+ reset(): void;
660
+ }
661
+
662
+ export const __test__resultReadStats: ResultArtifactReadStats = {
663
+ readFile: 0,
664
+ exists: 0,
665
+ hits: 0,
666
+ misses: 0,
667
+ reset() {
668
+ this.readFile = 0;
669
+ this.exists = 0;
670
+ this.hits = 0;
671
+ this.misses = 0;
672
+ },
673
+ };
674
+
675
+ function resultArtifactCacheKey(descriptor: ArtifactDescriptor): string | undefined {
676
+ if (descriptor.sizeBytes === undefined && descriptor.contentHash === undefined) return undefined;
677
+ return `${descriptor.path}|${descriptor.sizeBytes ?? ""}|${descriptor.contentHash ?? ""}`;
678
+ }
679
+
680
+ export function createResultArtifactReadCache(): ResultArtifactReadCache {
681
+ // Read once at creation — the bypass decision is fixed for the cache's
682
+ // (per-run) lifetime, matching PI_CREW_USE_BUNDLE-style one-shot env reads.
683
+ if (getCrewEnv("PI_CREW_DISABLE_RESULT_READ_CACHE") === "1") {
684
+ return {
685
+ lookup: () => undefined,
686
+ store: () => undefined,
687
+ };
688
+ }
689
+ const entries = new Map<string, ResultArtifactReadOutcome>();
690
+ return {
691
+ lookup(descriptor) {
692
+ const key = resultArtifactCacheKey(descriptor);
693
+ if (key === undefined) return undefined;
694
+ const hit = entries.get(key);
695
+ if (hit === undefined) {
696
+ __test__resultReadStats.misses += 1;
697
+ return undefined;
698
+ }
699
+ __test__resultReadStats.hits += 1;
700
+ return hit;
701
+ },
702
+ store(descriptor, outcome) {
703
+ const key = resultArtifactCacheKey(descriptor);
704
+ if (key !== undefined) entries.set(key, outcome);
705
+ },
706
+ };
707
+ }
708
+
709
+ /**
710
+ * Single read seam for {@link aggregateTaskOutputs}. Without a cache this is
711
+ * byte-for-byte the old behavior: readIfSmall + containedExists issued in the
712
+ * same order. With a cache, a hit returns the memoized pair (zero disk ops);
713
+ * a miss issues the same disk ops as the uncached path, counts them, and
714
+ * memoizes the outcome.
715
+ */
716
+ function readTaskResultArtifact(
717
+ descriptor: ArtifactDescriptor,
718
+ baseDir: string | undefined,
719
+ cache: ResultArtifactReadCache | undefined,
720
+ ): ResultArtifactReadOutcome {
721
+ const cached = cache?.lookup(descriptor);
722
+ if (cached !== undefined) return cached;
723
+ __test__resultReadStats.readFile += 1;
724
+ __test__resultReadStats.exists += 1;
725
+ // readIfSmall is a thin wrapper over readIfSmallWithTee(...).content — the
726
+ // direct call is byte-identical and also captures originalLength (R10-1
727
+ // tee-safety metadata consumed by readTaskResultArtifactWithTee).
728
+ const raw = readIfSmallWithTee(descriptor.path, { baseDir });
729
+ const outcome: ResultArtifactReadOutcome = {
730
+ body: raw?.content,
731
+ exists: containedExists(descriptor.path, baseDir),
732
+ ...(raw?.originalLength !== undefined ? { originalLength: raw.originalLength } : {}),
733
+ };
734
+ cache?.store(descriptor, outcome);
735
+ return outcome;
736
+ }
737
+
738
+ /**
739
+ * R10-1 residual: cached read seam for the dependency-context path
740
+ * ({@link collectDependencyOutputContext}). Mirrors {@link readTaskResultArtifact}
741
+ * while preserving the tee-recovery semantics of the direct
742
+ * `readIfSmallWithTee(path, { tee })` call it replaces:
743
+ *
744
+ * - A cache hit is reusable ONLY when the memoized read proves the uncached
745
+ * call would NOT have teed: `originalLength` is known AND
746
+ * `originalLength <= TEE_THRESHOLD_MULTIPLIER × MAX_RESULT_INLINE_BYTES`.
747
+ * In that band readIfSmallWithTee().content is byte-identical to the
748
+ * memoized body (same file, same truncation pipeline) and fullOutputPath is
749
+ * unset, so returning the memoized body cannot change the rendered prompt.
750
+ * - A memoized read that FAILED (body undefined) is also returned as-is: the
751
+ * direct readIfSmallWithTee would have thrown and returned undefined for
752
+ * the same descriptor identity.
753
+ * - Otherwise (miss, bypass, or tee-band truncation) the real
754
+ * readIfSmallWithTee runs — WITH tee, so the per-consumer
755
+ * `tee/<taskId>-<depId>.full.txt` write and `fullOutputPath` survive — and
756
+ * the disk ops are counted in `__test__resultReadStats` exactly like
757
+ * readTaskResultArtifact's miss branch (honest cached-vs-bypass benches).
758
+ * When a cache is provided the miss ALSO populates it with the SAME
759
+ * outcome readTaskResultArtifact would have stored (body = teeResult.content
760
+ * — byte-identical to readIfSmall's output since tee never alters content —
761
+ * exists via containedExists, originalLength), so the next dep collect /
762
+ * closeout aggregation hits. Truncation checks in later consumers use
763
+ * originalLength, never heuristics on the truncated body.
764
+ */
765
+ function readTaskResultArtifactWithTee(
766
+ descriptor: ArtifactDescriptor,
767
+ baseDir: string | undefined,
768
+ tee: TeeRecoveryOptions | undefined,
769
+ cache: ResultArtifactReadCache | undefined,
770
+ ): ReadIfSmallTeeResult | undefined {
771
+ const cached = cache?.lookup(descriptor);
772
+ if (cached !== undefined) {
773
+ if (cached.body === undefined) return undefined;
774
+ if (cached.originalLength !== undefined && cached.originalLength <= MAX_RESULT_INLINE_BYTES * TEE_THRESHOLD_MULTIPLIER) {
775
+ return { content: cached.body };
776
+ }
777
+ }
778
+ __test__resultReadStats.readFile += 1;
779
+ const result = readIfSmallWithTee(descriptor.path, { baseDir, ...(tee ? { tee } : {}) });
780
+ if (cache) {
781
+ // Populate with the outcome the closeout seam would have stored. The op
782
+ // order (read → existsSync) matches readTaskResultArtifact's miss branch.
783
+ __test__resultReadStats.exists += 1;
784
+ cache.store(descriptor, {
785
+ body: result?.content,
786
+ exists: containedExists(descriptor.path, baseDir),
787
+ ...(result?.originalLength !== undefined ? { originalLength: result.originalLength } : {}),
788
+ });
789
+ }
790
+ return result;
791
+ }
792
+
793
+ export function aggregateTaskOutputs(tasks: TeamTaskState[], manifest?: TeamRunManifest, cache?: ResultArtifactReadCache): string {
574
794
  return tasks
575
795
  .map((task, index) => {
576
- const body = task.resultArtifact ? readIfSmall(task.resultArtifact.path, manifest?.artifactsRoot) : undefined;
796
+ const read = task.resultArtifact ? readTaskResultArtifact(task.resultArtifact, manifest?.artifactsRoot, cache) : undefined;
797
+ const body = read?.body;
577
798
  const hasBody = Boolean(body?.trim());
578
- const expectedMissing = task.resultArtifact && !containedExists(task.resultArtifact.path, manifest?.artifactsRoot);
799
+ const expectedMissing = read ? !read.exists : undefined;
579
800
  const status =
580
801
  task.status === "skipped"
581
802
  ? "SKIPPED"
@@ -1,5 +1,6 @@
1
1
  import * as path from "node:path";
2
- import type { TaskPacket, TaskScope, TeamRunManifest, VerificationContract } from "../state/types.ts";
2
+ import { freezeSpecSnapshot, loadSpecRecord } from "../state/stores/spec-store.ts";
3
+ import type { SpecRecord, TaskPacket, TaskScope, TeamRunManifest, VerificationContract } from "../state/types.ts";
3
4
  import type { WorkflowStep } from "../workflows/workflow-config.ts";
4
5
  import { generateTaskHashId } from "./task-id.ts";
5
6
 
@@ -70,6 +71,10 @@ export function sanitizeTaskText(task: string): string {
70
71
  }
71
72
 
72
73
  export interface BuildTaskPacketInput {
74
+ /** T4/R6 (ADR-6 §2): workspace spec ids — loaded + FROZEN into the packet. */
75
+ specRefs?: string[];
76
+ /** T4/R6 (ADR-6 §7): strict mode — coverage AND machine-check (§4). */
77
+ specStrict?: boolean;
73
78
  manifest: TeamRunManifest;
74
79
  step: WorkflowStep;
75
80
  taskId: string;
@@ -98,6 +103,20 @@ export function defaultVerificationContract(step: WorkflowStep): VerificationCon
98
103
  }
99
104
 
100
105
  export function buildTaskPacket(input: BuildTaskPacketInput): TaskPacket {
106
+ // T4/R6 (ADR-6 §1/§2): the ONLY packet-creation path is also the freeze
107
+ // point — specRefs load from the workspace store and freeze into immutable
108
+ // snapshots embedded in the packet (later spec edits never rewrite them).
109
+ // Round-1 review (fail-closed freeze): declared-but-unresolvable ids are
110
+ // kept on the packet as unresolvedSpecRefs instead of being dropped — the
111
+ // gate surfaces them (badge non-strict / failure strict), never a silent
112
+ // no-op. Snapshots freeze trust via the store at THIS moment.
113
+ const declaredSpecRefs = input.specRefs ?? [];
114
+ const resolvedRecords = declaredSpecRefs
115
+ .map((id) => loadSpecRecord(input.manifest.cwd, id))
116
+ .filter((r): r is SpecRecord => r !== undefined);
117
+ const specSnapshots = resolvedRecords.map((r) => freezeSpecSnapshot(r, input.manifest.cwd));
118
+ const unresolvedSpecRefs = declaredSpecRefs.filter((id) => !specSnapshots.some((s) => s.specId === id));
119
+
101
120
  const scope = inferTaskScope(input.step);
102
121
  const reads = input.step.reads === false ? [] : (input.step.reads ?? []);
103
122
  const scopePath = reads.length === 1 ? reads[0] : reads.length > 1 ? reads.join(", ") : undefined;
@@ -112,6 +131,9 @@ export function buildTaskPacket(input: BuildTaskPacketInput): TaskPacket {
112
131
 
113
132
  return {
114
133
  objective: sanitizedTask.replaceAll("{goal}", sanitizedGoal),
134
+ ...(declaredSpecRefs.length > 0 ? { specRefs: declaredSpecRefs, specSnapshots } : {}),
135
+ ...(unresolvedSpecRefs.length > 0 ? { unresolvedSpecRefs } : {}),
136
+ ...(input.specStrict === true ? { specStrict: true } : {}),
115
137
  scope,
116
138
  scopePath,
117
139
  repo: path.basename(input.manifest.cwd) || input.manifest.cwd,