pi-crew 0.9.68 → 0.10.2

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 (253) hide show
  1. package/CHANGELOG.md +222 -0
  2. package/NOTICE.md +21 -0
  3. package/README.md +44 -2
  4. package/agents/analyst.md +1 -1
  5. package/agents/cold-verifier.md +3 -1
  6. package/agents/critic.md +1 -1
  7. package/agents/executor.md +1 -1
  8. package/agents/explorer.md +1 -1
  9. package/agents/planner.md +1 -1
  10. package/agents/reviewer.md +1 -1
  11. package/agents/security-reviewer.md +1 -1
  12. package/agents/test-engineer.md +1 -1
  13. package/agents/verifier.md +1 -1
  14. package/agents/writer.md +1 -1
  15. package/dist/index.mjs +68113 -60774
  16. package/docs/README.md +2 -0
  17. package/docs/actions-reference.md +31 -0
  18. package/docs/commands-reference.md +17 -6
  19. package/docs/resource-formats.md +13 -0
  20. package/package.json +4 -2
  21. package/schema.json +503 -91
  22. package/scripts/resource-sampler.mjs +36 -2
  23. package/skills/requirements-to-task-packet/SKILL.md +26 -0
  24. package/skills/widget-rendering/SKILL.md +7 -7
  25. package/src/agents/agent-config.ts +2 -1
  26. package/src/agents/discover-agents.ts +23 -14
  27. package/src/config/config-merge.ts +183 -0
  28. package/src/config/config-validation.ts +687 -0
  29. package/src/config/config.ts +22 -864
  30. package/src/config/defaults.ts +43 -2
  31. package/src/config/drift-detector.ts +1 -1
  32. package/src/config/env-vars.ts +691 -0
  33. package/src/config/role-tools.ts +11 -9
  34. package/src/config/sanitize-project-config.ts +172 -0
  35. package/src/config/types.ts +49 -1
  36. package/src/extension/async-notifier.ts +25 -2
  37. package/src/extension/crew-cleanup.ts +13 -0
  38. package/src/extension/crew-vibes/config.ts +2 -1
  39. package/src/extension/crew-vibes/footer.ts +19 -0
  40. package/src/extension/crew-vibes/index.ts +11 -1
  41. package/src/extension/plan-orchestrate.ts +132 -0
  42. package/src/extension/register.ts +8 -0
  43. package/src/extension/registration/command-registration.ts +1 -0
  44. package/src/extension/registration/commands/dashboard.ts +158 -0
  45. package/src/extension/registration/commands/index.ts +35 -0
  46. package/src/extension/registration/commands/manage.ts +303 -0
  47. package/src/extension/registration/commands/run.ts +228 -0
  48. package/src/extension/registration/commands/shared.ts +639 -0
  49. package/src/extension/registration/commands/status.ts +60 -0
  50. package/src/extension/registration/commands.ts +13 -1224
  51. package/src/extension/registration/foreground-run-controller.ts +10 -2
  52. package/src/extension/registration/lifecycle-handlers.ts +178 -17
  53. package/src/extension/registration/runtime-cleanup.ts +23 -5
  54. package/src/extension/registration/subagent-tools.ts +218 -9
  55. package/src/extension/registration/team-tool.ts +5 -1
  56. package/src/extension/registration/ui.ts +5 -4
  57. package/src/extension/rpc-hmac.ts +5 -3
  58. package/src/extension/team-tool/api/heartbeat.ts +47 -10
  59. package/src/extension/team-tool/api/plan-approval.ts +9 -0
  60. package/src/extension/team-tool/api/task-claims.ts +109 -40
  61. package/src/extension/team-tool/cancel.ts +84 -50
  62. package/src/extension/team-tool/dispatch/index.ts +1 -0
  63. package/src/extension/team-tool/dispatch/run.ts +4 -1
  64. package/src/extension/team-tool/doctor.ts +103 -1
  65. package/src/extension/team-tool/orchestrate.ts +66 -1
  66. package/src/extension/team-tool/plans.ts +192 -0
  67. package/src/extension/team-tool/respond.ts +197 -65
  68. package/src/extension/team-tool/run-deadline.ts +35 -3
  69. package/src/extension/team-tool/run-intent.ts +63 -0
  70. package/src/extension/team-tool/run.ts +74 -20
  71. package/src/extension/team-tool/status.ts +84 -26
  72. package/src/extension/team-tool.ts +11 -2
  73. package/src/hooks/registry.ts +1 -6
  74. package/src/i18n.ts +9 -0
  75. package/src/prompt/prompt-runtime.ts +521 -2
  76. package/src/prompt/worker-events-channel.ts +173 -0
  77. package/src/runtime/README.md +8 -8
  78. package/src/runtime/async-runner.ts +7 -3
  79. package/src/runtime/background-runner.ts +42 -14
  80. package/src/runtime/broker/broker-issuer.ts +9 -2
  81. package/src/runtime/broker/crew-broker-tokens.ts +43 -6
  82. package/src/runtime/broker/crew-broker.ts +838 -10
  83. package/src/runtime/broker/wait-status-cache.ts +157 -0
  84. package/src/runtime/budget-enforcement.ts +281 -0
  85. package/src/runtime/child-pi/child-pi-constants.ts +8 -0
  86. package/src/runtime/child-pi/child-pi-spawn.ts +60 -14
  87. package/src/runtime/child-pi/child-pi-streams.ts +21 -1
  88. package/src/runtime/child-pi/child-pi-timers.ts +324 -0
  89. package/src/runtime/child-pi/child-pi.ts +97 -201
  90. package/src/runtime/child-pi/mock-fixtures.ts +16 -2
  91. package/src/runtime/crew-agent-records.ts +259 -14
  92. package/src/runtime/delegate-spawn.ts +148 -0
  93. package/src/runtime/detached-run-results.ts +90 -0
  94. package/src/runtime/deterministic-ast.ts +2 -1
  95. package/src/runtime/dispatch-batch.ts +945 -0
  96. package/src/runtime/finalize-run.ts +557 -0
  97. package/src/runtime/goal-workflow/adaptive-plan.ts +116 -15
  98. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +8 -3
  99. package/src/runtime/goal-workflow/goal-state-store.ts +1 -1
  100. package/src/runtime/group-join.ts +11 -125
  101. package/src/runtime/live-session/live-session-runtime.ts +26 -1
  102. package/src/runtime/merge-gate.ts +32 -10
  103. package/src/runtime/merge-loop.ts +130 -0
  104. package/src/runtime/model/model-budget-summary.ts +53 -0
  105. package/src/runtime/model/model-fallback.ts +36 -2
  106. package/src/runtime/model/pi-args.ts +10 -0
  107. package/src/runtime/model/provider-extensions.ts +10 -0
  108. package/src/runtime/orphan-worker-registry.ts +1 -1
  109. package/src/runtime/output/output-validator.ts +45 -0
  110. package/src/runtime/parent-guard.ts +3 -1
  111. package/src/runtime/peer-dep.ts +2 -1
  112. package/src/runtime/per-write-validator.ts +0 -5
  113. package/src/runtime/pi-spawn.ts +61 -15
  114. package/src/runtime/plan-approval.ts +125 -0
  115. package/src/runtime/plan-replan.ts +151 -0
  116. package/src/runtime/process-status.ts +16 -1
  117. package/src/runtime/recovery/checkpoint.ts +0 -18
  118. package/src/runtime/recovery/crash-recovery.ts +111 -46
  119. package/src/runtime/run-tracker.ts +77 -10
  120. package/src/runtime/scheduler-context.ts +98 -0
  121. package/src/runtime/scheduling/coalesce-tasks.ts +5 -0
  122. package/src/runtime/scheduling/global-worker-cap.ts +2 -1
  123. package/src/runtime/scheduling/nested-slots.ts +70 -0
  124. package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -13
  125. package/src/runtime/scheduling/task-graph-scheduler.ts +0 -10
  126. package/src/runtime/settings-store.ts +219 -0
  127. package/src/runtime/spawn-policy.ts +217 -0
  128. package/src/runtime/stale-reconciler.ts +87 -6
  129. package/src/runtime/subagent-manager.ts +25 -1
  130. package/src/runtime/task-output-context.ts +230 -9
  131. package/src/runtime/task-packet.ts +23 -1
  132. package/src/runtime/task-runner/child-executor.ts +106 -7
  133. package/src/runtime/task-runner/post-execution.ts +125 -1
  134. package/src/runtime/task-runner/pre-execution.ts +39 -1
  135. package/src/runtime/task-runner/prompt-builder.ts +51 -1
  136. package/src/runtime/task-runner/retrieval-orchestrator.ts +72 -18
  137. package/src/runtime/task-runner/spec-evidence.ts +403 -0
  138. package/src/runtime/task-runner/state-helpers.ts +26 -24
  139. package/src/runtime/task-runner.ts +11 -0
  140. package/src/runtime/team-runner.ts +132 -1673
  141. package/src/runtime/verification/spec-sandbox.ts +255 -0
  142. package/src/runtime/verification/verification-gates.ts +3 -2
  143. package/src/runtime/verification/verification-worktree.ts +2 -1
  144. package/src/runtime/workflow-phase-advance.ts +100 -0
  145. package/src/runtime/workspace-tree.ts +9 -0
  146. package/src/schema/config-schema.ts +66 -26
  147. package/src/schema/sensitive-config-paths.ts +64 -0
  148. package/src/schema/team-tool-schema.ts +13 -3
  149. package/src/state/README.md +4 -10
  150. package/src/state/atomic-write.ts +20 -3
  151. package/src/state/contracts.ts +38 -0
  152. package/src/state/coordination/mailbox.ts +12 -2
  153. package/src/state/event-log/cursor.ts +223 -0
  154. package/src/state/event-log/event-log-rotation.ts +12 -4
  155. package/src/state/event-log/event-log.ts +152 -369
  156. package/src/state/event-log/sequence-cache.ts +373 -0
  157. package/src/state/event-log/worker-atomic-writer.ts +2 -1
  158. package/src/state/stores/active-run-registry.ts +3 -2
  159. package/src/state/stores/manifest-io.ts +237 -0
  160. package/src/state/stores/ownership-map.ts +162 -0
  161. package/src/state/stores/plan-store.ts +241 -0
  162. package/src/state/stores/run-cache.ts +0 -90
  163. package/src/state/stores/spec-store.ts +189 -0
  164. package/src/state/stores/state-store.ts +139 -232
  165. package/src/state/types.ts +199 -0
  166. package/src/ui/dashboard-panes/plan-pane.ts +136 -0
  167. package/src/ui/dashboard-panes/progress-pane.ts +6 -0
  168. package/src/ui/dashboard-panes/transcript-pane.ts +31 -0
  169. package/src/ui/dock-footer.ts +49 -0
  170. package/src/ui/heartbeat-aggregator.ts +9 -1
  171. package/src/ui/inline-panel/agent-pane.ts +375 -0
  172. package/src/ui/inline-panel/agent-transcript.ts +338 -0
  173. package/src/ui/inline-panel/agent-view-overlay.ts +225 -0
  174. package/src/ui/inline-panel/crew-editor.ts +192 -0
  175. package/src/ui/inline-panel/index.ts +290 -0
  176. package/src/ui/inline-panel/panel-rows.ts +37 -0
  177. package/src/ui/inline-panel/panel-selection.ts +157 -0
  178. package/src/ui/inline-panel/panel-store.ts +111 -0
  179. package/src/ui/inline-panel/view-session-store.ts +36 -0
  180. package/src/ui/keybinding-map.ts +54 -13
  181. package/src/ui/pi-ui-compat.ts +9 -0
  182. package/src/ui/powerbar-publisher.ts +52 -1
  183. package/src/ui/run-dashboard.ts +31 -5
  184. package/src/ui/run-snapshot-cache.ts +57 -30
  185. package/src/ui/snapshot-types.ts +6 -1
  186. package/src/ui/widget/index.ts +176 -22
  187. package/src/ui/widget/task-list.ts +198 -0
  188. package/src/ui/widget/widget-formatters.ts +240 -4
  189. package/src/ui/widget/widget-renderer.ts +243 -38
  190. package/src/ui/widget/widget-types.ts +11 -0
  191. package/src/utils/child-process-shield.ts +106 -0
  192. package/src/utils/file-coalescer.ts +0 -4
  193. package/src/utils/fs-errno.ts +66 -0
  194. package/src/utils/fs-watch.ts +1 -1
  195. package/src/utils/internal-error.ts +3 -1
  196. package/src/utils/paths.ts +11 -3
  197. package/src/utils/redaction.ts +7 -0
  198. package/src/utils/safe-abort.ts +45 -0
  199. package/src/utils/task-name-generator.ts +1 -8
  200. package/src/workflows/discover-workflows.ts +20 -2
  201. package/src/workflows/validate-workflow.ts +7 -1
  202. package/src/workflows/workflow-config.ts +17 -0
  203. package/src/workflows/workflow-serializer.ts +3 -0
  204. package/src/worktree/worktree-manager.ts +22 -0
  205. package/workflows/default.workflow.md +36 -26
  206. package/workflows/strict-fast-fix.workflow.md +26 -0
  207. package/src/agents/agent-search.ts +0 -98
  208. package/src/benchmark/benchmark-runner.ts +0 -313
  209. package/src/benchmark/feedback-loop.ts +0 -73
  210. package/src/config/resilient-parser.ts +0 -117
  211. package/src/extension/crew-vibes/cat-frames.ts +0 -18
  212. package/src/extension/result-watcher.ts +0 -139
  213. package/src/observability/exporters/prometheus-exporter.ts +0 -54
  214. package/src/observability/metric-retention.ts +0 -64
  215. package/src/runtime/compaction/compaction-summary.ts +0 -278
  216. package/src/runtime/errors/crew-errors.ts +0 -162
  217. package/src/runtime/live-session/intercom-bridge.ts +0 -187
  218. package/src/runtime/loop-gates.ts +0 -128
  219. package/src/runtime/metric-parser.ts +0 -36
  220. package/src/runtime/output/stream-preview.ts +0 -184
  221. package/src/runtime/output/tool-progress.ts +0 -278
  222. package/src/runtime/phase-tracker.ts +0 -385
  223. package/src/runtime/pipeline-runner.ts +0 -523
  224. package/src/runtime/process/process-lifecycle.ts +0 -491
  225. package/src/runtime/recovery/retry-runner.ts +0 -330
  226. package/src/runtime/run-drift.ts +0 -219
  227. package/src/runtime/task-quality.ts +0 -199
  228. package/src/runtime/task-runner/run-projection.ts +0 -128
  229. package/src/runtime/verification/post-checks.ts +0 -142
  230. package/src/state/coordination/schedule.ts +0 -166
  231. package/src/state/event-log/jsonl-writer.ts +0 -115
  232. package/src/state/hook-instinct-bridge.ts +0 -94
  233. package/src/state/hook-integrations.ts +0 -51
  234. package/src/state/session-state-map.ts +0 -51
  235. package/src/state/stores/blob-store.ts +0 -308
  236. package/src/state/stores/instinct-store.ts +0 -275
  237. package/src/state/stores/observation-store.ts +0 -176
  238. package/src/state/tiered-eval.ts +0 -480
  239. package/src/state/types-eval.ts +0 -58
  240. package/src/tools/safe-bash-extension.ts +0 -54
  241. package/src/tools/safe-bash.ts +0 -505
  242. package/src/ui/agent-management-overlay.ts +0 -160
  243. package/src/ui/crew-footer.ts +0 -102
  244. package/src/ui/crew-select-list.ts +0 -114
  245. package/src/ui/dashboard-panes/capability-pane.ts +0 -77
  246. package/src/ui/transcript-entries.ts +0 -256
  247. package/src/utils/conflict-detect.ts +0 -721
  248. package/src/utils/fingerprint.ts +0 -180
  249. package/src/utils/gh-protocol.ts +0 -556
  250. package/src/utils/project-detector.ts +0 -160
  251. package/src/utils/sse-parser.ts +0 -131
  252. package/src/workflows/cost-estimator.ts +0 -34
  253. package/src/workflows/intermediate-store.ts +0 -166
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Merge of settled dispatch units into the run state for the team-run
3
+ * scheduler loop.
4
+ *
5
+ * Extracted from team-runner.ts (2026-08, Phase 2.6 maintainability split —
6
+ * CORE-4 extraction 5). Pure code motion: isRunTerminalPreserved +
7
+ * mergeUnitResult moved verbatim, including the RT-12 / NEW-D1 / R15-2 /
8
+ * CANCEL-1 comments.
9
+ *
10
+ * `isRunTerminalPreserved` lives here (not in finalize-run.ts) because it is
11
+ * shared by mergeUnitResult, finalizeRun (finalize-run.ts imports it from
12
+ * here) and the core loop in team-runner.ts (which imports it from here) —
13
+ * keeping it here avoids a merge-loop ↔ finalize-run import cycle.
14
+ */
15
+ import { flushPendingAtomicWrites } from "../state/atomic-write.ts";
16
+ import { withRunLock } from "../state/coordination/locks.ts";
17
+ import { loadRunManifestById, saveRunManifestAsync, saveRunTasksAsync, updateRunStatus } from "../state/stores/state-store.ts";
18
+ import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
19
+ import { classifyFatalFsError } from "../utils/fs-errno.ts";
20
+ import { cancelNonTerminalTasks } from "./dispatch-batch.ts";
21
+ import { mergeTaskUpdatesPreservingTerminal } from "./merge-gate.ts";
22
+ import type { SchedulerContext, SchedulerDecision } from "./scheduler-context.ts";
23
+ import { mergeArtifacts } from "./team-runner-artifacts.ts";
24
+
25
+ /**
26
+ * CORE-4 extraction 5: wait for one in-flight dispatch unit to settle and
27
+ * merge its result into the run state.
28
+ *
29
+ * Awaits Promise.race on ctx.pendingUnits; the first settled unit is merged
30
+ * into ctx.manifest/tasks under the run lock (flushPendingAtomicWrites +
31
+ * loadRunManifestById + mergeTaskUpdatesPreservingTerminal + save). The settled
32
+ * unit is then deleted from ctx.pendingUnits, and the merge outcome (taskIds
33
+ * + result object) is recorded on ctx.settledMerge for the post-merge inline
34
+ * logic (cancel-during-exec check + batch summary).
35
+ *
36
+ * Returns null to continue to the phase/budget check. A `{ kind: "return" }`
37
+ * decision is reserved for future run-complete/failure detection during merge.
38
+ *
39
+ * Reads ctx.pendingUnits/manifest/tasks. Mutates ctx.pendingUnits (delete),
40
+ * ctx.manifest/tasks, ctx.settledMerge.
41
+ *
42
+ * @param ctx The scheduler context.
43
+ */
44
+ /** R15-2/R15-1: run statuses that must never be overwritten by an in-memory
45
+ * derived status (mergeUnitResult force-"running", finalizeRun "completed").
46
+ * "blocked" is deliberately EXCLUDED — a blocked run can be unblocked and the
47
+ * finalize chain may legitimately derive blocked from in-memory state. */
48
+ export function isRunTerminalPreserved(status: TeamRunManifest["status"]): boolean {
49
+ return status === "cancelled" || status === "failed" || status === "completed";
50
+ }
51
+
52
+ export async function mergeUnitResult(ctx: SchedulerContext): Promise<SchedulerDecision | null> {
53
+ // RT-12: race on pre-created wrapper promises (created once at dispatch
54
+ // time) instead of rebuilding a wrapper-promise array with new async
55
+ // closures every iteration. This reduces allocation from O(C×T) wrapper
56
+ // promises to O(C) total (one per unit, created once at dispatch).
57
+ const settled = await Promise.race([...ctx.pendingUnits.values()].map((u) => u.wrapped));
58
+ const completedUnit = ctx.pendingUnits.get(settled.unitKey)!;
59
+ ctx.pendingUnits.delete(settled.unitKey);
60
+
61
+ // Build the single result to merge. On rejection, synthesize a failed
62
+ // result so the run continues (mirrors the old validResults guard).
63
+ // bug-026 sub-issue B: a rejected unit means runTeamTask itself threw —
64
+ // typically an atomicWriteFile/persistSingleTaskUpdate ENOSPC mid-run.
65
+ // Classify the errno and stamp failureCause on the synthesized failed
66
+ // tasks so the operator sees "failed (disk full)", not a generic write
67
+ // error string.
68
+ const thrownFailureCause = settled.result ? undefined : classifyFatalFsError(settled.error);
69
+ const resultToMerge: { manifest: TeamRunManifest; tasks: TeamTaskState[] } = settled.result ?? {
70
+ manifest: ctx.manifest,
71
+ tasks: cancelNonTerminalTasks(ctx.tasks, "failed", settled.error!.message, (t) => completedUnit.taskIds.includes(t.id)).map((t) =>
72
+ thrownFailureCause && t.status === "failed" ? { ...t, failureCause: thrownFailureCause } : t,
73
+ ),
74
+ };
75
+ const validResults = [resultToMerge];
76
+ // Reconstruct manifest from the last worker's snapshot. The .artifacts field
77
+ // is re-merged from both the team-runner's in-memory state and all workers'
78
+ // snapshots, so artifact writes by task-runner (which individually save manifest
79
+ // after writing artifacts) are safely persisted. The in-memory manifest is only
80
+ // used for the next batch iteration's orchestration — actual persistence is safe.
81
+ // Use updateRunStatus to recompute manifest status from merged tasks rather than
82
+ // relying on the last result's manifest (which is arbitrary due to mapConcurrent
83
+ // returning results in arbitrary order).
84
+ // Use the in-memory manifest as base (not the last-completing worker's snapshot).
85
+ // Recompute status from merged tasks so the manifest reflects actual task state,
86
+ // not the arbitrary order in which mapConcurrent returned results.
87
+ // Read committed manifest from disk inside the lock so artifact merge is based
88
+ // on committed state, not in-memory state that may differ from disk.
89
+ const mergeResult = await withRunLock(ctx.manifest, async () => {
90
+ // NEW-D1: flush any pending coalesced atomic writes before reading from
91
+ // disk. Without this, a worker's async manifest save (coalesced by
92
+ // atomic-write) may not be committed yet, causing a lost-update on the
93
+ // merge read. flushPendingAtomicWrites forces all queued writes to disk.
94
+ flushPendingAtomicWrites();
95
+ const disk = loadRunManifestById(ctx.manifest.cwd, ctx.manifest.runId);
96
+ const diskManifest = disk?.manifest ?? ctx.manifest;
97
+ const diskArtifacts = diskManifest.artifacts;
98
+ const reconciledArtifacts = mergeArtifacts([...diskArtifacts, ...validResults.map((item) => item.manifest.artifacts)].flat());
99
+ // R15-2: only force "running" when the disk manifest is NON-terminal
100
+ // (queued/planning/running/blocked). If the disk status is already terminal
101
+ // (cancelled/failed/completed — an external cancel or reconciler write
102
+ // landing during the batch), PRESERVE that terminal status: forcing
103
+ // "running" would legally erase it (contracts.ts allows cancelled/failed/
104
+ // completed → running) and the loop would never observe the disk-terminal
105
+ // (CANCEL-1/CANCEL-2 only catch worker-reported cancel or signal abort).
106
+ const mergedBase = { ...diskManifest, artifacts: reconciledArtifacts };
107
+ const resultManifest = isRunTerminalPreserved(diskManifest.status)
108
+ ? mergedBase
109
+ : updateRunStatus(mergedBase, "running", "Merged task updates from parallel batch.");
110
+ // CANCEL-1: use the freshly-loaded disk tasks as the merge base instead
111
+ // of the in-memory `tasks` closure variable. The in-memory tasks reflect
112
+ // only team-runner's view; an external cancel (handleCancel, background
113
+ // race with SIGTERM arriving after cancel wrote but before merge ran)
114
+ // writes 'cancelled' to disk.tasks — using disk.tasks as base preserves
115
+ // that cancellation through the merge instead of overwriting it with the
116
+ // stale in-memory view. disk was loaded inside this lock, so it reflects
117
+ // the freshest committed state.
118
+ const resultTasks = mergeTaskUpdatesPreservingTerminal(disk?.tasks ?? ctx.tasks, validResults);
119
+ await saveRunManifestAsync(resultManifest);
120
+ await saveRunTasksAsync(resultManifest, resultTasks);
121
+ return { resultManifest, resultTasks };
122
+ });
123
+ ctx.manifest = mergeResult.resultManifest;
124
+ ctx.tasks = mergeResult.resultTasks;
125
+ ctx.settledMerge = { taskIds: completedUnit.taskIds, result: resultToMerge };
126
+ return null;
127
+ }
128
+
129
+ /** @internal 1.9(b) test export — exercise mergeUnitResult directly. */
130
+ export const __test__mergeUnitResult = mergeUnitResult;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * model-budget-summary.ts — R8 model-routing transparency (WP-8, T5).
3
+ *
4
+ * One pre-run summary line: the resolved fallback chain + the WORST-CASE
5
+ * spawn budget per task (`attemptModels × (maxAttempts + 1)` — the RT-6
6
+ * formula; always ≥ 1 full attempt above the theoretical max). Cost
7
+ * surprises in multi-model fallback runs were invisible until workers
8
+ * started burning through attempts; this makes the ceiling loud UP FRONT.
9
+ */
10
+
11
+ import { loadConfig } from "../../config/config.ts";
12
+ import { DEFAULT_RETRY_POLICY } from "../recovery/retry-executor.ts";
13
+ import { computeSpawnBudgetMax } from "../task-runner/child-executor.ts";
14
+ import { buildConfiguredModelRouting } from "./model-fallback.ts";
15
+
16
+ export interface ModelBudgetSummary {
17
+ /** Deduped resolved chain (requested first, then fallbacks). */
18
+ chain: string[];
19
+ /** Worst-case child spawns per task: chain.length × (maxAttempts + 1). */
20
+ worstCaseSpawnsPerTask: number;
21
+ /** Configured maxAttempts (or the default when unset/unreadable). */
22
+ maxAttempts: number;
23
+ /** Rendered single-line summary for console output. */
24
+ line: string;
25
+ }
26
+
27
+ /** Compute the pre-run model budget summary. Never throws — degrades to an
28
+ * empty chain + default attempts on any config/catalog read failure. */
29
+ export function summarizeModelBudget(cwd: string): ModelBudgetSummary {
30
+ let maxAttempts = DEFAULT_RETRY_POLICY.maxAttempts;
31
+ let chain: string[] = [];
32
+ try {
33
+ const { config } = loadConfig(cwd);
34
+ // The same routing pipeline tasks use (config catalog + auto tail policy):
35
+ // the summary must describe what would ACTUALLY spawn, not a parallel
36
+ // reconstruction of it.
37
+ const routing = buildConfiguredModelRouting({ cwd, policy: config.runtime?.modelFallback });
38
+ chain = routing.candidates;
39
+ if (config.reliability?.retryPolicy?.maxAttempts !== undefined) {
40
+ maxAttempts = config.reliability.retryPolicy.maxAttempts;
41
+ }
42
+ } catch {
43
+ /* degrade: defaults below */
44
+ }
45
+ const worst = computeSpawnBudgetMax(Math.max(1, chain.length), maxAttempts);
46
+ const chainText = chain.length ? chain.join(" → ") : "(default pi model)";
47
+ return {
48
+ chain,
49
+ worstCaseSpawnsPerTask: worst,
50
+ maxAttempts,
51
+ line: `[team-tool.run] model routing: ${chainText} · worst-case ${worst} spawns/task (chain=${Math.max(1, chain.length)} × maxAttempts+1=${maxAttempts + 1})`,
52
+ };
53
+ }
@@ -273,14 +273,47 @@ export function splitThinkingSuffix(model: string): {
273
273
  };
