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
package/docs/README.md CHANGED
@@ -30,6 +30,8 @@ stay at the root of this directory; historical/retired docs live in
30
30
  | [`COVERAGE-ASSESSMENT-2026-08-01.md`](./COVERAGE-ASSESSMENT-2026-08-01.md) | v0.9.56 coverage assessment + source-reorg history |
31
31
  | [`AUDIT-2026-07-30.md`](./AUDIT-2026-07-30.md) | v0.9.56 comprehensive audit (6 parallel streams) |
32
32
  | [`REMEDIATION-PLAN-2026-07-30.md`](./REMEDIATION-PLAN-2026-07-30.md) | v0.9.56 remediation plan |
33
+ | [`refactor-plan.md`](./refactor-plan.md) | Maintainability refactor plan (phased, v0.9.68) |
34
+ | [`refactor-plan.review.md`](./refactor-plan.review.md) | Verification sweep 3 (vòng 4) audit trail — 3 large-file baselines CONFIRMED, 6 corrections, Phase 4 reframe |
33
35
 
34
36
  ## Subdirectories
35
37
 
@@ -2,6 +2,23 @@
2
2
 
3
3
  The `team` tool is the primary tool that pi-crew registers with Pi. All operations go through `action`.
4
4
 
5
+ ### `plans` — Inspect and decide a run's plan record (T2/R4)
6
+
7
+ Reads the versioned `PlanRecord` list at `<stateRoot>/plans/plans.json` (ADR-4). Pre-v2 runs without a record degrade gracefully with the manifest-gate info.
8
+
9
+ ```json
10
+ { "action": "plans", "runId": "team_..." }
11
+ { "action": "plans", "runId": "team_...", "config": { "subAction": "get", "rev": 2 } }
12
+ { "action": "plans", "runId": "team_...", "config": { "subAction": "list" } }
13
+ { "action": "plans", "runId": "team_...", "config": { "subAction": "diff", "a": 1, "b": 2 } }
14
+ { "action": "plans", "runId": "team_...", "config": { "subAction": "approve" } }
15
+ ```
16
+
17
+ - `get` (default) renders the current (or pinned `rev`) revision with derived per-item progress (done/failed/running from linked `taskIds`).
18
+ - `list` renders the revision history.
19
+ - `diff` reports item-level changes between two revisions (added / removed / dropped / status changes / retitles).
20
+ - `approve` / `reject` delegate to the api `approve-plan` / `cancel-plan` ops (dual-write: manifest keeps its vocabulary — `cancelled` on deny — the record side uses `rejected`).
21
+
5
22
  ## Quick Reference
6
23
 
7
24
  | Action | Purpose | When to use |
@@ -10,6 +27,7 @@ The `team` tool is the primary tool that pi-crew registers with Pi. All operatio
10
27
  | `run` | Create a run and execute a workflow | Main operation |
11
28
  | `parallel` | Fan out independent tasks as concurrent runs | When you need N truly concurrent agents |
12
29
  | `plan` | Preview a workflow without running tasks | Dry-run planning |
30
+ | `plans` | Inspect/decide the versioned plan record of a run | Plan revisions, diff, approve/reject |
13
31
  | `orchestrate` | Execute from a plan document | Automate a plan |
14
32
  | `schedule` | Schedule recurring runs | Periodic automation |
15
33
  | `scheduled` | List scheduled jobs | View schedules |
@@ -800,6 +818,19 @@ Returns `status: "error"` when the run finished as `failed`.
800
818
  > ⚠️ **Internal / experimental.** Queues a steering message that is delivered to
801
819
  > a task's worker session on its next turn. The message is appended to
802
820
  > `pendingSteers` and also written to a live steering file for immediate delivery.
821
+ >
822
+ > **Updated (T1/WP-1, 2026-08-17, v0.10.1):** the agent-level tools
823
+ > `steer_subagent` / `crew_agent_steer` are now **implemented** — they resolve the
824
+ > subagent record via the unified ownership map (`src/state/stores/ownership-map.ts`,
825
+ > task ⇄ subagent ⇄ pid ⇄ artifacts) to the run's steering file and append a
826
+ > `{type:"steer", message, ts}` line that the worker polls every 500ms and picks
827
+ > up at its next turn boundary. Terminal-task refusal + size-cap parity with
828
+ > `team action=steer`; the steering file is truncated at each worker spawn so
829
+ > lines are scoped to one worker incarnation. The record's `taskId` is linked at
830
+ > dispatch time (onRunStarted), so mid-run steering of a live one-shot agent
831
+ > works. See ADR `docs/decisions/2026-08-14-agent-steer-tools-planned-not-implemented.md`
832
+ > (status: CLOSED). `team action=steer` (runId + taskId + message) remains the
833
+ > run-level path.
803
834
 
