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
@@ -2,7 +2,7 @@ import type { AgentConfig } from "../../agents/agent-config.ts";
2
2
  import type { CrewReliabilityConfig, CrewRuntimeConfig } from "../../config/types.ts";
3
3
  import { appendEventAsync } from "../../state/event-log/event-log.ts";
4
4
  import { writeArtifact } from "../../state/stores/artifact-store.ts";
5
- import { saveRunTasksAsync, updateRunStatus } from "../../state/stores/state-store.ts";
5
+ import { loadRunManifestById, saveRunTasksAsync, updateRunStatus } from "../../state/stores/state-store.ts";
6
6
  import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
7
7
  import type { WorkflowStep } from "../../workflows/workflow-config.ts";
8
8
  import { createWorkerHeartbeat, touchWorkerHeartbeat } from "../heartbeat/worker-heartbeat.ts";
@@ -115,17 +115,12 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
115
115
  heartbeatInFlight = true;
116
116
  heartbeatPromise = (async () => {
117
117
  try {
118
- updatedTasks = updatedTasks.map((t) => {
119
- if (!taskIds.includes(t.id)) return t;
120
- // FIND-06 belt-and-suspenders: never replace terminal state or its
121
- // resultArtifact with a heartbeat-only snapshot.
122
- if (t.status === "completed" || t.status === "failed" || t.status === "cancelled") return t;
123
- return {
124
- ...t,
125
- heartbeat: touchWorkerHeartbeat(t.heartbeat ?? createWorkerHeartbeat(t.id), { alive: true }),
126
- };
127
- });
128
- await saveRunTasksAsync(manifest, updatedTasks);
118
+ // R15-3: persist heartbeats for ONLY this group's tasks via a fresh
119
+ // disk read. The old closure `updatedTasks` is a dispatch-time
120
+ // snapshot of the FULL task array — a sibling cancelled on disk after
121
+ // dispatch could be un-cancelled by a late heartbeat save (the map
122
+ // only mutated group tasks, but the SAVE wrote the whole stale array).
123
+ await persistGroupHeartbeats(manifest, taskIds);
129
124
  } catch {
130
125
  // Run may have been pruned mid-dispatch — best-effort only.
131
126
  } finally {
@@ -134,9 +129,12 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
134
129
  // save was in flight, re-write the (now-terminal) updatedTasks so
135
130
  // our late snapshot cannot leave stale pre-terminal state on disk.
136
131
  // Runs after our own save resolves, so it always lands last.
132
+ // R15-3: the repair now fresh-reads disk and re-applies ONLY the
133
+ // group's terminal state — the old full-array write could resurrect
134
+ // a sibling cancelled on disk (same flaw as the heartbeat save).
137
135
  if (finalWriteStarted) {
138
136
  try {
139
- await saveRunTasksAsync(manifest, updatedTasks);
137
+ await repairGroupTerminalWrite(manifest, taskIds, updatedTasks);
140
138
  } catch {
141
139
  // best-effort repair — same swallow policy as the heartbeat.
142
140
  }
@@ -298,6 +296,59 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
298
296
  return { manifest: updatedManifest, tasks: updatedTasks, taskIds, rawOutput, success };
299
297
  }
300
298
 
299
+ /**
300
+ * R15-3: heartbeat save scoped to this coalesced group only.
301
+ *
302
+ * Previously the heartbeat wrote the dispatch-time closure `updatedTasks` —
303
+ * the FULL task array including sibling tasks outside this group. A sibling
304
+ * cancelled on disk after dispatch (external cancel / reconciler) could be
305
+ * un-cancelled ("resurrected") by a late heartbeat save, because the map only
306
+ * mutated group tasks but the SAVE wrote the whole stale array. Fix: fresh
307
+ * re-read tasks from disk (loadRunManifestById — the established fresh-read
308
+ * pattern used by team-runner) and write back ONLY the group's own task ids
309
+ * with heartbeats touched; sibling tasks are preserved untouched from the
310
+ * fresh disk read. Keeps the FIND-06 'never replace terminal state' guard.
311
+ */
312
+ async function persistGroupHeartbeats(manifest: TeamRunManifest, taskIds: string[]): Promise<void> {
313
+ const fresh = loadRunManifestById(manifest.cwd, manifest.runId);
314
+ if (!fresh) return; // run pruned mid-dispatch — best-effort only.
315
+ const merged = fresh.tasks.map((t) => {
316
+ if (!taskIds.includes(t.id)) return t; // sibling tasks: preserved untouched.
317
+ // FIND-06 belt-and-suspenders: never replace terminal state or its
318
+ // resultArtifact with a heartbeat-only snapshot.
319
+ if (t.status === "completed" || t.status === "failed" || t.status === "cancelled") return t;
320
+ return {
321
+ ...t,
322
+ heartbeat: touchWorkerHeartbeat(t.heartbeat ?? createWorkerHeartbeat(t.id), { alive: true }),
323
+ };
324
+ });
325
+ await saveRunTasksAsync(manifest, merged);
326
+ }
327
+
328
+ /**
329
+ * R15-3: FIND-06 P1 repair — re-apply the group's terminal state after a late
330
+ * heartbeat save may have landed last. Fresh-reads disk so sibling tasks are
331
+ * preserved untouched; only the group's own terminal state from `updatedTasks`
332
+ * is re-applied, and a disk-terminal group task is never flipped (same
333
+ * belt-and-suspenders guard as the heartbeat).
334
+ */
335
+ async function repairGroupTerminalWrite(manifest: TeamRunManifest, taskIds: string[], updatedTasks: TeamTaskState[]): Promise<void> {
336
+ const fresh = loadRunManifestById(manifest.cwd, manifest.runId);
337
+ if (!fresh) return; // run pruned mid-dispatch — best-effort only.
338
+ const groupById = new Map(updatedTasks.map((t) => [t.id, t] as const));
339
+ const merged = fresh.tasks.map((t) => {
340
+ if (!taskIds.includes(t.id)) return t; // sibling tasks: preserved untouched.
341
+ if (t.status === "completed" || t.status === "failed" || t.status === "cancelled") return t;
342
+ return groupById.get(t.id) ?? t;
343
+ });
344
+ await saveRunTasksAsync(manifest, merged);
345
+ }
346
+
347
+ /** @internal 3.5 R15-3 test export — exercise the heartbeat/repair writes directly. */
348
+ export const __test__persistGroupHeartbeats = persistGroupHeartbeats;
349
+ /** @internal 3.5 R15-3 test export — exercise the terminal repair write directly. */
350
+ export const __test__repairGroupTerminalWrite = repairGroupTerminalWrite;
351
+
301
352
  async function buildCoalescedPrompt(
302
353
  manifest: TeamRunManifest,
303
354
  step: WorkflowStep,
@@ -48,16 +48,6 @@ export function buildTaskGraphIndex(tasks: TeamTaskState[]): TaskGraphIndex {
48
48
  return fresh;
49
49
  }
50
50
 
51
- /** Test/diagnostic helper — invalidate the WeakMap-backed index cache. Not
52
- * used by production code paths; the WeakMap self-invalidates when a tasks
53
- * array goes out of scope. Provided for parity with `clearStablePrefixCache`. */
54
- export function clearTaskGraphIndexCache(): void {
55
- // WeakMap has no `.clear()`; rely on GC. Exposed as a no-op stub so callers
56
- // that import `clearStablePrefixCache` (also a no-op stub for symmetry) can
57
- // adopt a parallel API if needed in the future. Documented as no-op rather
58
- // than removed so the API stays discoverable.
59
- }
60
-
61
51
  function taskById(tasks: TeamTaskState[]): Map<string, TeamTaskState> {
62
52
  return new Map(tasks.map((task) => [task.id, task]));
63
53
  }
@@ -1,6 +1,8 @@
1
1
  import * as fs from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import * as path from "node:path";
4
+ import { projectOverrideWarning, sanitizeProjectConfig } from "../config/sanitize-project-config.ts";
5
+ import type { PiTeamsConfig } from "../config/types.ts";
4
6
  import { atomicWriteJson } from "../state/atomic-write.ts";
5
7
  import { withFileLockSync } from "../state/coordination/locks.ts";
6
8
  import { logInternalError } from "../utils/internal-error.ts";
@@ -12,6 +14,11 @@ export interface CrewSettings {
12
14
  graceTurns?: number;
13
15
  defaultJoinMode?: JoinMode;
14
16
  schedulingEnabled?: boolean;
17
+ /** Wave B2: user-tier-only opt-in for project-tier scheduledJobs. Semantics
18
+ * are enforced at the tiers layer (loadCrewSettingsTiers), NOT here — the
19
+ * flat sanitizeSettings allowlist must merely preserve the flag so the
20
+ * opt-in survives the read of the user-tier global file. */
21
+ allowProjectScheduledJobs?: boolean;
15
22
  notifierIntervalMs?: number;
16
23
  /** Scheduled jobs loaded from settings — opaque, passed to crewScheduler */
17
24
  scheduledJobs?: unknown[];
@@ -61,6 +68,9 @@ function sanitizeSettings(raw: unknown): CrewSettings {
61
68
  if (typeof r.schedulingEnabled === "boolean") {
62
69
  out.schedulingEnabled = r.schedulingEnabled;
63
70
  }
71
+ if (typeof r.allowProjectScheduledJobs === "boolean") {
72
+ out.allowProjectScheduledJobs = r.allowProjectScheduledJobs;
73
+ }
64
74
  if (typeof r.notifierIntervalMs === "number" && r.notifierIntervalMs >= 1000) {
65
75
  out.notifierIntervalMs = r.notifierIntervalMs;
66
76
  }
@@ -89,6 +99,21 @@ function readSettingsFile(filePath: string): CrewSettings {
89
99
  }
90
100
  }
91
101
 
102
+ /**
103
+ * LEGACY merged view — USER-TIER-ONLY ingestion path (P1 fix follow-up, bug-026 adjacent).
104
+ *
105
+ * Merges the user-tier global file OVER the project file into one flat object.
106
+ * This view is trusted and MUST NOT be applied to live config anymore: the
107
+ * project fragment bypasses `sanitizeProjectConfig`. The current consumer
108
+ * path is `loadCrewSettingsTiers()` + `applyCrewSettingsTiersToConfig()`
109
+ * (project fragment routed through the schema-driven tiering, Wave 2B).
110
+ *
111
+ * If you need to read crew settings for CONFIG APPLICATION, use the tiers
112
+ * API — never this function. Kept exported for back-compat reads/tests;
113
+ * adding a new consumer that writes into loadedConfig re-introduces the
114
+ * fixed P1 bypass (see docs/decisions/2026-08-15-schema-driven-sanitize.md
115
+ * and the Wave 2B execution log in docs/refactor-plan.md).
116
+ */
92
117
  export function loadCrewSettings(cwd: string = process.cwd()): CrewSettings {
93
118
  return {
94
119
  ...readSettingsFile(globalPath()),
@@ -135,6 +160,21 @@ export function updateCrewSettings(cwd: string, mutator: (settings: CrewSettings
135
160
  });
136
161
  }
137
162
 
163
+ /**
164
+ * LEGACY direct-apply — USER-TIER-ONLY (P1 fix follow-up).
165
+ *
166
+ * Writes settings straight into a config object with NO sanitize tiering.
167
+ * Applying a merged (user+project) `CrewSettings` through this function is
168
+ * exactly the P1 bypass fixed in Wave 2B: project-tier values would land in
169
+ * guard fields (`limits.maxConcurrentWorkers`, `runtime.maxTurns`, ...) with
170
+ * no conditional drops. The tiered replacement is
171
+ * `applyCrewSettingsTiersToConfig()` which routes the project fragment
172
+ * through `sanitizeProjectConfig` + tighten-only comparison.
173
+ *
174
+ * Do NOT add new call sites that pass a merged/project-influenced settings
175
+ * object. Acceptable use: tests, and user-tier-only tooling that has already
176
+ * separated tiers.
177
+ */
138
178
  export function applyCrewSettingsToConfig(
139
179
  config: {
140
180
  limits?: { maxConcurrentWorkers?: number };
@@ -153,3 +193,182 @@ export function applyCrewSettingsToConfig(
153
193
  if (settings.defaultJoinMode != null && config.runtime) config.runtime.groupJoin = settings.defaultJoinMode;
154
194
  if (settings.notifierIntervalMs != null) config.notifierIntervalMs = settings.notifierIntervalMs;
155
195
  }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Wave 2B (ITEM 1, P1 security): tiered loading + guarded apply.
199
+ //
200
+ // `<cwd>/.pi/crew-settings.json` is PROJECT-tier state — a cloned repository
201
+ // can ship it. `loadCrewSettings()` historically merged it OVER the user-tier
202
+ // `~/.pi/crew-settings.json` with only the type/range allowlist
203
+ // (`sanitizeSettings`), and `applyCrewSettingsToConfig` then wrote the merged
204
+ // result into guard fields (limits.maxConcurrentWorkers,
205
+ // runtime.maxTurns/graceTurns) AFTER `loadConfig()` had already sanitized the
206
+ // regular project config files — a full bypass of `sanitizeProjectConfig`.
207
+ // The functions below close that hole.
208
+ // ---------------------------------------------------------------------------
209
+
210
+ /** Tiered view of crew settings (see `loadCrewSettingsTiers`). */
211
+ export interface CrewSettingsTiers {
212
+ /** User-tier `~/.pi/crew-settings.json` — fully trusted. */
213
+ user: CrewSettings;
214
+ /** Project-tier `<cwd>/.pi/crew-settings.json` — UNTRUSTED input. */
215
+ project: CrewSettings;
216
+ /** Historical merge view (project wins over user). Kept for the write-path
217
+ * round-trip (`updateCrewSettings`). NOT the registration source for
218
+ * scheduledJobs anymore — use `effectiveScheduledJobs` (Wave B2 gate). */
219
+ merged: CrewSettings;
220
+ /** Wave B2 GATE — what consumers register: user-tier scheduledJobs plus
221
+ * project-tier scheduledJobs ONLY when the user tier explicitly opted in
222
+ * (user.schedulingEnabled === true && user.allowProjectScheduledJobs ===
223
+ * true). User-first concat order. */
224
+ effectiveScheduledJobs: unknown[];
225
+ /** Absolute path of the project-tier file (used in warnings). */
226
+ projectPath: string;
227
+ }
228
+
229
+ /** Wave B2 gate predicate: the user tier opts in to project-tier scheduledJobs
230
+ * only when BOTH flags are explicitly `true` in the user-tier global file.
231
+ * Any other combination (flags absent, either false) opts OUT. */
232
+ function projectScheduledJobsOptIn(user: CrewSettings): boolean {
233
+ return user.schedulingEnabled === true && user.allowProjectScheduledJobs === true;
234
+ }
235
+
236
+ /**
237
+ * Load crew settings keeping the trust tiers distinguishable. The optional
238
+ * `globalFile` parameter defaults to `~/.pi/crew-settings.json` and exists so
239
+ * callers/tests can pin a hermetic user-tier file.
240
+ */
241
+ export function loadCrewSettingsTiers(cwd: string = process.cwd(), globalFile: string = globalPath()): CrewSettingsTiers {
242
+ const user = readSettingsFile(globalFile);
243
+ const projectFilePath = projectPath(cwd);
244
+ const project = readSettingsFile(projectFilePath);
245
+ const effectiveScheduledJobs: unknown[] = [
246
+ ...(user.scheduledJobs ?? []),
247
+ ...(projectScheduledJobsOptIn(user) ? (project.scheduledJobs ?? []) : []),
248
+ ];
249
+ return { user, project, merged: { ...user, ...project }, effectiveScheduledJobs, projectPath: projectFilePath };
250
+ }
251
+
252
+ /**
253
+ * Apply crew settings to a config with correct trust tiering. Returns the
254
+ * list of drop warnings (standard project-override format).
255
+ *
256
+ * Pipeline for the PROJECT tier (the user tier is fully trusted and applied
257
+ * exactly like the legacy merged semantics):
258
+ * 1. CHOKE POINT — the fragment is mapped into `PiTeamsConfig` shape and run
259
+ * through `sanitizeProjectConfig`, the same schema-driven sanitize every
260
+ * other project-scoped config ingestion passes through (config.ts applies
261
+ * it to pi-teams.json and .pi/pi-crew.json). Today none of the mapped
262
+ * fields are sensitive-marked, so this is behavior-preserving — but any
263
+ * future `sensitive: true` mark or CONDITIONAL_PROJECT_DROPS entry on
264
+ * these paths automatically applies to crew-settings too.
265
+ * 2. TIGHTEN-ONLY TIERING — `limits.maxConcurrentWorkers`,
266
+ * `runtime.maxTurns` and `runtime.graceTurns` are resource guards: a
267
+ * project-tier value that RAISES the effective value (baseline = config
268
+ * after the trusted user tier was applied) is dropped with the standard
269
+ * warning; lowering/equal survives. A value with NO baseline is also
270
+ * dropped: introducing a limit the user never set cannot be verified as
271
+ * tightening, and the only legitimate writer (updateCrewSettings
272
+ * spreading merged settings into the project file) always mirrors a
273
+ * user-tier value, so the equal case survives. This comparison lives HERE
274
+ * and not in CONDITIONAL_PROJECT_DROPS: that table is a fixed value
275
+ * predicate with no access to the effective-config baseline (Wave 2B
276
+ * decision — predicate signature intentionally unchanged).
277
+ * 3. Survivors are applied over the user tier — safe by construction since
278
+ * survivors can only be equal-or-lower guard values.
279
+ *
280
+ * BOUNDARY (Wave B2 — was ITEM 1.4): scheduling is a USER-TIER decision.
281
+ * `schedulingEnabled` is never honored from the project tier — a project file
282
+ * can neither enable nor disable scheduling (no consumer reads it in src/
283
+ * today; if one is added it MUST read only the user tier). Project-tier
284
+ * `scheduledJobs` are DROPPED (warning emitted below + left out of the gated
285
+ * `effectiveScheduledJobs` view that the scheduler registration consumer
286
+ * loops) unless the user-tier global file explicitly opts in with BOTH
287
+ * `schedulingEnabled: true` AND `allowProjectScheduledJobs: true`. The
288
+ * tighten-only philosophy is intentionally NOT applied here: jobs are not
289
+ * numeric bounds — allowing them is an availability decision the human user
290
+ * makes in their global file. UX consequence (accepted): `crew schedule
291
+ * add/update/remove` persists jobs into the PROJECT file (handle-schedule.ts),
292
+ * so crew-schedule users must set both flags in ~/.pi/crew-settings.json or
293
+ * their own jobs stop registering on session_start.
294
+ *
295
+ * `notifierIntervalMs` (UI poll cadence) and `runtime.groupJoin` (join
296
+ * semantics) are not guard fields and ride the choke point only.
297
+ */
298
+ export function applyCrewSettingsTiersToConfig(config: PiTeamsConfig, tiers: CrewSettingsTiers): string[] {
299
+ const warnings: string[] = [];
300
+ // User tier: fully trusted, identical to the legacy merged semantics.
301
+ applyCrewSettingsToConfig(config, tiers.user);
302
+
303
+ const project = tiers.project;
304
+ // Wave B2 scheduling gate — MUST run BEFORE the empty-fragment early return
305
+ // below: a project file containing ONLY scheduling fields would otherwise
306
+ // skip the warnings entirely. Both warnings are warning-only: neither field
307
+ // maps into PiTeamsConfig; registration consumers read the gated
308
+ // `effectiveScheduledJobs` view instead.
309
+ if (project.schedulingEnabled !== undefined) {
310
+ // Project can neither enable nor disable scheduling — always dropped.
311
+ warnings.push(projectOverrideWarning(tiers.projectPath, "schedulingEnabled"));
312
+ }
313
+ if (!projectScheduledJobsOptIn(tiers.user) && Array.isArray(project.scheduledJobs) && project.scheduledJobs.length > 0) {
314
+ warnings.push(projectOverrideWarning(tiers.projectPath, "scheduledJobs"));
315
+ }
316
+ if (
317
+ project.maxConcurrent == null &&
318
+ project.defaultMaxTurns == null &&
319
+ project.graceTurns == null &&
320
+ project.defaultJoinMode == null &&
321
+ project.notifierIntervalMs == null
322
+ ) {
323
+ return warnings;
324
+ }
325
+
326
+ const fragment: PiTeamsConfig = {};
327
+ if (project.maxConcurrent != null) fragment.limits = { maxConcurrentWorkers: project.maxConcurrent };
328
+ if (project.defaultMaxTurns != null || project.graceTurns != null || project.defaultJoinMode != null) {
329
+ const runtime: NonNullable<PiTeamsConfig["runtime"]> = {};
330
+ if (project.defaultMaxTurns != null) runtime.maxTurns = project.defaultMaxTurns;
331
+ if (project.graceTurns != null) runtime.graceTurns = project.graceTurns;
332
+ // Legacy quirk (kept for behavior parity): crew-settings JoinMode allows
333
+ // "async", which is not part of runtime.groupJoin's union — the old
334
+ // structural param type let this through implicitly.
335
+ if (project.defaultJoinMode != null)
336
+ runtime.groupJoin = project.defaultJoinMode as NonNullable<PiTeamsConfig["runtime"]>["groupJoin"];
337
+ fragment.runtime = runtime;
338
+ }
339
+ if (project.notifierIntervalMs != null) fragment.notifierIntervalMs = project.notifierIntervalMs;
340
+
341
+ const sanitized = sanitizeProjectConfig(tiers.projectPath, config, fragment);
342
+ warnings.push(...sanitized.warnings);
343
+
344
+ const guards: Array<{ dotted: string; value: number | undefined; baseline: number | undefined }> = [
345
+ {
346
+ dotted: "limits.maxConcurrentWorkers",
347
+ value: sanitized.config.limits?.maxConcurrentWorkers,
348
+ baseline: config.limits?.maxConcurrentWorkers,
349
+ },
350
+ { dotted: "runtime.maxTurns", value: sanitized.config.runtime?.maxTurns, baseline: config.runtime?.maxTurns },
351
+ { dotted: "runtime.graceTurns", value: sanitized.config.runtime?.graceTurns, baseline: config.runtime?.graceTurns },
352
+ ];
353
+ const dropped = new Set<string>();
354
+ for (const guard of guards) {
355
+ if (guard.value === undefined) continue;
356
+ if (guard.baseline === undefined || guard.value > guard.baseline) {
357
+ dropped.add(guard.dotted);
358
+ warnings.push(projectOverrideWarning(tiers.projectPath, guard.dotted));
359
+ }
360
+ }
361
+
362
+ if (!dropped.has("limits.maxConcurrentWorkers") && sanitized.config.limits?.maxConcurrentWorkers != null && config.limits) {
363
+ config.limits.maxConcurrentWorkers = sanitized.config.limits.maxConcurrentWorkers;
364
+ }
365
+ if (sanitized.config.runtime != null && config.runtime) {
366
+ if (!dropped.has("runtime.maxTurns") && sanitized.config.runtime.maxTurns != null)
367
+ config.runtime.maxTurns = sanitized.config.runtime.maxTurns;
368
+ if (!dropped.has("runtime.graceTurns") && sanitized.config.runtime.graceTurns != null)
369
+ config.runtime.graceTurns = sanitized.config.runtime.graceTurns;
370
+ if (sanitized.config.runtime.groupJoin != null) config.runtime.groupJoin = sanitized.config.runtime.groupJoin;
371
+ }
372
+ if (sanitized.config.notifierIntervalMs != null) config.notifierIntervalMs = sanitized.config.notifierIntervalMs;
373
+ return warnings;
374
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Spawn policy for delegate-driven grandchild admission (ADR-5, WP-5 step 3).
3
+ *
4
+ * Pure evaluation — no I/O, no state mutation. The root-side delegate handler
5
+ * (broker `delegate` method, WP-5 step 5) gathers the inputs and calls
6
+ * `evaluateDelegateAdmission`; on denial it returns the fail-fast message to
7
+ * the worker and logs `delegate.rejected` to events.jsonl (never silent).
8
+ *
9
+ * Gate dimensions (each with a distinct machine-readable reason + fail-fast
10
+ * message; evaluated in this order — cheapest/most-global first):
11
+ * 1. nesting-disabled — `nesting.enabled` is false (DEFAULT; fail-closed)
12
+ * 2. role-denied — the PARENT task's role is not executor-class
13
+ * (read-only roles cannot raise spawn privileges),
14
+ * or the requested grandchild role is not one of
15
+ * the delegate-tool's allowed roles
16
+ * 3. trust-denied — untrusted escalation context (manual-only trust
17
+ * gate; the handler resolves trust, policy enforces)
18
+ * 4. depth-exceeded — child depth (parent.depth + 1) exceeds the
19
+ * resolved maxDepth (default 2). Depth comes from
20
+ * the parent task RECORD, never worker env
21
+ * (design §7 rev-2 P0-2)
22
+ * 5. slots-exhausted — nested-slot budget exhausted ("N/M in flight");
23
+ * fail-fast, never queue
24
+ * 6. budget-insufficient— requested budgetTokens exceed the parent task's
25
+ * remaining allocation (tokensGranted - tokensSpent)
26
+ * 7. model-invalid — requested model is not in the resolved model
27
+ * catalog (the unvalidated provider/model
28
+ * pass-through — model-fallback.ts:282, the
29
+ * 429-cascade root — must not be reachable here)
30
+ * 8. timeout-invalid — timeoutSec outside 1..MAX (default 900)
31
+ */
32
+
33
+ import { modelRefToString } from "./model/model-fallback.ts";
34
+ import type { NestedSlotSnapshot } from "./scheduling/nested-slots.ts";
35
+
36
+ /** Delegate tool's allowed grandchild roles (design §7 surface). */
37
+ export const DELEGATE_ALLOWED_ROLES = ["explorer", "analyst", "executor"] as const;
38
+ export type DelegateRole = (typeof DELEGATE_ALLOWED_ROLES)[number];
39
+
40
+ /**
41
+ * Roles permitted to CALL delegate (executor-class). Read-only/review roles
42
+ * (explorer, analyst, planner, critic, reviewer, verifier, writer,
43
+ * security-reviewer) are denied — delegation is a spawn privilege.
44
+ */
45
+ export const EXECUTOR_CLASS_ROLES = ["executor", "test-engineer"] as const;
46
+
47
+ export const DEFAULT_DELEGATE_TIMEOUT_SEC = 900;
48
+ export const MAX_DELEGATE_TIMEOUT_SEC = 86_400;
49
+
50
+ export type SpawnPolicyDenyReason =
51
+ | "nesting-disabled"
52
+ | "role-denied"
53
+ | "trust-denied"
54
+ | "depth-exceeded"
55
+ | "slots-exhausted"
56
+ | "budget-insufficient"
57
+ | "model-invalid"
58
+ | "timeout-invalid"
59
+ | "workspace-conflict";
60
+
61
+ export interface DelegateAdmissionInput {
62
+ /** Resolved `nesting.enabled` config (DEFAULT false — fail-closed, ADR-5 §10). */
63
+ nestingEnabled: boolean;
64
+ /** Resolved max depth (nesting.maxDepth default 2; env clamp mirrors PI_CREW_MAX_DEPTH 1..10). */
65
+ maxDepth: number;
66
+ /** Parent task record fields the policy is allowed to trust. */
67
+ parentTask: {
68
+ taskId: string;
69
+ role: string;
70
+ /** Absent on pre-v2 records — treated as depth 1 (a worker). */
71
+ depth?: number;
72
+ allocation?: { tokensGranted?: number; tokensSpent?: number };
73
+ };
74
+ /** Untrusted escalation context — the delegate handler resolves trust. */
75
+ untrusted?: boolean;
76
+ /** Nested-slot budget snapshot (used/max). */
77
+ slots: NestedSlotSnapshot;
78
+ /** Requested grandchild parameters (all optional in the tool surface). */
79
+ requested?: {
80
+ role?: string;
81
+ model?: string;
82
+ budgetTokens?: number;
83
+ timeoutSec?: number;
84
+ };
85
+ /**
86
+ * Resolved model catalog as canonical `provider/id` strings. When omitted
87
+ * (catalog unavailable at the call-site) model validation is SKIPPED —
88
+ * flag this in review; the broker handler must always supply it.
89
+ */
90
+ modelCatalog?: readonly string[];
91
+ /** ADR-5 §9 workspace interaction: when the requested grandchild role is
92
+ * write-capable (executor-class), an overlapping in-flight executor can
93
+ * only be tolerated when serialization is established. `serializeEnabled`
94
+ * mirrors config `limits.serializeOnPathOverlap`; `overlappingInFlightExecutors`
95
+ * counts OTHER running executor-class tasks sharing the parent task's cwd. */
96
+ workspace?: {
97
+ serializeEnabled?: boolean;
98
+ overlappingInFlightExecutors?: number;
99
+ };
100
+ }
101
+
102
+ export interface SpawnPolicyDecision {
103
+ allowed: boolean;
104
+ /** Machine-readable deny reason (undefined when allowed). */
105
+ reason?: SpawnPolicyDenyReason;
106
+ /** Fail-fast human message (undefined when allowed). */
107
+ message?: string;
108
+ /** The grandchild's depth (parent depth + 1) on success. */
109
+ childDepth?: number;
110
+ /** Effective timeout in seconds (requested or default 900) on success. */
111
+ timeoutSec?: number;
112
+ /** Effective model (normalized) on success. */
113
+ model?: string;
114
+ }
115
+
116
+ export function isExecutorClassRole(role: string): boolean {
117
+ return (EXECUTOR_CLASS_ROLES as readonly string[]).includes(role);
118
+ }
119
+
120
+ function deny(reason: SpawnPolicyDenyReason, message: string): SpawnPolicyDecision {
121
+ return { allowed: false, reason, message };
122
+ }
123
+
124
+ export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnPolicyDecision {
125
+ const { nestingEnabled, maxDepth, parentTask, requested } = input;
126
+ const parentDepth = parentTask.depth ?? 1; // pre-v2 records: workers are depth 1
127
+ const childDepth = parentDepth + 1;
128
+
129
+ // 1. Flag gate — fail-closed default (ADR-5 §10).
130
+ if (!nestingEnabled) {
131
+ return deny(
132
+ "nesting-disabled",
133
+ "delegate rejected: governed nesting is disabled (nesting.enabled=false, the fail-closed default; enable it in USER config)",
134
+ );
135
+ }
136
+
137
+ // 2. Role gate — parent must be executor-class; requested role must be in
138
+ // the tool's allowed set.
139
+ if (!isExecutorClassRole(parentTask.role)) {
140
+ return deny(
141
+ "role-denied",
142
+ `delegate rejected: role '${parentTask.role}' is not executor-class (${EXECUTOR_CLASS_ROLES.join("/")}) — read-only roles cannot delegate`,
143
+ );
144
+ }
145
+ const requestedRole = requested?.role;
146
+ if (requestedRole !== undefined && !(DELEGATE_ALLOWED_ROLES as readonly string[]).includes(requestedRole)) {
147
+ return deny(
148
+ "role-denied",
149
+ `delegate rejected: requested grandchild role '${requestedRole}' is not one of ${DELEGATE_ALLOWED_ROLES.join("/")}`,
150
+ );
151
+ }
152
+
153
+ // 3. Trust gate — untrusted escalation context never spawns.
154
+ if (input.untrusted) {
155
+ return deny("trust-denied", "delegate rejected: untrusted escalation context (trust gate is manual-only)");
156
+ }
157
+
158
+ // 4. Depth gate — computed from the parent RECORD (never worker env).
159
+ if (childDepth > maxDepth) {
160
+ return deny(
161
+ "depth-exceeded",
162
+ `delegate rejected: spawn depth ${childDepth} exceeds maxDepth ${maxDepth} (parent task '${parentTask.taskId}' is at depth ${parentDepth})`,
163
+ );
164
+ }
165
+
166
+ // 5. Nested-slot budget — fail-fast, never queue.
167
+ if (input.slots.used >= input.slots.max) {
168
+ return deny(
169
+ "slots-exhausted",
170
+ `delegate rejected: nested spawn budget exhausted; ${input.slots.used}/${input.slots.max} in flight`,
171
+ );
172
+ }
173
+
174
+ // 6. Parent allocation sufficiency — reserve up-front or reject.
175
+ if (requested?.budgetTokens !== undefined) {
176
+ const granted = parentTask.allocation?.tokensGranted ?? 0;
177
+ const spent = parentTask.allocation?.tokensSpent ?? 0;
178
+ const remaining = granted - spent;
179
+ if (requested.budgetTokens > remaining) {
180
+ return deny(
181
+ "budget-insufficient",
182
+ `delegate rejected: requested budget ${requested.budgetTokens} tokens exceeds parent task '${parentTask.taskId}' remaining allocation ${remaining} (${granted} granted - ${spent} spent)`,
183
+ );
184
+ }
185
+ }
186
+
187
+ // 7. Model validation against the resolved catalog.
188
+ const normalizedModel = requested?.model !== undefined ? modelRefToString(requested.model) : undefined;
189
+ if (normalizedModel !== undefined && input.modelCatalog !== undefined) {
190
+ if (!input.modelCatalog.includes(normalizedModel)) {
191
+ return deny("model-invalid", `delegate rejected: model '${requested?.model}' is not in the resolved model catalog`);
192
+ }
193
+ }
194
+
195
+ // 8. Timeout validation (mandatory default 900).
196
+ const timeoutSec = requested?.timeoutSec ?? DEFAULT_DELEGATE_TIMEOUT_SEC;
197
+ if (!Number.isFinite(timeoutSec) || timeoutSec < 1 || timeoutSec > MAX_DELEGATE_TIMEOUT_SEC) {
198
+ return deny(
199
+ "timeout-invalid",
200
+ `delegate rejected: timeoutSec ${String(requested?.timeoutSec)} is outside 1..${MAX_DELEGATE_TIMEOUT_SEC} (default ${DEFAULT_DELEGATE_TIMEOUT_SEC})`,
201
+ );
202
+ }
203
+
204
+ // 9. Workspace interaction (ADR-5 §9): a write-capable grandchild sharing
205
+ // the parent's cwd with ANOTHER in-flight executor is only admitted when
206
+ // serialization is established (limits.serializeOnPathOverlap) — otherwise
207
+ // reject; read-only grandchild roles (explorer/analyst) never conflict.
208
+ const requestedIsWriteCapable = requestedRole !== undefined && (EXECUTOR_CLASS_ROLES as readonly string[]).includes(requestedRole);
209
+ if (requestedIsWriteCapable && !input.workspace?.serializeEnabled && (input.workspace?.overlappingInFlightExecutors ?? 0) > 0) {
210
+ return deny(
211
+ "workspace-conflict",
212
+ `delegate rejected: parent task '${parentTask.taskId}' cwd overlaps ${input.workspace?.overlappingInFlightExecutors} in-flight executor(s) and limits.serializeOnPathOverlap is off — use a read-only grandchild role (explorer/analyst) or enable serialization`,
213
+ );
214
+ }
215
+
216
+ return { allowed: true, childDepth, timeoutSec, ...(normalizedModel !== undefined ? { model: normalizedModel } : {}) };
217
+ }