pi-crew 0.9.64 → 0.9.65

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 (36) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +46 -1
  3. package/dist/index.mjs +316 -299
  4. package/package.json +3 -2
  5. package/scripts/analyze-run.mjs +1333 -0
  6. package/scripts/pty_probe.py +10 -8
  7. package/scripts/resource-sampler.mjs +482 -0
  8. package/skills/real-test-pi-crew/SKILL.md +6 -6
  9. package/src/observability/event-to-metric.ts +29 -0
  10. package/src/observability/metrics-primitives.ts +41 -3
  11. package/src/runtime/README.md +1 -1
  12. package/src/runtime/broker/crew-broker.ts +0 -16
  13. package/src/runtime/effectiveness.ts +23 -1
  14. package/src/runtime/merge-gate.ts +202 -0
  15. package/src/runtime/model/model-fallback.ts +11 -0
  16. package/src/runtime/model/provider-extensions.ts +31 -12
  17. package/src/runtime/output/progress-tracker.ts +3 -33
  18. package/src/runtime/scratchpad/engine.ts +40 -2
  19. package/src/runtime/scratchpad/snapshot-hmac.ts +161 -0
  20. package/src/runtime/team-runner.ts +128 -203
  21. package/src/schema/team-tool-schema.ts +2 -0
  22. package/src/teams/discover-teams.ts +2 -0
  23. package/src/teams/team-config.ts +7 -0
  24. package/src/teams/team-serializer.ts +1 -0
  25. package/src/ui/mascot.ts +1 -14
  26. package/teams/default.team.md +1 -0
  27. package/teams/fast-fix.team.md +1 -0
  28. package/src/observability/event-bus.ts +0 -86
  29. package/src/plugins/plugin-define.ts +0 -6
  30. package/src/plugins/plugin-registry.ts +0 -32
  31. package/src/plugins/plugins/index.ts +0 -3
  32. package/src/plugins/plugins/nextjs.ts +0 -19
  33. package/src/plugins/plugins/vite.ts +0 -10
  34. package/src/plugins/plugins/vitest.ts +0 -9
  35. package/src/runtime/child-pi/child-pi-pool.ts +0 -68
  36. package/src/runtime/iteration-hooks.ts +0 -305