274
274
  }
275
275
 
276
+ // WP-8 (R8): loud passthrough warnings — deduped per model so a chain of
277
+ // tasks resolving the same unvalidated model warns once per process. The
278
+ // delegate surface (spawn-policy admission) validates models against the
279
+ // catalog itself (WP-5) and never routes through resolveModelCandidate.
280
+ const passthroughWarned = new Set<string>();
281
+ function warnUnvalidatedPassthrough(model: string, reason: string): void {
282
+ const key = `${model}|${reason}`;
283
+ if (passthroughWarned.has(key)) return;
284
+ passthroughWarned.add(key);
285
+ // Muted: per-model passthrough noise became too loud when many pi-crew
286
+ // child workers resolve their parent model + fallback chain against the
287
+ // full catalog (each worker printed one line). Dedup Set is preserved
288
+ // so re-enabling is a one-line revert.
289
+ // console.warn(
290
+ // `[model-routing] unvalidated passthrough: '${model}' (${reason}) — not confirmed against the configured model catalog; spawn may fail at the provider.`,
291
+ // );
292
+ }
293
+
294
+ /** Test seam: the dedup set is module-global (one warn per model+reason per
295
+ * process); tests reset it for deterministic counting. */
296
+ export function resetPassthroughWarnings(): void {
297
+ passthroughWarned.clear();
298
+ }
299
+
276
300
  export function resolveModelCandidate(
277
301
  model: string | undefined,
278
302
  availableModels: AvailableModelInfo[] | undefined,
279
303
  preferredProvider?: string,
280
304
  ): string | undefined {
281
305
  if (!model) return undefined;
282
- if (model.includes("/")) return model;
283
- if (!availableModels || availableModels.length === 0) return model;
306
+ // Provider-qualified refs ("provider/model") pass through UNVALIDATED —
307
+ // the caller asserted a full id; there is nothing to resolve. Loud (R8):
308
+ // this is the documented trust path, not a silent one.
309
+ if (model.includes("/")) {
310
+ warnUnvalidatedPassthrough(model, "provider-qualified ref, no catalog check");
311
+ return model;
312
+ }
313
+ if (!availableModels || availableModels.length === 0) {
314
+ warnUnvalidatedPassthrough(model, "no model catalog available");
315
+ return model;
316
+ }
284
317
 
285
318
  const { baseModel, thinkingSuffix } = splitThinkingSuffix(model);
286
319
  const matches = availableModels.filter((entry) => entry.id === baseModel);
@@ -294,6 +327,7 @@ export function resolveModelCandidate(
294
327
  // Fuzzy fallback: try to resolve via partial name matching
295
328
  const fuzzy = fuzzyResolveModelId(baseModel, availableModels);
296
329
  if (fuzzy) return `${fuzzy}${thinkingSuffix}`;
330
+ warnUnvalidatedPassthrough(model, "no exact or fuzzy catalog match");
297
331
  return model;
298
332
  }
299
333
  return `${matches[0]!.fullId}${thinkingSuffix}`;
@@ -328,6 +328,16 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
328
328
  ) {
329
329
  allowed = allowed.filter((ext) => path.resolve(ext) === path.resolve(PROMPT_RUNTIME_EXTENSION_PATH));
330
330
  }
