pi-crew 0.9.68 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (253) hide show
  1. package/CHANGELOG.md +222 -0
  2. package/NOTICE.md +21 -0
  3. package/README.md +44 -2
  4. package/agents/analyst.md +1 -1
  5. package/agents/cold-verifier.md +3 -1
  6. package/agents/critic.md +1 -1
  7. package/agents/executor.md +1 -1
  8. package/agents/explorer.md +1 -1
  9. package/agents/planner.md +1 -1
  10. package/agents/reviewer.md +1 -1
  11. package/agents/security-reviewer.md +1 -1
  12. package/agents/test-engineer.md +1 -1
  13. package/agents/verifier.md +1 -1
  14. package/agents/writer.md +1 -1
  15. package/dist/index.mjs +68113 -60774
  16. package/docs/README.md +2 -0
  17. package/docs/actions-reference.md +31 -0
  18. package/docs/commands-reference.md +17 -6
  19. package/docs/resource-formats.md +13 -0
  20. package/package.json +4 -2
  21. package/schema.json +503 -91
  22. package/scripts/resource-sampler.mjs +36 -2
  23. package/skills/requirements-to-task-packet/SKILL.md +26 -0
  24. package/skills/widget-rendering/SKILL.md +7 -7
  25. package/src/agents/agent-config.ts +2 -1
  26. package/src/agents/discover-agents.ts +23 -14
  27. package/src/config/config-merge.ts +183 -0
  28. package/src/config/config-validation.ts +687 -0
  29. package/src/config/config.ts +22 -864
  30. package/src/config/defaults.ts +43 -2
  31. package/src/config/drift-detector.ts +1 -1
  32. package/src/config/env-vars.ts +691 -0
  33. package/src/config/role-tools.ts +11 -9
  34. package/src/config/sanitize-project-config.ts +172 -0
  35. package/src/config/types.ts +49 -1
  36. package/src/extension/async-notifier.ts +25 -2
  37. package/src/extension/crew-cleanup.ts +13 -0
  38. package/src/extension/crew-vibes/config.ts +2 -1
  39. package/src/extension/crew-vibes/footer.ts +19 -0
  40. package/src/extension/crew-vibes/index.ts +11 -1
  41. package/src/extension/plan-orchestrate.ts +132 -0
  42. package/src/extension/register.ts +8 -0
  43. package/src/extension/registration/command-registration.ts +1 -0
  44. package/src/extension/registration/commands/dashboard.ts +158 -0
  45. package/src/extension/registration/commands/index.ts +35 -0
  46. package/src/extension/registration/commands/manage.ts +303 -0
  47. package/src/extension/registration/commands/run.ts +228 -0
  48. package/src/extension/registration/commands/shared.ts +639 -0
  49. package/src/extension/registration/commands/status.ts +60 -0
  50. package/src/extension/registration/commands.ts +13 -1224
  51. package/src/extension/registration/foreground-run-controller.ts +10 -2
  52. package/src/extension/registration/lifecycle-handlers.ts +178 -17
  53. package/src/extension/registration/runtime-cleanup.ts +23 -5
  54. package/src/extension/registration/subagent-tools.ts +218 -9
  55. package/src/extension/registration/team-tool.ts +5 -1
  56. package/src/extension/registration/ui.ts +5 -4
  57. package/src/extension/rpc-hmac.ts +5 -3
  58. package/src/extension/team-tool/api/heartbeat.ts +47 -10
  59. package/src/extension/team-tool/api/plan-approval.ts +9 -0
  60. package/src/extension/team-tool/api/task-claims.ts +109 -40
  61. package/src/extension/team-tool/cancel.ts +84 -50
  62. package/src/extension/team-tool/dispatch/index.ts +1 -0
  63. package/src/extension/team-tool/dispatch/run.ts +4 -1
  64. package/src/extension/team-tool/doctor.ts +103 -1
  65. package/src/extension/team-tool/orchestrate.ts +66 -1
  66. package/src/extension/team-tool/plans.ts +192 -0
  67. package/src/extension/team-tool/respond.ts +197 -65
  68. package/src/extension/team-tool/run-deadline.ts +35 -3
  69. package/src/extension/team-tool/run-intent.ts +63 -0
  70. package/src/extension/team-tool/run.ts +74 -20
  71. package/src/extension/team-tool/status.ts +84 -26
  72. package/src/extension/team-tool.ts +11 -2
  73. package/src/hooks/registry.ts +1 -6
  74. package/src/i18n.ts +9 -0
  75. package/src/prompt/prompt-runtime.ts +521 -2
  76. package/src/prompt/worker-events-channel.ts +173 -0
  77. package/src/runtime/README.md +8 -8
  78. package/src/runtime/async-runner.ts +7 -3
  79. package/src/runtime/background-runner.ts +42 -14
  80. package/src/runtime/broker/broker-issuer.ts +9 -2
  81. package/src/runtime/broker/crew-broker-tokens.ts +43 -6
  82. package/src/runtime/broker/crew-broker.ts +838 -10
  83. package/src/runtime/broker/wait-status-cache.ts +157 -0
  84. package/src/runtime/budget-enforcement.ts +281 -0
  85. package/src/runtime/child-pi/child-pi-constants.ts +8 -0
  86. package/src/runtime/child-pi/child-pi-spawn.ts +60 -14
  87. package/src/runtime/child-pi/child-pi-streams.ts +21 -1
  88. package/src/runtime/child-pi/child-pi-timers.ts +324 -0
  89. package/src/runtime/child-pi/child-pi.ts +97 -201
  90. package/src/runtime/child-pi/mock-fixtures.ts +16 -2
  91. package/src/runtime/crew-agent-records.ts +259 -14
  92. package/src/runtime/delegate-spawn.ts +148 -0
  93. package/src/runtime/detached-run-results.ts +90 -0
  94. package/src/runtime/deterministic-ast.ts +2 -1
  95. package/src/runtime/dispatch-batch.ts +945 -0
  96. package/src/runtime/finalize-run.ts +557 -0
  97. package/src/runtime/goal-workflow/adaptive-plan.ts +116 -15
  98. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +8 -3
  99. package/src/runtime/goal-workflow/goal-state-store.ts +1 -1
  100. package/src/runtime/group-join.ts +11 -125
  101. package/src/runtime/live-session/live-session-runtime.ts +26 -1
  102. package/src/runtime/merge-gate.ts +32 -10
  103. package/src/runtime/merge-loop.ts +130 -0
  104. package/src/runtime/model/model-budget-summary.ts +53 -0
  105. package/src/runtime/model/model-fallback.ts +36 -2
  106. package/src/runtime/model/pi-args.ts +10 -0
  107. package/src/runtime/model/provider-extensions.ts +10 -0
  108. package/src/runtime/orphan-worker-registry.ts +1 -1
  109. package/src/runtime/output/output-validator.ts +45 -0
  110. package/src/runtime/parent-guard.ts +3 -1
  111. package/src/runtime/peer-dep.ts +2 -1
  112. package/src/runtime/per-write-validator.ts +0 -5
  113. package/src/runtime/pi-spawn.ts +61 -15
  114. package/src/runtime/plan-approval.ts +125 -0
  115. package/src/runtime/plan-replan.ts +151 -0
  116. package/src/runtime/process-status.ts +16 -1
  117. package/src/runtime/recovery/checkpoint.ts +0 -18
  118. package/src/runtime/recovery/crash-recovery.ts +111 -46
  119. package/src/runtime/run-tracker.ts +77 -10
  120. package/src/runtime/scheduler-context.ts +98 -0
  121. package/src/runtime/scheduling/coalesce-tasks.ts +5 -0
  122. package/src/runtime/scheduling/global-worker-cap.ts +2 -1
  123. package/src/runtime/scheduling/nested-slots.ts +70 -0
  124. package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -13
  125. package/src/runtime/scheduling/task-graph-scheduler.ts +0 -10
  126. package/src/runtime/settings-store.ts +219 -0
  127. package/src/runtime/spawn-policy.ts +217 -0
  128. package/src/runtime/stale-reconciler.ts +87 -6
  129. package/src/runtime/subagent-manager.ts +25 -1
  130. package/src/runtime/task-output-context.ts +230 -9
  131. package/src/runtime/task-packet.ts +23 -1
  132. package/src/runtime/task-runner/child-executor.ts +106 -7
  133. package/src/runtime/task-runner/post-execution.ts +125 -1
  134. package/src/runtime/task-runner/pre-execution.ts +39 -1
  135. package/src/runtime/task-runner/prompt-builder.ts +51 -1
  136. package/src/runtime/task-runner/retrieval-orchestrator.ts +72 -18
  137. package/src/runtime/task-runner/spec-evidence.ts +403 -0
  138. package/src/runtime/task-runner/state-helpers.ts +26 -24
  139. package/src/runtime/task-runner.ts +11 -0
  140. package/src/runtime/team-runner.ts +132 -1673
  141. package/src/runtime/verification/spec-sandbox.ts +255 -0
  142. package/src/runtime/verification/verification-gates.ts +3 -2
  143. package/src/runtime/verification/verification-worktree.ts +2 -1
  144. package/src/runtime/workflow-phase-advance.ts +100 -0
  145. package/src/runtime/workspace-tree.ts +9 -0
  146. package/src/schema/config-schema.ts +66 -26
  147. package/src/schema/sensitive-config-paths.ts +64 -0
  148. package/src/schema/team-tool-schema.ts +13 -3
  149. package/src/state/README.md +4 -10
  150. package/src/state/atomic-write.ts +20 -3
  151. package/src/state/contracts.ts +38 -0
  152. package/src/state/coordination/mailbox.ts +12 -2
  153. package/src/state/event-log/cursor.ts +223 -0
  154. package/src/state/event-log/event-log-rotation.ts +12 -4
  155. package/src/state/event-log/event-log.ts +152 -369
  156. package/src/state/event-log/sequence-cache.ts +373 -0
  157. package/src/state/event-log/worker-atomic-writer.ts +2 -1
  158. package/src/state/stores/active-run-registry.ts +3 -2
  159. package/src/state/stores/manifest-io.ts +237 -0
  160. package/src/state/stores/ownership-map.ts +162 -0
  161. package/src/state/stores/plan-store.ts +241 -0
  162. package/src/state/stores/run-cache.ts +0 -90
  163. package/src/state/stores/spec-store.ts +189 -0
  164. package/src/state/stores/state-store.ts +139 -232
  165. package/src/state/types.ts +199 -0
  166. package/src/ui/dashboard-panes/plan-pane.ts +136 -0
  167. package/src/ui/dashboard-panes/progress-pane.ts +6 -0
  168. package/src/ui/dashboard-panes/transcript-pane.ts +31 -0
  169. package/src/ui/dock-footer.ts +49 -0
  170. package/src/ui/heartbeat-aggregator.ts +9 -1
  171. package/src/ui/inline-panel/agent-pane.ts +375 -0
  172. package/src/ui/inline-panel/agent-transcript.ts +338 -0
  173. package/src/ui/inline-panel/agent-view-overlay.ts +225 -0
  174. package/src/ui/inline-panel/crew-editor.ts +192 -0
  175. package/src/ui/inline-panel/index.ts +290 -0
  176. package/src/ui/inline-panel/panel-rows.ts +37 -0
  177. package/src/ui/inline-panel/panel-selection.ts +157 -0
  178. package/src/ui/inline-panel/panel-store.ts +111 -0
  179. package/src/ui/inline-panel/view-session-store.ts +36 -0
  180. package/src/ui/keybinding-map.ts +54 -13
  181. package/src/ui/pi-ui-compat.ts +9 -0
  182. package/src/ui/powerbar-publisher.ts +52 -1
  183. package/src/ui/run-dashboard.ts +31 -5
  184. package/src/ui/run-snapshot-cache.ts +57 -30
  185. package/src/ui/snapshot-types.ts +6 -1
  186. package/src/ui/widget/index.ts +176 -22
  187. package/src/ui/widget/task-list.ts +198 -0
  188. package/src/ui/widget/widget-formatters.ts +240 -4
  189. package/src/ui/widget/widget-renderer.ts +243 -38
  190. package/src/ui/widget/widget-types.ts +11 -0
  191. package/src/utils/child-process-shield.ts +106 -0
  192. package/src/utils/file-coalescer.ts +0 -4
  193. package/src/utils/fs-errno.ts +66 -0
  194. package/src/utils/fs-watch.ts +1 -1
  195. package/src/utils/internal-error.ts +3 -1
  196. package/src/utils/paths.ts +11 -3
  197. package/src/utils/redaction.ts +7 -0
  198. package/src/utils/safe-abort.ts +45 -0
  199. package/src/utils/task-name-generator.ts +1 -8
  200. package/src/workflows/discover-workflows.ts +20 -2
  201. package/src/workflows/validate-workflow.ts +7 -1
  202. package/src/workflows/workflow-config.ts +17 -0
  203. package/src/workflows/workflow-serializer.ts +3 -0
  204. package/src/worktree/worktree-manager.ts +22 -0
  205. package/workflows/default.workflow.md +36 -26
  206. package/workflows/strict-fast-fix.workflow.md +26 -0
  207. package/src/agents/agent-search.ts +0 -98
  208. package/src/benchmark/benchmark-runner.ts +0 -313
  209. package/src/benchmark/feedback-loop.ts +0 -73
  210. package/src/config/resilient-parser.ts +0 -117
  211. package/src/extension/crew-vibes/cat-frames.ts +0 -18
  212. package/src/extension/result-watcher.ts +0 -139
  213. package/src/observability/exporters/prometheus-exporter.ts +0 -54
  214. package/src/observability/metric-retention.ts +0 -64
  215. package/src/runtime/compaction/compaction-summary.ts +0 -278
  216. package/src/runtime/errors/crew-errors.ts +0 -162
  217. package/src/runtime/live-session/intercom-bridge.ts +0 -187
  218. package/src/runtime/loop-gates.ts +0 -128
  219. package/src/runtime/metric-parser.ts +0 -36
  220. package/src/runtime/output/stream-preview.ts +0 -184
  221. package/src/runtime/output/tool-progress.ts +0 -278
  222. package/src/runtime/phase-tracker.ts +0 -385
  223. package/src/runtime/pipeline-runner.ts +0 -523
  224. package/src/runtime/process/process-lifecycle.ts +0 -491
  225. package/src/runtime/recovery/retry-runner.ts +0 -330
  226. package/src/runtime/run-drift.ts +0 -219
  227. package/src/runtime/task-quality.ts +0 -199
  228. package/src/runtime/task-runner/run-projection.ts +0 -128
  229. package/src/runtime/verification/post-checks.ts +0 -142
  230. package/src/state/coordination/schedule.ts +0 -166
  231. package/src/state/event-log/jsonl-writer.ts +0 -115
  232. package/src/state/hook-instinct-bridge.ts +0 -94
  233. package/src/state/hook-integrations.ts +0 -51
  234. package/src/state/session-state-map.ts +0 -51
  235. package/src/state/stores/blob-store.ts +0 -308
  236. package/src/state/stores/instinct-store.ts +0 -275
  237. package/src/state/stores/observation-store.ts +0 -176
  238. package/src/state/tiered-eval.ts +0 -480
  239. package/src/state/types-eval.ts +0 -58
  240. package/src/tools/safe-bash-extension.ts +0 -54
  241. package/src/tools/safe-bash.ts +0 -505
  242. package/src/ui/agent-management-overlay.ts +0 -160
  243. package/src/ui/crew-footer.ts +0 -102
  244. package/src/ui/crew-select-list.ts +0 -114
  245. package/src/ui/dashboard-panes/capability-pane.ts +0 -77
  246. package/src/ui/transcript-entries.ts +0 -256
  247. package/src/utils/conflict-detect.ts +0 -721
  248. package/src/utils/fingerprint.ts +0 -180
  249. package/src/utils/gh-protocol.ts +0 -556
  250. package/src/utils/project-detector.ts +0 -160
  251. package/src/utils/sse-parser.ts +0 -131
  252. package/src/workflows/cost-estimator.ts +0 -34
  253. package/src/workflows/intermediate-store.ts +0 -166
