pi-crew 0.10.1 → 0.10.3

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 (127) hide show
  1. package/CHANGELOG.md +347 -0
  2. package/NOTICE.md +21 -0
  3. package/README.md +44 -2
  4. package/agents/executor.md +1 -1
  5. package/agents/test-engineer.md +1 -1
  6. package/dist/index.mjs +2555 -932
  7. package/package.json +2 -1
  8. package/schema.json +503 -94
  9. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +6 -2
  10. package/skills/real-test-pi-crew/SKILL.md +278 -79
  11. package/src/config/config-merge.ts +11 -1
  12. package/src/config/config-validation.ts +47 -2
  13. package/src/config/config.ts +28 -6
  14. package/src/config/defaults.ts +43 -11
  15. package/src/config/env-vars.ts +27 -2
  16. package/src/config/role-tools.ts +4 -2
  17. package/src/config/types.ts +55 -1
  18. package/src/extension/crew-cleanup.ts +13 -0
  19. package/src/extension/crew-vibes/footer.ts +19 -0
  20. package/src/extension/crew-vibes/index.ts +11 -1
  21. package/src/extension/register.ts +8 -0
  22. package/src/extension/registration/command-registration.ts +1 -0
  23. package/src/extension/registration/commands/run.ts +15 -1
  24. package/src/extension/registration/commands/shared.ts +8 -0
  25. package/src/extension/registration/foreground-run-controller.ts +10 -2
  26. package/src/extension/registration/lifecycle-handlers.ts +92 -11
  27. package/src/extension/registration/runtime-cleanup.ts +23 -5
  28. package/src/extension/registration/team-tool.ts +58 -6
  29. package/src/extension/registration/ui.ts +5 -4
  30. package/src/extension/team-tool/doctor.ts +364 -7
  31. package/src/extension/team-tool/handle-settings.ts +19 -0
  32. package/src/extension/team-tool/inspect.ts +10 -2
  33. package/src/extension/team-tool/run-deadline.ts +20 -3
  34. package/src/extension/team-tool/run.ts +26 -2
  35. package/src/extension/team-tool/status.ts +7 -0
  36. package/src/extension/team-tool.ts +35 -2
  37. package/src/hooks/registry.ts +59 -56
  38. package/src/prompt/inbox-poll.ts +90 -0
  39. package/src/prompt/message-tool.ts +166 -0
  40. package/src/prompt/prompt-runtime.ts +201 -18
  41. package/src/prompt/surface-worker.ts +720 -0
  42. package/src/prompt/worker-events-channel.ts +49 -3
  43. package/src/runtime/async-runner.ts +29 -1
  44. package/src/runtime/background-runner.ts +13 -7
  45. package/src/runtime/broker/broker-issuer.ts +27 -2
  46. package/src/runtime/broker/crew-broker-tokens.ts +56 -4
  47. package/src/runtime/broker/crew-broker.ts +261 -41
  48. package/src/runtime/child-pi/child-pi-constants.ts +8 -0
  49. package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
  50. package/src/runtime/child-pi/child-pi-streams.ts +30 -2
  51. package/src/runtime/child-pi/child-pi.ts +353 -5
  52. package/src/runtime/crew-agent-records.ts +13 -1
  53. package/src/runtime/detached-run-results.ts +90 -0
  54. package/src/runtime/dispatch-batch.ts +12 -1
  55. package/src/runtime/event-log-tail-source.ts +374 -0
  56. package/src/runtime/finalize-run.ts +4 -0
  57. package/src/runtime/goal-workflow/adaptive-plan.ts +30 -3
  58. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +5 -1
  59. package/src/runtime/live-session/live-agent-manager.ts +34 -1
  60. package/src/runtime/live-session/live-control-realtime.ts +10 -0
  61. package/src/runtime/live-session/live-session-runtime.ts +47 -27
  62. package/src/runtime/manifest-cache.ts +128 -17
  63. package/src/runtime/merge-gate.ts +25 -9
  64. package/src/runtime/model/model-fallback.ts +7 -3
  65. package/src/runtime/model/pi-args.ts +54 -65
  66. package/src/runtime/output/sidechain-output.ts +61 -6
  67. package/src/runtime/process/proc-stat.ts +46 -0
  68. package/src/runtime/process/zombie-scanner.ts +32 -19
  69. package/src/runtime/process-status.ts +16 -1
  70. package/src/runtime/recovery/crash-recovery.ts +16 -0
  71. package/src/runtime/run-tracker.ts +77 -10
  72. package/src/runtime/spawn-policy.ts +27 -41
  73. package/src/runtime/surface/degrade.ts +776 -0
  74. package/src/runtime/surface/herdr-provider.ts +546 -0
  75. package/src/runtime/surface/launch-script.ts +172 -0
  76. package/src/runtime/surface/resolve-surface.ts +274 -0
  77. package/src/runtime/surface/surface-provider.ts +129 -0
  78. package/src/runtime/surface/surface-spawn.ts +475 -0
  79. package/src/runtime/surface/tmux-provider.ts +400 -0
  80. package/src/runtime/task-runner/child-executor.ts +47 -0
  81. package/src/runtime/task-runner/post-execution.ts +57 -2
  82. package/src/runtime/task-runner/prompt-builder.ts +1 -0
  83. package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
  84. package/src/runtime/task-runner/state-helpers.ts +54 -30
  85. package/src/runtime/task-runner.ts +4 -2
  86. package/src/runtime/team-runner.ts +104 -2
  87. package/src/schema/config-schema.ts +25 -1
  88. package/src/state/atomic-write.ts +219 -40
  89. package/src/state/coordination/locks.ts +7 -5
  90. package/src/state/coordination/mailbox.ts +56 -10
  91. package/src/state/event-log/cursor.ts +413 -23
  92. package/src/state/event-log/event-log.ts +120 -113
  93. package/src/state/event-log/sequence-cache.ts +21 -3
  94. package/src/state/stores/plan-store.ts +1 -1
  95. package/src/state/stores/state-store.ts +171 -9
  96. package/src/state/types.ts +53 -0
  97. package/src/ui/dock-footer.ts +49 -0
  98. package/src/ui/inline-panel/agent-pane.ts +378 -0
  99. package/src/ui/inline-panel/agent-transcript.ts +338 -0
  100. package/src/ui/inline-panel/agent-view-overlay.ts +225 -0
  101. package/src/ui/inline-panel/crew-editor.ts +192 -0
  102. package/src/ui/inline-panel/index.ts +290 -0
  103. package/src/ui/inline-panel/panel-rows.ts +37 -0
  104. package/src/ui/inline-panel/panel-selection.ts +157 -0
  105. package/src/ui/inline-panel/panel-store.ts +111 -0
  106. package/src/ui/inline-panel/view-session-store.ts +36 -0
  107. package/src/ui/pi-ui-compat.ts +9 -0
  108. package/src/ui/render-diff.ts +16 -8
  109. package/src/ui/run-dashboard.ts +87 -42
  110. package/src/ui/run-event-bus.ts +10 -1
  111. package/src/ui/run-snapshot-cache.ts +83 -35
  112. package/src/ui/transcript-cache.ts +101 -13
  113. package/src/ui/transcript-viewer.ts +92 -24
  114. package/src/ui/widget/index.ts +203 -25
  115. package/src/ui/widget/task-list.ts +198 -0
  116. package/src/ui/widget/widget-formatters.ts +240 -4
  117. package/src/ui/widget/widget-renderer.ts +234 -37
  118. package/src/ui/widget/widget-types.ts +11 -0
  119. package/src/utils/child-process-shield.ts +106 -0
  120. package/src/utils/redaction.ts +7 -0
  121. package/src/utils/safe-abort.ts +45 -0
  122. package/src/utils/visual.ts +43 -0
  123. package/src/workflows/discover-workflows.ts +1 -0
  124. package/src/workflows/workflow-config.ts +7 -0
  125. package/src/worktree/worktree-manager.ts +65 -4
  126. package/workflows/default.workflow.md +36 -26
  127. package/workflows/strict-fast-fix.workflow.md +26 -0
