pi-crew 0.11.23 → 0.11.25

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 (156) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +2 -2
  3. package/agents/designer.md +23 -0
  4. package/agents/explorer.md +3 -3
  5. package/agents/librarian.md +3 -3
  6. package/agents/orchestrator.md +17 -0
  7. package/dist/index.mjs +567 -555
  8. package/{workflows/chain.workflow.md → docs/chain-workflow.md} +8 -0
  9. package/docs/dynamic-workflows.md +37 -2
  10. package/docs/publishing.md +34 -5
  11. package/docs/troubleshooting.md +50 -2
  12. package/docs/usage.md +7 -2
  13. package/install.mjs +2 -1
  14. package/package.json +6 -4
  15. package/skills/read-only-explorer/SKILL.md +15 -5
  16. package/skills/real-test-pi-crew/SKILL.md +7 -7
  17. package/src/agents/discover-agents.ts +8 -2
  18. package/src/config/config-validation.ts +61 -1
  19. package/src/config/config.ts +90 -5
  20. package/src/config/env-allowlist-registry.ts +5 -0
  21. package/src/config/env-vars.ts +23 -13
  22. package/src/config/role-tools.ts +14 -11
  23. package/src/config/types.ts +4 -1
  24. package/src/extension/crew-cleanup.ts +6 -0
  25. package/src/extension/management.ts +7 -0
  26. package/src/extension/project-init.ts +19 -1
  27. package/src/extension/register.ts +83 -30
  28. package/src/extension/registration/artifact-cleanup.ts +52 -11
  29. package/src/extension/registration/commands/manage.ts +9 -2
  30. package/src/extension/registration/hook-registration.ts +3 -0
  31. package/src/extension/registration/lazy-configurers.ts +2 -0
  32. package/src/extension/registration/lifecycle-handlers.ts +79 -18
  33. package/src/extension/registration/render-loop.ts +27 -2
  34. package/src/extension/registration/tool-loop-guard.ts +5 -3
  35. package/src/extension/registration/verify-gate-tool.ts +7 -3
  36. package/src/extension/run-index.ts +24 -1
  37. package/src/extension/team-tool/api/read.ts +12 -3
  38. package/src/extension/team-tool/cancel.ts +11 -0
  39. package/src/extension/team-tool/config-patch.ts +56 -11
  40. package/src/extension/team-tool/destructive-gate.ts +268 -0
  41. package/src/extension/team-tool/dispatch/manage.ts +9 -1
  42. package/src/extension/team-tool/goal-wrap.ts +11 -1
  43. package/src/extension/team-tool/goal.ts +13 -1
  44. package/src/extension/team-tool/handle-schedule.ts +38 -4
  45. package/src/extension/team-tool/handle-settings.ts +76 -6
  46. package/src/extension/team-tool/health-monitor.ts +14 -2
  47. package/src/extension/team-tool/lifecycle-actions.ts +28 -10
  48. package/src/extension/team-tool/respond.ts +12 -5
  49. package/src/extension/team-tool/run-deadline.ts +29 -2
  50. package/src/extension/team-tool/run.ts +11 -0
  51. package/src/extension/team-tool/status.ts +64 -8
  52. package/src/extension/team-tool/workflow-manage.ts +5 -1
  53. package/src/extension/team-tool.ts +13 -0
  54. package/src/i18n.ts +18 -14
  55. package/src/observability/exporters/otlp-exporter.ts +11 -1
  56. package/src/prompt/inbox-poll.ts +14 -2
  57. package/src/prompt/message-tool.ts +1 -1
  58. package/src/prompt/prompt-runtime.ts +127 -21
  59. package/src/prompt/scratchpad-lifecycle.ts +14 -13
  60. package/src/prompt/surface-worker.ts +35 -9
  61. package/src/runtime/broker/crew-broker-client.ts +7 -0
  62. package/src/runtime/broker/crew-broker.ts +216 -242
  63. package/src/runtime/broker/delegate/delegate-abort.ts +118 -0
  64. package/src/runtime/broker/delegate/delegate-event.ts +3 -1
  65. package/src/runtime/broker/delegate/delegate-finalize.ts +179 -0
  66. package/src/runtime/broker/protocol/connection-state.ts +159 -1
  67. package/src/runtime/broker/protocol/events-replay.ts +50 -10
  68. package/src/runtime/broker/protocol/wait-resolve.ts +71 -5
  69. package/src/runtime/budget-enforcement.ts +26 -6
  70. package/src/runtime/chain-parser.ts +5 -3
  71. package/src/runtime/chain-runner.ts +41 -22
  72. package/src/runtime/child-pi/child-pi-constants.ts +9 -0
  73. package/src/runtime/child-pi/child-pi-rpc.ts +72 -7
  74. package/src/runtime/child-pi/child-pi-streams.ts +19 -3
  75. package/src/runtime/child-pi/mock-fixtures.ts +12 -5
  76. package/src/runtime/child-pi/postmortem-registry.ts +8 -2
  77. package/src/runtime/classifier/ambient-noise-gate.ts +5 -1
  78. package/src/runtime/compaction/tool-output-pruner.ts +28 -5
  79. package/src/runtime/crew-agent-records.ts +67 -4
  80. package/src/runtime/custom-tools/submit-result-tool.ts +17 -0
  81. package/src/runtime/deadletter.ts +5 -1
  82. package/src/runtime/delegate-spawn.ts +61 -8
  83. package/src/runtime/dispatch-batch.ts +61 -14
  84. package/src/runtime/effectiveness.ts +55 -4
  85. package/src/runtime/finalize-run.ts +12 -1
  86. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +12 -3
  87. package/src/runtime/goal-workflow/goal-evaluator.ts +30 -5
  88. package/src/runtime/goal-workflow/goal-loop-runner.ts +60 -3
  89. package/src/runtime/live-session/live-agent-control.ts +123 -20
  90. package/src/runtime/live-session/live-agent-manager.ts +114 -15
  91. package/src/runtime/live-session/live-session-runtime.ts +75 -83
  92. package/src/runtime/live-session/usage-bridge.ts +66 -2
  93. package/src/runtime/mcp-proxy.ts +10 -0
  94. package/src/runtime/model/model-fallback.ts +74 -9
  95. package/src/runtime/model/pi-args.ts +3 -3
  96. package/src/runtime/model/runtime-policy.ts +22 -6
  97. package/src/runtime/model/runtime-resolver.ts +11 -5
  98. package/src/runtime/orphan-worker-registry.ts +64 -24
  99. package/src/runtime/pi-spawn.ts +4 -1
  100. package/src/runtime/process/orphan-reaper.ts +143 -0
  101. package/src/runtime/process-status.ts +38 -16
  102. package/src/runtime/recovery/crash-recovery.ts +35 -2
  103. package/src/runtime/resilient-edit.ts +170 -93
  104. package/src/runtime/role-permission.ts +3 -1
  105. package/src/runtime/run-tracker.ts +9 -1
  106. package/src/runtime/sanitize/worker-text.ts +98 -0
  107. package/src/runtime/scheduling/batch-barrier.ts +42 -5
  108. package/src/runtime/scheduling/run-coalesced-task-group.ts +82 -28
  109. package/src/runtime/scheduling/scheduler.ts +74 -29
  110. package/src/runtime/spawn-policy.ts +190 -8
  111. package/src/runtime/stale-reconciler.ts +77 -6
  112. package/src/runtime/subagent-manager.ts +29 -0
  113. package/src/runtime/surface/herdr-provider.ts +85 -2
  114. package/src/runtime/surface/launch-script.ts +7 -1
  115. package/src/runtime/surface/resolve-surface.ts +4 -1
  116. package/src/runtime/surface/tmux-provider.ts +32 -2
  117. package/src/runtime/task-output-context.ts +58 -11
  118. package/src/runtime/task-runner/child-executor.ts +3 -2
  119. package/src/runtime/task-runner/post-execution.ts +144 -0
  120. package/src/runtime/task-runner/prompt-builder.ts +41 -8
  121. package/src/runtime/task-runner.ts +16 -3
  122. package/src/runtime/verification/classifier-pre-gate.ts +27 -6
  123. package/src/runtime/verification/verify-gate.ts +153 -30
  124. package/src/runtime/workspace-lock.ts +52 -4
  125. package/src/state/atomic-write.ts +73 -4
  126. package/src/state/contracts.ts +9 -0
  127. package/src/state/coordination/mailbox.ts +40 -11
  128. package/src/state/coordination/process-lock.ts +50 -0
  129. package/src/state/event-log/cursor.ts +10 -1
  130. package/src/state/event-log/event-log-rotation.ts +54 -8
  131. package/src/state/event-log/poison-marker.ts +16 -1
  132. package/src/state/event-log/worker-atomic-writer.ts +5 -0
  133. package/src/state/stores/active-run-registry.ts +56 -5
  134. package/src/state/stores/artifact-store.ts +181 -6
  135. package/src/state/stores/manifest-io.ts +126 -8
  136. package/src/state/stores/ownership-map.ts +26 -7
  137. package/src/state/stores/plan-store.ts +30 -1
  138. package/src/state/stores/sqlite-run-state.ts +91 -1
  139. package/src/state/stores/state-store.ts +30 -31
  140. package/src/state/types.ts +18 -0
  141. package/src/state/usage.ts +41 -0
  142. package/src/ui/dashboard-panes/agents-pane.ts +4 -3
  143. package/src/ui/dashboard-panes/plan-pane.ts +5 -3
  144. package/src/ui/dashboard-panes/transcript-pane.ts +2 -1
  145. package/src/ui/overlays/mailbox-detail-overlay.ts +5 -3
  146. package/src/ui/run-dashboard.ts +9 -7
  147. package/src/ui/run-snapshot-cache.ts +3 -2
  148. package/src/ui/settings-overlay.ts +87 -11
  149. package/src/ui/theme-discovery.ts +26 -7
  150. package/src/ui/transcript-viewer.ts +5 -3
  151. package/src/utils/internal-error.ts +5 -1
  152. package/src/utils/timer-limits.ts +30 -0
  153. package/src/utils/token-counter.ts +47 -0
  154. package/src/utils/visual.ts +84 -0
  155. package/src/workflows/discover-workflows.ts +9 -1
  156. package/src/worktree/worktree-manager.ts +128 -18