@@ -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
+ }
@@ -3,8 +3,10 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { errors } from "../errors.ts";
5
5
  import { atomicWriteFile, atomicWriteJson } from "../state/atomic-write.ts";
6
+ import { getCurrentPlanRecord } from "../state/stores/plan-store.ts";
6
7
  import { loadManifestWithRecovery, loadTasksWithRecovery, saveRunManifest } from "../state/stores/state-store.ts";
7
8
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
9
+ import { logInternalError } from "../utils/internal-error.ts";
8
10
  import { recordFromTask, upsertCrewAgent } from "./crew-agent-records.ts";
9
11
  import { checkProcessLiveness } from "./process-status.ts";
10
12
 
@@ -23,7 +25,7 @@ const ORPHAN_TEMP_SCAN_BATCH_SIZE = 50;
23
25
  export interface ReconcileResult {
24
26
  runId: string;
25
27
  /** What was found and what action was taken */
26
- verdict: "healthy" | "blocked_awaiting_approval" | "result_exists" | "pid_dead" | "pid_alive_stale" | "no_status";
28
+ verdict: "healthy" | "blocked_awaiting_approval" | "waiting_answer" | "result_exists" | "pid_dead" | "pid_alive_stale" | "no_status";
27
29
  /** Whether repair was applied */
28
30
  repaired: boolean;
29
31
  /** Human-readable detail */
@@ -45,6 +47,50 @@ export function isPlanApprovalPending(manifest: TeamRunManifest): boolean {
45
47
  return manifest.status === "blocked" && manifest.planApproval?.required === true && manifest.planApproval.status === "pending";
46
48
  }
47
49
 
50
+ /** T2/R4 (ADR-4 §2 reader migration): plan-record-first variant — the current
51
+ * PlanRecord's approval (when present) is authoritative; pre-v2 runs fall
52
+ * back to the manifest gate above. The `blocked + pending → protected`
53
+ * invariant is preserved on BOTH paths. */
54
+ export function isPlanApprovalPendingEffective(manifest: TeamRunManifest): boolean {
55
+ if (isPlanApprovalPending(manifest)) return true;
56
+ if (manifest.status !== "blocked") return false;
57
+ return getCurrentPlanRecord(manifest)?.approval?.status === "pending";
58
+ }
59
+
60
+ /** WP-2/R2 (ADR-0 item 9, docs/decisions/2026-08-17-waiting-producer-ask.md):
61
+ * waiting TTL — a manifest.waitState.askedAt younger than this marks an
62
+ * intentional ask-wait; older marks a leaked park marker (worker died without
63
+ * resolving the ask) and the run loses reconciler protection. Leak guard. */
64
+ const WAITING_TTL_MS = 24 * 60 * 60 * 1000;
65
+
66
+ /**
67
+ * Is this run parked in the ask tool awaiting a human answer (WP-2/R2)?
68
+ *
69
+ * True when waitState is set and its askedAt is within waitingTtl. The parked
70
+ * worker is blocked polling the mailbox for a response — not crashed — and it
71
+ * typically stops heartbeating while parked, so the run must be protected at
72
+ * run level (before the no-PID/heartbeat staleness phases) exactly like
73
+ * plan-approval runs. askedAt exactly at the TTL boundary still counts as
74
+ * pending (<=, fail-safe toward preservation); a future askedAt (clock skew)
75
+ * also passes. TTL-EXPIRED waitState returns false — the marker leaked and the
76
+ * normal staleness repair paths apply (leak guard).
77
+ */
78
+ export function isWaitAnswerPending(manifest: TeamRunManifest, now = Date.now()): boolean {
79
+ const askedAtMs = manifest.waitState?.askedAt ? new Date(manifest.waitState.askedAt).getTime() : Number.NaN;
80
+ return Number.isFinite(askedAtMs) && now - askedAtMs <= WAITING_TTL_MS;
81
+ }
82
+
83
+ /**
84
+ * Generalized intentional-wait predicate (ADR-0 item 9, extended by ADR-4 §2):
85
+ * the run is blocked on a human decision — plan approval (record-first with
86
+ * manifest fallback — pre-v2 semantics preserved on the fallback path) or a
87
+ * pending ask answer within waitingTtl. Such runs are not crashes and must not
88
+ * be stale-repaired or cancelled by reconciliation.
89
+ */
90
+ export function isIntentionalWait(manifest: TeamRunManifest, now = Date.now()): boolean {
91
+ return isPlanApprovalPendingEffective(manifest) || isWaitAnswerPending(manifest, now);
92
+ }
93
+
48
94
  const STALE_ALIVE_PID_MS = 24 * 60 * 60 * 1000; // 24 hours
49
95
  const ACTIVE_EVIDENCE_TTL_MS = 5 * 60 * 1000;
50
96
  /** For no-PID runs, repair when ALL running tasks have heartbeat stale beyond this threshold. */
@@ -315,7 +361,9 @@ export function reconcileStaleRun(manifest: TeamRunManifest, tasks: TeamTaskStat
315
361
  // Preserve runs intentionally blocked on human plan approval. These are not
316
362
  // crashes even if the owning PID is gone — they are waiting for a decision.
317
363
  // Must short-circuit before Phase 1 (result check) and Phase 2 (PID liveness).
318
- if (isPlanApprovalPending(manifest)) {
364
+ // ADR-4 §2: record-first predicate — a PlanRecord approval is authoritative,
365
+ // the manifest field remains the pre-v2 fallback (never dropped).
366
+ if (isPlanApprovalPendingEffective(manifest)) {
319
367
  return {
320
368
  runId,
321
369
  verdict: "blocked_awaiting_approval",
@@ -324,6 +372,22 @@ export function reconcileStaleRun(manifest: TeamRunManifest, tasks: TeamTaskStat
324
372
  };
325
373
  }
326
374
 
375
+ // WP-2/R2 (ADR-0 item 9): a run parked in the ask tool with a fresh
376
+ // waitState.askedAt is intentionally waiting on a human answer — not stale.
377
+ // Short-circuits before Phase 1/2 like plan-approval above: a parked worker
378
+ // does not heartbeat, so the no-PID/heartbeat staleness phases would
379
+ // otherwise cancel it long before its (server-clamped <=1h) ask deadline.
380
+ // TTL-expired waitState (>24h) falls through — the park marker leaked
381
+ // (worker died without resolving) and normal staleness repair applies.
382
+ if (isWaitAnswerPending(manifest, now)) {
383
+ return {
384
+ runId,
385
+ verdict: "waiting_answer",
386
+ repaired: false,
387
+ detail: "Ask answer pending (manifest.waitState.askedAt within waiting TTL); run is intentionally waiting and must not be stale-repaired",
388
+ };
389
+ }
390
+
327
391
  // Phase 1: Check if results already exist
328
392
  const phase1 = checkResultFile(manifest, tasks);
329
393
  if (phase1.found) {
@@ -538,7 +602,14 @@ export function reconcileOrphanedTempWorkspaces(
538
602
  }
539
603
  }
540
604
  // If still running after reconciliation attempt, mark for dir-preserving
541
- if (result.verdict === "healthy" || (result.verdict === "no_status" && !result.repaired)) {
605
+ if (
606
+ result.verdict === "healthy" ||
607
+ // WP-2/R2: a run intentionally waiting on an ask answer keeps the
608
+ // temp workspace alive (its stateRoot + mailbox must survive for
609
+ // the parked worker to receive the answer).
610
+ result.verdict === "waiting_answer" ||
611
+ (result.verdict === "no_status" && !result.repaired)
612
+ ) {
542
613
  hasRunning = true;
543
614
  }
544
615
  } catch (err) {
@@ -546,14 +617,19 @@ export function reconcileOrphanedTempWorkspaces(
546
617
  // Note: manifestPath here refers to the variable defined in the
547
618
  // for loop at line 442 (outer scope of this catch block).
548
619
  const scanManifestPath = manifestPath;
549
- console.warn(`[stale-reconciler] Skipping manifest due to parse error: ${scanManifestPath}: ${err}`);
620
+ logInternalError(
621
+ "stale-reconciler",
622
+ new Error(`Skipping manifest due to parse error: ${scanManifestPath}: ${err}`),
623
+ undefined,
624
+ "warn",
625
+ );
550
626
  }
551
627
  }
552
628
  } catch (err) {
553
629
  // Cannot determine running state — treat as if running to prevent
554
630
  // premature cleanup of a potentially active workspace.
555
631
  hasRunning = true;
556
- console.warn(`[stale-reconciler] Skipping unreadable runs dir: ${stateRunsDir}: ${err}`);
632
+ logInternalError("stale-reconciler", new Error(`Skipping unreadable runs dir: ${stateRunsDir}: ${err}`), undefined, "warn");
557
633
  }
558
634
 
559
635
  // Post-loop: check if this workspace dir can be cleaned up.
@@ -617,7 +693,12 @@ export function reconcileOrphanedTempWorkspaces(
617
693
  }
618
694
  }
619
695
  } catch (err) {
620
- console.warn(`[stale-reconciler] Skipping unreadable runs dir: ${stateRunsDir}: ${err}`);
696
+ logInternalError(
697
+ "stale-reconciler",
698
+ new Error(`Skipping unreadable runs dir: ${stateRunsDir}: ${err}`),
699
+ undefined,
700
+ "warn",
701
+ );
621
702
  }
622
703
  }
623
704
  }