331
+ // ADR-5 §8 (governed nesting, T3/WP-5 step 8): every spawn this builder
332
+ // produces runs at depth > 0 (workers are depth 1, delegate grandchildren
333
+ // are 2+; currentCrewDepth(base)+1 is ALWAYS >= 1) — the extension list
334
+ // is an unconditional ALLOWLIST: only the trusted
335
+ // PROMPT_RUNTIME_EXTENSION_PATH passes, REGARDLESS OF SOURCE. User-sourced
336
+ // agent declarations can no longer inject extensions into sub-agents
337
+ // (SEC-1's project-only strip left user/builtin declarations unfiltered —
338
+ // the audit's untested hole). The denylist + SEC-1 strip above remain as
339
+ // defense-in-depth; the allowlist is authoritative.
340
+ allowed = [];
331
341
  for (const extension of [PROMPT_RUNTIME_EXTENSION_PATH, ...allowed]) args.push("--extension", extension);
332
342
  } else {
333
343
  args.push("--extension", PROMPT_RUNTIME_EXTENSION_PATH);
@@ -44,6 +44,12 @@ export interface DiscoveredProviderExtension {
44
44
  */
45
45
  const cache = new Map<string, { mtimeMs: number; result: DiscoveredProviderExtension[] }>();
46
46
 
47
+ // R5-L3 (Round 5 LOW-3): FIFO cap at insertion (mirrors knowledgeCache in
48
+ // knowledge-injection.ts). Entries are only removed when a re-access sees a
49
+ // changed mtime, so distinct settings paths (e.g. per-test temp roots) would
50
+ // otherwise accumulate for the process lifetime.
51
+ const MAX_PROVIDER_EXTENSION_CACHE = 64;
52
+
47
53
  function cachedResult(settingsPath: string): { mtimeMs: number; result: DiscoveredProviderExtension[] } | undefined {
48
54
  const entry = cache.get(settingsPath);
49
55
  if (!entry) return undefined;
@@ -157,6 +163,10 @@ export function discoverProviderExtensions(settingsPath?: string): DiscoveredPro
157
163
  // Cache the resolved result keyed on the settings.json path + mtime.
158
164
  try {
159
165
  cache.set(settingsFile, { mtimeMs: fs.statSync(settingsFile).mtimeMs, result: out });
166
+ if (cache.size > MAX_PROVIDER_EXTENSION_CACHE) {
167
+ const oldest = cache.keys().next().value;
168
+ if (oldest !== undefined) cache.delete(oldest);
169
+ }
160
170
  } catch {
161
171
  cache.delete(settingsFile);
162
172
  }
@@ -201,7 +201,7 @@ function readRegistry(): OrphanWorkerEntry[] {
201
201
  }
202
202
  // Silent failure is deliberate for robustness (registry read failures
203
203
  // shouldn't crash the process), but log at warning level to aid troubleshooting.
204
- console.warn(`[orphan-worker-registry] readRegistry failed: ${error}`);
204
+ logInternalError("orphan-worker-registry", new Error(`readRegistry failed: ${error}`), undefined, "warn");
205
205
  return [];
206
206
  }
207
207
  }
@@ -115,6 +115,51 @@ export function validateWorkerOutput(role: string, output: string): OutputValida
115
115
  };