@@ -24,16 +24,27 @@ export interface CrewWidgetModel {
24
24
  /** P3 (#9): workspace/session ID for filtering runs by ownerSessionId
25
25
  * on every render, not just the first frame. */
26
26
  workspaceId?: string;
27
+ /** Per-agent row layout; defaults to the historical two-line tree. */
28
+ rowStyle?: import("./widget-renderer.ts").WidgetRowStyle;
27
29
  }
28
30
 
29
31
  export interface CrewWidgetState {
30
32
  frame: number;
31
33
  lastPlacement?: string;
32
34
  lastVisibility?: "hidden" | "visible";
35
+ /** Whether the aboveEditor task-list widget (pi-crew-tasks) is mounted. */
36
+ lastTasksVisibility?: "hidden" | "visible";
33
37
  lastKey?: string;
34
38
  lastMaxLines?: number;
35
39
  lastCwd?: string;
36
40
  legacyCleared?: boolean;
37
41
  model?: CrewWidgetModel;
38
42
  notificationCount?: number;
43
+ /**
44
+ * Non-serializable dock host used when `widgetPlacement` is `"bottom"`:
45
+ * the dock renders through the crew-vibes footer instead of a pi widget
46
+ * slot. Kept off `model` so serialization/persistence of the rest of the
47
+ * state stays unaffected.
48
+ */
49
+ footerDock?: { render(width: number): string[]; dispose(): void };
39
50
  }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * child-process-shield.ts — stop abort-driven child-process errors from
