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.
- package/CHANGELOG.md +347 -0
- package/NOTICE.md +21 -0
- package/README.md +44 -2
- package/agents/executor.md +1 -1
- package/agents/test-engineer.md +1 -1
- package/dist/index.mjs +2555 -932
- package/package.json +2 -1
- package/schema.json +503 -94
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +6 -2
- package/skills/real-test-pi-crew/SKILL.md +278 -79
- package/src/config/config-merge.ts +11 -1
- package/src/config/config-validation.ts +47 -2
- package/src/config/config.ts +28 -6
- package/src/config/defaults.ts +43 -11
- package/src/config/env-vars.ts +27 -2
- package/src/config/role-tools.ts +4 -2
- package/src/config/types.ts +55 -1
- package/src/extension/crew-cleanup.ts +13 -0
- package/src/extension/crew-vibes/footer.ts +19 -0
- package/src/extension/crew-vibes/index.ts +11 -1
- package/src/extension/register.ts +8 -0
- package/src/extension/registration/command-registration.ts +1 -0
- package/src/extension/registration/commands/run.ts +15 -1
- package/src/extension/registration/commands/shared.ts +8 -0
- package/src/extension/registration/foreground-run-controller.ts +10 -2
- package/src/extension/registration/lifecycle-handlers.ts +92 -11
- package/src/extension/registration/runtime-cleanup.ts +23 -5
- package/src/extension/registration/team-tool.ts +58 -6
- package/src/extension/registration/ui.ts +5 -4
- package/src/extension/team-tool/doctor.ts +364 -7
- package/src/extension/team-tool/handle-settings.ts +19 -0
- package/src/extension/team-tool/inspect.ts +10 -2
- package/src/extension/team-tool/run-deadline.ts +20 -3
- package/src/extension/team-tool/run.ts +26 -2
- package/src/extension/team-tool/status.ts +7 -0
- package/src/extension/team-tool.ts +35 -2
- package/src/hooks/registry.ts +59 -56
- package/src/prompt/inbox-poll.ts +90 -0
- package/src/prompt/message-tool.ts +166 -0
- package/src/prompt/prompt-runtime.ts +201 -18
- package/src/prompt/surface-worker.ts +720 -0
- package/src/prompt/worker-events-channel.ts +49 -3
- package/src/runtime/async-runner.ts +29 -1
- package/src/runtime/background-runner.ts +13 -7
- package/src/runtime/broker/broker-issuer.ts +27 -2
- package/src/runtime/broker/crew-broker-tokens.ts +56 -4
- package/src/runtime/broker/crew-broker.ts +261 -41
- package/src/runtime/child-pi/child-pi-constants.ts +8 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
- package/src/runtime/child-pi/child-pi-streams.ts +30 -2
- package/src/runtime/child-pi/child-pi.ts +353 -5
- package/src/runtime/crew-agent-records.ts +13 -1
- package/src/runtime/detached-run-results.ts +90 -0
- package/src/runtime/dispatch-batch.ts +12 -1
- package/src/runtime/event-log-tail-source.ts +374 -0
- package/src/runtime/finalize-run.ts +4 -0
- package/src/runtime/goal-workflow/adaptive-plan.ts +30 -3
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +5 -1
- package/src/runtime/live-session/live-agent-manager.ts +34 -1
- package/src/runtime/live-session/live-control-realtime.ts +10 -0
- package/src/runtime/live-session/live-session-runtime.ts +47 -27
- package/src/runtime/manifest-cache.ts +128 -17
- package/src/runtime/merge-gate.ts +25 -9
- package/src/runtime/model/model-fallback.ts +7 -3
- package/src/runtime/model/pi-args.ts +54 -65
- package/src/runtime/output/sidechain-output.ts +61 -6
- package/src/runtime/process/proc-stat.ts +46 -0
- package/src/runtime/process/zombie-scanner.ts +32 -19
- package/src/runtime/process-status.ts +16 -1
- package/src/runtime/recovery/crash-recovery.ts +16 -0
- package/src/runtime/run-tracker.ts +77 -10
- package/src/runtime/spawn-policy.ts +27 -41
- package/src/runtime/surface/degrade.ts +776 -0
- package/src/runtime/surface/herdr-provider.ts +546 -0
- package/src/runtime/surface/launch-script.ts +172 -0
- package/src/runtime/surface/resolve-surface.ts +274 -0
- package/src/runtime/surface/surface-provider.ts +129 -0
- package/src/runtime/surface/surface-spawn.ts +475 -0
- package/src/runtime/surface/tmux-provider.ts +400 -0
- package/src/runtime/task-runner/child-executor.ts +47 -0
- package/src/runtime/task-runner/post-execution.ts +57 -2
- package/src/runtime/task-runner/prompt-builder.ts +1 -0
- package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
- package/src/runtime/task-runner/state-helpers.ts +54 -30
- package/src/runtime/task-runner.ts +4 -2
- package/src/runtime/team-runner.ts +104 -2
- package/src/schema/config-schema.ts +25 -1
- package/src/state/atomic-write.ts +219 -40
- package/src/state/coordination/locks.ts +7 -5
- package/src/state/coordination/mailbox.ts +56 -10
- package/src/state/event-log/cursor.ts +413 -23
- package/src/state/event-log/event-log.ts +120 -113
- package/src/state/event-log/sequence-cache.ts +21 -3
- package/src/state/stores/plan-store.ts +1 -1
- package/src/state/stores/state-store.ts +171 -9
- package/src/state/types.ts +53 -0
- package/src/ui/dock-footer.ts +49 -0
- package/src/ui/inline-panel/agent-pane.ts +378 -0
- package/src/ui/inline-panel/agent-transcript.ts +338 -0
- package/src/ui/inline-panel/agent-view-overlay.ts +225 -0
- package/src/ui/inline-panel/crew-editor.ts +192 -0
- package/src/ui/inline-panel/index.ts +290 -0
- package/src/ui/inline-panel/panel-rows.ts +37 -0
- package/src/ui/inline-panel/panel-selection.ts +157 -0
- package/src/ui/inline-panel/panel-store.ts +111 -0
- package/src/ui/inline-panel/view-session-store.ts +36 -0
- package/src/ui/pi-ui-compat.ts +9 -0
- package/src/ui/render-diff.ts +16 -8
- package/src/ui/run-dashboard.ts +87 -42
- package/src/ui/run-event-bus.ts +10 -1
- package/src/ui/run-snapshot-cache.ts +83 -35
- package/src/ui/transcript-cache.ts +101 -13
- package/src/ui/transcript-viewer.ts +92 -24
- package/src/ui/widget/index.ts +203 -25
- package/src/ui/widget/task-list.ts +198 -0
- package/src/ui/widget/widget-formatters.ts +240 -4
- package/src/ui/widget/widget-renderer.ts +234 -37
- package/src/ui/widget/widget-types.ts +11 -0
- package/src/utils/child-process-shield.ts +106 -0
- package/src/utils/redaction.ts +7 -0
- package/src/utils/safe-abort.ts +45 -0
- package/src/utils/visual.ts +43 -0
- package/src/workflows/discover-workflows.ts +1 -0
- package/src/workflows/workflow-config.ts +7 -0
- package/src/worktree/worktree-manager.ts +65 -4
- package/workflows/default.workflow.md +36 -26
- 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
|
+
}
|
package/src/utils/redaction.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/utils/visual.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
4
|
-
topology:
|
|
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
|
-
##
|
|
8
|
-
role: explorer
|
|
9
|
-
|
|
10
|
-
Explore the codebase for the goal: {goal}
|
|
11
|
-
|
|
12
|
-
## plan
|
|
8
|
+
## assess
|
|
13
9
|
role: planner
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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.
|