116
116
  }
117
117
 
118
+ /**
119
+ * bug-026 sub-issue A: strict log-noise line shapes observed in the corrupted
120
+ * `02_explore-core.txt` result artifact (run team_20260815144514) — extension/
121
+ * MCP session-log stderr that the child-executor result fallback chain leaked
122
+ * into the result artifact when the worker payload was corrupted/empty.
123
+ *
124
+ * A trimmed non-empty line counts as log noise ONLY if it matches one of:
125
+ * 1. a bracket-tag extension log line: `[oc-go] ...`, `[pi-qwen-mm] ...`,
126
+ * `[pi-qwen-mm] [core] [stderr] ...` — the leading tag is a lowercase
127
+ * identifier (deliberately excludes capitalized bracketed prose like
128
+ * `[Note] ...`), optionally followed by known sub-tags
129
+ * (core/mcp/stderr/stdout/warn/info/error/debug);
130
+ * 2. a Python `warnings.warn(` line (deprecation-warning continuation);
131
+ * 3. a timestamped logging line: `2026-08-15 21:49:02,986 WARNING ...`.
132
+ *
133
+ * Anything else — prose, markdown, file:line results, `OK done.` — is NOT
134
+ * noise, so a single such line marks the artifact as usable content.
135
+ *
136
+ * NOTE: a `true` return is NOT by itself failure evidence; the caller
137
+ * (post-execution.ts) applies the two-gate rule (authoritative output
138
+ * sources empty AND artifact log-noise-only) before failing a task.
139
+ */
140
+ const BRACKET_TAG_LOG_LINE = /^\[[a-z0-9][a-z0-9_.-]*\](?:\s*\[(?:core|mcp|stderr|stdout|warn|info|error|debug)\])*(?:\s.*)?$/;
141
+ const PYTHON_WARNING_LINE = /(?:^|\s)warnings\.warn\(/;
142
+ const TIMESTAMPED_LOG_LINE = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:[.,]\d{1,6})?(?:Z|[+-]\d{2}:?\d{2})?\s+[A-Za-z]+\b/;
143
+
144
+ /**
145
+ * Detect a "stderr-only" result artifact: every non-empty trimmed line is
146
+ * strict log noise (see the pattern docs above). Empty/whitespace-only input
147
+ * returns false — emptiness is the caller's separate, explicit check.
148
+ * Conservative by design: any prose/markdown line makes it return false.
149
+ */
150
+ export function isStderrOnlyResult(text: string): boolean {
151
+ if (!text?.trim()) return false;
152
+ let noiseLines = 0;
153
+ for (const line of text.split("\n")) {
154
+ const trimmed = line.trim();
155
+ if (!trimmed) continue;
156
+ const isNoise = BRACKET_TAG_LOG_LINE.test(trimmed) || PYTHON_WARNING_LINE.test(trimmed) || TIMESTAMPED_LOG_LINE.test(trimmed);
157
+ if (!isNoise) return false;
158
+ noiseLines++;
159
+ }
160
+ return noiseLines > 0;
161
+ }
162
+
118
163
  /**
119
164
  * Extract structured findings from reviewer output.
120
165
  * Returns array of { file, line, severity, message } objects.
@@ -1,3 +1,5 @@
1
+ import { getCrewEnv } from "../config/env-vars.ts";
2
+
1
3
  /**
2
4
  * Parent liveness guard for pi-crew background-runner process.
3
5
  *
@@ -70,7 +72,7 @@
70
72
  * polling approach for near-instantaneous parent-death detection on Unix
71
73
  * systems, avoiding the polling overhead entirely.
72
74
  */