@@ -6,6 +6,14 @@ topology: dynamic
6
6
 
7
7
  # Chain Workflow - Sequential execution with context passing
8
8
 
9
+ > **This file is DOCUMENTATION, not a runnable workflow (R28-M1 / wave-6 M3-24).**
10
+ > It lives in `docs/` because the framework discovers every
11
+ > `workflows/*.workflow.md` as a workflow resource; as a doc it parsed to 10
12
+ > phantom steps (its prose headings) and the advertised `chain` workflow could
13
+ > never run. Chain execution is implemented by the chain runner
14
+ > (`src/runtime/chain-runner.ts` + `src/extension/team-tool/chain-dispatch.ts`);
15
+ > there is no `chain` workflow resource.
16
+
9
17
  **Source:** pi-boomerang integration plan
10
18
  **Syntax:** `step1 -> step2 -> step3`
11
19
  **Version:** 1.0.0
@@ -25,6 +25,9 @@ export default async function (ctx) {
25
25
  const synth = await ctx.agent({ role: "analyst", prompt: "Merge + dedupe findings", inputs: reports.map(r => r.artifactPath) });
26
26
 
27
27
  ctx.phase("Review");
28
+ // NOTE (R2-M-B5): the identical-call cache is RESUME-ONLY — every
29
+ // ctx.review / ctx.retry iteration really re-runs the task (fixed
30
+ // semantics; see the caveat under WorkflowCtx API below).
28
31
  for (let i = 0; i < 3; i++) {
29
32
  const review = await ctx.review(synth.taskId, "reviewer");
30
33
  if (review.outcome === "accept") break;
@@ -52,8 +55,8 @@ Slash command: `/workflows` lists all workflows (static + dynamic).
52
55
  | `ctx.agent({role, prompt, model?, skill?, maxTurns?, inputs?, schema?, worktree?})` | Spawn one agent, await `{ok, text, structured, artifactPath, usage}`. Concurrency enforced by `ctx.semaphore`. `schema?` (round-13) is a TypeBox schema — when set, output is validated and mismatch yields `ok:false`. `worktree?` (round-17) spawns the agent in an isolated git worktree (default false; falls back to normal cwd + warning in a non-git repo). |
53
56
  | `ctx.fanOut(items, limit, fn)` | Bounded parallel fan-out (wraps `mapConcurrent`). |
54
57
  | `ctx.pipeline(items, ...stages)` | **round-16.** Multi-stage pipeline: each item passes through all stages sequentially; different items run concurrently (bounded by `ctx.semaphore`). A failed stage yields `null` for that item (logged via `ctx.log`) and other items continue. Aborts propagate. Returns `(TResult\|null)[]`. |
55
- | `ctx.review(taskId, reviewerRole?)` | Run a reviewer; parse `{outcome, feedback}`. |
56
- | `ctx.retry(taskId, {feedback?})` | Re-run with feedback (wraps `executeWithRetry`). |
58
+ | `ctx.review(taskId, reviewerRole?)` | Run a reviewer; parse `{outcome, feedback}`. Re-runs the task every call (see the identical-call cache caveat below). |
59
+ | `ctx.retry(taskId, {feedback?})` | Re-run with feedback (wraps `executeWithRetry`). Always re-spawns (never cached). |
57
60
  | `ctx.mail(to, body, opts?)` | Mailbox message to another agent/leader. |
58
61
  | `ctx.gatherReplies(ids, deadlineMs)` | Block until N replies arrive or deadline. |
59
62
  | `ctx.renderTemplate(name, vars)` | Render a built-in plan template. |
@@ -68,6 +71,15 @@ Slash command: `/workflows` lists all workflows (static + dynamic).
68
71
  explicit `agent` name → `team.roles[].agent` → `discoverAgents` by name → synthesize
69
72
  minimal (`source:'dynamic'`).
70
73
 
74
+ > **Identical-call cache caveat (R2-M-B5, fixed semantics).** The
75
+ > completed-calls cache is consulted ONLY for calls hydrated from a resumed
76
+ > checkpoint — identical call options within a LIVE run always re-spawn.
77
+ > Historically the cache also hit within a run, which made
78
+ > `while(true){review; if accept break; retry}` loops replay round-1 verdicts
79
+ > forever (stale verdicts until the agent-call cap aborted the run). If you
80
+ > need true idempotency within a run, structure the workflow to call each
81
+ > distinct step once and rely on resume for crash recovery.
82
+
71
83
  ### Pipeline (round-16 P2-1)
72
84
 
73
85
  `ctx.pipeline(items, ...stages)` runs a **multi-stage transform** over a list of
@@ -263,6 +275,29 @@ script can reach `process`/`require` directly or via constructor walking. The
263
275
  (e.g. `require('child'+'_process')`, `globalThis.process.mainModule.require`).
264
276
  The real boundary is commit-review + the path-allowlist, not the content check.
265
277
 
278
+ ### Project-sourced workflows are DEFAULT-DENY (F-01)
279
+
280
+ `.dwf.ts` files that resolve to a **project** source (`.crew/workflows/`,
281
+ `<proj>/.pi/teams/workflows/`) are refused at run time unless you explicitly opt
282
+ in with the env var `PI_CREW_TRUST_PROJECT_DWF=1`
283
+ (`src/runtime/goal-workflow/dynamic-workflow-runner.ts:189`). The rejected run
284
+ emits a `dwf.trust_denied` event and throws before the script is loaded:
285
+
286
+ ```text
287
+ Project dynamic workflow requires explicit trust. Set PI_CREW_TRUST_PROJECT_DWF=1
288
+ ```
289
+
290
+ The gate compares against the literal string `"1"` — `true`, `yes`, or `on` all
291
+ FAIL closed. Cloning a repo and running its workflow is otherwise arbitrary code
292
+ execution, hence the opt-in.
293
+
294
+ **Scope of the gate (honest):** only `source === "project"` is gated. **Builtin**
295
+ workflows and **user-scope** workflows (`~/.pi/agent/extensions/pi-crew/workflows/`)
296
+ proceed with **no** trust prompt — they run as ordinary trusted local code. The
297
+ `PI_CREW_TRUST_PROJECT_DWF` gate is therefore not a blanket guarantee that every
298
+ `.dwf.ts` you execute was reviewed: it protects against *repo-supplied* authoring,
299
+ not against user-scope authoring.
300
+
266
301
  ## Determinism (round-13 P0-2)
267
302
 
268
303
  Dynamic workflow scripts must be **deterministic** — the runner rejects
@@ -24,8 +24,8 @@ Before publishing to npm:
24
24
  npm run check
25
25
  ```
26
26
 
27
- For a fast pre-publish smoke (97 broker/UI/config tests in ~20s, instead
28
- of the full ~6,500-test `npm run check` which takes minutes):
27
+ For a fast pre-publish smoke (124 broker/UI/config tests in ~15s, instead
28
+ of the full `npm run check` unit+integration suite, which takes minutes):
29
29
 
30
30
  ```bash
31
31
  npm run test:critical && npm run typecheck && npm run build:bundle
@@ -94,7 +94,7 @@ lockfile drift):
94
94
  - **push to `refs/heads/main`** — `check:conflict-markers` + `typecheck`,
95
95
  then the **FULL unit suite** (`npm run test:unit`, the same gate CI
96
96
  runs). Any failure blocks the push. This closes the 2026-10 gap where
97
- local pushes ran only `test:critical` (~120 tests) while CI ran the full
97
+ local pushes ran only `test:critical` (124 tests) while CI ran the full
98
98
  suite — three waves shipped red that way.
99
99
  - **push to any other ref** — fast gates only (`check:conflict-markers` +
100
100
  `typecheck`).
@@ -128,8 +128,37 @@ The package exports:
128
128
  ./schema.json
129
129
  ```
130
130
 
131
- Use this for editor validation of:
131
+ Use this for editor validation of the **current** config path
132
+ (`src/config/config.ts:208`; the `~/.pi/agent/extensions/pi-crew/config.json`
133
+ path is legacy/read-only-for-migration — see `docs/usage.md`):
132
134
 
133
135
  ```text
134
- ~/.pi/agent/extensions/pi-crew/config.json
136
+ ~/.pi/agent/pi-crew.json
135
137
  ```
138
+
139
+ ## Known tarball / consumer-side notes (R26/R29 census, 2026-10-19)
140
+
141
+ Verified with `npm pack --dry-run`. These are **declared defects**, not hidden:
142
+
143
+ - **`skills/research/scripts/__pycache__/safe_io.cpython-312.pyc` ships** in the
144
+ tarball (11.5 kB): the `files` entry `"skills/**"` plus the missing
145
+ `.npmignore` rule means compiled Python bytecode is published and it embeds
146
+ build-machine paths. Fix = add `__pycache__` to a `.npmignore` / tighten
147
+ `files`.
148
+ - **Dev tooling ships to consumers:** `scripts/analyze-run.mjs` (64.5 kB),
149
+ `scripts/resource-sampler.mjs`, `scripts/watch-agent-transcript.mjs`. Harmless
150
+ bulk; consider moving them behind a dev-only entry.
151
+ - **Consumer-side git hooks reference scripts that are NOT published:**
152
+ `scripts/install-hooks.mjs` (published) sets `core.hooksPath=.githooks`, and
153
+ `.githooks/pre-commit` runs `node scripts/check-lockfile-sync.mjs` — but
154
+ `files[]` ships neither `scripts/check-lockfile-sync.mjs` nor
155
+ `scripts/test-runner.mjs`, so an installed-from-npm consumer's hook fails.
156
+ - **Peer range on `pi-coding-agent` is unbounded** (`>=1.0.4`, no ceiling) while
157
+ the `pi-tui` peer IS bounded (`<2.0.0`); pi-crew consumes pi-tui symbols
158
+ re-exported through `pi-coding-agent`, so a future 2.x installs clean and fails
159
+ at load (R49-F2 — bounding it is prospective work).
160
+ - **Committed `dist/` must match the tag:** the `--committed-hash` gate reads the
161
+ committed blob (`dist/index.mjs` — the ONLY tracked dist artifact) from the git
162
+ object DB, so every commit that touches `src/` needs
163
+ `npm run build:bundle && git add -f dist/index.mjs` rerun and committed
164
+ (`dist/` is `.gitignore`d, hence `-f`).
@@ -18,8 +18,11 @@ run/process status. Start here.
18
18
  **Symptom:** `team action='run'` returns a `blocked` status with a message like
19
19
  "Child worker execution is disabled".
20
20
 
21
- **Cause:** Worker execution is off. pi-crew refuses to create no-op scaffold
22
- subagents by default until you opt in.
21
+ **Cause:** Worker execution is off. When `executeWorkers` is **explicitly**
22
+ `false` (or `PI_CREW_EXECUTE_WORKERS=0`), pi-crew refuses to create no-op
23
+ scaffold subagents and returns `blocked`. Note the default is the **opposite**:
24
+ child-worker execution is enabled by default
25
+ (`src/extension/team-tool/run.ts:205`).
23
26
 
24
27
  **Fix — pick one:**
25
28
  - Set in your config (`~/.pi/agent/pi-crew.json`): `"executeWorkers": true`
@@ -149,6 +152,51 @@ code + a help hint inline. Common ones:
149
152
  | E012 | RunStale | run reconciled as stale | see "Stale async process" above |
150
153
  | E013 | ModelOutOfScope | requested model not in pi's `enabledModels` allowlist | pick a model listed in `enabledModels` (settings.json) or disable `reliability.scopeModels` |
151
154
 
155
+ ## Doctor says OK but the run is dead (doctor blind spots, R48)
156
+
157
+ `team action='doctor'` is a **liveness sanity check, not a watchdog**. The
158
+ deep-review health/status/doctor census (round 48) found these classes are
159
+ **NOT** detected — status/health can print `running` for a dead host:
160
+
161
+ | Class | Why it is missed | What to do |
162
+ |---|---|---|
163
+ | Host process dead, manifest frozen `running` | orphan detection keys on updatedAt/magic-string heuristics (`src/runtime/process-status.ts`) | check the host pid yourself; `team action='cancel'` then re-run |
164
+ | Disk full (`ENOSPC`) | no free-space probe in doctor | `df -h` the state root / TMPDIR |
165
+ | Bad state-file permissions (`EACCES`) | reads may classify as unreadable, not as a doctor failure | `ls -l <crewRoot>/state/runs/*` |
166
+ | Orphaned worker processes (reparented to PID 1) | only reaped when a run is reconciled | `ps -ef \| grep ' pi '`, then `team action='cleanup'` |
167
+ | Broken `node_modules` / missing `pi` resolve | spawn succeeds, worker dies instantly | run `pi --version` in the run cwd |
168
+
169
+ Only two classes are reliably caught: stale broker socket and orphan mux pane.
170
+ Treat a green `doctor` as "no *known* problem", not as "the run is alive".
171
+
172
+ ## Debug logging: `PI_CREW_DEBUG` / `PI_TEAMS_DEBUG` are TRUTHY, not "=1" (R45-F4)
173
+
174
+ Internal debug logging (`logInternalError(..., "debug")`, `src/utils/internal-error.ts:10`)
175
+ is gated on a raw truthiness check, so **any non-empty value enables it** —
176
+ including `PI_CREW_DEBUG=0`, `PI_TEAMS_DEBUG=false`, and `PI_TEAMS_DEBUG=off`.
177
+ To actually silence debug output, **unset** the variable (do not set it to `0`).
178
+
179
+ ## Mux surfaces (tmux / herdr) — known sharp edges (R42)
180
+
181
+ `runtime.surface.mode: 'tmux' | 'herdr'` (default `auto`) runs tier-1 workers in
182
+ real mux panes. Live-probed known limits:
183
+
184
+ - **Panic characters in commands:** tmux `send-keys -l` swallows a trailing `\n`,
185
+ while herdr `pane.send_text` treats `\n` as Enter — a boot line containing a
186
+ newline would be split/executed early. pi-crew's own callers pass
187
+ shell-escaped single-line paths, so this is latent, not live.
188
+ - **Pane/tab ids are not regex-validated before `-t`:** a malformed id in a
189
+ manifest (`kill-pane -t "-x"`) can act on the *wrong* pane (today every path is
190
+ liveness-gated, so it is defence-in-depth, not an open hole).
191
+ - **`capture-pane -p` trims trailing spaces** (cosmetic; only feeds doctor
192
+ liveness).
193
+ - **No tmux minimum-version gate:** old tmux silently falls back to a two-strike
194
+ headless degrade.
195
+ - **herdr is an external socket daemon** — pi-crew never spawns it, and the
196
+ server-wide subscription (no pane filter) amplifies traffic.
197
+ - Pane ids are unique only within one mux server lifetime; a stale manifest can
198
+ name an id now owned by an unrelated pane.
199
+
152
200
  ## Still stuck
153
201
 
154
202
  - `team action='explain' runId=…` — structured per-task analysis (why, files,
package/docs/usage.md CHANGED
@@ -42,7 +42,7 @@ Supported fields:
42
42
  "requirePlanApproval": false
43
43
  },
44
44
  "ui": {
45
- "widgetPlacement": "aboveEditor",
45
+ "widgetPlacement": "bottom",
46
46
  "widgetMaxLines": 8,
47
47
  "powerbar": true,
48
48
  "dashboardPlacement": "right",
@@ -239,6 +239,12 @@ Dirty worktrees are preserved unless `force: true` is provided.
239
239
 
240
240
  ## Slash commands
241
241
 
242
+ > **`/team-cleanup <runId>` does not exist** (R27-D1). The registered commands
243
+ > are `/team-manager` (and its alias `/team-cleanup-menu`) — runId-targeted
244
+ > cleanup is the `cleanup` action on the `team` tool (`team action='cleanup'
245
+ > runId=…`). Only `team-cleanup-menu` is registered
246
+ > (`src/extension/registration/commands/manage.ts:99-107`).
247
+
242
248
  ```text
243
249
  /teams
244
250
  /team-run default "Implement login with tests"
@@ -251,7 +257,6 @@ Dirty worktrees are preserved unless `force: true` is provided.
251
257
  /team-events team_...
252
258
  /team-artifacts team_...
253
259
  /team-worktrees team_...
254
- /team-cleanup team_...
255
260
  /team-forget team_... --confirm
256
261
  /team-export team_...
257
262
  /team-import .crew/artifacts/team_.../export/run-export.json # or .pi/teams/artifacts/... on legacy layout
package/install.mjs CHANGED
@@ -13,7 +13,8 @@ const legacyConfigPath = path.join(agentDir, "extensions", "pi-crew", "config.js
13
13
  * Default config written to `~/.pi/agent/pi-crew.json` on fresh installs.
14
14
  *
15
15
  * WI-1b.2 (G17): this object is intentionally an embedded literal — install.mjs
16
- * runs under bare `node` as the `pi-crew` bin (engines floor is >=22.0.0; .ts
16
+ * runs under bare `node` as the `pi-crew` bin (engines floor is >=22.19.0 — the
17
+ * pre-R29-F3 comment said >=22.0.0; .ts
17
18
  * imports would require >=22.18 or `--experimental-strip-types`, which the npm
18
19
  * bin shim cannot pass). Runtime jiti/esbuild loading was rejected as a
19
20
  * postinstall failure surface; build-time codegen would need package.json /
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.11.23",
3
+ "version": "0.11.25",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -57,6 +57,7 @@
57
57
  "docs/troubleshooting.md",
58
58
  "docs/usage.md",
59
59
  "docs/architecture.md",
60
+ "docs/chain-workflow.md",
60
61
  "docs/dynamic-workflows.md",
61
62
  "docs/resource-formats.md",
62
63
  "docs/publishing.md",
@@ -116,6 +117,7 @@
116
117
  "smoke:pi": "pi install .",
117
118
  "smoke:release": "node scripts/release-smoke.mjs",
118
119
  "prepack": "node scripts/clean-strip-types.mjs",
120
+ "prepublishOnly": "npm run ci",
119
121
  "test:fast": "npm run test:critical && npm run test:bundle",
120
122
  "check:wc-gate": "node scripts/wc-gate.mjs"
121
123
  },
@@ -134,9 +136,9 @@
134
136
  ]
135
137
  },
136
138
  "peerDependencies": {
137
- "@earendil-works/pi-agent-core": "*",
138
- "@earendil-works/pi-ai": "*",
139
- "@earendil-works/pi-coding-agent": "*",
139
+ "@earendil-works/pi-agent-core": ">=1.0.4",
140
+ "@earendil-works/pi-ai": ">=1.0.4",
141
+ "@earendil-works/pi-coding-agent": ">=1.0.4 <2.0.0",
140
142
  "@earendil-works/pi-tui": ">=1.0.4 <2.0.0",
141
143
  "@sinclair/typebox": "*"
142
144
  },
@@ -17,11 +17,21 @@ triggers:
17
17
 
18
18
  Use this skill for explorer, analyst, reviewer, and source-audit roles. These roles must inspect code without modifying it.
19
19
 
20
+ > **Tool availability (enforced — R27-D6).** `explorer` (and `librarian`) ship
21
+ > **without a `bash` tool**: `src/config/role-tools.ts:25-29` sets
22
+ > `tools: ["read", "grep", "find", "ls", "glob", "ask"]` +
23
+ > `excludeTools: ["edit", "write", "bash", "web"]`. The shell syntax below
24
+ > (`rg …`, `find …`, `git status`) is therefore **reference syntax for the
25
+ > intent**, not runnable in an explorer session. Map each example to the tool you
26
+ > actually have: `rg` → `grep`, `find`/shell globbing → `find`/`glob`,
27
+ > `ls` → `ls`, `cat`/`sed -n` → `read` (with offset/limit). `analyst`/`reviewer`
28
+ > roles keep `bash` (`role-tools.ts:33-35`, `:64-68`) — the restriction is per-role.
29
+
20
30
  ## Core Contract
21
31
 
22
32
  1. **Do not edit files** — no write, no edit, no delete
23
33
  2. **Do not write generated artifacts** outside the run artifact directory
24
- 3. **Prefer read-only commands**: `read`, `rg`, `find`, `ls`, `git status`
34
+ 3. **Prefer read-only tools**: `read`, `grep`, `find`, `ls`, `glob` (explorer has no shell — see the note above)
25
35
  4. **Record exact files inspected** — include path and relevant line numbers
26
36
  5. **Distinguish direct evidence from inference** — don't guess
27
37
  6. **If implementation is needed, recommend** — don't modify code
@@ -242,10 +252,10 @@ If ANY answer is NO → Stop. Adhere to read-only contract.
242
252
 
243
253
  ## Source patterns
244
254
 
245
- - `src/runtime/task-runner.ts` — task execution pipeline
246
- - `src/runtime/child-pi.ts` — worker spawning
247
- - `src/runtime/live-agent-manager.ts` — live agent lifecycle
248
- - `src/state/event-log.ts` — event logging system
255
+ - `src/runtime/task-runner.ts` + `src/runtime/task-runner/` — task execution pipeline
256
+ - `src/runtime/child-pi/` — worker spawning
257
+ - `src/runtime/live-session/live-agent-manager.ts` — live agent lifecycle
258
+ - `src/state/event-log/` — event logging system
249
259
  - `src/extension/team-tool/` — API and tool handling
250
260
  - `src/ui/` — widget and TUI rendering
251
261
 
@@ -275,7 +275,7 @@ To add Tier 1 to CI as a fast-feedback gate (under 30s):
275
275
 
276
276
  ---
277
277
 
278
- ## Tier 1 — Critical unit tests (~19s, 120 tests, the only suite you need for broker/UI changes)
278
+ ## Tier 1 — Critical unit tests (~13s, 124 tests, the only suite you need for broker/UI changes)
279
279
 
280
280
  **What**: run the curated 15-file fast subset.
281
281
 
@@ -287,7 +287,7 @@ To add Tier 1 to CI as a fast-feedback gate (under 30s):
287
287
  time npm run test:critical
288
288
  ```
289
289
 
290
- Expected output: `# tests 120 # pass 120 # fail 0 # cancelled 0 # duration_ms ~19965`. (Count was 97 at v0.9.46, 101 at v0.9.66, 102 at the waitMethodsEnabled flip, 116 since 2026-09-20, **120 @ 2026-10-07** (+`child-executor-heartbeat-pulse.test.ts` → 15 files) — verify with the actual run; the skill's hard-coded numbers drift between releases.) **Since RR-021 round-2 the runner itself fails loudly on `# cancelled N>0`** (dual-reporter TAP scrape — the round-1 unref incident shipped green with 2 cancelled tests), and **round-3 added the missing-file pre-check**: an explicitly-listed file that does not exist now exits 1 naming the path instead of being silently skipped by node:test.
290
+ Expected output: `# tests 124 # pass 124 # fail 0 # cancelled 0 # duration_ms ~12800`. (Count was 97 at v0.9.46, 101 at v0.9.66, 102 at the waitMethodsEnabled flip, 116 since 2026-09-20, 120 @ 2026-10-07, **124 @ 2026-10-18** (+`child-executor-heartbeat-pulse.test.ts` → 15 files) — verify with the actual run; the skill's hard-coded numbers drift between releases.) **Since RR-021 round-2 the runner itself fails loudly on `# cancelled N>0`** (dual-reporter TAP scrape — the round-1 unref incident shipped green with 2 cancelled tests), and **round-3 added the missing-file pre-check**: an explicitly-listed file that does not exist now exits 1 naming the path instead of being silently skipped by node:test.
291
291
 
292
292
  **References**:
293
293
 
@@ -327,7 +327,7 @@ PI_CREW_BROKER=0 npm run test:critical
327
327
  PI_CREW_BROKER=1 npm run test:critical
328
328
  ```
329
329
 
330
- All three must show `# pass 120 # fail 0`. Measured times in this session (2026-08-11): ~26s for default, ~26s for `PI_CREW_BROKER=0`, ~26s for `PI_CREW_BROKER=1` (varies ±1-2s run-to-run).
330
+ All three must show `# pass 124 # fail 0`. Measured times in this session (2026-08-11): ~26s for default, ~26s for `PI_CREW_BROKER=0`, ~26s for `PI_CREW_BROKER=1` (varies ±1-2s run-to-run).
331
331
 
332
332
  **References**:
333
333
 
@@ -609,7 +609,7 @@ If the two md5s match → session is on the latest code. If not → user must `/
609
609
 
610
610
  **What**: drive the team tool + subagent tools through a spread of actions from the parent Pi session to prove the full surface works end-to-end, not just one smoke run.
611
611
 
612
- **Why this exists**: Tier 7 proves one team run completes. But pi-crew has **55 `team` actions across 5 domain dispatchers** (`RUN` 10: run/parallel/plan/plans/orchestrate/resume/retry/wait/steer/goal · `STATUS` 16: status/list/get/events/artifacts/summary/graph/search/health/worktrees/checkpoint/cache/explain/onboard/recommend/help · `CONTROL` 7 · `MANAGE` 16 · `AUTOMATE` 6 — `src/schema/team-tool-schema.ts:391-437`) plus the subagent tools (`Agent`, `crew_agent`, `get_subagent_result`, `steer_subagent` — with `crew_agent_result`/`crew_agent_steer` aliases), the worker-side tools (`ask`, `delegate`, `message`), and the `team-settings` config surface, dispatched through several code paths (sync run, async run, chain, parallel, direct subagent). A schema or registration regression can break *some* paths while others still pass. The battery catches path-specific breakage.
612
+ **Why this exists**: Tier 7 proves one team run completes. But pi-crew has **57 `team` actions across 5 domain dispatchers** (`RUN` 10: run/parallel/plan/plans/orchestrate/resume/retry/wait/steer/goal · `STATUS` 17: status/list/get/events/artifacts/breakdown/summary/graph/search/health/worktrees/checkpoint/cache/explain/onboard/recommend/help · `CONTROL` 7 · `MANAGE` 17 · `AUTOMATE` 6 — `src/schema/team-tool-schema.ts:437-489`) plus the subagent tools (`Agent`, `crew_agent`, `get_subagent_result`, `steer_subagent` — with `crew_agent_result`/`crew_agent_steer` aliases), the worker-side tools (`ask`, `delegate`, `message`), and the `team-settings` config surface, dispatched through several code paths (sync run, async run, chain, parallel, direct subagent). A schema or registration regression can break *some* paths while others still pass. The battery catches path-specific breakage.
613
613
 
614
614
  **When required**: any change to `src/schema/team-tool-schema.ts`, `src/extension/registration/team-tool.ts`, `src/extension/team-tool/*.ts` (handler dispatch), or the subagent-tool registration. Optional but cheap for any change — the read-only actions are free.
615
615
 
@@ -1383,7 +1383,7 @@ Use this to answer "đủ full tính năng chưa?" without re-deriving. Every us
1383
1383
 
1384
1384
  | Feature | Code entry | Verify via |
1385
1385
  |---|---|---|
1386
- | Team tool — 56 actions / 5 domains | `src/schema/team-tool-schema.ts:391-437`, dispatch in `src/extension/team-tool/` | 9a (read-only) + 9b/9c/9d/9e/9f theo domain |
1386
+ | Team tool — 57 actions / 5 domains | `src/schema/team-tool-schema.ts:437-489`, dispatch in `src/extension/team-tool/` | 9a (read-only) + 9b/9c/9d/9e/9f theo domain |
1387
1387
  | Runtime mode `child-process` (default) | `src/runtime/child-pi/` | 9b sync run + T7 |
1388
1388
  | Runtime mode `scaffold` (dry-run) | `src/runtime/task-runner/pre-execution.ts:176` | 9b `action='plan'`/`'plans'` (preview không spawn) hoặc run với `runtime.mode='scaffold'` |
1389
1389
  | Runtime mode `live-session` (experimental) | `src/runtime/live-session/` | Run với `runtime.mode='live-session'` + irc tool xuất hiện trong worker (`src/runtime/custom-tools/irc-tool.ts`) |
@@ -1464,7 +1464,7 @@ The skill does NOT need to be updated for every commit — only when the cited l
1464
1464
  ## Quick reference — exact commands
1465
1465
 
1466
1466
  ```bash
1467
- # Tier 1 (critical unit, ~19s, 120 tests)
1467
+ # Tier 1 (critical unit, ~13s, 124 tests)
1468
1468
  npm run test:critical
1469
1469
  # Tier 2 (3-path proof, broker changes only)
1470
1470
  PI_CREW_BROKER=0 npm run test:critical
@@ -1548,7 +1548,7 @@ PY
1548
1548
 
1549
1549
  Before claiming "tested":
1550
1550
 
1551
- - [ ] Tier 1: `test:critical` fresh-run, all pass (<25s). Count varies by release — was 97 at v0.9.46, 101 at v0.9.66, 102 at the waitMethodsEnabled flip, 116 @ 2026-09-20, **120 @ 2026-10-07**; record the actual count in the report.
1551
+ - [ ] Tier 1: `test:critical` fresh-run, all pass (<25s). Count varies by release — was 97 at v0.9.46, 101 at v0.9.66, 102 at the waitMethodsEnabled flip, 116 @ 2026-09-20, 120 @ 2026-10-07, **124 @ 2026-10-18**; record the actual count in the report.
1552
1552
  - [ ] Tier 2: 3-path proof all pass — **required if you touched `src/config/defaults.ts` or `src/extension/registration/lifecycle-handlers.ts`**
1553
1553
  - [ ] Tier 3: `npm run typecheck` exit 0, `npm run build:bundle` exit 0
1554
1554
  - [ ] Tier 4: bundle md5 matches what the session loaded (or user has `/quit`-ed + reopened)
@@ -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 { LIMIT_CEILINGS } from "../config/config-validation.ts";
4
5
  import { getCrewEnv } from "../config/env-vars.ts";
5
6
  import { discoverProviderExtensionPaths } from "../runtime/model/provider-extensions.ts";
6
7
  import { parseCsv, parseFrontmatter } from "../utils/frontmatter.ts";
@@ -462,11 +463,16 @@ function parseAgentFile(filePath: string, source: ResourceSource): AgentConfig |
462
463
  contextMode,
463
464
  maxTurns: (() => {
464
465
  const n = Number.parseInt(frontmatter.maxTurns, 10);
465
- return Number.isFinite(n) && n > 0 ? n : undefined;
466
+ // R7-A3 (wave-2b): clamp to the config ceiling — a repo-sourced agent
467
+ // file with maxTurns: 1e9 became the effective run cap whenever
468
+ // runtime.maxTurns was unset (config paths already bound at 10k).
469
+ return Number.isFinite(n) && n > 0 ? Math.min(n, LIMIT_CEILINGS.runtimeMaxTurns) : undefined;
466
470
  })(),
467
471
  maxTokens: (() => {
468
472
  const n = Number.parseInt(frontmatter.maxTokens, 10);
469
- return Number.isFinite(n) && n > 0 ? n : undefined;
473
+ // R7-A3: same clamp class — budget-equivalent token counts use the
474
+ // handoff-budget ceiling (1M) as the family bound.
475
+ return Number.isFinite(n) && n > 0 ? Math.min(n, LIMIT_CEILINGS.runtimeHandoffBudgetTokens) : undefined;
470
476
  })(),
471
477
  effort:
472
478
  frontmatter.effort === "low" || frontmatter.effort === "medium" || frontmatter.effort === "high"
@@ -63,7 +63,9 @@ function validateConfigWithWarnings(raw: unknown): string[] {
63
63
  return [];
64
64
  }
65
65
 
66
- const LIMIT_CEILINGS = {
66
+ // wave-2b (R7-A3): exported so agent frontmatter parsing (discover-agents)
67
+ // clamps project-sourced values with the SAME ceilings the config paths use.
68
+ export const LIMIT_CEILINGS = {
67
69
  maxConcurrentWorkers: 1024,
68
70
  maxTaskDepth: 100,
69
71
  maxChildrenPerTask: 1000,
@@ -108,6 +110,64 @@ export const DANGEROUS_OBJECT_KEYS = new Set([
108
110
  "__lookupSetter__",
109
111
  ]);
110
112
 
113
+ /**
114
+ * Case-normalized view of DANGEROUS_OBJECT_KEYS. The canonical set keeps the
115
+ * real member spellings (toString, valueOf, …), but key matching must be
116
+ * lowercase on BOTH sides — `"valueOf".toLowerCase()` is `"valueof"`, which
117
+ * would never match a camelCase entry.
118
+ */
119
+ const DANGEROUS_KEYS_LOWER = new Set([...DANGEROUS_OBJECT_KEYS].map((key) => key.toLowerCase()));
120
+
121
+ /**
122
+ * True when `key` collides with a reserved Object.prototype member.
123
+ * Case-insensitive to catch `__Proto__`, `CONSTRUCTOR`, `TOSTRING`, etc.
124
+ */
125
+ export function isDangerousKey(key: string): boolean {
126
+ return DANGEROUS_KEYS_LOWER.has(key.toLowerCase());
127
+ }
128
+
129
+ /**
130
+ * True when ANY dot-separated segment of a dotted config path collides with a
131
+ * reserved Object.prototype member. This is the guard for dotted-path walkers
132
+ * (setNested/unsetPath): it catches the NESTED pollution vectors
133
+ * `__proto__.polluted` and `constructor.prototype.x` that single-key checks
134
+ * miss, because the walk hits `obj["__proto__"]` (the [[Prototype]] getter)
135
+ * long before the final segment is written.
136
+ */
137
+ export function isDangerousPath(dottedPath: string): boolean {
138
+ return dottedPath.split(".").some((segment) => segment.length > 0 && isDangerousKey(segment));
139
+ }
140
+
141
+ /**
142
+ * Deeply strip dangerous prototype-pollution keys from an arbitrary value
143
+ * (objects, arrays, primitives pass through). Returns a fresh plain-object
144
+ * tree that is safe to merge/assign at a parse boundary.
145
+ *
146
+ * M1-1 shared boundary contract: every boundary that accepts a user-supplied
147
+ * config patch, dotted key, or translation registration (team-settings
148
+ * set/unset, updateConfig unsetPaths, config/autonomy patch parsing, i18n
149
+ * addTranslations) routes through isDangerousPath/isDangerousKey/
150
+ * stripDangerousKeysDeep here — ONE canonical drop rule. This is
151
+ * BOUNDARY-LEVEL defense only; it must not grow handler-specific predicates:
152
+ * carrier keys of tool payloads (`args`, `scope`, `unset`, …) are
153
+ * intentionally NOT dangerous keys — never add them (RT24-F1 lesson: mirrored
154
+ * handler predicates drifted and over-blocked reads).
155
+ *
156
+ * Case-insensitive superset of the retired per-module strippers (which covered
157
+ * only `__proto__`/`constructor`/`prototype` case-sensitively): this drops the
158
+ * full DANGEROUS_OBJECT_KEYS set at every level.
159
+ */
160
+ export function stripDangerousKeysDeep<T>(value: T): T {
161
+ if (value === null || typeof value !== "object") return value;
162
+ if (Array.isArray(value)) return value.map((item) => stripDangerousKeysDeep(item)) as T;
163
+ const safe: Record<string, unknown> = {};
164
+ for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
165
+ if (isDangerousKey(key)) continue;
166
+ safe[key] = stripDangerousKeysDeep(child);
167
+ }
168
+ return safe as T;
169
+ }
170
+
111
171
  /**
112
172
  * Strips dangerous Object.prototype keys from an object.
113
173
  * Returns a new object built with Object.create(null) to prevent
@@ -6,7 +6,7 @@ import { withFileLockSync } from "../state/coordination/locks.ts";
6
6
  import { logInternalError } from "../utils/internal-error.ts";
7
7
  import { isJunkPathLiteral, projectCrewRoot, projectPiRoot } from "../utils/paths.ts";
8
8
  import { mergeConfig } from "./config-merge.ts";
9
- import { parseConfig, parseConfigWithWarnings } from "./config-validation.ts";
9
+ import { isDangerousKey, isDangerousPath, parseConfig, parseConfigWithWarnings } from "./config-validation.ts";
10
10
  import { DEFAULT_BROKER, DEFAULT_NESTING, DEFAULT_PERSISTENCE, resolveBrokerEnvOverride } from "./defaults.ts";
11
11
  import { getCrewEnv, getCrewEnvBool } from "./env-vars.ts";
12
12
  import { sanitizeProjectConfig } from "./sanitize-project-config.ts";
@@ -258,6 +258,13 @@ function resolvePersistenceEnvOverrideAndDefaults(parsed: PersistenceConfig | un
258
258
  }
259
259
 
260
260
  function unsetPath(record: Record<string, unknown>, dottedPath: string): void {
261
+ // R24-H2 (M1-1): walking a raw dotted key through `target["__proto__"]`
262
+ // resolves to Object.prototype via the [[Prototype]] getter, so a key like
263
+ // `__proto__.toString` DELETES Object.prototype members. Refuse dangerous
264
+ // segments at the walker itself (shared proto-safe helpers in
265
+ // config-validation.ts) — this covers
266
+ // every unsetPaths consumer (team-settings unset, team `config` action).
267
+ if (isDangerousPath(dottedPath)) return;
261
268
  const parts = dottedPath.split(".").filter(Boolean);
262
269
  if (parts.length === 0) return;
263
270
  let target: Record<string, unknown> = record;
@@ -305,6 +312,66 @@ function measureJsonDepth(root: unknown): number {
305
312
  return maxDepth;
306
313
  }
307
314
 
315
+ /**
316
+ * M3-13 (R45-F1): `parseConfig` is a strict WHITELIST builder (see
317
+ * config-validation.ts) — it returns an object literal of known keys only, so
318
+ * merging a patch into `parseConfig(current)` and writing the result back
319
+ * silently DELETES every unknown on-disk key (typos, forward-compat keys
320
+ * written by a newer version, keys owned by another tool). updateConfig used
321
+ * to do exactly that on every write.
322
+ *
323
+ * This helper re-attaches the raw on-disk shape underneath the normalized
324
+ * merge result: `parsed` wins for every key/value it defines (so mergeConfig's
325
+ * per-key semantics and its sanitizers keep applying to known keys), while raw
326
+ * keys that the whitelist dropped are carried through untouched.
327
+ *
328
+ * Dangerous prototype keys are NEVER carried through (M1-1/R24-H1: the parse
329
+ * boundary drops `__proto__`/`constructor`/`prototype` at every depth).
330
+ *
331
+ * @param parsed normalized merge result (what the whitelist understands)
332
+ * @param raw raw on-disk record, already with `unsetPaths` applied
333
+ * @returns a new record shaped like the on-disk file, preserving key order
334
+ */
335
+ function preserveUnknownKeys(parsed: Record<string, unknown>, raw: Record<string, unknown>): Record<string, unknown> {
336
+ const target = cloneRawRecord(raw, 0);
337
+ return overlayKnownKeys(target, parsed, 0);
338
+ }
339
+
340
+ const MAX_PRESERVED_DEPTH = 100;
341
+
342
+ function isPlainRecordObject(value: unknown): value is Record<string, unknown> {
343
+ return value !== null && typeof value === "object" && !Array.isArray(value);
344
+ }
345
+
346
+ /** Depth-bounded clone that keeps raw key order and skips dangerous keys. */
347
+ function cloneRawRecord(source: Record<string, unknown>, depth: number): Record<string, unknown> {
348
+ const out: Record<string, unknown> = {};
349
+ if (depth > MAX_PRESERVED_DEPTH) return out;
350
+ for (const [key, value] of Object.entries(source)) {
351
+ if (isDangerousKey(key)) continue;
352
+ out[key] = isPlainRecordObject(value) ? cloneRawRecord(value, depth + 1) : value;
353
+ }
354
+ return out;
355
+ }
356
+
357
+ /** Overlay `source` (normalized) onto `target` (raw clone), recursing into shared objects. */
358
+ function overlayKnownKeys(target: Record<string, unknown>, source: Record<string, unknown>, depth: number): Record<string, unknown> {
359
+ if (depth > MAX_PRESERVED_DEPTH) return target;
360
+ for (const [key, value] of Object.entries(source)) {
361
+ // `undefined` means "not set in this layer" (mergeConfig's withoutUndefined
362
+ // contract) — never write it over a preserved raw value.
363
+ if (value === undefined) continue;
364
+ if (isDangerousKey(key)) continue;
365
+ const existing = target[key];
366
+ if (isPlainRecordObject(value) && isPlainRecordObject(existing)) {
367
+ overlayKnownKeys(existing, value, depth + 1);
368
+ continue;
369
+ }
370
+ target[key] = value;
371
+ }
372
+ return target;
373
+ }
374
+
308
375
  function readConfigRecord(filePath: string): Record<string, unknown> {
309
376
  if (!fs.existsSync(filePath)) return {};
310
377
  // Defense-in-depth: reject config files larger than 10 MB before parsing.
@@ -462,7 +529,11 @@ export function updateConfig(patch: PiTeamsConfig, options: UpdateConfigOptions
462
529
  const message = error instanceof Error ? error.message : String(error);
463
530
  throw new Error(`Could not update pi-crew config: ${message}`);
464
531
  }
465
- let merged = mergeConfig(parseConfig(current), patch);
532
+ const normalizedCurrent = parseConfig(current);
533
+ // Known-key semantics (per-key merge + otlp header sanitization) stay in
534
+ // mergeConfig — see M3-13 below for why the WRITE target is rebuilt from
535
+ // the raw record instead of from this whitelist-normalized result.
536
+ let merged = mergeConfig(normalizedCurrent, patch);
466
537
  if (options.unsetPaths?.length) {
467
538
  const raw = JSON.parse(JSON.stringify(merged)) as Record<string, unknown>;
468
539
  for (const unset of options.unsetPaths) unsetPath(raw, unset);
@@ -472,12 +543,26 @@ export function updateConfig(patch: PiTeamsConfig, options: UpdateConfigOptions
472
543
  // (e.g. `team action='config'` with an empty patch — read-only path).
473
544
  // Both sides are parseConfig-normalized, so JSON.stringify key order is
474
545
  // deterministic (same construction path); no key sorting needed.
475
- const normalizedCurrent = parseConfig(current);
476
- if (JSON.stringify(merged) === JSON.stringify(normalizedCurrent)) {
546
+ const normalizedUnchanged = JSON.stringify(merged) === JSON.stringify(normalizedCurrent);
547
+ // M3-13 (R45-F1): parseConfig is a whitelist, so writing `merged` erased
548
+ // every unknown on-disk key (typos, forward-compat keys). Rebuild the
549
+ // written shape from the RAW record: known keys take the merged value,
550
+ // unknown keys survive untouched. `unsetPaths` are applied to the raw
551
+ // base too, so an explicit unset of an unknown key still removes it.
552
+ const rawBase = cloneRawRecord(current, 0);
553
+ for (const unset of options.unsetPaths ?? []) unsetPath(rawBase, unset);
554
+ const toWrite = preserveUnknownKeys(merged as unknown as Record<string, unknown>, rawBase);
555
+ // An unsetPaths entry may name a key the whitelist cannot see (an unknown
556
+ // key), in which case the normalized comparison above reports "unchanged"
557
+ // even though the raw record must change. Only that case overrides the
558
+ // skip: if applying the unsets left the raw base byte-identical, nothing
559
+ // outside the whitelist moved and the legacy skip stands.
560
+ const unsetsChangedRaw = JSON.stringify(rawBase) !== JSON.stringify(current);
561
+ if (normalizedUnchanged && !unsetsChangedRaw) {
477
562
  return { path: filePath, config: merged, written: false }; // unchanged — skip write
478
563
  }
479
564
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
480
- atomicWriteFile(filePath, `${JSON.stringify(merged, null, 2)}\n`);
565
+ atomicWriteFile(filePath, `${JSON.stringify(toWrite, null, 2)}\n`);
481
566
  // (F16) Invalidate the loadConfig cache after a write — the next
482
567
  // caller must see the new value, not a 0-2s stale snapshot.
483
568
  invalidateConfigCache();
@@ -69,6 +69,11 @@ export const ESSENTIAL_PROCESS_ENV_VARS: readonly string[] = [
69
69
  "XDG_DATA_HOME",
70
70
  "XDG_CACHE_HOME",
71
71
  "XDG_RUNTIME_DIR",
72
+ // R1-M1 (wave-2b): Node's fs.realpathSync.native / os.tmpdir / sqlite WAL
73
+ // all consult TMPDIR; a host with a non-default TMPDIR spawned children that
74
+ // diverged from it (temp paths under /tmp vs $TMPDIR) — pass it through so
75
+ // parent and children agree on the temp root.
76
+ "TMPDIR",
72
77
  ];
73
78
 
74
79
  /** Node/npm toolchain vars (nvm prefixes, npm registry/config). */