@@ -1,68 +0,0 @@
1
- // 2.6 — Child-pi warm pool skeleton (ADR 0008 Proposed).
2
- //
3
- // This module exposes the pool interface that child-pi.ts will consume
4
- // once Pi gains the wait-for-prompt handshake protocol. Until the Pi-side
5
- // support lands, the pool is permanently disabled (size=0): callers always
6
- // receive null from `acquirePooledChild` and fall through to the regular
7
- // spawn path. This keeps the integration point ready without altering
8
- // production behaviour.
9
- //
10
- // Enabling a real pool requires three pieces in order:
11
- //
12
- // 1. Pi runtime: respond to a `PI_CREW_POOL_HEALTH=1` ping on stdin and
13
- // block in a "wait-for-prompt" state until the parent writes a real
14
- // prompt. Pi does not currently implement this.
15
- // 2. This module: replace the `acquirePooledChild` returning null with
16
- // an actual pool that spawn-and-park N processes.
17
- // 3. child-pi.ts: prefer pooled children on `runChildPi` entry; fall
18
- // back to fresh spawn on miss.
19
- //
20
- // Disabled by default; opt-in via `runtime.warmPool.size > 0` config or
21
- // the `PI_CREW_WARM_POOL_SIZE` env var.
22
- import type { ChildProcess } from "node:child_process";
23
-
24
- export interface WarmPoolOptions {
25
- /** Number of warm processes to maintain. 0 disables the pool. */
26
- size: number;
27
- /** Drop pooled processes that have been idle longer than this. */
28
- maxIdleMs: number;
29
- }
30
-
31
- export const DEFAULT_WARM_POOL_OPTIONS: WarmPoolOptions = {
32
- size: 0,
33
- maxIdleMs: 5 * 60_000,
34
- };
35
-
36
- /** Resolve the effective pool size from env / config / defaults. */
37
- export function resolveWarmPoolSize(env: NodeJS.ProcessEnv = process.env, configured?: number): number {
38
- const fromEnv = Number.parseInt(env.PI_CREW_WARM_POOL_SIZE ?? "", 10);
39
- if (Number.isFinite(fromEnv) && fromEnv >= 0) return fromEnv;
40
- if (typeof configured === "number" && Number.isFinite(configured) && configured >= 0) return configured;
41
- return DEFAULT_WARM_POOL_OPTIONS.size;
42
- }
43
-
44
- /**
45
- * Try to acquire a parked child from the pool. Returns null when the pool
46
- * is disabled or empty; caller should spawn a fresh child instead.
47
- *
48
- * Skeleton — currently always returns null. See module docstring.
49
- */
50
- export function acquirePooledChild(_options: Partial<WarmPoolOptions> = {}): ChildProcess | null {
51
- return null;
52
- }
53
-
54
- /**
55
- * Mark a pooled child as done. Pool processes are single-use: this terminates
56
- * the child rather than returning it to the pool, because state contamination
57
- * across runs would be unsafe (file handles, env mutations, mounted FDs).
58
- *
59
- * Skeleton — currently a no-op since acquirePooledChild never returns a child.
60
- */
61
- export function releasePooledChild(_child: ChildProcess | null | undefined): void {
62
- // no-op while the pool is disabled
63
- }
64
-
65
- /** Drain and terminate every parked child. Call on cleanupRuntime. */
66
- export function disposeWarmPool(): void {
67
- // no-op while the pool is disabled
68
- }
@@ -1,305 +0,0 @@
1
- /**
2
- * Transparent iteration hooks — runs user-supplied before/after task scripts
3
- * with structured JSON payload on stdin.
4
- *
5
- * Distilled from pi-autoresearch's iteration hook pattern.
6
- */
7
- import { spawn } from "node:child_process";
8
- import * as fs from "node:fs";
9
- import * as path from "node:path";
10
- import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
11
- import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
12
- import { resolveShellForScript } from "../utils/resolve-shell.ts";
13
- import { HeadSnapStage } from "./compaction/compact-stages/index.ts";
14
- import { DENIED_METRIC_NAMES } from "./metric-parser.ts";
15
-
16
- /** Hook execution stage. */
17
- export type HookStage = "before" | "after";
18
-
19
- /** Payload sent to the hook script via stdin as JSON. */
20
- export interface HookPayload {
21
- event: HookStage;
22
- cwd: string;
23
- taskId: string;
24
- runId: string;
25
- taskRole: string;
26
- lastResult?: {
27
- status: string;
28
- description: string;
29
- diagnostics?: Record<string, unknown>;
30
- } | null;
31
- session: {
32
- teamName: string;
33
- workflowName: string;
34
- goal: string;
35
- completedTasks: number;
36
- totalTasks: number;
37
- };
38
- }
39
-
40
- /** Result of executing an iteration hook. */
41
- export interface HookResult {
42
- /** Whether the hook script was actually executed. */
43
- fired: boolean;
44
- /** Captured stdout (truncated to 8KB). */
45
- stdout: string;
46
- /** Captured stderr. */
47
- stderr: string;
48
- /** Exit code of the hook process. */
49
- exitCode: number | null;
50
- /** Whether the hook timed out. */
51
- timedOut: boolean;
52
- /** Wall-clock duration in milliseconds. */
53
- durationMs: number;
54
- }
55
-
56
- /** Maximum stdout capture size in bytes (8 KB). */
57
- const MAX_STDOUT_BYTES = 8192;
58
-
59
- /** Hook execution timeout in milliseconds (30 seconds). */
60
- const HOOK_TIMEOUT_MS = 30_000;
61
-
62
- /**
63
- * Validates that a hook script path is within an allowed directory.
64
- * Allowed paths:
65
- * - Relative paths starting with ".hooks/" (case-sensitive)
66
- * - Absolute paths under $HOME/.pi/hooks/
67
- * All other paths are rejected to prevent arbitrary script execution.
68
- * @param hookPath - The hook script path to validate
69
- * @returns true if the path is allowed, false otherwise
70
- */
71
- export function isAllowedHookPath(hookPath: string): boolean {
72
- if (!hookPath || hookPath.trim().length === 0) return false;
73
- if (!path.isAbsolute(hookPath)) {
74
- // Use path.posix.normalize to ensure forward-slash normalization on all platforms.
75
- // On Windows, path.normalize converts .hooks/hook.sh to .hooks\hook.sh (backslash),
76
- // breaking the startsWith(".hooks/") check. path.posix.normalize always uses /.
77
- const normalized = path.posix.normalize(hookPath);
78
- return normalized === ".hooks" || normalized.startsWith(".hooks/");
79
- }
80
- // Normalize to forward slashes for consistent cross-platform comparison.
81
- // e.g., "C:\\Users\\runner\\.pi\\hooks\\hook.sh" matches
82
- // "C:\\Users\\runner\\.pi\\hooks/hook.sh" from path.join.
83
- const normalizedHookPath = hookPath.replace(/\\/g, "/");
84
- const homeHooksNormalized = (process.env.HOME ?? "").replace(/\\/g, "/") + "/.pi/hooks";
85
- return normalizedHookPath === homeHooksNormalized || normalizedHookPath.startsWith(homeHooksNormalized + "/");
86
- }
87
-
88
- /**
89
- * Create a not-fired result for when the hook script is absent or not executable.
90
- */
91
- function notFiredResult(): HookResult {
92
- return {
93
- fired: false,
94
- stdout: "",
95
- stderr: "",
96
- exitCode: null,
97
- timedOut: false,
98
- durationMs: 0,
99
- };
100
- }
101
-
102
- /**
103
- * Check if a script path exists and is executable.
104
- */
105
- function isScriptRunnable(scriptPath: string): boolean {
106
- try {
107
- if (!fs.existsSync(scriptPath)) return false;
108
-
109
- // On Windows, X_OK is unreliable — just check F_OK (file exists).
110
- // On Unix, check both F_OK and X_OK.
111
- if (process.platform === "win32") {
112
- fs.accessSync(scriptPath, fs.constants.F_OK);
113
- } else {
114
- fs.accessSync(scriptPath, fs.constants.F_OK | fs.constants.X_OK);
115
- }
116
- return true;
117
- } catch {
118
- return false;
119
- }
120
- }
121
-
122
- /**
123
- * Run an iteration hook script with JSON payload on stdin.
124
- *
125
- * Spawns `bash <script>` with the hook payload as JSON on stdin.
126
- * Captures stdout (capped at 8KB) and stderr. Enforces a 30-second timeout.
127
- *
128
- * **Security note:** Hook paths are restricted to `.hooks/` relative paths
129
- * or `$HOME/.pi/hooks/` absolute paths. All other paths are rejected before
130
- * execution.
131
- *
132
- * @param payload - Structured hook payload
133
- * @param hookScriptPath - Absolute or relative path to the hook script
134
- * @returns HookResult indicating whether the hook fired and its output
135
- */
136
- export async function runIterationHook(
137
- payload: HookPayload,
138
- hookScriptPath: string,
139
- options?: { timeoutMs?: number },
140
- ): Promise<HookResult> {
141
- if (!isAllowedHookPath(hookScriptPath)) {
142
- return {
143
- fired: false,
144
- stdout: "",
145
- stderr: "hook path not allowed: " + hookScriptPath,
146
- exitCode: null,
147
- timedOut: false,
148
- durationMs: 0,
149
- };
150
- }
151
- // Resolve relative paths relative to cwd
152
- const resolvedScript = path.isAbsolute(hookScriptPath) ? hookScriptPath : path.join(payload.cwd, hookScriptPath);
153
- if (!isScriptRunnable(resolvedScript)) {
154
- return notFiredResult();
155
- }
156
-
157
- const startTime = Date.now();
158
- const stdinJson = JSON.stringify(payload);
159
- const stdoutChunks: Buffer[] = [];
160
- const stderrChunks: Buffer[] = [];
161
-
162
- return new Promise<HookResult>((resolve) => {
163
- const { command, args } = resolveShellForScript(resolvedScript);
164
- const child = spawn(command, args, {
165
- cwd: payload.cwd,
166
- env: {
167
- ...sanitizeEnvSecrets(process.env, {
168
- allowList: ["PATH", "HOME", "USER", ...WINDOWS_ESSENTIAL_ENV_VARS, "TMPDIR", "LANG", "LC_ALL", "PI_CREW_*"],
169
- }),
170
- PI_CREW_HOOK: "1",
171
- },
172
- stdio: ["pipe", "pipe", "pipe"],
173
- });
174
-
175
- let killed = false;
176
- const timeoutMs = options?.timeoutMs ?? HOOK_TIMEOUT_MS;
177
- const timeout = setTimeout(() => {
178
- killed = true;
179
- child.kill("SIGKILL");
180
- }, timeoutMs);
181
-
182
- child.stdout.on("data", (chunk: Buffer) => {
183
- stdoutChunks.push(chunk);
184
- });
185
-
186
- child.stderr.on("data", (chunk: Buffer) => {
187
- stderrChunks.push(chunk);
188
- });
189
-
190
- child.on("close", (code: number | null) => {
191
- clearTimeout(timeout);
192
- const durationMs = Date.now() - startTime;
193
-
194
- const rawStdout = Buffer.concat(stdoutChunks);
195
- // Sprint 5: refactored onto HeadSnapStage. Convert to UTF-8 string once,
196
- // then apply the byte-cap stage with newline-snap so partial lines
197
- // never appear in the captured preview. HeadSnapStage is byte-cap-safe
198
- // (walks back partial UTF-8 sequences at the cut boundary).
199
- const stdoutText = new HeadSnapStage({
200
- maxBytes: MAX_STDOUT_BYTES,
201
- }).apply(rawStdout.toString("utf-8"));
202
-
203
- const rawStderr = Buffer.concat(stderrChunks);
204
-
205
- resolve({
206
- fired: true,
207
- stdout: stdoutText,
208
- stderr: rawStderr.toString("utf-8"),
209
- exitCode: code,
210
- timedOut: killed,
211
- durationMs,
212
- });
213
- });
214
-
215
- child.on("error", (err: Error) => {
216
- clearTimeout(timeout);
217
- const durationMs = Date.now() - startTime;
218
- resolve({
219
- fired: true,
220
- stdout: "",
221
- stderr: err.message,
222
- exitCode: null,
223
- timedOut: false,
224
- durationMs,
225
- });
226
- });
227
-
228
- // Write payload to stdin and close it.
229
- // Handle EPIPE errors gracefully (occurs if the hook script exits before
230
- // reading all of stdin, which is normal for some hook scripts on certain OS).
231
- child.stdin.on("error", () => {
232
- /* ignore EPIPE — hook exited early */
233
- });
234
- try {
235
- child.stdin.write(stdinJson, "utf-8");
236
- child.stdin.end();
237
- } catch {
238
- // ignore
239
- }
240
- });
241
- }
242
-
243
- /**
244
- * Derive a steer message from the hook result.
245
- *
246
- * - Non-zero exit → error steer message
247
- * - Timeout → timeout steer message
248
- * - Empty stdout → null (no steer)
249
- * - Otherwise → trimmed stdout content
250
- */
251
- export function steerMessageFromHook(stage: HookStage, result: HookResult): string | null {
252
- if (!result.fired) return null;
253
-
254
- if (result.timedOut) {
255
- return `[${stage}-hook] Hook timed out after ${result.durationMs}ms`;
256
- }
257
-
258
- if (result.exitCode !== null && result.exitCode !== 0) {
259
- const stderrSnippet = result.stderr.trim().slice(0, 200);
260
- return `[${stage}-hook] Hook exited with code ${result.exitCode}${stderrSnippet ? `: ${stderrSnippet}` : ""}`;
261
- }
262
-
263
- const trimmed = result.stdout.trim();
264
- if (trimmed.length === 0) return null;
265
-
266
- // Filter out prototype-polluting metric names from hook output
267
- const lines = trimmed.split("\n");
268
- const safeLines = lines.filter((line) => {
269
- const match = /^CREW_METRIC\s+(\w+)=/.exec(line);
270
- if (match) {
271
- const name = match[1];
272
- return !DENIED_METRIC_NAMES.has(name);
273
- }
274
- return true;
275
- });
276
-
277
- return safeLines.join("\n");
278
- }
279
-
280
- /**
281
- * Build a log entry for recording hook execution in events.jsonl.
282
- */
283
- export function hookLogEntry(stage: HookStage, result: HookResult): Record<string, unknown> {
284
- const entry: Record<string, unknown> = {
285
- type: "iteration-hook",
286
- stage,
287
- fired: result.fired,
288
- durationMs: result.durationMs,
289
- };
290
-
291
- if (result.fired) {
292
- entry.exitCode = result.exitCode;
293
- entry.timedOut = result.timedOut;
294
-
295
- // Include truncated stdout/stderr for diagnostics
296
- if (result.stdout.length > 0) {
297
- entry.stdoutPreview = result.stdout.slice(0, 512);
298
- }
299
- if (result.stderr.length > 0) {
300
- entry.stderrPreview = result.stderr.slice(0, 512);
301
- }
302
- }
303
-
304
- return entry;
305
- }