804
835
  ```json
805
836
  {
@@ -8,21 +8,22 @@ Slash commands are manual actions triggered from the Pi chat. Autonomous tool us
8
8
  |---------|-------|
9
9
  | `/teams` | List teams, agents, workflows, and recent runs |
10
10
  | `/team-run [options] <goal>` | Run a team workflow |
11
- | `/team-orchestrate <planPath>` | Execute from a plan document |
12
- | `/team-schedule [options]` | Schedule a recurring run |
13
- | `/team-scheduled` | List scheduled jobs |
14
11
  | `/team-cancel <runId>` | Cancel a run |
15
12
  | `/team-status <runId>` | View status |
16
13
  | `/team-summary <runId>` | View or write a summary |
17
14
  | `/team-resume <runId>` | Resume a stopped run |
18
- | `/team-search <query>` | BM25-ranked discovery |
19
- | `/team-graph <runId>` | Load, save, or list run graphs |
15
+ | `/team-retry <runId>` | Retry a failed run |
16
+ | `/team-result <runId>` | Show a task result artifact |
17
+ | `/team-respond <taskId> <message>` | Respond to a waiting worker task |
18
+ | `/team-follow-up [options]` | Manage follow-up notifications |
20
19
  | `/team-events <runId>` | View the event log |
21
20
  | `/team-artifacts <runId>` | View artifacts |
21
+ | `/team-transcript <runId>` | Open the transcript viewer |
22
22
  | `/team-worktrees <runId>` | View worktree metadata |
23
- | `/team-cleanup <runId>` | Remove worktrees |
23
+ | `/team-cleanup <runId>` | Remove worktrees (interactive menu: `/team-cleanup-menu`) |
24
24
  | `/team-forget <runId>` | Fully delete a run |
25
25
  | `/team-prune` | Delete multiple old runs |
26
+ | `/team-invalidate [scope]` | Invalidate caches |
26
27
  | `/team-export <runId>` | Export a run bundle |
27
28
  | `/team-import <path>` | Import a run bundle |
28
29
  | `/team-imports` | List imported bundles |
@@ -37,9 +38,16 @@ Slash commands are manual actions triggered from the Pi chat. Autonomous tool us
37
38
  | `/team-validate` | Validate resources |
38
39
  | `/team-help` | Help text |
39
40
  | `/team-doctor` | Diagnose the environment |
41
+ | `/team-mascot` | Toggle the mascot overlay |
40
42
  | **`/team-goal`** | **v0.9.0** Start autonomous goal loop (sub-actions: `start/status/pause/resume/stop/step/clear`) |
41
43
  | **`/workflows`** | **v0.9.0** List static + dynamic workflows (`.dwf.ts`) |
42
44
 
45
+ **Removed (v0.10.1 docs hygiene):** `/team-orchestrate`, `/team-schedule`,
46
+ `/team-scheduled`, `/team-search`, `/team-graph` — phantom entries; none of
47
+ these slash commands is registered. Their functionality is reachable via the
48
+ `team` tool actions (`action: "orchestrate" | "schedule" | "scheduled" |
49
+ "search" | "graph"`) instead.
50
+
43
51
  ---
44
52
 
45
53
  ## `/team-run` — Details
@@ -296,6 +304,9 @@ Creates directories:
296
304
  | `u` | View summary |
297
305
  | `i` | API read-manifest |
298
306
  | `q` or `Esc` | Close |
307
+ | `1`–`7` | Pane: agents / progress / mailbox / output / health / metrics / **plan** (v0.10.1, `PI_CREW_PLAN_UI=1`) |
308
+ | `A` / `n` | Plan approval approve/deny (progress + plan panes, while pending) |
309
+ | `X` | Plan revision diff (plan pane) |
299
310
 
300
311
  ---
301
312
 
@@ -107,6 +107,8 @@ Format:
107
107
  ---
108
108
  name: default
109
109
  description: Explore, plan, execute, verify
110
+ maxConcurrency: 3
111
+ specStrict: false
110
112
  ---
111
113
 
112
114
  ## explore
@@ -122,6 +124,15 @@ output: plan.md
122
124
  Create a plan for: {goal}
123
125
  ```
124
126
 
127
+ Frontmatter fields:
128
+
129
+ | Field | Meaning |
130
+ | --- | --- |
131
+ | `maxConcurrency` | Max parallel tasks for the run |
132
+ | `topology` | Explicit topology classification |
133
+ | `coalesceMicroTasks` | Coalesce micro-tasks sharing role+cwd into one worker prompt |
134
+ | `specStrict` | T4/R6 (ADR-6): strict spec mode — coverage AND machine-check; requires a `verifier` role step (reject-start) |
135
+
125
136
  Step fields:
126
137
 
127
138
  | Field | Meaning |
@@ -136,5 +147,7 @@ Step fields:
136
147
  | `progress` | `true`/`false` |
137
148
  | `worktree` | `true`/`false` metadata |
138
149
  | `verify` | `true`/`false` verification marker |
150
+ | `specRefs` | T4/R6 (ADR-6): workspace SpecRecord ids this step is held to (frozen into the packet at dispatch) |
151
+ | `specStrict` | Per-step strict override (`true`/`1`) |
139
152
 