3
+ * killing the host pi process.
4
+ *
5
+ * THE CRASH
6
+ * ---------
7
+ * Node spawns a child with `options.signal` and the signal fires while that
8
+ * child is still tracked (before its 'exit' event has dispatched), Node runs:
9
+ *
10
+ * abortChildProcess(child, killSignal, reason)
11
+ * try { if (child.kill(killSignal)) {
12
+ * child.emit("error", new AbortError(...)) // node:child_process
13
+ * } } catch (err) { child.emit("error", err); }
14
+ *
15
+ * `child.emit("error", ...)` THROWS synchronously when the ChildProcess has
16
+ * no 'error' listener (EventEmitter 'error'-event semantics). That throw is
17
+ * captured by the AbortSignal dispatch machinery and re-thrown on the next
18
+ * tick (`process.nextTick(() => { throw err; })`, node:internal/event_target)
19
+ * — OUTSIDE any try/catch, so it surfaces as `uncaughtException` and pi dies
20
+ * (`pi exiting due to uncaughtException: AbortError: The operation was
21
+ * aborted`). This is exactly the crash seen on /crew-view session switches
22
+ * mid-foreground-run: the run's signal propagates into the vendored pi SDK's
23
+ * session machinery, an internal controller abort reaches a signal-spawned
24
+ * child with no error listener, and the terminal dies.
25
+ *
26
+ * IMPORTANT: because the rethrow is asynchronous (next tick), wrapping the
27
+ * AbortController.abort() call in try/catch (see safe-abort.ts) CANNOT stop
28
+ * it. The only reliable fix is to make sure the spawned child ALWAYS has an
29
+ * 'error' listener before an abort can fire, so child.emit("error", ...)
30
+ * dispatches instead of throwing.
31
+ *
32
+ * THE SHIELD
33
+ * ----------
34
+ * 1. `ChildProcess.prototype.kill` — every abort path in node runs through
35
+ * `child.kill()` FIRST (abortChildProcess calls it before emitting), so a
36
+ * no-op 'error' listener attached inside kill() protects every signal-
37
+ * spawned child IN THE PROCESS, regardless of when the spawning module
38
+ * captured its `spawn` reference (the vendored SDK may bind `spawn` at a
39
+ * point we cannot observe).
40
+ * 2. `child_process.spawn` wrapper — attaches the same no-op 'error' listener
41
+ * at spawn time when `options.signal` is present, so the listener exists
42
+ * before Node's pre-aborted-signal nextTick abort and before spawn-failure
43
+ * errors (ENOENT) are emitted.
44
+ *
45
+ * The no-op listener never swallows anything a consumer cares about: 'error'
46
+ * events still reach every other listener (multiple listeners run), and code
47
+ * that previously died on an unhandled 'error' event keeps its semantics for
48
+ * genuinely broken spawns — it just no longer takes down the whole pi process
49
+ * for the abort-after-exit case, which is a semantic no-op by definition (the
50
+ * child was already gone).
51
+ */
52
+ import type { ChildProcess } from "node:child_process";
53
+ import { createRequire } from "node:module";
54
+
55
+ const require = createRequire(import.meta.url);
56
+ const childProcessModule = require("node:child_process") as {
57
+ spawn: (file: string, args?: readonly string[] | undefined, options?: object) => ChildProcess;
58
+ ChildProcess: { prototype: { kill: (signal?: number | string) => boolean } };
59
+ };
60
+
61
+ const SHIELD_MARKER = Symbol.for("pi-crew.childProcessAbortShieldInstalled");
62
+
63
+ /** No-op 'error' listener: prevents the EventEmitter throw, never filters data. */
64
+ function shieldErrorListener(): void {
65
+ /* deliberately empty — see module docstring */
66
+ }
67
+
68
+ /**
69
+ * Install the child-process abort shield. Idempotent: subsequent calls are
70
+ * no-ops. MUST be called at extension registration, before any team run can
71
+ * start (the vendored SDK is imported lazily later, so any spawn it binds
72
+ * afterwards already sees the shielded spawn).
73
+ */
74
+ export function installChildProcessAbortShield(): void {
75
+ const proto = childProcessModule.ChildProcess.prototype as typeof childProcessModule.ChildProcess.prototype & {
76
+ [SHIELD_MARKER]?: boolean;
77
+ };
78
+ if (proto[SHIELD_MARKER]) return;
79
+
80
+ const originalKill = proto.kill;
81
+ proto.kill = function shieldedKill(this: ChildProcess, signal?: number | string): boolean {
82
+ // A signal-spawned child may be aborted synchronously by node's
83
+ // abortChildProcess right after this kill() returns; guarantee a
84
+ // listener is present so child.emit("error", AbortError) dispatches
85
+ // instead of throwing.
86
+ if (this.listenerCount("error") === 0) this.on("error", shieldErrorListener);
87
+ return originalKill.call(this, signal);
88
+ };
89
+
90
+ const moduleObject = childProcessModule as {
91
+ spawn: typeof childProcessModule.spawn;
92
+ };
93
+ const originalSpawn = moduleObject.spawn;
94
+ moduleObject.spawn = function shieldedSpawn(this: unknown, ...args: Parameters<typeof childProcessModule.spawn>) {
95
+ const child = originalSpawn.apply(this, args);
96
+ // Attach at spawn time: covers pre-aborted signals (node defers the
97
+ // abort to the next tick) and spawn-failure errors (ENOENT etc.)
98
+ // before any abort could fire.
99
+ if (child && (args[2] as { signal?: unknown } | undefined)?.signal !== undefined) {
100
+ child.on("error", shieldErrorListener);
101
+ }
102
+ return child;
103
+ };
104
+
105
+ proto[SHIELD_MARKER] = true;
106
+ }
@@ -34,7 +34,14 @@ export const PEM_PRIVATE_KEY_PATTERN = /-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]
34
34
  // keyword-scan matches '_token' in 'prompt_tokens' (underscore + "token"),
