pi-crew 0.11.1 → 0.11.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 (121) hide show
  1. package/CHANGELOG.md +39 -9
  2. package/README.md +161 -1036
  3. package/agents/verifier.md +18 -7
  4. package/dist/index.mjs +744 -91462
  5. package/docs/README.md +57 -46
  6. package/docs/architecture.md +87 -33
  7. package/docs/commands-reference.md +9 -5
  8. package/docs/troubleshooting.md +3 -2
  9. package/package.json +1 -3
  10. package/schema.json +29 -0
  11. package/skills/real-test-pi-crew/SKILL.md +193 -36
  12. package/src/agents/agent-config.ts +1 -1
  13. package/src/agents/discover-agents.ts +1 -1
  14. package/src/config/config-validation.ts +15 -0
  15. package/src/config/config.ts +47 -13
  16. package/src/config/env-vars.ts +35 -0
  17. package/src/config/types.ts +19 -0
  18. package/src/errors.ts +2 -2
  19. package/src/extension/async-notifier.ts +23 -0
  20. package/src/extension/help.ts +21 -10
  21. package/src/extension/knowledge-injection.ts +2 -1
  22. package/src/extension/management.ts +8 -3
  23. package/src/extension/notification-sink.ts +17 -0
  24. package/src/extension/registration/command-utils.ts +28 -2
  25. package/src/extension/registration/commands/dashboard.ts +11 -1
  26. package/src/extension/registration/commands/manage.ts +31 -15
  27. package/src/extension/registration/commands/run.ts +24 -2
  28. package/src/extension/registration/commands/shared.ts +23 -0
  29. package/src/extension/registration/commands/status.ts +25 -2
  30. package/src/extension/registration/context-builder.ts +8 -2
  31. package/src/extension/registration/health-notify-policy.ts +100 -0
  32. package/src/extension/registration/lazy-configurers.ts +35 -0
  33. package/src/extension/registration/lifecycle-handlers.ts +91 -30
  34. package/src/extension/registration/lifecycle.ts +75 -10
  35. package/src/extension/registration/observability.ts +98 -35
  36. package/src/extension/registration/registration-types.ts +7 -5
  37. package/src/extension/registration/runtime-cleanup.ts +9 -3
  38. package/src/extension/registration/subagent-helpers.ts +38 -0
  39. package/src/extension/registration/wire-cross-extension.ts +28 -0
  40. package/src/extension/run-compare.ts +220 -0
  41. package/src/extension/run-export.ts +37 -5
  42. package/src/extension/run-maintenance.ts +155 -5
  43. package/src/extension/team-tool/dispatch/index.ts +3 -2
  44. package/src/extension/team-tool/dispatch/manage.ts +5 -2
  45. package/src/extension/team-tool/goal.ts +4 -1
  46. package/src/extension/team-tool/handle-settings.ts +19 -2
  47. package/src/extension/team-tool/health-monitor.ts +21 -7
  48. package/src/extension/team-tool/lifecycle-actions.ts +49 -1
  49. package/src/extension/team-tool/plan.ts +10 -0
  50. package/src/extension/team-tool/routing-hint.ts +63 -0
  51. package/src/extension/team-tool/status.ts +4 -0
  52. package/src/extension/team-tool.ts +52 -6
  53. package/src/extension/webhook-notify.ts +382 -0
  54. package/src/observability/metric-sink.ts +12 -2
  55. package/src/prompt/prompt-runtime.ts +82 -31
  56. package/src/prompt/worker-events-channel.ts +12 -0
  57. package/src/runtime/README.md +1 -1
  58. package/src/runtime/async-runner.ts +87 -1
  59. package/src/runtime/background-runner.ts +313 -234
  60. package/src/runtime/broker/crew-broker.ts +17 -11
  61. package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
  62. package/src/runtime/broker/wait-status-cache.ts +1 -1
  63. package/src/runtime/child-pi/child-pi-timers.ts +1 -1
  64. package/src/runtime/child-pi/mock-fixtures.ts +48 -0
  65. package/src/runtime/crew-agent-records.ts +337 -45
  66. package/src/runtime/deadletter.ts +43 -1
  67. package/src/runtime/delegate-spawn.ts +5 -1
  68. package/src/runtime/dispatch-batch.ts +72 -5
  69. package/src/runtime/goal-workflow/goal-loop-runner.ts +73 -4
  70. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  71. package/src/runtime/model/model-fallback.ts +21 -1
  72. package/src/runtime/model/pi-args.ts +8 -10
  73. package/src/runtime/recovery/crash-recovery.ts +25 -1
  74. package/src/runtime/run-worker.ts +12 -1
  75. package/src/runtime/scheduling/global-worker-cap.ts +13 -6
  76. package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
  77. package/src/runtime/scheduling/scheduler.ts +49 -13
  78. package/src/runtime/scheduling/semaphore.ts +148 -20
  79. package/src/runtime/scratchpad/README.md +1 -1
  80. package/src/runtime/scratchpad/protocol.ts +1 -1
  81. package/src/runtime/settings-store.ts +1 -1
  82. package/src/runtime/skill-instructions.ts +22 -0
  83. package/src/runtime/stale-reconciler.ts +85 -13
  84. package/src/runtime/task-runner/pre-execution.ts +26 -2
  85. package/src/runtime/task-runner/prompt-builder.ts +142 -45
  86. package/src/runtime/task-runner.ts +21 -1
  87. package/src/runtime/team-runner.ts +38 -1
  88. package/src/runtime/workspace-lock.ts +4 -1
  89. package/src/schema/config-schema.ts +14 -0
  90. package/src/schema/team-tool-schema.ts +17 -0
  91. package/src/state/atomic-write.ts +53 -0
  92. package/src/state/contracts.ts +109 -0
  93. package/src/state/coordination/locks.ts +191 -33
  94. package/src/state/coordination/mailbox.ts +140 -15
  95. package/src/state/crew-init.ts +87 -12
  96. package/src/state/event-log/cursor.ts +37 -1
  97. package/src/state/event-log/event-log-rotation.ts +72 -7
  98. package/src/state/stores/active-run-registry.ts +13 -1
  99. package/src/state/stores/state-store.ts +112 -22
  100. package/src/state/types.ts +4 -0
  101. package/src/ui/dashboard-panes/agents-pane.ts +11 -2
  102. package/src/ui/heartbeat-aggregator.ts +34 -0
  103. package/src/ui/keybinding-map.ts +22 -4
  104. package/src/ui/live-conversation-overlay.ts +6 -3
  105. package/src/ui/run-dashboard.ts +98 -5
  106. package/src/ui/run-snapshot-cache.ts +18 -1
  107. package/src/ui/spinner.ts +26 -2
  108. package/src/ui/tool-progress-formatter.ts +2 -1
  109. package/src/ui/tool-renderers/brief-mode.ts +2 -1
  110. package/src/ui/tool-renderers/index.ts +3 -3
  111. package/src/utils/incremental-reader.ts +11 -3
  112. package/src/utils/paths.ts +94 -12
  113. package/src/utils/project-markers.ts +40 -0
  114. package/src/worktree/worktree-manager.ts +206 -26
  115. package/workflows/distill.workflow.md +3 -3
  116. package/workflows/fast-fix.workflow.md +1 -1
  117. package/workflows/plan-execute.workflow.md +1 -1
  118. package/workflows/review.workflow.md +1 -1
  119. package/workflows/strict-fast-fix.workflow.md +1 -1
  120. package/docs/migration-v0.4-v0.5.md +0 -208
  121. package/docs/runtime-flow.md +0 -148
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { DEFAULT_PATHS } from "../config/defaults.ts";
4
+ import { getCrewEnv } from "../config/env-vars.ts";
4
5
  import { createCancellationToken } from "../runtime/process/cancellation-token.ts";