140
153
  Each step starts with `## step-id` followed by recognized step metadata such as `role:` before the blank line. Level-2 headings inside task bodies are preserved unless they look like a step section with recognized metadata; use `###` or lower for maximum compatibility.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.9.68",
3
+ "version": "0.10.1",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -30,6 +30,7 @@
30
30
  ],
31
31
  "files": [
32
32
  "src/",
33
+ "!src/benchmark",
33
34
  "dist/index.mjs",
34
35
  "index.ts",
35
36
  "install.mjs",
@@ -67,13 +68,14 @@
67
68
  ],
68
69
  "scripts": {
69
70
  "check": "npm run ci",
70
- "ci": "npm run check:lockfile-sync && npm run typecheck && npm run lint && npm run format:check && npm run check:conflict-markers && npm run check:decision-drift && npm run check:event-types && npm run check:lazy-imports && npm run check:bundle-staleness && npm run build:bundle && npm run check:bundle-size && npm run test:bundle && npm test && npm pack --dry-run",
71
+ "ci": "npm run check:lockfile-sync && npm run typecheck && npm run lint && npm run format:check && npm run check:conflict-markers && npm run check:decision-drift && npm run check:env-vars && npm run check:event-types && npm run check:lazy-imports && npm run check:bundle-staleness && npm run build:bundle && npm run check:bundle-size && npm run test:bundle && npm test && npm pack --dry-run",
71
72
  "check:lockfile-sync": "node scripts/check-lockfile-sync.mjs",
72
73
  "check:lazy-imports": "node scripts/check-lazy-imports.mjs",
73
74
  "check:bundle-staleness": "node scripts/check-bundle-staleness.mjs",
74
75
  "check:bundle-size": "node scripts/check-bundle-size.mjs",
75
76
  "check:conflict-markers": "node scripts/check-conflict-markers.mjs",
76
77
  "check:decision-drift": "node scripts/check-decision-drift.mjs",
78
+ "check:env-vars": "node scripts/check-env-vars.mjs",
77
79
  "check:event-types": "node scripts/check-event-types-registry.mjs",
78
80
  "typecheck": "tsc --noEmit && node --experimental-strip-types -e \"await import('./index.ts'); console.log('strip-types import ok')\"",
79
81
  "lint": "biome check --linter-enabled=true --formatter-enabled=false --max-diagnostics=50 .",
package/schema.json CHANGED
@@ -256,7 +256,21 @@
256
256
  "enabled": { "type": "boolean" },
257
257
  "pathHashLen": { "type": "integer", "minimum": 4, "maximum": 32 },
258
258
  "maxFrameBytes": { "type": "integer", "minimum": 1024, "maximum": 1048576 },
259
- "outboundQueueCap": { "type": "integer", "minimum": 32, "maximum": 4096 }
259
+ "outboundQueueCap": { "type": "integer", "minimum": 32, "maximum": 4096 },
260
+ "waitMethodsEnabled": {
261
+ "type": "boolean",
262
+ "description": "WP-2/R2 (ADR-0 2026-08-17-waiting-producer-ask item 7): gate for the broker wait.* methods. Default false - fail-closed."
263
+ }
264
+ }
265
+ },
266
+ "nesting": {
267
+ "type": "object",
268
+ "additionalProperties": false,
269
+ "description": "Governed nesting (ADR-5 docs/decisions/2026-08-17-governed-nesting.md). Worker-to-grandchild delegation via the delegate tool. Default false - fail-closed until WP-5 completes.",
270
+ "properties": {
271
+ "enabled": { "type": "boolean", "sensitive": true, "description": "Master switch. Privilege-raising: user config only; project-level nesting.enabled is ignored." },
272
+ "maxSlots": { "type": "integer", "minimum": 1, "maximum": 64, "description": "Nested-slot budget override. Default max(1, floor(globalSem/2))." },
273
+ "maxDepth": { "type": "integer", "minimum": 1, "maximum": 10, "description": "Max crew depth for delegate-driven nesting. Default 2 (mirrors PI_CREW_MAX_DEPTH clamp)." }
260
274
  }
261
275
  }
262
276
  }