73
- const POLL_INTERVAL_MS = Number(process.env.PI_CREW_PARENT_GUARD_INTERVAL_MS) || 500;
75
+ const POLL_INTERVAL_MS = Number(getCrewEnv("PI_CREW_PARENT_GUARD_INTERVAL_MS")) || 500;
74
76
 
75
77
  const guardIntervals = new Map<number, ReturnType<typeof setInterval>>();
76
78
 
@@ -38,6 +38,7 @@ import * as fs from "node:fs";
38
38
  import * as os from "node:os";
39
39
  import * as path from "node:path";
40
40
  import { fileURLToPath, pathToFileURL } from "node:url";
41
+ import { getCrewEnv } from "../config/env-vars.ts";
41
42
  import { resolveNpmGlobalRoot } from "./pi-spawn.ts";
42
43
 
43
44
  /**
@@ -78,7 +79,7 @@ export function peerDepResolutionBases(): string[] {
78
79
  const bases: string[] = [];
79
80
 
80
81
  // 0. Parent-provided hint (fastest — no probe). Set by async-runner.
81
- const envHint = process.env[PEER_DEP_DIR_ENV]?.trim();
82
+ const envHint = getCrewEnv(PEER_DEP_DIR_ENV)?.trim();
82
83
  if (envHint) bases.push(path.resolve(envHint));
83
84
 
84
85
  // 1. This file's location — works when pi-crew and pi-coding-agent share a
@@ -87,11 +87,6 @@ function rememberSeen(path: string, content: string): void {
87
87
  }
88
88
  }
89
89
 
90
- /** Test seam: reset the dedup cache between tests. */
91
- export function resetPerWriteValidatorCache(): void {
92
- seenContent.clear();
93
- }
94
-
95
90
  /**
96
91
  * Replace the validator registry (test seam). Production uses
97
92
  * DEFAULT_VALIDATORS; tests inject a custom map to exercise specific extensions.
@@ -3,6 +3,8 @@ import * as fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { getCrewEnv } from "../config/env-vars.ts";
7
+ import { logInternalError } from "../utils/internal-error.ts";
6
8
 
7
9
  export interface PiSpawnCommand {
8
10
  command: string;
@@ -30,8 +32,11 @@ function isWithinAllowedPrefixes(resolvedPath: string): boolean {
30
32
  const execDir = path.dirname(fs.realpathSync.native(process.execPath));
31
33
  allowedPrefixes.push(execDir.toLowerCase());
32
34
  allowedPrefixes.push(path.join(path.dirname(execDir), "lib", "node_modules").toLowerCase());
33
- } catch {
34
- /* ignore */
35
+ } catch (error) {
36
+ // R17-B3 (LOW): execPath realpath failure is genuinely unexpected (the
37
+ // running binary vanished / permission issue) — surface it so the
38
+ // "cannot find pi" fallback is diagnosable.
39
+ logInternalError("pi-spawn.allowlist-prefixes", error, "execPath realpath failed", "warn");
35
40
  }