5
6
  import type { TeamRunManifest } from "../state/types.ts";
6
7
  import { logInternalError } from "../utils/internal-error.ts";
@@ -22,6 +23,95 @@ export interface PruneRunsOptions {
22
23
  /** When true, compute the removal list WITHOUT deleting any state/artifacts,
23
24
  * running worktree cleanup, or writing an audit. Non-destructive preview. */
24
25
  dryRun?: boolean;
26
+ /** DP-01: never remove a finished run younger than this, even beyond
27
+ * top-keep. Evidence/incident runs must survive a session restart (the
28
+ * 2026-09-21 battery lost a morning of evidence to the next session start).
29
+ * 0 disables the floor. Defaults to the PI_CREW_AUTO_PRUNE_AGE_FLOOR_HOURS
30
+ * value (24h) for auto-prune callers; manual prune passes 0 explicitly. */
31
+ ageFloorMs?: number;
32
+ }
33
+
34
+ /**
35
+ * DP-01: resolve the auto-prune keep count from PI_CREW_AUTO_PRUNE_KEEP.
36
+ * Invalid/negative/non-finite values fall back to 10 (the historical hard-coded
37
+ * value) with a logged warning — never a crash and never an infinite keep.
38
+ */
39
+ export function resolveAutoPruneKeep(env: (name: string) => string | undefined = getCrewEnv): number {
40
+ const raw = env("PI_CREW_AUTO_PRUNE_KEEP")?.trim();
41
+ if (raw === undefined || raw === "") return 10;
42
+ const parsed = Number.parseInt(raw, 10);
43
+ if (!Number.isFinite(parsed) || parsed < 0) {
44
+ logInternalError(
45
+ "prune.auto-keep-invalid",
46
+ new Error(`PI_CREW_AUTO_PRUNE_KEEP=${JSON.stringify(raw)} is not a non-negative integer; using 10`),
47
+ );
48
+ return 10;
49
+ }
50
+ return parsed;
51
+ }
52
+
53
+ /**
54
+ * DP-01: resolve the auto-prune age floor (ms) from
55
+ * PI_CREW_AUTO_PRUNE_AGE_FLOOR_HOURS (default 24h; 0 disables).
56
+ */
57
+ export function resolveAutoPruneAgeFloorMs(env: (name: string) => string | undefined = getCrewEnv): number {
58
+ const raw = env("PI_CREW_AUTO_PRUNE_AGE_FLOOR_HOURS")?.trim();
59
+ if (raw === undefined || raw === "") return 24 * 60 * 60 * 1000;
60
+ const hours = Number.parseFloat(raw);
61
+ if (!Number.isFinite(hours) || hours < 0) {
62
+ logInternalError(
63
+ "prune.auto-age-floor-invalid",
64
+ new Error(`PI_CREW_AUTO_PRUNE_AGE_FLOOR_HOURS=${JSON.stringify(raw)} is not a non-negative number; using 24`),
65
+ );
66
+ return 24 * 60 * 60 * 1000;
67
+ }
68
+ return hours * 60 * 60 * 1000;
69
+ }
70
+
71
+ /** DP-01: rotate prune.jsonl when it exceeds this size (one generation kept). */
72
+ export const PRUNE_AUDIT_MAX_BYTES = 512 * 1024;
73
+
74
+ /**
75
+ * DP-01: size-based rotation for the prune audit log. Best-effort: a rotation
76
+ * failure must never break the prune path (the audit is observability, not
77
+ * correctness). One generation is enough — the audit is for recent forensics
78
+ * (the 2026-09-21 storm RCA read exactly the current window).
79
+ */
80
+ function rotatePruneAuditIfNeeded(filePath: string): void {
81
+ try {
82
+ if (!fs.existsSync(filePath)) return;
83
+ if (fs.statSync(filePath).size < PRUNE_AUDIT_MAX_BYTES) return;
84
+ fs.renameSync(filePath, `${filePath}.1`);
85
+ } catch (error) {
86
+ logInternalError("prune.audit-rotate", error, `path=${filePath}`);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * DP-01: record a pruned FAILED/BLOCKED run's reason in a durable index that
92
+ * survives run-dir pruning. Turn-run dirs are aggressively pruned (keep=10 at
93
+ * session start); without this, a failure reason becomes unrecoverable (live
94
+ * incident goal_20260921111305 — see docs/specs/DP-01.md). Best-effort.
95
+ */
96
+ function appendPrunedFailureIndex(cwd: string, run: TeamRunManifest): void {
97
+ try {
98
+ const crewRoot = projectCrewRoot(cwd);
99
+ if (!fs.existsSync(crewRoot)) return;
100
+ const filePath = path.join(crewRoot, "state", "pruned-failures.jsonl");
101
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
102
+ const entry = {
103
+ runId: run.runId,
104
+ status: run.status,
105
+ summary: (run.summary ?? "").split("\n")[0]?.slice(0, 500),
106
+ goal: (run.goal ?? "").split("\n")[0]?.slice(0, 200),
107
+ team: run.team,
108
+ createdAt: run.createdAt,
109
+ prunedAt: new Date().toISOString(),
110
+ };
111
+ fs.appendFileSync(filePath, `${JSON.stringify(redactSecrets(entry))}\n`, "utf-8");
112
+ } catch (error) {
113
+ logInternalError("prune.failure-index-write", error, `runId=${run.runId}`);
114
+ }
25
115
  }
26
116
 
27
117
  /**
@@ -98,8 +188,19 @@ function isSafeToPrune(cwd: string, run: TeamRunManifest): boolean {
98
188
 
99
189
  function appendPruneAudit(cwd: string, payload: Record<string, unknown>): string | undefined {
100
190
  try {
101
- const filePath = path.join(projectCrewRoot(cwd), "audit", "prune.jsonl");
191
+ // RR-020 Fix 2: a no-op prune must NOT materialize the project crew
192
+ // root. `<crewRoot>/audit/` only exists because a real prune removed
193
+ // runs, and a real prune implies the crew root (+ state/runs) exists —
194
+ // so bail out instead of mkdir-ing a `<crewRoot>/audit/` tree on
195
+ // session start for a project that never ran a team.
196
+ const crewRoot = projectCrewRoot(cwd);
197
+ if (!fs.existsSync(crewRoot)) return undefined;
198
+ const filePath = path.join(crewRoot, "audit", "prune.jsonl");
102
199
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
200
+ // DP-01: rotate the audit log before it grows unbounded (it reached
201
+ // 720 KB / 1,436 entries with no cap). Keep the current file under
202
+ // PRUNE_AUDIT_MAX_BYTES; older entries rotate to .1 (one generation).
203
+ rotatePruneAuditIfNeeded(filePath);
103
204
  fs.appendFileSync(filePath, `${JSON.stringify(redactSecrets({ ...payload, auditedAt: new Date().toISOString() }))}\n`, "utf-8");
104
205
  return filePath;
105
206
  } catch (error) {
@@ -115,7 +216,18 @@ export function pruneFinishedRuns(cwd: string, keep: number, options: PruneRunsO
115
216
  .sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
116
217
  const kept = finished.slice(0, keep).map((run) => run.runId);
117
218
  const removed: string[] = [];
118
- const toRemove = finished.slice(keep);
219
+ // DP-01: age floor — a finished run younger than the floor is NEVER removed
220
+ // by auto-prune, even beyond top-keep. Manual prune passes 0 (no floor).
221
+ const ageFloorMs = options.ageFloorMs ?? 0;
222
+ const floorCutoff = ageFloorMs > 0 ? Date.now() - ageFloorMs : 0;
223
+ const toRemove = finished.slice(keep).filter((run) => {
224
+ if (floorCutoff === 0) return true;
225
+ const ts = Date.parse(run.updatedAt ?? run.createdAt ?? "");
226
+ // Unparseable timestamps are treated as OLD (removable) to preserve
227
+ // pre-DP-01 behavior for malformed manifests.
228
+ if (!Number.isFinite(ts)) return true;
229
+ return ts < floorCutoff;
230
+ });
119
231
  for (let i = 0; i < toRemove.length; i++) {
120
232
  if (i % 5 === 0) token.heartbeat(`prune:${i}/${toRemove.length}`);
121
233
  const run = toRemove[i];
@@ -158,6 +270,9 @@ export function pruneFinishedRuns(cwd: string, keep: number, options: PruneRunsO
158
270
  }
159
271
  fs.rmSync(run.stateRoot, { recursive: true, force: true });
160
272
  fs.rmSync(run.artifactsRoot, { recursive: true, force: true });
273
+ // DP-01: preserve the reason of a pruned failed/blocked run in a durable
274
+ // index (the run dir that held it is gone now).
275
+ if (run.status === "failed" || run.status === "blocked") appendPrunedFailureIndex(cwd, run);
161
276
  removed.push(run.runId);
162
277
  }
163
278
  // ST-6: Sweep stale .corrupt-* quarantine files to prevent unbounded growth.
@@ -165,6 +280,11 @@ export function pruneFinishedRuns(cwd: string, keep: number, options: PruneRunsO
165
280
  sweepStaleCorruptFiles(path.join(projectCrewRoot(cwd), DEFAULT_PATHS.state.runsSubdir));
166
281
  }
167
282
 
283
+ // RR-020 Fix 2 note: appendPruneAudit itself bails when the crew root does
284
+ // not exist, so a no-op prune on a fresh project writes nothing. When the
285
+ // crew root DOES exist, HEAD parity is preserved — a zero-candidate prune
286
+ // still records its `kept:[] removed:[]` audit line (cold-verify correction:
287
+ // the earlier `finished.length === 0` bail suppressed that line).
168
288
  const auditPath = options.dryRun
169
289
  ? undefined
170
290
  : appendPruneAudit(cwd, {
@@ -186,7 +306,7 @@ export function pruneFinishedRuns(cwd: string, keep: number, options: PruneRunsO
186
306
  * @param keep Number of most recent finished runs to retain
187
307
  * @returns kept and removed run IDs
188
308
  */
189
- export function pruneUserLevelRuns(keep: number): PruneRunsResult {
309
+ export function pruneUserLevelRuns(keep: number, options: PruneRunsOptions = {}): PruneRunsResult {
190
310
  const crewRoot = userCrewRoot();
191
311
  const runsRoot = path.join(crewRoot, DEFAULT_PATHS.state.runsSubdir);
192
312
  if (!fs.existsSync(runsRoot)) return { kept: [], removed: [] };
@@ -195,7 +315,12 @@ export function pruneUserLevelRuns(keep: number): PruneRunsResult {
195
315
  const MAX_DIRS = 500;
196
316
  const finished: Array<{
197
317
  runId: string;
198
- updatedAt: string;
318
+ updatedAt?: string;
319
+ createdAt?: string;
320
+ status?: string;
321
+ summary?: string;
322
+ goal?: string;
323
+ team?: string;
199
324
  stateRoot: string;
200
325
  artifactsRoot: string;
201
326
  }> = [];
@@ -240,18 +365,43 @@ export function pruneUserLevelRuns(keep: number): PruneRunsResult {
240
365
  finished.push({
241
366
  runId: manifest.runId,
242
367
  updatedAt: manifest.updatedAt,
368
+ createdAt: manifest.createdAt,
369
+ status: manifest.status,
370
+ summary: manifest.summary,
371
+ goal: manifest.goal,
372
+ team: manifest.team,
243
373
  stateRoot: manifest.stateRoot,
244
374
  artifactsRoot: manifest.artifactsRoot,
245
375
  });
246
376
  }
247
377
 
248
- // Sort newest first, keep top N, remove the rest
378
+ // Sort newest first, keep top N, remove the rest.
249
379
  finished.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
250
380
  const kept = finished.slice(0, keep).map((r) => r.runId);
251
381
  const removed: string[] = [];
382
+ // DP-01: same age floor as the project-level prune (user-level runs are
383
+ // equally easy to lose — the 2026-09-21 incident evidence lived here too).
384
+ const ageFloorMs = options.ageFloorMs ?? 0;
385
+ const floorCutoff = ageFloorMs > 0 ? Date.now() - ageFloorMs : 0;
252
386
  for (const run of finished.slice(keep)) {
387
+ if (floorCutoff > 0) {
388
+ const ts = Date.parse(run.updatedAt ?? run.createdAt ?? "");
389
+ if (Number.isFinite(ts) && ts >= floorCutoff) continue; // too young — protect
390
+ }
253
391
  fs.rmSync(run.stateRoot, { recursive: true, force: true });
254
392
  fs.rmSync(run.artifactsRoot, { recursive: true, force: true });
393
+ // DP-01: preserve a pruned failed/blocked run's reason (the user-level
394
+ // index lives under the user crew root).
395
+ if (run.status === "failed" || run.status === "blocked") {
396
+ appendPrunedFailureIndex(crewRoot, {
397
+ runId: run.runId,
398
+ status: run.status,
399
+ summary: run.summary,
400
+ goal: run.goal,
401
+ team: run.team,
402
+ createdAt: run.createdAt,
403
+ } as TeamRunManifest);
404
+ }
255
405
  removed.push(run.runId);
256
406
  }
257
407
 
@@ -10,8 +10,8 @@
10
10
  * status (16): status, list, get, events, artifacts, summary, graph, search,
11
11
  * health, worktrees, checkpoint, cache, explain, onboard, recommend, help
12
12
  * control (7): cancel, invalidate, respond, cleanup, prune, forget, doctor
13
- * manage (16): create, update, delete, init, config, validate, autonomy, settings,
14
- * workflow-create/get/list/save/delete, import, imports, export
13
+ * manage (17): create, update, delete, init, config, validate, autonomy, settings,
14
+ * workflow-create/get/list/save/delete, import, imports, export, compare
15
15
  * automate(6): schedule, scheduled, anchor, auto-summarize, auto_boomerang, api
16
16
  */
17
17
  import type { TeamDomain } from "../../../schema/team-tool-schema.ts";
@@ -80,6 +80,7 @@ const ACTION_TO_DOMAIN: Record<string, TeamDomain> = {
80
80
  import: "manage",
81
81
  imports: "manage",
82
82
  export: "manage",
83
+ compare: "manage",
83
84
 
84
85
  // automate domain (6)
85
86
  schedule: "automate",
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Actions: create, update, delete, init, config, validate, autonomy, settings,
5
5
  * workflow-create, workflow-get, workflow-list, workflow-save, workflow-delete,
6
- * import, imports, export.
6
+ * import, imports, export, compare.
7
7
  *
8
8
  * Inline cases (init, autonomy, config, validate) are moved verbatim from the
9
9
  * former handleTeamTool switch.
@@ -18,7 +18,7 @@ import { formatValidationReport, validateResources } from "../../validate-resour
18
18
  import { autonomousPatchFromConfig, configPatchFromConfig, formatAutonomyStatus } from "../config-patch.ts";
19
19
  import { configRecord, result, type TeamContext } from "../context.ts";
20
20
  import { handleSettings } from "../handle-settings.ts";
21
- import { handleExport, handleImport, handleImports } from "../lifecycle-actions.ts";
21
+ import { handleCompare, handleExport, handleImport, handleImports } from "../lifecycle-actions.ts";
22
22
  import {
23
23
  handleWorkflowCreate,
24
24
  handleWorkflowDelete,
@@ -51,6 +51,7 @@ export const MANAGE_DOMAIN_ACTIONS = [
51
51
  "import",
52
52
  "imports",
53
53
  "export",
54
+ "compare",
54
55
  ] as const;
55
56
  type ManageDomainAction = (typeof MANAGE_DOMAIN_ACTIONS)[number];
56
57
 
@@ -161,6 +162,8 @@ export async function handleManageDomain(params: TeamToolParamsValue, ctx: TeamC
161
162
  }
162
163
  case "export":
163
164
  return handleExport(params, ctx);
165
+ case "compare":
166
+ return handleCompare(params, ctx);
164
167
  case "import":
165
168
  return handleImport(params, ctx);
166
169
  case "imports":
@@ -23,6 +23,7 @@ import { appendEventAsync } from "../../state/event-log/event-log.ts";
23
23
  import { createRunPaths, saveRunManifestAsync } from "../../state/stores/state-store.ts";
24
24
  import type { GoalLoopState, GoalLoopStatus, TeamRunManifest } from "../../state/types.ts";
25
25
  import { logInternalError } from "../../utils/internal-error.ts";
26
+ import { projectCrewRoot } from "../../utils/paths.ts";
26
27
  import { result, type TeamContext } from "./context.ts";
27
28
  import { paramRequired } from "./param-error.ts";
28
29
 
@@ -200,7 +201,9 @@ async function handleStart(input: GoalSubActionInput): Promise<ReturnType<typeof
200
201
  goalId,
201
202
  objective,
202
203
  maxTurns,
203
- statePath: `${cwd}/.crew/state/goals/${goalId}.json`,
204
+ // RR-020 Fix 4: resolve the layout (`<crewRoot>/state/goals/...`) instead
205
+ // of hardcoding `.crew` — GoalStore writes to projectCrewRoot(cwd).
206
+ statePath: `${projectCrewRoot(cwd)}/state/goals/${goalId}.json`,
204
207
  },
205
208
  });
206
209
 
@@ -1,5 +1,6 @@
1
1
  import { loadConfig, updateConfig } from "../../config/config.ts";
2
2
  import { suggestConfigKey } from "../../config/suggestions.ts";
3
+ import { collectSensitiveConfigPaths } from "../../schema/sensitive-config-paths.ts";
3
4
  import { discoverPiThemes, formatThemesListing, setPiTheme } from "../../ui/theme-discovery.ts";
4
5
  import { configPatchFromConfig } from "../team-tool/config-patch.ts";
5
6
  import type { TeamContext } from "../team-tool/context.ts";
@@ -214,6 +215,12 @@ const KNOWN_KEYS = new Set([
214
215
  "notifications.enabled",
215
216
  "notifications.severityFilter",
216
217
  "notifications.dedupWindowMs",
218
+ "notifications.quietHours",
219
+ "notifications.webhook",
220
+ "notifications.webhook.url",
221
+ "notifications.webhook.enabled",
222
+ "notifications.webhook.secret",
223
+ "notifications.webhook.allowLocalhost",
217
224
  "notifications.batchWindowMs",
218
225
  "notifications.quietHours",
219
226
  "notifications.sinkRetentionDays",
@@ -459,10 +466,19 @@ export function handleSettings(params: { config?: Record<string, unknown> }, ctx
459
466
  if (suggestion) note = `\n(did you mean '${suggestion}'?)`;
460
467
  else note = "\n(unknown key — may not take effect)";
461
468
  }
462
- return result(`${key} = ${formatValue(value, key)}${note}`, {
469
+ // US-030 security review: a value whose dotted path is schema-marked
470
+ // sensitive (webhook.secret, otlp.headers, …) must not be echoed into
471
+ // the session transcript — any in-session agent can read tool output.
472
+ const sensitive = collectSensitiveConfigPaths().some((path) => key === path || key.startsWith(`${path}.`));
473
+ const rendered = sensitive
474
+ ? value === undefined || value === null
475
+ ? "<not set>"
476
+ : "***(redacted — sensitive)"
477
+ : formatValue(value, key);
478
+ return result(`${key} = ${rendered}${note}`, {
463
479
  ...OK,
464
480
  key,
465
- value,
481
+ value: sensitive ? undefined : value,
466
482
  } as never);
467
483
  }
468
484
 
@@ -516,6 +532,7 @@ export function handleSettings(params: { config?: Record<string, unknown> }, ctx
516
532
  // Check if project config would sanitize this key
517
533
  if (scope === "project") {
518
534
  const sensitiveKeys = [
535
+ "notifications.webhook",
519
536
  "executeWorkers",
520
537
  "asyncByDefault",
521
538
  "runtime.mode",
@@ -9,6 +9,7 @@ import { hasStaleAsyncProcess, isActiveRunStatus, isLikelyOrphanedActiveRun } fr
9
9
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
10
10
  import { loadTasksWithRecovery } from "../../state/stores/state-store.ts";
11
11
  import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
12
+ import { findRunStateDir } from "../../utils/paths.ts";
12
13
  import { listRuns } from "../run-index.ts";
13
14
  import type { PiTeamsToolResult } from "../tool-result.ts";
14
15
  import { result, type TeamContext } from "./context.ts";
@@ -115,7 +116,8 @@ export function detectStuckTasks(run: { runId: string; stateRoot: string }, now:
115
116
 
116
117
  /**
117
118
  * Scan a directory for "pi-crew-*" subdirs that contain
118
- * .crew/state/runs/ with at least one valid run manifest.
119
+ * `.crew/state/runs/` OR `.pi/teams/state/runs/` with at least one valid run
120
+ * manifest (both supported layouts — RR-020 Fix 3).
119
121
  * Read-only — does NOT mutate anything.
120
122
  * Pattern adapted from stale-reconciler.ts:reconcileOrphanedTempWorkspaces().
121
123
  */
@@ -128,12 +130,19 @@ export function scanZombieTempWorkspaces(tmpDir: string, now: number): ZombieWor
128
130
  for (const entry of entries) {
129
131
  if (!entry.isDirectory() || !entry.name.startsWith("pi-crew-")) continue;
130
132
  const workspaceDir = path.join(tmpDir, entry.name);
131
- const stateRunsDir = path.join(workspaceDir, ".crew", "state", "runs");
132
- if (!fs.existsSync(stateRunsDir)) continue;
133
+ // RR-020 Fix 3: both layouts (`.crew` and `.pi/teams`) are recognised —
134
+ // checking only `.crew` made a `.pi/teams` workspace invisible as a
135
+ // zombie (and eligible for debris deletion).
136
+ const stateRunsDir = findRunStateDir(workspaceDir);
137
+ if (!stateRunsDir) continue;
133
138
 
134
139
  let runCount = 0;
135
140
  try {
136
- for (const runDir of fs.readdirSync(stateRunsDir)) {
141
+ // Cold-verify F-2: Dirent.isDirectory() is FALSE for a symlink-to-dir,
142
+ // so a planted `runs/<runId>` symlink is skipped (no out-of-tree read).
143
+ for (const entry of fs.readdirSync(stateRunsDir, { withFileTypes: true })) {
144
+ if (!entry.isDirectory()) continue;
145
+ const runDir = entry.name;
137
146
  const manifestPath = path.join(stateRunsDir, runDir, "manifest.json");
138
147
  if (fs.existsSync(manifestPath)) {
139
148
  try {
@@ -194,11 +203,16 @@ export function collectTempWorkspaceRuns(
194
203
  const entries = fs.readdirSync(tmpDir, { withFileTypes: true });
195
204
  for (const entry of entries) {
196
205
  if (!entry.isDirectory() || !entry.name.startsWith("pi-crew-")) continue;
197
- const stateRunsDir = path.join(tmpDir, entry.name, ".crew", "state", "runs");
198
- if (!fs.existsSync(stateRunsDir)) continue;
206
+ // RR-020 Fix 3: both layouts (`.crew` and `.pi/teams`).
207
+ const stateRunsDir = findRunStateDir(path.join(tmpDir, entry.name));
208
+ if (!stateRunsDir) continue;
199
209
 
200
210
  try {
201
- for (const runDir of fs.readdirSync(stateRunsDir)) {
211
+ // Cold-verify F-2: symlinked `runs/<runId>` entries are not directories
212
+ // per Dirent (withFileTypes does not follow) — skipped.
213
+ for (const entry of fs.readdirSync(stateRunsDir, { withFileTypes: true })) {
214
+ if (!entry.isDirectory()) continue;
215
+ const runDir = entry.name;
202
216
  const manifestPath = path.join(stateRunsDir, runDir, "manifest.json");
203
217
  if (!fs.existsSync(manifestPath)) continue;
204
218
  try {
@@ -13,6 +13,7 @@ import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
13
13
  import { sleep } from "../../utils/sleep.ts";
14
14
  import { cleanupRunWorktrees } from "../../worktree/cleanup.ts";
15
15
  import { listImportedRuns } from "../import-index.ts";
16
+ import { runCompareBundle } from "../run-compare.ts";
16
17
  import { exportRunBundle } from "../run-export.ts";
17
18
  import { importRunBundle } from "../run-import.ts";
18
19
  import { pruneFinishedRuns } from "../run-maintenance.ts";
@@ -144,6 +145,49 @@ export async function handleExport(params: TeamToolParamsValue, ctx: TeamContext
144
145
  );
145
146
  }
146
147
 
148
+ // US-021 (2026-09-22): compare two runs (before/after a fix, two attempts at
149
+ // the same goal). Pure diff logic lives in run-compare.ts; this handler owns
150
+ // param validation, ownership security (mirrors handleExport) and the
151
+ // no-partial-artifact guarantee (both runs load BEFORE anything is written).
152
+ export function handleCompare(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
153
+ const raw = params.runIds ?? [];
154
+ const runIds = Array.isArray(raw) ? raw.filter((id): id is string => typeof id === "string" && id !== "") : [];
155
+ if (runIds.length !== 2)
156
+ return result(
157
+ paramRequired("compare", "runIds", "{ action: 'compare', runIds: ['team_a', 'team_b'] }"),
158
+ { action: "compare", status: "error" },
159
+ true,
160
+ );
161
+ const a = loadRunManifestById(ctx.cwd, runIds[0]);
162
+ if (!a) return result(`Run '${runIds[0]}' not found.${RUN_NOT_FOUND_HINT}`, { action: "compare", status: "error" }, true);
163
+ const b = loadRunManifestById(ctx.cwd, runIds[1]);
164
+ if (!b) return result(`Run '${runIds[1]}' not found.${RUN_NOT_FOUND_HINT}`, { action: "compare", status: "error" }, true);
165
+
166
+ // SECURITY: Ownership check — mirrors handleExport. A comparison reveals
167
+ // another session's task statuses, costs and event types; foreign-run
168
+ // comparison therefore requires confirm: true (explicit user intent).
169
+ for (const loaded of [a, b]) {
170
+ const foreignRun = typeof loaded.manifest.ownerSessionId === "string" && loaded.manifest.ownerSessionId !== ctx.sessionId;
171
+ if (foreignRun && !params.confirm)
172
+ return result(
173
+ `Run ${loaded.manifest.runId} belongs to another session. Use confirm: true to compare anyway.`,
174
+ { action: "compare", status: "error", runId: loaded.manifest.runId },
175
+ true,
176
+ );
177
+ }
178
+
179
+ const { comparison, markdownPath } = runCompareBundle(a.manifest, a.tasks, b.manifest, b.tasks);
180
+ return result(
181
+ [
182
+ `Compared ${a.manifest.runId} vs ${b.manifest.runId}: ${comparison.identical ? "identical" : "different"}.`,
183
+ `Tasks: ${comparison.tasks.length} compared, ${comparison.tasksOnlyInA.length} only in a, ${comparison.tasksOnlyInB.length} only in b.`,
184
+ `Tokens Δ: ${comparison.usage.tokensDelta >= 0 ? "+" : ""}${comparison.usage.tokensDelta}, Cost Δ: ${comparison.usage.costDelta >= 0 ? "+" : ""}${comparison.usage.costDelta.toFixed(4)}`,
185
+ `Markdown: ${markdownPath}`,
186
+ ].join("\n"),
187
+ { action: "compare", status: "ok", runId: a.manifest.runId },
188
+ );
189
+ }
190
+
147
191
  // Note: handlePrune has no ownership check — intentionally cross-session.
148
192
  // Prune is a maintenance-level operation that removes ALL finished runs
149
193
  // regardless of which session created them. Requires confirm: true.
@@ -425,7 +469,11 @@ function handleProjectCleanup(params: TeamToolParamsValue, ctx: TeamContext): Pi
425
469
  lines.push(` - ERROR: could not resolve ${crewRoot} (skipped)`);
426
470
  return result(lines.join("\n"), { action: "cleanup", status: "ok", scope }, false);
427
471
  }
428
- if (!resolved.endsWith(path.sep + ".crew") && !resolved.endsWith("/teams") && path.basename(resolved) !== ".crew") {
472
+ // RR-020 A0-4 (round-4 sweep): `"/teams"` hardcoded the POSIX separator,
473
+ // so on Windows the `.pi\teams` layout never matched and cleanup REFUSED
474
+ // to remove it (fail-closed, but the feature was broken there).
475
+ // `path.sep + "teams"` is identical on POSIX and correct on Windows.
476
+ if (!resolved.endsWith(path.sep + ".crew") && !resolved.endsWith(path.sep + "teams") && path.basename(resolved) !== ".crew") {
429
477
  lines.push(` - ERROR: refused to remove ${resolved} (does not look like a .crew dir) — skipped`);
430
478
  } else {
431
479
  if (!dryRun) {
@@ -5,6 +5,7 @@ import { allWorkflows, discoverWorkflows } from "../../workflows/discover-workfl
5
5
  import { validateWorkflowForTeam } from "../../workflows/validate-workflow.ts";
6
6
  import type { PiTeamsToolResult } from "../tool-result.ts";
7
7
  import { result, type TeamContext } from "./context.ts";
8
+ import { resolveRoutingHint } from "./routing-hint.ts";
8
9
 
9
10
  export function handlePlan(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
10
11
  const teamName = params.team ?? "default";
@@ -21,6 +22,12 @@ export function handlePlan(params: TeamToolParamsValue, ctx: TeamContext): PiTea
21
22
  true,
22
23
  );
23
24
  const goal = params.goal ?? params.task ?? "(not provided)";
25
+ // DP-04: small-goal routing hint (suggest-only by default; never overrides
26
+ // an explicit team/workflow choice). See routing-hint.ts for the rationale.
27
+ const routingHint = resolveRoutingHint({
28
+ goal,
29
+ explicitOverride: params.team !== undefined || params.workflow !== undefined,
30
+ });
24
31
  // ROADMAP T2.2: single-agent composition mode (cliff hedge).
25
32
  if (params.singleAgent) {
26
33
  const composed = composeSingleAgentPrompt(workflow, goal);
@@ -46,5 +53,8 @@ export function handlePlan(params: TeamToolParamsValue, ctx: TeamContext): PiTea
46
53
  `${index + 1}. ${step.id} [${step.role}]${step.dependsOn?.length ? ` after ${step.dependsOn.join(", ")}` : ""}`,
47
54
  ),
48
55
  ];
56
+ if (routingHint.suggested && routingHint.message) {
57
+ lines.push("", routingHint.message);
58
+ }
49
59
  return result(lines.join("\n"), { action: "plan", status: "ok" });
50
60
  }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * DP-04 (2026-09-22): small-goal routing hint.
3
+ *
4
+ * Measured: a trivial goal through the default team burns 7.6–9k tokens just on
5
+ * the 3 context-setup steps (assess → execute → verify). The engine ALREADY
6
+ * supports `singleAgent: true` (handlePlan → composeSingleAgentPrompt); nothing
7
+ * suggested it for small goals.
8
+ *
9
+ * This module decides the hint. It is deliberately PURE and explainable — no
10
+ * ML, no hidden state — so the routing decision can be asserted in tests and
11
+ * audited from the plan output. Default mode is "suggest" (visible nudge, the
12
+ * caller/agent still decides), NOT a silent switch: this project has been
13
+ * burned by silent routing changes before (the auto-boomerang aliasing trap),
14
+ * so the decision must be visible.
15
+ */
16
+
17
+ export type SmallGoalRoutingMode = "off" | "suggest" | "auto";
18
+
19
+ /** Goals at or below this many whitespace tokens are considered "small". */
20
+ export const SMALL_GOAL_TOKEN_THRESHOLD = 40;
21
+
22
+ export interface RoutingHintInput {
23
+ goal: string;
24
+ /** True when the caller supplied an explicit team and/or workflow override. */
25
+ explicitOverride: boolean;
26
+ /** Configured mode; defaults to "suggest". */
27
+ mode?: SmallGoalRoutingMode;
28
+ }
29
+
30
+ export interface RoutingHint {
31
+ /** Whether a cheaper single-agent composition is advised. */
32
+ suggested: boolean;
33
+ /** Machine-readable reason (empty when not suggested). */
34
+ reason?: string;
35
+ /** Human-readable, shown in the plan output. */
36
+ message?: string;
37
+ /** Rough token saving estimate for the plan text (informational). */
38
+ estimatedSavings?: string;
39
+ }
40
+
41
+ /** Cheap, deterministic token count (whitespace split) — no tokenizer dep. */
42
+ export function goalTokenCount(goal: string): number {
43
+ const trimmed = goal.trim();
44
+ if (trimmed.length === 0) return 0;
45
+ return trimmed.split(/\s+/).length;
46
+ }
47
+
48
+ export function resolveRoutingHint(input: RoutingHintInput): RoutingHint {
49
+ const mode = input.mode ?? "suggest";
50
+ if (mode === "off") return { suggested: false };
51
+ // Never second-guess an explicit team/workflow choice.
52
+ if (input.explicitOverride) return { suggested: false };
53
+ const tokens = goalTokenCount(input.goal);
54
+ if (tokens === 0 || tokens > SMALL_GOAL_TOKEN_THRESHOLD) return { suggested: false };
55
+ return {
56
+ suggested: true,
57
+ reason: `goal is small (${tokens} tokens ≤ ${SMALL_GOAL_TOKEN_THRESHOLD})`,
58
+ message:
59
+ `Hint: this goal is small (${tokens} tokens). The default team runs 3 context-setup phases ` +
60
+ `(~8k tokens overhead) before real work — consider \`singleAgent: true\` (or a 2-step chain) to skip them.`,
61
+ estimatedSavings: "~5-6k tokens",
62
+ };
63
+ }
@@ -2,6 +2,7 @@ import { loadConfig } from "../../config/config.ts";
2
2
  import { applyAttentionState, formatActivityAge, resolveCrewControlConfig } from "../../runtime/agent-control.ts";
3
3
  import { extractCommandTrace } from "../../runtime/command-trace.ts";
4
4
  import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
5
+ import { deadletterStatusLine } from "../../runtime/deadletter.ts";
5
6
  import { evaluateRunEffectiveness } from "../../runtime/effectiveness.ts";
6
7
  import { computePhaseProgress } from "../../runtime/phase-progress.ts";
7
8
  import { checkProcessLiveness, isActiveRunStatus } from "../../runtime/process-status.ts";
@@ -150,6 +151,7 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
150
151
  }
151
152
  const counts = new Map<string, number>();
152
153
  for (const task of tasks) counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
154
+ const deadletterLine = deadletterStatusLine(manifest);
153
155
  const phaseProgress = computePhaseProgress(tasks);
154
156
  // PERF (2026-08-24): intentionally NOT passing `limit` here — readEventsCursor's
155
157
  // limit is a HEAD cap (oldest-first slice for streaming pagination), not a tail
@@ -267,6 +269,8 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
267
269
  )
268
270
  : ["- (none)"]),
269
271
  `Task counts: ${[...counts.entries()].map(([status, count]) => `${status}=${count}`).join(", ") || "none"}`,
272
+ // US-003 AC-3: surface exhausted-retry failures — only when entries exist.
273
+ ...(deadletterLine ? [deadletterLine] : []),
270
274
  "Effectiveness:",
271
275
  `- observable=${effectiveness.observable}/${Math.max(1, effectiveness.completed)} completed tasks`,
272
276
  `- workerExecution=${effectiveness.workerExecution} guard=${effectiveness.guardMode} severity=${effectiveness.severity}`,