@@ -147,9 +147,16 @@ function readProcStatus(pid) {
147
147
 
148
148
  // ---------- fallback via ps (non-Linux) ----------
149
149
  function readPs(pid) {
150
- const res = spawnSync("ps", ["-o", "rss=,pcpu=", "-p", String(pid)], { encoding: "utf8" });
150
+ // state= makes zombie detection possible on BSD ps (macOS): a dead child
151
+ // stays listed by ps until reaped — without the state check, watch-parent
152
+ // (R4) never sees the watched PID die and live-warn (proc_died) never fires
153
+ // (CI incident: resource-sampler-audit.test.ts on macos-latest). Linux ps
154
+ // also supports state=. Z/X (and empty) = dead.
155
+ const res = spawnSync("ps", ["-o", "rss=,pcpu=,state=", "-p", String(pid)], { encoding: "utf8" });
151
156
  if (res.status !== 0 || !res.stdout.trim()) return null;
152
157
  const parts = res.stdout.trim().split(/\s+/);
158
+ const state = parts.length >= 3 ? parts[2] : "";
159
+ if (state.startsWith("Z") || state.startsWith("X")) return null;
153
160
  return {
154
161
  pid,
155
162
  ppid: 0,
@@ -163,7 +170,34 @@ function readPs(pid) {
163
170
  // ---------- child discovery ----------
164
171
  function findDescendants(rootPid) {
165
172
  // BFS over /proc to find all PIDs whose ppid chain leads to rootPid.
166
- if (!existsSync("/proc")) return [rootPid];
173
+ // Non-Linux (macOS/BSD): no /proc — build the ppid map from `ps -eo pid=,ppid=`
174
+ // instead (R3 on macos-latest CI: /proc-less platforms previously returned
175
+ // [rootPid] only, so descendants were never sampled).
176
+ if (!existsSync("/proc")) {
177
+ try {
178
+ const res = spawnSync("ps", ["-eo", "pid=,ppid="], { encoding: "utf8" });
179
+ if (res.status !== 0 || !res.stdout.trim()) return [rootPid];
180
+ const ppidOf = new Map();
181
+ for (const line of res.stdout.trim().split("\n")) {
182
+ const parts = line.trim().split(/\s+/);
183
+ if (parts.length >= 2) ppidOf.set(Number.parseInt(parts[0], 10), Number.parseInt(parts[1], 10));
184
+ }
185
+ const result = new Set([rootPid]);
186
+ let grew = true;
187
+ while (grew) {
188
+ grew = false;
189
+ for (const [pid, ppid] of ppidOf) {
190
+ if (result.has(ppid) && !result.has(pid)) {
191
+ result.add(pid);
192
+ grew = true;
193
+ }
194
+ }
195
+ }
196
+ return [...result];
197
+ } catch {
198
+ return [rootPid];
199
+ }
200
+ }
167
201
  const all = [];
168
202
  try {
169
203
  for (const name of readdirSync("/proc")) {
@@ -62,6 +62,32 @@ Use observable checks:
62
62
  - compatibility requirements such as Windows paths or Pi CLI flags;
63
63
  - rollback notes.
64
64
 
65
+ ## Spec Pairs (pi-crew v0.10.1+, ADR-6)
66
+
67
+ When the target runs pi-crew, author the **SpecRecord + TaskPacket pair** instead of prose-only acceptance:
68
+
69
+ 1. **SpecRecord** (workspace `state/specs/<id>.json`): `requirements[]` with
70
+ `must|should|could` priority + stable ids; `acceptance[]` entries each tied to
71
+ a `requirementId`. Machine-checkable acceptances carry `command`,
72
+ `expectedDigest` (sha-256 hex of stdout) or `expectedExitCode`, and
73
+ `idempotent: true` — only idempotent commands are ever re-run.
74
+ 2. **PROVENANCE — critical**: specs you (an agent/skill path) write are persisted
75
+ `generated` and NEVER re-executed by the orchestrator. `manual`+`trusted`
76
+ (the only specs strict mode re-runs) are minted exclusively by USER-facing
77
+ import actions — a worker cannot author a command the root re-executes.
78
+ 3. **Wire the workflow**: add `specRefs: [<spec ids>]` to steps held to the spec;
79
+ `specStrict: true` in frontmatter opts the whole workflow into strict mode
80
+ (requires a `verifier` role step — the run rejects at start otherwise).
81
+ 4. **Executor footer contract**: workers must END results with
82
+
83
+ ```text
84
+ SPEC-EVIDENCE:
85
+ <acceptanceId>: <one-line evidence>
86
+ ```
87
+
88
+ Non-strict = mechanical coverage only (`unverified` badge on gaps, never
89
+ blocks). Strict = coverage AND machine-check; failures fail the run.
90
+
65
91
  ## Enforcement — Requirements to Task Packet Gate
66
92
 
67
93
  **Before dispatching workers, verify task packet has:**
@@ -32,16 +32,16 @@ In-memory map from `live-agent-manager.ts`. Provides:
32
32
 
33
33
  **When NOT used:** After `evictStaleLiveAgentHandles()` removes a handle, widget falls back to agent records on disk.
34
34
 
35
- ### 2. Snapshot cache (500ms TTL)
35
+ ### 2. Snapshot cache (1500ms TTL)
36
36
 
37
- `RunSnapshotCache` from `run-snapshot-cache.ts` caches parsed manifests and agents for 500ms. Reduces disk reads during rapid refresh.
37
+ `RunSnapshotCache` from `run-snapshot-cache.ts` caches parsed manifests and agents for 1500ms. Reduces disk reads during rapid refresh.
38
38
 
39
39
  **When used:** As the fallback when no live handle exists. Prevents excessive disk reads on every render tick.
40
40
 
41
41
  **Invalidation:** Cache is invalidated when:
42
42
  - `invalidate()` is called on a specific run
43
43
  - An empty result is returned (forces refresh on next tick)
44
- - TTL expires (500ms)
44
+ - TTL expires (1500ms)
45
45
 
46
46
  ### 3. `agents.json` on disk (durables, lowest priority)
47
47
 
@@ -156,7 +156,7 @@ Every render cycle (`renderTick` / `requestAnimationFrame`) must complete in <16
156
156
 
157
157
  ### TTL interactions
158
158
 
159
- - Snapshot cache TTL = 500ms
159
+ - Snapshot cache TTL = 1500ms
160
160
  - Preload interval must be < TTL to avoid render-time gaps
161
161
  - If preload interval ≥ TTL, the cache always has fresh data for render
162
162
 
@@ -248,7 +248,7 @@ If ANY answer is NO → Stop. Fix widget rendering issues before proceeding.
248
248
  ## Anti-patterns
249
249
 
250
250
  - **Blocking render with fs calls**: Every `readFileSync`, `readdirSync`, `fs.statSync` in the render path causes frame drops. Preload everything async.
251
- - **Stale cache in hot path**: If snapshot cache TTL is too long, widget shows outdated state. Keep TTL at 500ms or less.
251
+ - **Stale cache in hot path**: If snapshot cache TTL is too long, widget shows outdated state. Keep TTL at 1500ms or less (the code default; lower only with cause).
252
252
  - **No invalidation on empty**: When `readCrewAgents` returns `[]` (no agents yet), the cache must be invalidated on next tick to prevent showing empty for too long.
253
253
  - **Expired handles accumulating**: Without `evictStaleLiveAgentHandles`, the Map grows indefinitely. Call it on every refresh.
254
254
  - **Widget showing stale health warnings**: Completed/cancelled/failed runs should not show health warnings. Filter by status.
@@ -258,7 +258,7 @@ If ANY answer is NO → Stop. Fix widget rendering issues before proceeding.
258
258
  ## Source patterns
259
259
 
260
260
  - `src/ui/crew-widget.ts` — render, refresh, activeWidgetRuns, evictStaleLiveAgentHandles, agentActivity, describeLiveActivity
261
- - `src/ui/run-snapshot-cache.ts` — SnapshotCache, get, refreshIfStale, TTL=500ms
261
+ - `src/ui/run-snapshot-cache.ts` — SnapshotCache, get, refreshIfStale, TTL=1500ms
262
262
  - `src/runtime/crew-agent-records.ts` — readCrewAgents, agents.json
263
263
  - `src/runtime/process-status.ts` — hasStaleAsyncProcess, isDisplayActiveRun
264
264
  - `src/runtime/background-runner.ts` — active run filtering with async PID check
@@ -275,7 +275,7 @@ Render-path performance for the widget is non-negotiable. Treat every `render(wi
275
275
  - **Prefer `snapshotCache.get(runId)`** on render paths. If a synchronous fallback is genuinely unavoidable, classify it as first-load/rare and document why it can't be preloaded.
276
276
  - **Keep panes pure.** Dashboard panes must accept a snapshot/model and format strings only. Never call `fs.readFileSync`, `fs.readdirSync`, `fs.statSync`, network APIs, or large JSON parsing from pane render methods.
277
277
  - **Stay non-blocking at 60fps.** Each render cycle must complete in under ~16ms. Anything that can't finish synchronously (reads, fetches, directory scans) belongs in the async preload path, not `renderTick()`.
278
- - **Respect the snapshot-cache TTL of ≤500ms.** Keep the `RunSnapshotCache` TTL at 500ms or less so the widget never shows stale state. Watch TTL interactions: the preload interval must be shorter than the cache TTL, otherwise render-time refresh gaps appear.
278
+ - **Respect the snapshot-cache TTL of ≤1500ms.** Keep the `RunSnapshotCache` TTL at 1500ms or less (the code default; lower only with cause) so the widget never shows stale state. Watch TTL interactions: the preload interval must be shorter than the cache TTL, otherwise render-time refresh gaps appear.
279
279
  - **Guard session switches.** On a session switch, cancel timers and ensure in-flight async preloads cannot update a now-stale session's UI.
280
280
  - **Filter stale warnings by terminal status.** Do not surface health warnings for completed/failed/cancelled runs.
281
281
 
@@ -1,3 +1,4 @@
1
+ import { getCrewEnv } from "../config/env-vars.ts";
1
2
  import type { RoleToolConfig } from "../config/role-tools.ts";
2
3
  import { getToolConfig, isScratchpadEnabledForRole } from "../config/role-tools.ts";
3
4
 
@@ -196,7 +197,7 @@ export function resolveToolPolicy(agent: AgentConfig, role?: string): ResolvedTo
196
197
  * live-session filterActiveTools) via the unified policy.
197
198
  */
198
199
  function shouldDemoteBashForScratchpad(role: string | undefined, agent: AgentConfig): boolean {
199
- if (process.env.PI_CREW_SCRATCHPAD_DEMOTE_BASH !== "1") return false;
200
+ if (getCrewEnv("PI_CREW_SCRATCHPAD_DEMOTE_BASH") !== "1") return false;
200
201
  if (!role) return false;
201
202
  return isScratchpadEnabledForRole(role, { scratchpad: agent.scratchpad });
202
203
  }
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { type LoadedPiTeamsConfig, loadConfig } from "../config/config.ts";
4
+ import { getCrewEnv } from "../config/env-vars.ts";
4
5
  import { discoverProviderExtensionPaths } from "../runtime/model/provider-extensions.ts";
5
6
  import { parseCsv, parseFrontmatter } from "../utils/frontmatter.ts";
6
7
  import { logInternalError } from "../utils/internal-error.ts";
@@ -136,19 +137,13 @@ function logSecurityEvent(event: SecurityEvent): void {
136
137
  }
137
138
 
138
139
  /**
139
- * Get recent security events (for debugging/testing).
140
+ * Get recent security events. Production reader: the team doctor report
141
+ * surfaces a compact summary (R7-13); tests assert on the full list.
140
142
  */
141
143
  export function getSecurityEventLog(): readonly SecurityEvent[] {
142
144
  return securityEventLog;
143
145
  }
144
146
 
145
- /**
146
- * Clear security event log (for testing).
147
- */
148
- export function clearSecurityEventLog(): void {
149
- securityEventLog.length = 0;
150
- }
151
-
152
147
  /**
153
148
  * Security check: throws if the agent name is protected.
154
149
  *
@@ -373,7 +368,11 @@ export function sanitizeAgentSystemPrompt(content: string, source: ResourceSourc
373
368
  * emitted the `contextMode: fork` warn-only notice. Avoids spam when
374
369
  * the discovery cache reloads or the same agent is parsed multiple
375
370
  * times in a session. Exported for test reset.
371
+ * R5-L5: FIFO cap so the set stays bounded in long-lived sessions
372
+ * (Set preserves insertion order; bounded naturally <50, cap is a
373
+ * safety net).
376
374
  */
375
+ const MAX_WARNED_FORK_AGENTS = 128;
377
376
  const warnedForkAgents = new Set<string>();
378
377
  export function __test_resetForkWarnings(): void {
379
378
  warnedForkAgents.clear();
@@ -406,14 +405,19 @@ function parseAgentFile(filePath: string, source: ResourceSource): AgentConfig |
406
405
  // so the agent will behave as `fresh` regardless of the setting.
407
406
  // We warn (not throw) so existing configs that predate live-session
408
407
  // keep working. Deduped per-filePath so we don't spam on cache
409
- // reload. Use console.warn (no logger hook here yet; a future
410
- // refactor could pipe through the same log channel as
411
- // logInternalError below).
408
+ // reload. User-facing config notice (kept on console.warn deliberately
409
+ // so the user sees it in the interactive session; internal failures
410
+ // below use logInternalError).
412
411
  if (contextMode === "fork" && !warnedForkAgents.has(filePath)) {
413
412
  console.warn(
414
413
  "contextMode: 'fork' is only effective in live-session runtime; current default child-process will behave as 'fresh'. See docs/runtime-flow.md.",
415
414
  );
416
415
  warnedForkAgents.add(filePath);
416
+ // R5-L5: FIFO eviction — drop the oldest entry past the cap.
417
+ if (warnedForkAgents.size > MAX_WARNED_FORK_AGENTS) {
418
+ const oldest = warnedForkAgents.values().next().value;
419
+ if (oldest !== undefined) warnedForkAgents.delete(oldest);
420
+ }
417
421
  }
418
422
 
419
423
  return {
@@ -442,7 +446,7 @@ function parseAgentFile(filePath: string, source: ResourceSource): AgentConfig |
442
446
  // code. Bypass only when PI_CREW_TRUST_PROJECT_AGENT_EXTENSIONS=1 is
443
447
  // explicitly set. buildPiWorkerArgs also enforces this as
444
448
  // defense-in-depth.
445
- ...((source === "project" || source === "project-pi") && process.env.PI_CREW_TRUST_PROJECT_AGENT_EXTENSIONS !== "1"
449
+ ...((source === "project" || source === "project-pi") && getCrewEnv("PI_CREW_TRUST_PROJECT_AGENT_EXTENSIONS") !== "1"
446
450
  ? { extensions: [], excludeExtensions: [] }
447
451
  : {
448
452
  extensions: frontmatter.extensions === "" ? [] : parseCsv(frontmatter.extensions),
@@ -494,7 +498,12 @@ function readAgentDir(dir: string, source: ResourceSource): AgentConfig[] {
494
498
  try {
495
499
  const stat = fs.statSync(fullPath);
496
500
  if (stat.size > MAX_AGENT_FILE_BYTES) {
497
- console.warn(`[pi-crew] Skipping oversized agent file (${stat.size} > ${MAX_AGENT_FILE_BYTES} bytes): ${fullPath}`);
501
+ logInternalError(
502
+ "discover-agents",
503
+ new Error(`Skipping oversized agent file (${stat.size} > ${MAX_AGENT_FILE_BYTES} bytes): ${fullPath}`),
504
+ undefined,
505
+ "warn",
506
+ );
498
507
  return undefined;
499
508
  }
500
509
  } catch {
@@ -536,7 +545,7 @@ function applyAgentOverrides(agents: AgentConfig[], cwd: string, loadedConfig?:
536
545
  // user-trusted config knob (provider extensions like pi-commandcode-provider)
537
546
  // and applies to builtin / user agents only.
538
547
  const isUntrustedProject = (agent: AgentConfig) =>
539
- (agent.source === "project" || agent.source === "project-pi") && process.env.PI_CREW_TRUST_PROJECT_AGENT_EXTENSIONS !== "1";
548
+ (agent.source === "project" || agent.source === "project-pi") && getCrewEnv("PI_CREW_TRUST_PROJECT_AGENT_EXTENSIONS") !== "1";
540
549
  const withGlobalExtensions = (agent: AgentConfig): AgentConfig => {
541
550
  if (isUntrustedProject(agent)) return agent;
542
551
  return deduped.length > 0 || agent.extensions !== undefined
@@ -0,0 +1,183 @@
1
+ import { DANGEROUS_OBJECT_KEYS } from "./config-validation.ts";
2
+ import type { AgentOverrideConfig, PiTeamsConfig } from "./types.ts";
3
+
4
+ function withoutUndefined<T extends Record<string, unknown>>(value: T): Partial<T> {
5
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)) as Partial<T>;
6
+ }
7
+
8
+ export function mergeConfig(base: PiTeamsConfig, override: PiTeamsConfig): PiTeamsConfig {
9
+ const warnings: string[] = [];
10
+ const merged: PiTeamsConfig = {
11
+ ...base,
12
+ ...withoutUndefined(override as Record<string, unknown>),
13
+ };
14
+ if (base.autonomous || override.autonomous) {
15
+ merged.autonomous = {
16
+ ...(base.autonomous ?? {}),
17
+ ...withoutUndefined((override.autonomous ?? {}) as Record<string, unknown>),
18
+ };
19
+ }
20
+ if (base.broker || override.broker) {
21
+ // WP-2/R2 fix (B1 battery 2026-08-18): `broker` was previously merged by
22
+ // the top-level spread only — a user-side broker object (defaults or a
23
+ // user config file with ANY broker block) replaced the project config's
24
+ // block WHOLESALE, silently dropping keys like waitMethodsEnabled that
25
+ // the user side did not set (enabled:true survived only because it
26
+ // matched DEFAULT_BROKER). Per-key user-wins, same as runtime/ui/….
27
+ merged.broker = {
28
+ ...(base.broker ?? {}),
29
+ ...withoutUndefined((override.broker ?? {}) as Record<string, unknown>),
30
+ };
31
+ }
32
+ if (base.nesting || override.nesting) {
33
+ // ADR-5: per-key user-wins deep merge — a partial user-side nesting block
34
+ // (e.g. just maxSlots) must not erase DEFAULT_NESTING.enabled=false.
35
+ merged.nesting = {
36
+ ...(base.nesting ?? {}),
37
+ ...withoutUndefined((override.nesting ?? {}) as Record<string, unknown>),
38
+ };
39
+ }
40
+ if (base.limits || override.limits) {
41
+ merged.limits = {
42
+ ...(base.limits ?? {}),
43
+ ...withoutUndefined((override.limits ?? {}) as Record<string, unknown>),
44
+ };
45
+ }
46
+ if (base.runtime || override.runtime) {
47
+ merged.runtime = {
48
+ ...(base.runtime ?? {}),
49
+ ...withoutUndefined((override.runtime ?? {}) as Record<string, unknown>),
50
+ };
51
+ // F19-1 (Round 19 parity): deep-merge modelFallback like
52
+ // reliability.retryPolicy so a partial override cannot erase base fields
53
+ // (user-wins precedence per key). Assigned only when a side defines it —
54
+ // unlike retryPolicy we avoid a stray `modelFallback: undefined` key so
55
+ // the merged runtime shape stays byte-identical for configs without it.
56
+ if (base.runtime?.modelFallback || override.runtime?.modelFallback) {
57
+ merged.runtime.modelFallback = {
58
+ ...(base.runtime?.modelFallback ?? {}),
59
+ ...withoutUndefined((override.runtime?.modelFallback ?? {}) as Record<string, unknown>),
60
+ };
61
+ }
62
+ }
63
+ if (base.control || override.control) {
64
+ merged.control = {
65
+ ...(base.control ?? {}),
66
+ ...withoutUndefined((override.control ?? {}) as Record<string, unknown>),
67
+ };
68
+ }
69
+ if (base.worktree || override.worktree) {
70
+ merged.worktree = {
71
+ ...(base.worktree ?? {}),
72
+ ...withoutUndefined((override.worktree ?? {}) as Record<string, unknown>),
73
+ };
74
+ }
75
+ if (base.ui || override.ui) {
76
+ merged.ui = {
77
+ ...(base.ui ?? {}),
78
+ ...withoutUndefined((override.ui ?? {}) as Record<string, unknown>),
79
+ };
80
+ }
81
+ if (base.agents || override.agents) {
82
+ merged.agents = {
83
+ ...(base.agents ?? {}),
84
+ ...withoutUndefined((override.agents ?? {}) as Record<string, unknown>),
85
+ overrides: {
86
+ ...(base.agents?.overrides ?? {}),
87
+ ...(withoutUndefined((override.agents?.overrides ?? {}) as Record<string, unknown>) as Record<string, AgentOverrideConfig>),
88
+ },
89
+ };
90
+ }
91
+ if (base.tools || override.tools) {
92
+ merged.tools = {
93
+ ...(base.tools ?? {}),
94
+ ...withoutUndefined((override.tools ?? {}) as Record<string, unknown>),
95
+ };
96
+ }
97
+ if (base.telemetry || override.telemetry) {
98
+ merged.telemetry = {
99
+ ...(base.telemetry ?? {}),
100
+ ...withoutUndefined((override.telemetry ?? {}) as Record<string, unknown>),
101
+ };
102
+ }
103
+ if (base.policy || override.policy) {
104
+ merged.policy = {
105
+ ...(base.policy ?? {}),
106
+ ...withoutUndefined((override.policy ?? {}) as Record<string, unknown>),
107
+ };
108
+ }
109
+ if (base.notifications || override.notifications) {
110
+ merged.notifications = {
111
+ ...(base.notifications ?? {}),
112
+ ...withoutUndefined((override.notifications ?? {}) as Record<string, unknown>),
113
+ };
114
+ }
115
+ if (base.observability || override.observability) {
116
+ merged.observability = {
117
+ ...(base.observability ?? {}),
118
+ ...withoutUndefined((override.observability ?? {}) as Record<string, unknown>),
119
+ };
120
+ }
121
+ if (base.reliability || override.reliability) {
122
+ merged.reliability = {
123
+ ...(base.reliability ?? {}),
124
+ ...withoutUndefined((override.reliability ?? {}) as Record<string, unknown>),
125
+ retryPolicy:
126
+ base.reliability?.retryPolicy || override.reliability?.retryPolicy
127
+ ? {
128
+ ...(base.reliability?.retryPolicy ?? {}),
129
+ ...withoutUndefined((override.reliability?.retryPolicy ?? {}) as Record<string, unknown>),
130
+ }
131
+ : undefined,
132
+ };
133
+ }
134
+ if (base.otlp || override.otlp) {
135
+ merged.otlp = {
136
+ ...(base.otlp ?? {}),
137
+ ...withoutUndefined((override.otlp ?? {}) as Record<string, unknown>),
138
+ headers: {
139
+ ...(base.otlp?.headers ?? {}),
140
+ ...(override.otlp?.headers ?? {}),
141
+ },
142
+ };
143
+ if (Object.keys(merged.otlp.headers ?? {}).length === 0) delete merged.otlp.headers;
144
+ // Validate OTLP headers for injection attacks:
145
+ // - Check top-level keys for dangerous prototype pollution patterns
146
+ // - Block ALL control characters except tab (0x09) to prevent header
147
+ // injection via CR/LF/zero-byte/etc.
148
+ // BUG (Round 28, CRLF injection): the previous range
149
+ // /[\x00-\x08\x0b\x0c\x0e-\x1f]/ left THREE chars unblocked: tab (0x09,
150
+ // intentionally allowed), LF (0x0A) AND CR (0x0D). The comment claimed to
151
+ // "prevent header injection via CR/LF" but CR was never matched, and LF
152
+ // was explicitly allowed — both are CRLF injection vectors that can split
153
+ // HTTP headers. Fix: block 0x00-0x08 and 0x0A-0x1F, allowing only tab.
154
+ const invalidHeaders: string[] = [];
155
+ for (const [k, v] of Object.entries(merged.otlp.headers ?? {})) {
156
+ // Check top-level key for dangerous names (only top-level keys are checked)
157
+ const checkKey = (key: string): boolean => {
158
+ const lowerKey = key.toLowerCase();
159
+ if (DANGEROUS_OBJECT_KEYS.has(lowerKey)) return true;
160
+ return false;
161
+ };
162
+ if (checkKey(k)) {
163
+ invalidHeaders.push(k);
164
+ continue;
165
+ }
166
+ // Block any control characters except tab (0x09) in values.
167
+ // Round 28 fix: /[\x00-\x08\x0a-\x1f]/ blocks LF (0x0A) and CR (0x0D) too.
168
+ const valStr = String(v);
169
+ if (/[\x00-\x08\x0a-\x1f]/.test(valStr)) {
170
+ invalidHeaders.push(k);
171
+ }
172
+ }
173
+ if (invalidHeaders.length > 0) {
174
+ delete merged.otlp.headers;
175
+ warnings.push(`OTLP headers blocked due to invalid characters: ${invalidHeaders.join(", ")}`);
176
+ }
177
+ }
178
+ if (merged.agents?.overrides && Object.keys(merged.agents.overrides).length === 0) delete merged.agents.overrides;
179
+ return merged;
180
+ }
181
+
182
+ /** @internal — direct-test seam for Phase 2.2 extraction target (refactor-plan step 1.9c). */
183
+ export const __test__mergeConfig = mergeConfig;