36
41
 
37
42
  // npm global bin via APPDATA
@@ -49,24 +54,46 @@ function isWithinAllowedPrefixes(resolvedPath: string): boolean {
49
54
  try {
50
55
  const projectBin = path.resolve("node_modules", ".bin");
51
56
  allowedPrefixes.push(projectBin.toLowerCase());
52
- } catch {
53
- /* ignore */
57
+ } catch (error) {
58
+ // R17-B3: probe failure → prefix simply not allowed; keep the cause
59
+ // visible under PI_TEAMS_DEBUG.
60
+ logInternalError("pi-spawn.allowlist-prefixes.project-bin", error, undefined, "debug");
54
61
  }
55
62
 
56
63
  // User home npm-global
57
64
  try {
58
65
  const homeNpm = path.join(os.homedir(), ".npm-global", "bin");
59
66
  allowedPrefixes.push(homeNpm.toLowerCase());
60
- } catch {
61
- /* ignore */
67
+ } catch (error) {
68
+ // R17-B3: probe failure → prefix simply not allowed; keep the cause
69
+ // visible under PI_TEAMS_DEBUG.
70
+ logInternalError("pi-spawn.allowlist-prefixes.npm-global", error, undefined, "debug");
62
71
  }
63
72
 
64
73
  // User home .local/bin
65
74
  try {
66
75
  const homeLocal = path.join(os.homedir(), ".local", "bin");
67
76
  allowedPrefixes.push(homeLocal.toLowerCase());
68
- } catch {
69
- /* ignore */
77
+ } catch (error) {
78
+ // R17-B3: probe failure → prefix simply not allowed; keep the cause
79
+ // visible under PI_TEAMS_DEBUG.
80
+ logInternalError("pi-spawn.allowlist-prefixes.local-bin", error, undefined, "debug");
81
+ }
82
+
83
+ // Canonicalize prefixes (macOS: /var/folders/... realpath → /private/var/folders/...).
84
+ // validateExplicitBin() compares fs.realpathSync(resolved) against this list —
85
+ // without the canonical forms, any prefix reached through a symlink (macOS
86
+ // tmpdir, ~/.npm-global as symlink) rejects its own realpath'd target
87
+ // (CI incident: run-worker-cap.test.ts on macos-latest). Additive: original
88
+ // forms stay, so Windows \\?\-prefixed realpaths simply never match.
89
+ for (let i = 0; i < allowedPrefixes.length; i++) {
90
+ try {
91
+ const real = fs.realpathSync.native(allowedPrefixes[i]!).toLowerCase();
92
+ if (real !== allowedPrefixes[i] && !allowedPrefixes.includes(real)) allowedPrefixes.push(real);
93
+ } catch {
94
+ // Prefix path doesn't exist (env var points nowhere) — the original form
95
+ // stays; nothing to canonicalize.
96
+ }
70
97
  }
71
98
 
72
99
  return allowedPrefixes.some((prefix) => normalized.startsWith(prefix));
@@ -81,12 +108,20 @@ function resolvePiPackageRoot(): string | undefined {
81
108
  try {
82
109
  const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf-8")) as { name?: string };
83
110
  if (pkg.name && PI_PACKAGE_NAMES.includes(pkg.name)) return dir;
84
- } catch {
111
+ } catch (error) {
112
+ // R17-B3: the upward walk EXPECTS ENOENT probes (most dirs have no
113
+ // readable package.json) — debug-gated so the walk stays silent
114
+ // unless PI_TEAMS_DEBUG is set, but EACCES-style causes are visible.
115
+ logInternalError("pi-spawn.resolve-pi-package-root.probe", error, `dir=${dir}`, "debug");
85
116
  // Continue walking upward.
86
117
  }
87
118
  dir = path.dirname(dir);
88
119
  }
89
- } catch {
120
+ } catch (error) {
121
+ // R17-B3: realpath failure on argv[1] is unexpected (not the normal
122
+ // missing-entry case, which is handled by the !entry guard above) —
123
+ // debug-gated visibility for the "cannot find pi" diagnosis.
124
+ logInternalError("pi-spawn.resolve-pi-package-root", error, `argv1=${process.argv[1] ?? ""}`, "debug");
90
125
  return undefined;
91
126
  }