35
35
  // falsely classifying usage counts as secrets. See performance/quality
36
36
  // assessment fix #5.
37
+ //
38
+ // `tokens` alone must stay too: pi-crew state records use
39
+ // `progress.tokens` / `status.tokens` as the per-task usage total; the
40
+ // camelCase fallback scan in isSecretKey() matches the bare keyword at
41
+ // start+end of the name and would otherwise rewrite `15` to the literal
42
+ // "***" on disk, which the widget then renders verbatim as `*** tok`.
37
43
  const TOKEN_COUNT_KEYS = new Set([
44
+ "tokens",
38
45
  "prompt_tokens",
39
46
  "completion_tokens",
40
47
  "total_tokens",
@@ -0,0 +1,45 @@
1
+ /**
2
+ * safe-abort.ts — abort helpers that cannot crash the host process.
3
+ *
4
+ * When a child process was spawned with `options.signal` and the signal fires
5
+ * AFTER that child already exited (fast workers, cleanup-after-completion,
6
+ * session switches mid-run), Node's child_process abort listener throws
7
+ * `AbortError: The operation was aborted` SYNCHRONOUSLY from
8
+ * `AbortController.abort()` (node:child_process abortChildProcess).
9
+ *
10
+ * In pi extension event handlers that throw propagates straight into pi's
11
+ * `uncaughtException` handling and kills the whole terminal session — the
12
+ * crash seen here: session_before_switch → stopSessionBoundSubagents → abort
13
+ * → AbortError → "pi exiting due to uncaughtException".
14
+ *
15
+ * Abort-after-exit is a semantic no-op (the work is already gone), so every
16
+ * controller abort that can be linked to a spawned child MUST go through
17
+ * these helpers: swallow the throw, log it, carry on.
18
+ */
19
+
20
+ import { logInternalError } from "./internal-error.ts";
21
+
22
+ /** Minimal controller shape — anything with an `abort()` method. */
23
+ export interface Abortable {
24
+ abort(): void;
25
+ }
26
+
27
+ /**
28
+ * Abort a controller, tolerating Node's abort-after-child-exit AbortError.
29
+ * Never throws. `scope` names the call site for the internal error log.
30
+ */
31
+ export function safeAbort(controller: Abortable | undefined, scope: string): void {
32
+ if (!controller) return;
33
+ try {
34
+ controller.abort();
35
+ } catch (error) {
36
+ logInternalError(`safe-abort.${scope}`, error);
37
+ }
38
+ }
39
+
40
+ /** Abort every controller in an iterable; a throwing controller does not
41
+ * prevent the remaining ones from being aborted. */
42
+ export function safeAbortAll(controllers: Iterable<Abortable> | undefined, scope: string): void {
43
+ if (!controllers) return;
44
+ for (const controller of controllers) safeAbort(controller, scope);
45
+ }
@@ -4,6 +4,12 @@ export const ANSI_PATTERN = /\u001b\[[0-?]*[ -/]*[@-~]/g;
4
4
 
5
5
  const WIDTH_CACHE_LIMIT = 256;
6
6
  const widthCache = new Map<string, number>();
7
+ // PERF (2026-08-24): truncateToWidth/wrapHard measure ONE codepoint at a time;
8
+ // those 1-2 char keys thrashed the 256-entry LRU, evicting every long-string
9
+ // entry that could actually hit. Short keys get their own cache (bounded
10
+ // alphabet — hit rate ~100%) so the LRU serves only whole lines/segments.
11
+ const SHORT_WIDTH_CACHE_LIMIT = 1024;
12
+ const shortWidthCache = new Map<string, number>();
7
13
 
8
14
  // NOTE: width measurement is delegated to pi-tui's `visibleWidth` (see below).
9
15
  // pi-tui is the renderer that HARD-ABORTS the session if any line exceeds the
@@ -20,6 +26,17 @@ export function visibleWidth(value: string): number {
20
26
  // LRU cache on top (pi-tui caches too, but this avoids repeated calls for the
21
27
  // hot render path).
22
28
  if (value.length > 4096) return tuiVisibleWidth(value);
29
+ if (value.length <= 2) {
30
+ const s = shortWidthCache.get(value);
31
+ if (s !== undefined) return s;
32
+ const w = tuiVisibleWidth(value);
33
+ if (shortWidthCache.size >= SHORT_WIDTH_CACHE_LIMIT) {
34
+ const first = shortWidthCache.keys().next().value;
35
+ if (first !== undefined) shortWidthCache.delete(first);
36
+ }
37
+ shortWidthCache.set(value, w);
38
+ return w;
39
+ }
23
40
  const cached = widthCache.get(value);
24
41
  if (cached !== undefined) return cached;
25
42
  const length = tuiVisibleWidth(value);
@@ -33,6 +50,7 @@ export function visibleWidth(value: string): number {
33
50
 
34
51
  export function __test__clearVisibleWidthCache(): void {
35
52
  widthCache.clear();
53
+ shortWidthCache.clear();
36
54
  }
37
55
 
38
56
  export function __test__visibleWidthCacheSize(): number {
@@ -205,3 +223,28 @@ export function truncateToVisualLines(text: string, maxVisualLines: number, widt
205
223
  const truncated = visualLines.slice(-limit);
206
224
  return { visualLines: truncated, skippedCount: visualLines.length - limit };
207
225
  }
226
+
227
+ /**
228
+ * PERF (2026-08-24): tail-windowed twin of truncateToVisualLines. Wraps lines
229
+ * from the END backwards and stops as soon as `maxVisualLines` visual lines
230
+ * exist — O(visible window) instead of O(whole transcript). `skippedCount`
231
+ * counts skipped SOURCE lines (a lower bound on skipped visual lines; the
232
+ * caller's total display therefore reads "≥ N lines" when scrolled to bottom).
233
+ */
234
+ export function truncateToVisualLinesTail(text: string, maxVisualLines: number, width: number, paddingX = 0): VisualTruncateResult {
235
+ if (!text) return { visualLines: [], skippedCount: 0 };
236
+ const effectiveWidth = Math.max(1, width - paddingX * 2);
237
+ const limit = Math.max(1, maxVisualLines);
238
+ const sourceLines = text.split("\n");
239
+ const wrapped: string[][] = [];
240
+ let collected = 0;
241
+ let index = sourceLines.length - 1;
242
+ while (index >= 0 && collected < limit) {
243
+ const w = wrapHard(pad(sourceLines[index]!, Math.max(0, effectiveWidth)).trimEnd(), effectiveWidth);
244
+ wrapped.push(w);
245
+ collected += w.length;
246
+ index -= 1;
247
+ }
248
+ const visualLines = wrapped.reverse().flat().slice(-limit);
249
+ return { visualLines, skippedCount: index + 1 };
250
+ }
@@ -191,6 +191,7 @@ function parseWorkflowFile(filePath: string, source: ResourceSource): WorkflowCo
191
191
  topology: parseTopology(frontmatter.topology),
192
192
  coalesceMicroTasks: parseOptionalBoolean(frontmatter.coalesceMicroTasks),
193
193
  specStrict: parseOptionalBoolean(frontmatter.specStrict),
194
+ adaptive: parseOptionalBoolean(frontmatter.adaptive),
194
195
  steps,
195
196
  };
196
197
  } catch {
@@ -75,6 +75,13 @@ export interface WorkflowConfig {
75
75
  * Requires a verifier-role step (reject-start, §5) and fails the write-gate
76
76
  * on coverage gaps or machine-check failures (§4). */
77
77
  specStrict?: boolean;
78
+ /** Frontmatter `adaptive: true` — the workflow's `assess` step (planner role)
79
+ * emits an ADAPTIVE_PLAN_JSON block that the runtime parses and injects as
80
+ * concrete tasks, so the plan painted in the task list is derived from the
81
+ * goal, not from fixed phase templates. The `implementation` workflow is
82
+ * adaptive by name (backward compat); this flag extends the mechanism to
83
+ * any workflow. See src/runtime/goal-workflow/adaptive-plan.ts. */
84
+ adaptive?: boolean;
78
85
  }
79
86
 
80
87
  /** A dynamic workflow (runtime === "dynamic"). steps is empty — the script is the source of truth. */
@@ -129,19 +129,59 @@ function sanitizeBranchPart(value: string): string {
129
129
  );
130
130
  }
131
131
 
132
+ // PERF (2026-08-24): the sync path (prepareTaskWorkspace — per task!) ran
133
+ // findGitRoot (rev-parse spawn) + assertCleanLeader (status --porcelain spawn)
134
+ // on every call, and the reuse path ran assertCleanLeader twice. Mirror the
135
+ // async caches below (_gitRootCache/_cleanLeaderCache): same repoRoot → same
136
+ // answer within a run.
137
+ //
138
+ // Mirrored invalidation semantics (from the async caches below):
139
+ // - gitRoot: plain Map, NO TTL — the async _gitRootCache is process-lifetime with
140
+ // a FIFO cap; a cwd's repo root cannot change without moving .git itself.
141
+ // - cleanLeader: ONLY clean verdicts are cached — the async _cleanLeaderCache is
142
+ // a Set of verified-clean repoRoots and a dirty verdict throws BEFORE caching,
143
+ // so every call while dirty re-probes (mirrored here). The async cache has NO
144
+ // TTL (a clean verdict is trusted for the whole process), so the 30s TTL below
145
+ // is strictly MORE conservative than the async path: a leader dirtied after a
146
+ // cached clean verdict can slip through for at most 30s here vs. forever on
147
+ // the async path. The reuse-path re-check in prepareTaskWorkspace deletes the
148
+ // entry first, mirroring `_cleanLeaderCache.delete(repoRoot)` in the async variant.
149
+ const SYNC_WT_CACHE_TTL_MS = 30_000;
150
+ const MAX_SYNC_GIT_ROOT_CACHE = 256;
151
+ const MAX_SYNC_CLEAN_LEADER_CACHE = 256;
152
+ const syncGitRootCache = new Map<string, string>();
153
+ const syncCleanLeaderCache = new Map<string, { clean: boolean; expiresAt: number }>();
154
+
132
155
  export function findGitRoot(cwd: string): string {
133
- return git(cwd, ["rev-parse", "--show-toplevel"]);
156
+ const cached = syncGitRootCache.get(cwd);
157
+ if (cached) return cached;
158
+ const root = git(cwd, ["rev-parse", "--show-toplevel"]);
159
+ syncGitRootCache.set(cwd, root);
160
+ if (syncGitRootCache.size > MAX_SYNC_GIT_ROOT_CACHE) {
161
+ const oldest = syncGitRootCache.keys().next().value;
162
+ if (oldest !== undefined) syncGitRootCache.delete(oldest);
163
+ }
164
+ return root;
134
165
  }
135
166
 
136
167
  export function assertCleanLeader(repoRoot: string): void {
168
+ const cached = syncCleanLeaderCache.get(repoRoot);
169
+ if (cached?.clean && Date.now() < cached.expiresAt) return;
137
170
  // H-7 follow-up: --untracked-files=no so pi-crew's own auto-created .gitignore
138
171
  // (and any other untracked files the user hasn't staged) doesn't block worktree mode.
139
172
  // The worktree contract is "no tracked changes" — untracked files are safe since
140
173
  // they're either in .gitignore or the user can decide later.
141
174
  const status = git(repoRoot, ["status", "--porcelain", "--untracked-files=no"]);
142
175
  if (status.trim()) {
176
+ // Dirty verdicts are never cached (async mirror): the user may commit/stash
177
+ // seconds later, so the next call must re-probe.
143
178
  throw new Error("Worktree mode requires a clean leader repository. Commit/stash changes or use workspaceMode: 'single'.");
144
179
  }
180
+ syncCleanLeaderCache.set(repoRoot, { clean: true, expiresAt: Date.now() + SYNC_WT_CACHE_TTL_MS });
181
+ if (syncCleanLeaderCache.size > MAX_SYNC_CLEAN_LEADER_CACHE) {
182
+ const oldest = syncCleanLeaderCache.keys().next().value;
183
+ if (oldest !== undefined) syncCleanLeaderCache.delete(oldest);
184
+ }
145
185
  }
146
186
 
147
187
  // --- Async versions ---
@@ -453,6 +493,25 @@ function pruneStaleWorktrees(repoRoot: string): void {
453
493
  }
454
494
  }
455
495
 
496
+ // PERF (2026-08-24): worktree prune is a repo-level write; running it per
497
+ // task in a 50-task run is 50 prunes. At most once per repoRoot per minute.
498
+ // (The async variant memoizes at-most-once per process — see P1-11 above —
499
+ // so a 60s window prunes strictly more often than the async path already does.)
500
+ // R5-L2 mirror: FIFO-cap the key set so the map cannot grow unbounded.
501
+ const PRUNE_MIN_INTERVAL_MS = 60_000;
502
+ const MAX_PRUNE_THROTTLE_KEYS = 128;
503
+ const lastPruneAt = new Map<string, number>();
504
+ function pruneStaleWorktreesThrottled(repoRoot: string): void {
505
+ const last = lastPruneAt.get(repoRoot) ?? 0;
506
+ if (Date.now() - last < PRUNE_MIN_INTERVAL_MS) return;
507
+ lastPruneAt.set(repoRoot, Date.now());
508
+ if (lastPruneAt.size > MAX_PRUNE_THROTTLE_KEYS) {
509
+ const oldest = lastPruneAt.keys().next().value;
510
+ if (oldest !== undefined) lastPruneAt.delete(oldest);
511
+ }
512
+ pruneStaleWorktrees(repoRoot); // original body — best-effort, failures swallowed
513
+ }
514
+
456
515
  async function branchExistsAsync(repoRoot: string, branch: string): Promise<{ local: boolean; remoteOnly: boolean }> {
457
516
  let local = false;
458
517
  try {
@@ -818,11 +877,13 @@ export function prepareTaskWorkspace(manifest: TeamRunManifest, task: TeamTaskSt
818
877
  if (mergedReused.length > 0) {
819
878
  overlaySeedPaths(repoRoot, worktreePath, mergedReused);
820
879
  }
821
- // Re-validate leader is still clean before reusing — leader state may have changed since first preparation
880
+ // Re-validate leader is still clean before reusing — leader state may have changed since first preparation.
881
+ // Mirrors prepareTaskWorkspaceAsync: delete the cached verdict so this re-check probes live state.
882
+ syncCleanLeaderCache.delete(repoRoot);
822
883
  assertCleanLeader(repoRoot);
823
884
  return { cwd: worktreePath, worktreePath, branch, reused: true };
824
885
  }
825
- pruneStaleWorktrees(repoRoot);
886
+ pruneStaleWorktreesThrottled(repoRoot);
826
887
  const exists = branchExists(repoRoot, branch);
827
888
  let worktreeCreated = false;
828
889
  try {
@@ -1141,7 +1202,7 @@ export function prepareAgentWorktree(manifest: TeamRunManifest, agentId: string)
1141
1202
  const stamp = `${Date.now()}-${randomBytes(4).toString("hex")}`;
1142
1203
  const worktreePath = path.join(worktreeRoot, `${sanitizedAgentId}-${stamp}`);
1143
1204
  const branch = `pi-crew/${sanitizedRunId}/${sanitizedAgentId}-${stamp}`;
1144
- pruneStaleWorktrees(repoRoot);
1205
+ pruneStaleWorktreesThrottled(repoRoot);
1145
1206
  git(repoRoot, ["worktree", "add", "-b", branch, worktreePath, "HEAD"]);
1146
1207
  const nodeModulesLinked =
1147
1208
  loadedConfig.config.worktree?.linkNodeModules === true ? linkNodeModulesIfPresent(repoRoot, worktreePath) : false;
@@ -1,31 +1,41 @@
1
1
  ---
2
2
  name: default
3
- description: Explore, plan, execute, and verify
4
- topology: sequential
3
+ description: Plan the goal into concrete tasks, execute them in parallel phases, verify
4
+ topology: complex-dag
5
+ adaptive: true
5
6
  ---
6
7
 
7
- ## explore
8
- role: explorer
9
-
10
- Explore the codebase for the goal: {goal}
11
-
12
- ## plan
8
+ ## assess
13
9
  role: planner
14
- dependsOn: explore
15
- output: plan.md
16
-
17
- Create a concise implementation plan for: {goal}
18
-
19
- ## execute
20
- role: executor
21
- dependsOn: plan
22
-
23
- Implement the plan for: {goal}
24
-
25
- ## verify
26
- role: verifier
27
- dependsOn: execute
28
- verify: true
29
-
30
- Verify completion for: {goal}
31
- Run FAST checks ONCE (cache output to .crew/cache/): `npm run test:critical && npx tsc --noEmit` (completes in <60s). Do NOT run `npm run test:unit` or `npm test` — too slow (642 files, >4 min). Cross-reference cached output with the changes. Do NOT re-run tests. Give PASS or FAIL with specific test evidence.
10
+ output: adaptive-plan.json
11
+
12
+ Plan the concrete task list for: {goal}
13
+
14
+ You are the orchestration planner. Inspect the repository enough to break the goal into specific, executable tasks; do not use a fixed template. Small tasks may need one executor plus one verifier; broader tasks may need parallel explorers, executors, and a verification phase.
15
+
16
+ Return a concise rationale, then include exactly one JSON block between these markers:
17
+
18
+ ADAPTIVE_PLAN_JSON_START
19
+ {
20
+ "phases": [
21
+ {
22
+ "name": "short-phase-name",
23
+ "tasks": [
24
+ {
25
+ "role": "explorer|executor|verifier",
26
+ "title": "short task title",
27
+ "task": "specific autonomous task prompt for this subagent"
28
+ }
29
+ ]
30
+ }
31
+ ]
32
+ }
33
+ ADAPTIVE_PLAN_JSON_END
34
+
35
+ Rules:
36
+ - **MAXIMIZE PARALLELISM**: Put independent tasks in the SAME phase so they run concurrently. Never create sequential phases when tasks are independent.
37
+ - Task titles name the concrete piece of work — they become the task list the user watches.
38
+ - Choose the smallest effective number of tasks per phase; a simple goal may have 1-2 phases with 1-2 tasks.
39
+ - End implementation plans with a verification phase (`verifier` role): run FAST targeted checks only (e.g. the affected test files), never the full suite.
40
+ - Tasks within the same phase run in parallel; phases run sequentially.
41
+ - Do not include more than 12 tasks per phase; split or summarize oversized plans instead.
@@ -0,0 +1,26 @@
1
+ ---
2
+ name: strict-fast-fix
3
+ description: T4 follow-up — fast-fix variant with strict spec binding (ADR-6 §5). Use this to exercise provenance v2 / freeze-at-dispatch / strict-mode gate end-to-end. Imports the spec via `scripts/spec-import.mjs` first.
4
+ topology: sequential
5
+ ---
6
+
7
+ ## explore
8
+ role: explorer
9
+ specRefs: b5-t4-strict-probe
10
+ specStrict: true
11
+
12
+ Find the likely source of the issue: {goal}. You are held to the SPEC-EVIDENCE contract for the referenced spec — emit a `=== SPEC-EVIDENCE ===` block with `cited: <id>:<id>` lines for each requirement the work touches, then `=== END ===`.
13
+
14
+ ## execute
15
+ role: executor
16
+ dependsOn: explore
17
+
18
+ Make the smallest safe fix. Continue the SPEC-EVIDENCE contract from explore: cite every requirement the implementation satisfies.
19
+
20
+ ## verify
21
+ role: verifier
22
+ dependsOn: execute
23
+ verify: true
24
+
25
+ Verify the fix with available evidence, AND cross-reference the SPEC-EVIDENCE footer from explore+execute against the spec's requirements (machine-check the citations vs the spec id set).
26
+ Run FAST checks ONCE (cache output to .crew/cache/): `npm run test:critical && npx tsc --noEmit` (completes in <60s). Do NOT run `npm run test:unit` or `npm test` — too slow (642 files, >4 min). Cross-reference cached output with the fix. Do NOT re-run tests. Give PASS or FAIL with specific test evidence + spec gate verdict.