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
@@ -22,7 +22,7 @@ export const ROLE_TOOL_CONFIGS: Record<string, RoleToolConfig> = {
22
22
  // excluded. State-mutation safety is enforced separately by
23
23
  // READ_ONLY_ROLES in role-permission.ts.
24
24
  explorer: {
25
- tools: ["read", "grep", "find", "ls", "glob", "bash"],
25
+ tools: ["read", "grep", "find", "ls", "glob", "bash", "ask"],
26
26
  excludeTools: ["edit", "write", "web"],
27
27
  },
28
28
 
@@ -39,14 +39,14 @@ export const ROLE_TOOL_CONFIGS: Record<string, RoleToolConfig> = {
39
39
  // planner to WRITE_ROLES would fire the gate before planning, breaking the
40
40
  // default/implementation workflows.
41
41
  planner: {
42
- tools: ["read", "grep", "find", "ls", "glob"],
42
+ tools: ["read", "grep", "find", "ls", "glob", "ask"],
43
43
  excludeTools: ["edit", "write", "bash", "web", "ask_question"],
44
44
  },
45
45
 
46
46
  // Critic - Read-only plan/design critique (F2: was missing from the map,
47
47
  // so a custom critic agent had no tool-level read-only enforcement).
48
48
  critic: {
49
- tools: ["read", "grep", "find", "ls", "glob"],
49
+ tools: ["read", "grep", "find", "ls", "glob", "ask"],
50
50
  excludeTools: ["edit", "write", "bash", "web"],
51
51
  },
52
52
 
@@ -59,13 +59,13 @@ export const ROLE_TOOL_CONFIGS: Record<string, RoleToolConfig> = {
59
59
 
60
60
  // Reviewer - Read and review, no write
61
61
  reviewer: {
62
- tools: ["read", "grep", "find", "ls", "glob", "bash"],
62
+ tools: ["read", "grep", "find", "ls", "glob", "bash", "ask"],
63
63
  excludeTools: ["edit", "write"],
64
64
  },
65
65
 
66
66
  // Writer - Documentation focused
67
67
  writer: {
68
- tools: ["read", "edit", "write", "ls"],
68
+ tools: ["read", "edit", "write", "ls", "ask"],
69
69
  excludeTools: ["bash", "web", "ask_question"],
70
70
  },
71
71
 
@@ -74,7 +74,7 @@ export const ROLE_TOOL_CONFIGS: Record<string, RoleToolConfig> = {
74
74
  // security-reviewer.md → "security-reviewer"). The underscore form never
75
75
  // resolved at runtime (returned {}), silently dropping enforcement.
76
76
  "security-reviewer": {
77
- tools: ["read", "grep", "find"],
77
+ tools: ["read", "grep", "find", "ask"],
78
78
  excludeTools: ["edit", "write", "bash", "web", "ask_question"],
79
79
  },
80
80
 
@@ -84,15 +84,17 @@ export const ROLE_TOOL_CONFIGS: Record<string, RoleToolConfig> = {
84
84
  // agents/verifier.md). Tool-set keeps bash but excludes edit/write so source
85
85
  // integrity is preserved during verification. Mirrors cold-verifier behavior.
86
86
  verifier: {
87
- tools: ["read", "grep", "find", "ls", "bash", "scratchpad"],
87
+ tools: ["read", "grep", "find", "ls", "bash", "scratchpad", "ask"],
88
88
  excludeTools: ["edit", "write", "web"],
89
89
  // Phase 1 scratchpad: multi-cell test/verify flows reuse parsed state.
90
90
  scratchpad: true,
91
91
  },
92
92
 
93
- // Test Engineer - Can write tests (F1: hyphenated key)
93
+ // Test Engineer - Can write tests (F1: hyphenated key). Tool list mirrors
94
+ // agents/test-engineer.md frontmatter (T3 binding, commit c25dfbe2):
95
+ // search tools + delegate for spawning depth-1 helpers.
94
96
  "test-engineer": {
95
- tools: ["read", "edit", "write", "bash", "ls", "scratchpad"],
97
+ tools: ["read", "edit", "write", "bash", "ls", "glob", "grep", "find", "scratchpad", "ask", "delegate"],
96
98
  excludeTools: ["web"],
97
99
  // Phase 1 scratchpad: build/run test suites with state across cells.
98
100
  scratchpad: true,
@@ -0,0 +1,172 @@
1
+ import { collectSensitiveConfigPaths } from "../schema/sensitive-config-paths.ts";
2
+ import type { ConfigValidationResult, PiTeamsConfig } from "./types.ts";
3
+
4
+ /**
5
+ * Phase 5.1 (ADR docs/decisions/2026-08-15-schema-driven-sanitize.md): the
6
+ * drop-list is derived from `sensitive: true` marks in the config schema
7
+ * (see src/schema/sensitive-config-paths.ts) instead of being hardcoded.
8
+ *
9
+ * Legacy shape preserved verbatim from the pre-Phase-5.1 implementation:
10
+ * - warning format: `${projectPath}: project-level sensitive config '<dotted>' is ignored; set it in user config to trust it explicitly`
11
+ * - warning order: top-level keys first (executeWorkers, asyncByDefault,
12
+ * requireCleanWorktreeLeader), then sections runtime → autonomous →
13
+ * worktree → otlp → agents → tools; newly-marked sections (policy) follow
14
+ * in schema order. Within a section, keys follow schema property order.
15
+ * - empty-object collapse: a section whose defined keys were all dropped
16
+ * becomes `undefined`.
17
+ * - worktree/otlp redact via `{ ...section, key: undefined }` (the dropped
18
+ * key remains as an own `undefined` key) while other sections `delete` it —
19
+ * pinned by config-sanitize-merge.test.ts. runtime/autonomous are always
20
+ * replaced by a shallow copy even when nothing was dropped (legacy shape).
21
+ *
22
+ * Wave 1A (S19-1/S19-2/S19-5, F19-4/F19-7): two extensions on top of the
23
+ * unconditional sensitive walk —
24
+ * - new unconditional marks: `goalWrap` (whole subtree, terminal Record
25
+ * mark) and `autonomous.magicKeywords` are now user-only;
26
+ * - CONDITIONAL_PROJECT_DROPS (below): guard-tiering — the project tier may
27
+ * only TIGHTEN these fields, never loosen them. The legacy inline
28
+ * `requirePlanApproval === false` special-case is folded into that table
29
+ * (same warning output).
30
+ */
31
+
32
+ export function projectOverrideWarning(projectPath: string, dottedPath: string): string {
33
+ return `${projectPath}: project-level sensitive config '${dottedPath}' is ignored; set it in user config to trust it explicitly`;
34
+ }
35
+
36
+ /** Wave 1A guard-tiering (S19-1/S19-5, F19-4/F19-7 — ADR
37
+ * docs/decisions/2026-08-15-schema-driven-sanitize.md, "Wave 1A remediation"):
38
+ * dotted path -> predicate over the project value. When the predicate holds
39
+ * (the value LOOSENS a guard or disables an availability control) the key is
40
+ * dropped with the standard warning; every other value survives untouched.
41
+ * Applied AFTER the unconditional sensitive walk.
42
+ *
43
+ * `limits.maxConcurrentWorkers` deliberately has NO entry: the schema already
44
+ * bounds it (Type.Integer minimum 1, reader-side sanity ceilings) and a project
45
+ * can only lower it — i.e. tighten — so no drop is needed (ADR Wave 1A).
46
+ */
47
+ export const CONDITIONAL_PROJECT_DROPS: Record<string, (value: unknown) => boolean> = {
48
+ // Legacy exception (ADR Decision 3, folded in here): only `=== false`
49
+ // loosens plan approval; `true` tightens and survives.
50
+ "runtime.requirePlanApproval": (value) => value === false,
51
+ // S19-1: both guards are ACTIVE by default ("warn") — "off" silently
52
+ // disables them for every contributor who has not pinned the field in
53
+ // user config. "warn"/"block"/"fail" tighten or keep the guard and survive.
54
+ "runtime.completionMutationGuard": (value) => value === "off",
55
+ "runtime.effectivenessGuard": (value) => value === "off",
56
+ // S19-1: unbounded concurrency is a resource-exhaustion loosening.
57
+ "limits.allowUnboundedConcurrency": (value) => value === true,
58
+ // S19-1: disabling recovery / model scoping loosens reliability guards.
59
+ "reliability.autoRecover": (value) => value === false,
60
+ "reliability.scopeModels": (value) => value === false,
61
+ // F19-7 (availability-only): a project may ENABLE the broker, never
62
+ // disable it repo-wide. Other broker fields are unmarked (as-is).
63
+ "broker.enabled": (value) => value === false,
64
+ };
65
+
66
+ /** Legacy top-level warning order (kept byte-identical to the pre-5.1 code). */
67
+ const TOP_LEVEL_LEGACY_ORDER = ["executeWorkers", "asyncByDefault", "requireCleanWorktreeLeader"] as const;
68
+
69
+ /** Legacy section processing order; unlisted sections (e.g. policy) follow in schema order. */
70
+ const SECTION_LEGACY_ORDER = ["runtime", "autonomous", "worktree", "otlp", "agents", "tools"] as const;
71
+
72
+ /** Sections where the pre-5.1 code always replaced `sanitized.<section>` with a
73
+ * shallow copy, even when nothing was dropped (runtime/autonomous). */
74
+ const LEGACY_ALWAYS_COPY_SECTIONS = new Set<string>(["runtime", "autonomous"]);
75
+
76
+ /** Sections redacting via assignment (`key: undefined`, own key remains) instead
77
+ * of `delete` — legacy quirk pinned by config-sanitize-merge.test.ts. */
78
+ const ASSIGN_REDACT_SECTIONS = new Set<string>(["worktree", "otlp"]);
79
+
80
+ function groupPathsBySection(dottedPaths: string[]): Map<string, string[]> {
81
+ const groups = new Map<string, string[]>();
82
+ for (const dotted of dottedPaths) {
83
+ const separator = dotted.indexOf(".");
84
+ const section = separator === -1 ? "" : dotted.slice(0, separator);
85
+ const key = separator === -1 ? dotted : dotted.slice(separator + 1);
86
+ const bucket = groups.get(section);
87
+ if (bucket) bucket.push(key);
88
+ else groups.set(section, [key]);
89
+ }
90
+ return groups;
91
+ }
92
+
93
+ function sectionProcessingOrder(sections: string[]): string[] {
94
+ return [...sections].sort((a, b) => {
95
+ const rankA = (SECTION_LEGACY_ORDER as readonly string[]).indexOf(a);
96
+ const rankB = (SECTION_LEGACY_ORDER as readonly string[]).indexOf(b);
97
+ // Unlisted sections (new sensitive marks) sort after legacy ones,
98
+ // preserving their schema-walk order (stable sort).
99
+ if (rankA === -1 && rankB === -1) return 0;
100
+ if (rankA === -1) return 1;
101
+ if (rankB === -1) return -1;
102
+ return rankA - rankB;
103
+ });
104
+ }
105
+
106
+ export function sanitizeProjectConfig(projectPath: string, userConfig: PiTeamsConfig, config: PiTeamsConfig): ConfigValidationResult {
107
+ const sanitized: PiTeamsConfig = { ...config };
108
+ const warnings: string[] = [];
109
+ const groups = groupPathsBySection(collectSensitiveConfigPaths());
110
+
111
+ const dropTopLevel = (key: string): void => {
112
+ if ((config as Record<string, unknown>)[key] === undefined) return;
113
+ delete (sanitized as Record<string, unknown>)[key];
114
+ warnings.push(projectOverrideWarning(projectPath, key));
115
+ };
116
+ const topLevelKeys = groups.get("") ?? [];
117
+ for (const key of [...topLevelKeys].sort((a, b) => {
118
+ const rankA = (TOP_LEVEL_LEGACY_ORDER as readonly string[]).indexOf(a);
119
+ const rankB = (TOP_LEVEL_LEGACY_ORDER as readonly string[]).indexOf(b);
120
+ if (rankA === -1 && rankB === -1) return 0;
121
+ if (rankA === -1) return 1;
122
+ if (rankB === -1) return -1;
123
+ return rankA - rankB;
124
+ })) {
125
+ dropTopLevel(key);
126
+ }
127
+
128
+ const sanitizedRecord = sanitized as Record<string, unknown>;
129
+ const configRecord = config as Record<string, unknown>;
130
+ for (const section of sectionProcessingOrder([...groups.keys()].filter((s) => s !== ""))) {
131
+ const sectionValue = configRecord[section];
132
+ if (sectionValue === undefined || typeof sectionValue !== "object") continue;
133
+ const sectionConfig = { ...(sectionValue as Record<string, unknown>) };
134
+ let changed = false;
135
+ for (const key of groups.get(section) ?? []) {
136
+ if (sectionConfig[key] === undefined) continue;
137
+ if (ASSIGN_REDACT_SECTIONS.has(section)) sectionConfig[key] = undefined;
138
+ else delete sectionConfig[key];
139
+ warnings.push(projectOverrideWarning(projectPath, `${section}.${key}`));
140
+ changed = true;
141
+ }
142
+ if (!changed && !LEGACY_ALWAYS_COPY_SECTIONS.has(section)) continue;
143
+ sanitizedRecord[section] = Object.values(sectionConfig).some((entry) => entry !== undefined) ? sectionConfig : undefined;
144
+ }
145
+
146
+ // Wave 1A guard-tiering: conditional drops run AFTER the unconditional
147
+ // sensitive walk. Sections holding conditional keys that carry no sensitive
148
+ // marks (limits, reliability, broker) never appear in `groups`, so this pass
149
+ // reads the SANITIZED config (runtime is already a shallow copy there),
150
+ // shallow-copies before deleting (input stays unmutated), and applies the
151
+ // same empty-object-collapse convention. runtime.* conditional drops thus
152
+ // integrate with the existing runtime section copy into one coherent result.
153
+ const conditionalGroups = groupPathsBySection(Object.keys(CONDITIONAL_PROJECT_DROPS));
154
+ for (const section of sectionProcessingOrder([...conditionalGroups.keys()].filter((s) => s !== ""))) {
155
+ const sectionValue = sanitizedRecord[section];
156
+ if (sectionValue === undefined || typeof sectionValue !== "object") continue;
157
+ const sectionConfig = { ...(sectionValue as Record<string, unknown>) };
158
+ let changed = false;
159
+ for (const key of conditionalGroups.get(section) ?? []) {
160
+ if (!CONDITIONAL_PROJECT_DROPS[`${section}.${key}`](sectionConfig[key])) continue;
161
+ delete sectionConfig[key];
162
+ warnings.push(projectOverrideWarning(projectPath, `${section}.${key}`));
163
+ changed = true;
164
+ }
165
+ if (!changed) continue;
166
+ sanitizedRecord[section] = Object.values(sectionConfig).some((entry) => entry !== undefined) ? sectionConfig : undefined;
167
+ }
168
+ return { config: sanitized, warnings };
169
+ }
170
+
171
+ /** @internal — direct-test seam for Phase 2.2 extraction target (refactor-plan step 1.9c). */
172
+ export const __test__sanitizeProjectConfig = sanitizeProjectConfig;
@@ -119,6 +119,10 @@ export interface CrewModelFallbackConfig {
119
119
  export interface CrewControlConfig {
120
120
  enabled?: boolean;
121
121
  needsAttentionAfterMs?: number;
122
+ /** Consecutive tool-failure threshold before a worker is flagged. Default 3 (applied at read site). */
123
+ consecutiveFailureThreshold?: number;
124
+ /** Minutes after which a running worker is flagged long-running. Default 10 (applied at read site). */
125
+ longRunningMinutes?: number;
122
126
  }
123
127
 
124
128
  export interface CrewWorktreeConfig {
@@ -139,8 +143,26 @@ export interface GoalWrapWorkflowConfig {
139
143
  }
140
144
 
141
145
  export interface CrewUiConfig {
142
- widgetPlacement?: "aboveEditor" | "belowEditor";
146
+ /**
147
+ * Where the crew dock (agents list) renders:
148
+ * - `aboveEditor` / `belowEditor`: pi's widget slots around the prompt;
149
+ * - `bottom`: inside the crew-vibes footer, BELOW the quota/meter lines
150
+ * (the very bottom of the screen). Falls back to `belowEditor` when
151
+ * crew-vibes is not active (no footer sink).
152
+ */
153
+ widgetPlacement?: "aboveEditor" | "belowEditor" | "bottom";
143
154
  widgetMaxLines?: number;
155
+ /**
156
+ * Per-agent row layout in the widget. `compact` is one width-budgeted line
157
+ * per agent; `detailed` keeps the two-line tree (name row + activity row).
158
+ */
159
+ widgetRowStyle?: "compact" | "detailed";
160
+ /**
161
+ * Keyboard-navigable agent rows under the prompt (`↓` from an empty prompt).
162
+ * Requires owning pi's editor component, so it yields to any other extension
163
+ * that already installed a custom editor.
164
+ */
165
+ inlinePanel?: boolean;
144
166
  powerbar?: boolean;
145
167
  dashboardPlacement?: "center" | "right";
146
168
  dashboardWidth?: number;
@@ -282,6 +304,26 @@ export interface PiTeamsConfig {
282
304
  * schema (`src/schema/config-schema.ts`).
283
305
  */
284
306
  broker?: CrewBrokerConfig;
307
+ /**
308
+ * Governed nesting (ADR-5 docs/decisions/2026-08-17-governed-nesting.md).
309
+ * Worker→grandchild delegation via the `delegate` tool. Default is OFF
310
+ * (`enabled:false`) — fail-closed until WP-5 completes (B3 battery +
311
+ * security sign-off), then flipped to true. While false, `delegate`
312
+ * admission is rejected with a structured policy message and a
313
+ * `delegate.rejected` event (never silent).
314
+ */
315
+ nesting?: CrewNestingConfig;
316
+ }
317
+
318
+ /** Governed-nesting config (ADR-5). */
319
+ export interface CrewNestingConfig {
320
+ /** Master switch. `false` (default) keeps the `delegate` surface dormant. */
321
+ enabled?: boolean;
322
+ /** Nested-slot budget override. Default max(1, floor(globalSem/2)). 1..64. */
323
+ maxSlots?: number;
324
+ /** Max crew depth for delegate-driven nesting. Default 2. 1..10 (mirrors
325
+ * the PI_CREW_MAX_DEPTH env clamp). */
326
+ maxDepth?: number;
285
327
  }
286
328
 
287
329
  /** CrewBroker config (Phase 0 inter-pi broker). */
@@ -294,6 +336,12 @@ export interface CrewBrokerConfig {
294
336
  maxFrameBytes?: number;
295
337
  /** Per-connection outbound queue cap. 32..4096 (default 256). */
296
338
  outboundQueueCap?: number;
339
+ /** WP-2/R2 (ADR-0 docs/decisions/2026-08-17-waiting-producer-ask item 7):
340
+ * capability gate for the broker `wait.*` methods (waiting-producer park
341
+ * contract). DEFAULT FALSE — fail-closed until WP-2 completes; while
342
+ * false, wait.request/wait.resolve are rejected with a policy-disabled
343
+ * error plus a policy.action event in events.jsonl (never silent). */
344
+ waitMethodsEnabled?: boolean;
297
345
  }
298
346
 
299
347
  export interface LoadedPiTeamsConfig {
@@ -26,6 +26,21 @@ function isFinished(status: string): boolean {
26
26
  return status === "completed" || status === "failed" || status === "cancelled" || status === "blocked";
27
27
  }
28
28
 
29
+ // R5-M2 (Round 5 MEDIUM-2): FIFO cap for the dedupe Set. Set preserves
30
+ // insertion order, so evicting .values().next().value drops the oldest-seen
31
+ // runId. 256 >> the ~20 runs polled per tick; eviction only matters for a
32
+ // long-lived host session that observes thousands of finished runs.
33
+ const MAX_SEEN_FINISHED_RUN_IDS = 256;
34
+
35
+ function addSeenFinishedRunId(state: AsyncNotifierState, runId: string): void {
36
+ state.seenFinishedRunIds.add(runId);
37
+ while (state.seenFinishedRunIds.size > MAX_SEEN_FINISHED_RUN_IDS) {
38
+ const oldest = state.seenFinishedRunIds.values().next().value;
39
+ if (oldest === undefined) break;
40
+ state.seenFinishedRunIds.delete(oldest);
41
+ }
42
+ }
43
+
29
44
  export function isAsyncTerminalEvent(event: TeamEvent): boolean {
30
45
  return event.type === "async.completed" || event.type === "async.failed" || event.type === "async.died";
31
46
  }
@@ -129,11 +144,15 @@ export function startAsyncRunNotifier(
129
144
  const sid = extractSessionId(ctx);
130
145
  const ownsRun = (run: TeamRunManifest): boolean => !sid || !run.ownerSessionId || run.ownerSessionId === sid;
131
146
  for (const run of listRuns(ctx.cwd).filter(ownsRun)) {
147
+ // R5-M2: the seed scans the FULL listRuns() index (newest-first); stop
148
+ // seeding once the Set is at capacity so a huge historical index cannot
149
+ // push the newest finished runIds out via FIFO eviction.
150
+ if (state.seenFinishedRunIds.size >= MAX_SEEN_FINISHED_RUN_IDS) break;
132
151
  // Suppress only terminal runs that were already finished before this owner
133
152
  // session (or before the previous session switch). Active runs must remain
134
153
  // un-seen so completions during auto-compaction/session restart are delivered.
135
154
  const updatedAtMs = timeMs(run.updatedAt) ?? 0;
136
- if (isFinished(run.status) && updatedAtMs < staleBeforeMs) state.seenFinishedRunIds.add(run.runId);
155
+ if (isFinished(run.status) && updatedAtMs < staleBeforeMs) addSeenFinishedRunId(state, run.runId);
137
156
  }
138
157
  let cachedRuns: TeamRunManifest[] | undefined;
139
158
  state.interval = setInterval(() => {
@@ -147,7 +166,7 @@ export function startAsyncRunNotifier(
147
166
  for (const run of cachedRuns) {
148
167
  const current = markDeadAsyncRunIfNeeded(run) ?? run;
149
168
  if (!isFinished(current.status) || state.seenFinishedRunIds.has(current.runId)) continue;
150
- state.seenFinishedRunIds.add(current.runId);
169
+ addSeenFinishedRunId(state, current.runId);
151
170
  // Suppress notifications for INTERNAL goal-loop sub-runs.
152
171
  // The outer goal-loop creates a synthetic 'goal-turn' workflow per turn
153
172
  // (see buildTurnWorkflow in goal-loop-runner.ts). These runs are
@@ -182,6 +201,10 @@ export function startAsyncRunNotifier(
182
201
  export function stopAsyncRunNotifier(state: AsyncNotifierState): void {
183
202
  if (state.interval) clearInterval(state.interval);
184
203
  state.interval = undefined;
204
+ // R5-M2: clear the dedupe Set on stop so entries do not persist across
205
+ // stop/start cycles; startAsyncRunNotifier re-seeds from listRuns() using
206
+ // lastStoppedAtMs, so runs finished before the stop stay suppressed.
207
+ state.seenFinishedRunIds.clear();
185
208
  state.generation = (state.generation ?? 0) + 1;
186
209
  state.lastStoppedAtMs = Date.now();
187
210
  }
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { cleanupAllTrackedTempDirs } from "../runtime/model/pi-args.ts";
3
+ import { isSessionSwitchInFlight } from "../ui/inline-panel/view-session-store.ts";
3
4
  import { logInternalError } from "../utils/internal-error.ts";
4
5
 
5
6
  // NOTE: globalProgressTracker import kept for documentation but not directly used
@@ -67,6 +68,18 @@ export function registerCleanupHandler(pi: ExtensionAPI, opts?: { disposeTermina
67
68
  console.log("[pi-crew] Session shutdown - cleaning up resources");
68
69
 
69
70
  try {
71
+ // Navigational switches (pi's own resume/new/fork) tear the session
72
+ // down on purpose — the user is only LOOKING at a different pi
73
+ // session; foreground team runs + their workers share this process
74
+ // and must keep running (P0: "jumping into any session stops the
75
+ // run" — exit 143 signature). Killing them here bypasses child-pi's
76
+ // abort flags, so the worker exit reads as an "unexpected
77
+ // code=143 signal=null" and the run is cancelled. Same contract as
78
+ // stopSessionBoundSubagents and the run-deadline caller-abort
79
+ // suppression; on REAL shutdowns (quit/reload) the flag is unset
80
+ // and this cleanup still drains the registry, as does the
81
+ // process-exit hook (tryRegisterSessionCleanup) when pi dies.
82
+ if (isSessionSwitchInFlight()) return;
70
83
  // Kill all child-pi processes
71
84
  await cleanupChildProcesses();
72
85
 
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
+ import { getCrewEnv } from "../../config/env-vars.ts";
3
4
  import { hasCrewFontFile, isWebTerminal } from "./font-detect.ts";
4
5
 
5
6
  /**
@@ -13,7 +14,7 @@ export const CAPACITY_STATUS_ID = "pi-crew-bar";
13
14
  export const PROVIDER_STATUS_ID = "pi-crew-bar";
14
15
 
15
16
  function resolveHome(): string {
16
- return (process.env.PI_TEAMS_HOME ?? process.env.PI_CREW_HOME)?.trim() || process.env.HOME || process.env.USERPROFILE || "";
17
+ return getCrewEnv("PI_CREW_HOME")?.trim() || process.env.HOME || process.env.USERPROFILE || "";
17
18
  }
18
19
 
19
20
  export function configPath(): string {
@@ -1,7 +1,9 @@
1
1
  import { isAbsolute, relative, resolve, sep } from "node:path";
2
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { getFooterDockProvider } from "../../ui/dock-footer.ts";
3
4
  import { requestRenderTarget } from "../../ui/pi-ui-compat.ts";
4
5
  import { asCrewTheme, type CrewTheme } from "../../ui/theme-adapter.ts";
6
+ import { colorWidgetLine, renderLines } from "../../ui/widget/widget-renderer.ts";
5
7
  import { truncateToWidth, visibleWidth } from "../../utils/visual.ts";
6
8
  import type { CrewVibesConfig } from "./config.ts";
7
9
  import type { ProviderUsage } from "./provider-usage.ts";
@@ -280,11 +282,28 @@ class CrewVibesFooter implements FooterComponent {
280
282
  return [truncateToWidth(cap, width, "…"), this.rightAlign(quota, width)];
281
283
  }
282
284
 
285
+ /** Dock lines registered by the crew widget (`widgetPlacement: "bottom"`):
286
+ * painted as the very last block of the footer, below the quota/meters.
287
+ * The provider returns RAW lines; we color them with this footer's theme
288
+ * (same per-line palette the widget would have used) and truncate to the
289
+ * real render width. */
290
+ private buildDockLines(width: number): string[] {
291
+ const provider = getFooterDockProvider();
292
+ if (!provider) return [];
293
+ const raw = provider(width);
294
+ if (raw.length === 0) return [];
295
+ return renderLines(
296
+ raw.map((line, index) => colorWidgetLine(line, index, this.theme)),
297
+ width,
298
+ ).map((line) => truncateToWidth(line, width, "…"));
299
+ }
300
+
283
301
  render(width: number): string[] {
284
302
  const lines = [this.buildPwdLine(width), this.buildStatsLine(width)];
285
303
  const statusLine = this.buildStatusLine(width);
286
304
  if (statusLine) lines.push(statusLine);
287
305
  lines.push(...this.buildMeterLines(width));
306
+ lines.push(...this.buildDockLines(width));
288
307
  return lines;
289
308
  }
290
309
  }
@@ -19,6 +19,7 @@ function safeUiCall(scope: string, fn: () => void): void {
19
19
  }
20
20
  }
21
21
 
22
+ import { setFooterDockSinkActive } from "../../ui/dock-footer.ts";
22
23
  import { type CrewVibesConfig, loadConfig, saveConfig } from "./config.ts";
23
24
  import { intervalForSpeed } from "./figures.ts";
24
25
  import { type CrewVibesFooterSource, createCrewVibesFooter } from "./footer.ts";
@@ -91,11 +92,18 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
91
92
  function installFooter(ctx: ExtensionContext): void {
92
93
  if (!ctx?.hasUI) return;
93
94
  if (!metersActive()) {
94
- safeUiCall("clear-footer", () => setFooter(ctx, undefined));
95
+ safeUiCall("clear-footer", () => {
96
+ // The crew dock can no longer render at the very bottom — the
97
+ // widget falls back to pi's belowEditor slot automatically.
98
+ setFooterDockSinkActive(false);
99
+ setFooter(ctx, undefined);
100
+ });
95
101
  return;
96
102
  }
97
103
  safeUiCall("install-footer", () => {
98
104
  setFooter(ctx, (tui, theme, footerData) => createCrewVibesFooter({ tui, theme, footerData, ctx, source: footerSource }));
105
+ // The footer can now host the crew dock below the quota lines.
106
+ setFooterDockSinkActive(true);
99
107
  requestRender(ctx);
100
108
  });
101
109
  }
@@ -245,6 +253,7 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
245
253
  stopFooterTimer();
246
254
  stopCapacityTimer();
247
255
  stopProviderTimer();
256
+ setFooterDockSinkActive(false);
248
257
  setFooter(ctx, undefined);
249
258
  clearVibesStatus(ctx);
250
259
  return;
@@ -370,6 +379,7 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
370
379
  stopFooterTimer();
371
380
  stopCapacityTimer();
372
381
  stopProviderTimer();
382
+ setFooterDockSinkActive(false);
373
383
  setFooter(ctx, undefined);
374
384
  clearVibesStatus(ctx);
375
385
  });
@@ -5,7 +5,9 @@
5
5
  * for sequential agent chain execution based on ECC recommendations.
6
6
  */
7
7
 
8
+ import { randomUUID } from "node:crypto";
8
9
  import * as fs from "node:fs";
10
+ import type { PlanRecord } from "../state/types.ts";
9
11
 
10
12
  /**
11
13
  * Tag → Agent chain mapping from ECC recommendations.
@@ -300,3 +302,133 @@ export async function orchestratePlan(options: OrchestrateOptions): Promise<{
300
302
 
301
303
  return { steps, chain, overview };
302
304
  }
305
+
306
+ /**
307
+ * T2/R4 (ADR-4 §6, producer 1): convert parsed orchestrate steps into a
308
+ * PlanRecord. Pure — persistence (appendPlanRevision) is the caller's job.
309
+ * Phases group steps by tag in encounter order; item ids reuse the stable
310
+ * stepId so revisions diff cleanly. taskIds stay EMPTY (single-writer rule:
311
+ * only the scheduler links tasks, ADR-4 §3).
312
+ */
313
+ export function stepsToPlanRecord(steps: OrchestratedStep[], runId: string, opts: { title?: string } = {}): PlanRecord {
314
+ const phaseOrder: string[] = [];
315
+ const byTag = new Map<string, OrchestratedStep[]>();
316
+ for (const step of steps) {
317
+ if (!byTag.has(step.tag)) {
318
+ byTag.set(step.tag, []);
319
+ phaseOrder.push(step.tag);
320
+ }
321
+ (byTag.get(step.tag) as OrchestratedStep[]).push(step);
322
+ }
323
+ return {
324
+ id: randomUUID(),
325
+ runId,
326
+ version: 1,
327
+ title: opts.title ?? "Orchestrated plan",
328
+ phases: phaseOrder.map((tag, i) => ({
329
+ id: `phase-${i + 1}-${tag}`,
330
+ title: byTag.get(tag)?.[0]?.heading ?? tag,
331
+ itemIds: (byTag.get(tag) ?? []).map((sqs) => sqs.stepId),
332
+ status: "pending",
333
+ })),
334
+ items: steps.map((sqs) => ({
335
+ id: sqs.stepId,
336
+ ref: sqs.stepId,
337
+ title: sqs.heading ?? sqs.prompt.slice(0, 80),
338
+ taskIds: [],
339
+ specIds: [],
340
+ acceptance: [],
341
+ status: "pending",
342
+ })),
343
+ createdAt: new Date().toISOString(),
344
+ };
345
+ }
346
+
347
+ /**
348
+ * T2/R4 (ADR-4 §6, producer 3): parse a planner-role task output into a
349
+ * PlanRecord. Contract: one fenced/tagged block
350
+ *
351
+ * <plan>
352
+ * { "title": "...", "phases": [{ "title": "...", "items": [{"id": "...", "title": "...", "task": "...", "acceptance": ["..."]}] }] }
353
+ * </plan>
354
+ *
355
+ * The block content is JSON (code fences tolerated). Returns undefined when no
356
+ * block or malformed — callers treat that as "no plan record" (pre-v2 path).
357
+ * Item ids are namespaced `pi-<n>-<slug>` when the planner omits them, so ids
358
+ * stay stable across revisions for diff/carry-over.
359
+ */
360
+ export function parsePlannerPlanOutput(text: string, runId: string, authorTaskId?: string): PlanRecord | undefined {
361
+ const blockMatch = text.match(/<plan>\s*([\s\S]*?)\s*<\/plan>/);
362
+ if (!blockMatch) return undefined;
363
+ let raw = blockMatch[1] as string;
364
+ // Security hardening (review S5): producer text is untrusted worker output —
365
+ // bound the parse input (deep nesting already fails safe via JSON.parse
366
+ // RangeError → catch → undefined; this caps the flat size too).
367
+ if (raw.length > 1_048_576) return undefined;
368
+ raw = raw.replace(/^```[a-z]*\n?/, "").replace(/\n?```$/, "");
369
+ let parsed: unknown;
370
+ try {
371
+ parsed = JSON.parse(raw);
372
+ } catch {
373
+ return undefined;
374
+ }
375
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
376
+ const obj = parsed as { title?: unknown; phases?: unknown };
377
+ if (!Array.isArray(obj.phases) || obj.phases.length === 0) return undefined;
378
+ const phases: PlanRecord["phases"] = [];
379
+ const items: PlanRecord["items"] = [];
380
+ let n = 0;
381
+ for (const [phaseIndex, phaseRaw] of obj.phases.entries()) {
382
+ if (!phaseRaw || typeof phaseRaw !== "object" || Array.isArray(phaseRaw)) return undefined;
383
+ const phaseObj = phaseRaw as { title?: unknown; items?: unknown };
384
+ if (!Array.isArray(phaseObj.items) || phaseObj.items.length === 0) return undefined;
385
+ const phaseId = `phase-${phaseIndex + 1}`;
386
+ const itemIds: string[] = [];
387
+ for (const itemRaw of phaseObj.items) {
388
+ if (!itemRaw || typeof itemRaw !== "object" || Array.isArray(itemRaw)) return undefined;
389
+ const itemObj = itemRaw as { id?: unknown; title?: unknown; task?: unknown; acceptance?: unknown; ref?: unknown };
390
+ const taskText = typeof itemObj.task === "string" ? itemObj.task : undefined;
391
+ const title = typeof itemObj.title === "string" && itemObj.title.trim() ? itemObj.title.trim() : taskText?.slice(0, 80);
392
+ if (!title) return undefined;
393
+ n++;
394
+ const id = typeof itemObj.id === "string" && itemObj.id.trim() ? itemObj.id.trim() : `pi-${n}-${slugText(title)}`;
395
+ if (items.some((i) => i.id === id)) return undefined;
396
+ itemIds.push(id);
397
+ items.push({
398
+ id,
399
+ ref: typeof itemObj.ref === "string" ? itemObj.ref : undefined,
400
+ title,
401
+ taskIds: [],
402
+ specIds: [],
403
+ acceptance: Array.isArray(itemObj.acceptance) ? itemObj.acceptance.filter((a): a is string => typeof a === "string") : [],
404
+ status: "pending",
405
+ });
406
+ }
407
+ phases.push({
408
+ id: phaseId,
409
+ title: typeof phaseObj.title === "string" && phaseObj.title.trim() ? phaseObj.title.trim() : `Phase ${phaseIndex + 1}`,
410
+ itemIds,
411
+ status: "pending",
412
+ });
413
+ }
414
+ return {
415
+ id: randomUUID(),
416
+ runId,
417
+ version: 1,
418
+ title: typeof obj.title === "string" && obj.title.trim() ? obj.title.trim() : "Planner plan",
419
+ phases,
420
+ items,
421
+ createdAt: new Date().toISOString(),
422
+ authorTaskId,
423
+ };
424
+ }
425
+
426
+ function slugText(value: string): string {
427
+ return (
428
+ value
429
+ .toLowerCase()
430
+ .replace(/[^a-z0-9]+/g, "-")
431
+ .replace(/^-+|-+$/g, "")
432
+ .slice(0, 32) || "item"
433
+ );
434
+ }