92
127
  return undefined;
@@ -101,7 +136,10 @@ function packageBinScript(packageJsonPath: string): string | undefined {
101
136
  if (!binPath) return undefined;
102
137
  const candidate = path.resolve(path.dirname(packageJsonPath), binPath);
103
138
  return isRunnableNodeScript(candidate) ? candidate : undefined;
104
- } catch {
139
+ } catch (error) {
140
+ // R17-B3 (LOW): a package.json that EXISTS but fails to parse is corrupt —
141
+ // not the expected ENOENT of the upward-walk probes.
142
+ logInternalError("pi-spawn.package-bin-script", error, `packageJsonPath=${packageJsonPath}`, "warn");
105
143
  return undefined;
106
144
  }
107
145
  }
@@ -115,7 +153,10 @@ function findPiPackageJsonFrom(startDir: string): string | undefined {
115
153
  name?: string;
116
154
  };
117
155
  if (pkg.name && PI_PACKAGE_NAMES.includes(pkg.name)) return direct;
118
- } catch {
156
+ } catch (error) {
157
+ // R17-B3: same EXPECTED-ENOENT upward-walk probe as
158
+ // resolvePiPackageRoot — debug-gated, keeps walking either way.
159
+ logInternalError("pi-spawn.find-pi-package-json.probe", error, `dir=${dir}`, "debug");
119
160
  // Continue searching upward and in node_modules.
120
161
  }
121
162
  for (const pkgName of PI_PACKAGE_NAMES) {
@@ -163,7 +204,12 @@ export function resolveNpmGlobalRoot(): string | undefined {
163
204
  windowsHide: true,
164
205
  }).trim();
165
206
  resolved = out.length > 0 ? out : undefined;
166
- } catch {
207
+ } catch (error) {
208
+ // R17-B3 (LOW): `npm root -g` failing (npm not on PATH / timeout) means
209
+ // Windows non-APPDATA installs fall back to the static roots and often
210
+ // fail with ENOENT downstream — surface the root cause here. Memoized,
211
+ // so at most one warn per process.
212
+ logInternalError("pi-spawn.npm-root-g", error, "npm root -g probe failed", "warn");
167
213
  resolved = undefined;
168
214
  }
169
215
  cachedNpmGlobalRoot = resolved ?? null;
@@ -240,14 +286,14 @@ function validateExplicitBin(explicit: string): string | undefined {
240
286
  }
241
287
  } catch (e) {
242
288
  if (e instanceof Error && e.message.includes("allowed prefixes")) throw e;
243
- console.error("[pi-spawn] validateExplicitBin: unexpected realpathSync error:", e);
289
+ logInternalError("pi-spawn", e, "validateExplicitBin: unexpected realpathSync error", "error");
244
290
  return undefined;
245
291
  }
246
292
  return resolved;
247
293
  }
248
294
 
249
295
  export function getPiSpawnCommand(args: string[]): PiSpawnCommand {
250
- const explicit = process.env.PI_TEAMS_PI_BIN?.trim();
296
+ const explicit = getCrewEnv("PI_TEAMS_PI_BIN")?.trim();
251
297
  if (explicit) {
252
298
  const validated = validateExplicitBin(explicit);
253
299
  if (validated) {