pi-crew 0.9.65 → 0.9.67
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 +79 -0
- package/README.md +2 -3
- package/agents/executor.md +1 -1
- package/agents/test-engineer.md +1 -1
- package/agents/verifier.md +1 -1
- package/dist/index.mjs +4526 -4100
- package/package.json +7 -4
- package/skills/real-test-pi-crew/SKILL.md +10 -9
- package/src/config/config.ts +19 -3
- package/src/config/role-tools.ts +2 -2
- package/src/config/types.ts +2 -0
- package/src/extension/knowledge-injection.ts +17 -0
- package/src/extension/pi-api.ts +0 -16
- package/src/extension/team-tool/api/agent-control.ts +358 -0
- package/src/extension/team-tool/api/handler-context.ts +57 -0
- package/src/extension/team-tool/api/heartbeat.ts +75 -0
- package/src/extension/team-tool/api/mailbox.ts +242 -0
- package/src/extension/team-tool/api/plan-approval.ts +190 -0
- package/src/extension/team-tool/api/read.ts +443 -0
- package/src/extension/team-tool/api/task-claims.ts +207 -0
- package/src/extension/team-tool/api.ts +56 -1301
- package/src/extension/team-tool/cancel.ts +49 -4
- package/src/extension/team-tool/dispatch/manage.ts +7 -4
- package/src/extension/team-tool/explain.ts +3 -1
- package/src/extension/team-tool/goal-wrap.ts +2 -2
- package/src/extension/team-tool/goal.ts +4 -4
- package/src/extension/team-tool/lifecycle-actions.ts +9 -6
- package/src/extension/team-tool/parallel-dispatch.ts +2 -2
- package/src/extension/team-tool/respond.ts +11 -3
- package/src/extension/team-tool/run-intent.ts +357 -0
- package/src/extension/team-tool/run.ts +8 -285
- package/src/extension/team-tool/status.ts +56 -21
- package/src/extension/team-tool-types.ts +2 -0
- package/src/extension/team-tool.ts +10 -8
- package/src/prompt/scratchpad-lifecycle.ts +80 -5
- package/src/runtime/child-pi/child-pi-spawn.ts +13 -7
- package/src/runtime/child-pi/child-pi.ts +22 -144
- package/src/runtime/child-pi/mock-fixtures.ts +171 -0
- package/src/runtime/crew-agent-records.ts +18 -1
- package/src/runtime/output/output-validator.ts +34 -6
- package/src/runtime/scheduling/scheduler.ts +67 -19
- package/src/runtime/scratchpad/README.md +6 -0
- package/src/runtime/scratchpad/guest.ts +54 -5
- package/src/runtime/scratchpad/snapshot-hmac.ts +7 -1
- package/src/runtime/supervisor-contact.ts +0 -16
- package/src/runtime/task-runner/child-executor.ts +1 -0
- package/src/runtime/task-runner/state-helpers.ts +9 -1
- package/src/runtime/team-runner.ts +39 -2
- package/src/state/contracts.ts +3 -0
- package/src/state/event-log/event-log.ts +19 -27
- package/src/state/gitignore-manager.ts +61 -7
- package/src/utils/glob-match.ts +29 -0
- package/types/dwf.d.ts +1 -1
- package/src/types/new-api-types.ts +0 -35
|
@@ -229,24 +229,72 @@ function parseIntervalMs(s: string): number | undefined {
|
|
|
229
229
|
return ms;
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
232
|
+
/** Named-token maps for cron DOW (SUN=0..SAT=6) and month (JAN=1..DEC=12). */
|
|
233
|
+
const CRON_DOW_NAMES: Record<string, number> = { SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6 };
|
|
234
|
+
const CRON_MONTH_NAMES: Record<string, number> = {
|
|
235
|
+
JAN: 1,
|
|
236
|
+
FEB: 2,
|
|
237
|
+
MAR: 3,
|
|
238
|
+
APR: 4,
|
|
239
|
+
MAY: 5,
|
|
240
|
+
JUN: 6,
|
|
241
|
+
JUL: 7,
|
|
242
|
+
AUG: 8,
|
|
243
|
+
SEP: 9,
|
|
244
|
+
OCT: 10,
|
|
245
|
+
NOV: 11,
|
|
246
|
+
DEC: 12,
|
|
247
|
+
};
|
|
236
248
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
249
|
+
/**
|
|
250
|
+
* Match a single cron field value against a cron field expression.
|
|
251
|
+
* Supports the standard cron grammar: wildcard, single N, range a-b, list a,b,c,
|
|
252
|
+
* step syntax (wildcard-step, range-step, from-step), and named tokens
|
|
253
|
+
* (MON, JAN) when a `names` map is passed. Fixes the prior matcher that
|
|
254
|
+
* rejected step values and named DOW (parseInt returned NaN).
|
|
255
|
+
*/
|
|
256
|
+
function cronFieldMatches(value: number, field: string, min: number, max: number, names?: Record<string, number>): boolean {
|
|
257
|
+
let normalized = field.trim().toUpperCase();
|
|
258
|
+
if (names) {
|
|
259
|
+
for (const name of Object.keys(names).sort((a, b) => b.length - a.length)) {
|
|
260
|
+
normalized = normalized.split(name).join(String(names[name]));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// Cron permits 7 for Sunday in the DOW field — normalize to 0.
|
|
264
|
+
if (min === 0 && max === 6) normalized = normalized.replace(/\b7\b/g, "0");
|
|
265
|
+
const matched = new Set<number>();
|
|
266
|
+
for (const rawPart of normalized.split(",")) {
|
|
267
|
+
const part = rawPart.trim();
|
|
268
|
+
if (part === "") return false;
|
|
269
|
+
const stepMatch = part.match(/^(.*)\/(\d+)$/);
|
|
270
|
+
const step = stepMatch ? Number.parseInt(stepMatch[2], 10) : 1;
|
|
271
|
+
if (!Number.isFinite(step) || step < 1) return false;
|
|
272
|
+
const rangeStr = stepMatch ? stepMatch[1] : part;
|
|
273
|
+
let lo: number;
|
|
274
|
+
let hi: number;
|
|
275
|
+
if (rangeStr === "*") {
|
|
276
|
+
lo = min;
|
|
277
|
+
hi = max;
|
|
278
|
+
} else if (/^\d+$/.test(rangeStr)) {
|
|
279
|
+
lo = Number.parseInt(rangeStr, 10);
|
|
280
|
+
hi = stepMatch ? max : lo; // bare `N` = single value; `N/S` = N..max step S
|
|
281
|
+
} else {
|
|
282
|
+
const rm = rangeStr.match(/^(\d+)-(\d+)$/);
|
|
283
|
+
if (!rm) return false;
|
|
284
|
+
lo = Number.parseInt(rm[1], 10);
|
|
285
|
+
hi = Number.parseInt(rm[2], 10);
|
|
244
286
|
}
|
|
245
|
-
|
|
246
|
-
|
|
287
|
+
for (let v = lo; v <= hi; v += step) {
|
|
288
|
+
if (v >= min && v <= max) matched.add(v);
|
|
247
289
|
}
|
|
248
|
-
return false;
|
|
249
290
|
}
|
|
291
|
+
return matched.has(value);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function nextCronDate(spec: string, from: Date): Date | { error: string } | null {
|
|
295
|
+
const parts = spec.split(/\s+/);
|
|
296
|
+
if (parts.length < 5) return { error: "Invalid cron expression" };
|
|
297
|
+
const [minStr, hourStr, domStr, monthStr, dowStr] = parts;
|
|
250
298
|
|
|
251
299
|
let cursor = new Date(from.getTime());
|
|
252
300
|
cursor.setSeconds(0, 0);
|
|
@@ -261,11 +309,11 @@ function nextCronDate(spec: string, from: Date): Date | { error: string } | null
|
|
|
261
309
|
const dow = cursor.getUTCDay();
|
|
262
310
|
|
|
263
311
|
if (
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
312
|
+
cronFieldMatches(min, minStr, 0, 59) &&
|
|
313
|
+
cronFieldMatches(hour, hourStr, 0, 23) &&
|
|
314
|
+
cronFieldMatches(dom, domStr, 1, 31) &&
|
|
315
|
+
cronFieldMatches(month, monthStr, 1, 12, CRON_MONTH_NAMES) &&
|
|
316
|
+
cronFieldMatches(dow, dowStr, 0, 6, CRON_DOW_NAMES)
|
|
269
317
|
) {
|
|
270
318
|
return cursor;
|
|
271
319
|
}
|
|
@@ -123,6 +123,12 @@ attempt N+1 worker (scratchpad-lifecycle.ts)
|
|
|
123
123
|
the guest already runs at full worker trust (it holds provider keys + broker
|
|
124
124
|
token), so this does not cross the existing boundary. An HMAC over the payload
|
|
125
125
|
is a Phase 2.5/3 hardening if artifacts ever land in a shared location.
|
|
126
|
+
|
|
127
|
+
> ⚠️ **NOT WIRED (2026-08-11):** `src/runtime/scratchpad/snapshot-hmac.ts` is an
|
|
128
|
+
> unconnected helper — zero production call sites, 11 green unit tests in
|
|
129
|
+
> isolation. Wire-or-delete decision in `docs/improvement-plan-2026-08-11.md`
|
|
130
|
+
> §4 J1. Until then the note above (v8.deserialize unauthenticated) is exactly
|
|
131
|
+
> how the code behaves.
|
|
126
132
|
- **Secret-at-rest under a benign key name** persists as base64 for the run's
|
|
127
133
|
retention (structural redaction is key-name based, best-effort). A sanitized-
|
|
128
134
|
namespace policy is a Phase 2.5 concern.
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
29
|
+
import { execFile } from "node:child_process";
|
|
29
30
|
import { writeSync } from "node:fs";
|
|
30
31
|
import { createInterface } from "node:readline";
|
|
31
32
|
import { format, inspect } from "node:util";
|
|
@@ -179,14 +180,61 @@ console.error = consoleErr;
|
|
|
179
180
|
console.trace = consoleErr;
|
|
180
181
|
|
|
181
182
|
// ── bootstrap bindings ───────────────────────────────────────────────────────
|
|
182
|
-
// pi-rlm mounts rlm/tools/Bun handles here.
|
|
183
|
-
//
|
|
184
|
-
//
|
|
183
|
+
// pi-rlm mounts rlm/tools/Bun handles here. Pattern 12 (shell guard): the
|
|
184
|
+
// guest-local `sh` helper narrows raw child_process — it refuses null/undefined
|
|
185
|
+
// arguments (the `rm -rf undefined` class bug) and returns a value, not a
|
|
186
|
+
// string, so cell 2 can reuse cell 1's result. Advisory guard only — cells run
|
|
187
|
+
// at full worker trust (no VM sandbox), so `sh` is a nudge, not a boundary.
|
|
185
188
|
|
|
186
189
|
const INTERNAL_BINDINGS = new Map<string, unknown>();
|
|
187
190
|
|
|
191
|
+
/** Pattern-12 nullish guard: refuse undefined/null BEFORE spawning. */
|
|
192
|
+
function assertShellArgNotNullish(name: string, value: unknown): void {
|
|
193
|
+
if (value === undefined || value === null) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`sh: argument '${name}' is null/undefined — a missing variable would stringify to the literal 'undefined' in the command. Re-verify variables after a restore before using them in shell commands.`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* sh(cmd, args[]) — pattern-12 shell interpolation guard.
|
|
202
|
+
* Runs via execFile (shell:false, args as an array — no shell interpolation
|
|
203
|
+
* of the command string), refuses nullish args, and returns a value:
|
|
204
|
+
* `{ exitCode, stdout, stderr }` (pi-rlm "shell as value"). Non-zero exit does
|
|
205
|
+
* NOT throw — it is returned in `exitCode`.
|
|
206
|
+
*/
|
|
207
|
+
function makeSh(): (cmd: string, args?: unknown[]) => Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
208
|
+
const execFileAsync = (cmd: string, args: string[]) =>
|
|
209
|
+
new Promise<{ exitCode: number; stdout: string; stderr: string }>((resolve, reject) => {
|
|
210
|
+
execFile(cmd, args, { shell: false, encoding: "utf8" }, (error, stdout, stderr) => {
|
|
211
|
+
if (error) {
|
|
212
|
+
// error.code is the exit code for a non-zero exit; otherwise a real
|
|
213
|
+
// spawn failure (ENOENT etc.) — surface it as exitCode 127-like.
|
|
214
|
+
const exitCode = typeof error.code === "number" ? error.code : 127;
|
|
215
|
+
resolve({ exitCode, stdout: String(stdout), stderr: String(stderr || error.message) });
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
resolve({ exitCode: 0, stdout: String(stdout), stderr: String(stderr) });
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
return async (cmd: string, args?: unknown[]) => {
|
|
222
|
+
assertShellArgNotNullish("cmd", cmd);
|
|
223
|
+
const stringArgs = (args ?? []).map((a, i) => {
|
|
224
|
+
assertShellArgNotNullish(`arg[${i}]`, a);
|
|
225
|
+
return String(a);
|
|
226
|
+
});
|
|
227
|
+
return execFileAsync(cmd, stringArgs);
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
188
231
|
function installBootstrapBindings(): void {
|
|
189
|
-
|
|
232
|
+
const sh = makeSh();
|
|
233
|
+
// Register the SAME function reference in INTERNAL_BINDINGS and the
|
|
234
|
+
// namespace so snapshotNamespace()'s identity skip (===) excludes it from
|
|
235
|
+
// serialization, and so cells can call `sh(...)` directly.
|
|
236
|
+
INTERNAL_BINDINGS.set("sh", sh);
|
|
237
|
+
namespace.sh = sh;
|
|
190
238
|
}
|
|
191
239
|
|
|
192
240
|
installBootstrapBindings();
|
|
@@ -294,7 +342,8 @@ function restoreNamespace(vars: Record<string, string>): {
|
|
|
294
342
|
}
|
|
295
343
|
}
|
|
296
344
|
// Bootstrap runs after restore: live handles would overwrite anything
|
|
297
|
-
// revived.
|
|
345
|
+
// revived. `sh` is re-installed so a revived stale value cannot shadow the
|
|
346
|
+
// live handle (I6).
|
|
298
347
|
installBootstrapBindings();
|
|
299
348
|
return { restored, failed };
|
|
300
349
|
}
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Snapshot HMAC helper — opt-in integrity for scratchpad snapshots.
|
|
3
3
|
*
|
|
4
|
+
* ⚠️ NOT WIRED — see ADR 2026-08-10, Phase 2. This module is NOT connected to
|
|
5
|
+
* the snapshot write/read path (`guest.ts` restore uses plain `v8.deserialize`,
|
|
6
|
+
* `snapshotNamespace` writes unsigned payloads). Zero production call sites.
|
|
7
|
+
* The 11 unit tests here are green but exercise the helper in isolation.
|
|
8
|
+
* Wire-or-delete decision: docs/improvement-plan-2026-08-11.md §4 J1.
|
|
9
|
+
*
|
|
4
10
|
* Closes the E.2 gap declared in docs/improvement-plan-2026-08-09.md and
|
|
5
|
-
*
|
|
11
|
+
* src/runtime/scratchpad/README.md:120 ("v8.deserialize of restore content
|
|
6
12
|
* is unauthenticated (no HMAC)"). The threat model — a same-uid attacker
|
|
7
13
|
* plants a crafted V8 blob at the snapshot path to run deserialize gadgets
|
|
8
14
|
* in the guest — does not cross the existing same-uid boundary, but HMAC
|
|
@@ -61,19 +61,3 @@ export function supervisorContactFromEvent(event: unknown): Omit<SupervisorConta
|
|
|
61
61
|
: undefined,
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* @deprecated The compact pipeline now passes `supervisor_contact` events through
|
|
67
|
-
* to `onJsonEvent` with their full payload (P2-25 fix). Prefer
|
|
68
|
-
* `supervisorContactFromEvent` on the parsed event. Kept for backward compat.
|
|
69
|
-
*/
|
|
70
|
-
export function parseSupervisorContactFromLine(line: string): Omit<SupervisorContactPayload, "timestamp" | "runId"> | undefined {
|
|
71
|
-
if (!line.trim()) return undefined;
|
|
72
|
-
let parsed: unknown;
|
|
73
|
-
try {
|
|
74
|
-
parsed = JSON.parse(line);
|
|
75
|
-
} catch {
|
|
76
|
-
return undefined;
|
|
77
|
-
}
|
|
78
|
-
return supervisorContactFromEvent(parsed);
|
|
79
|
-
}
|
|
@@ -544,6 +544,7 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
|
|
|
544
544
|
runId: manifest.runId,
|
|
545
545
|
agentId: task.id,
|
|
546
546
|
artifactsRoot: manifest.artifactsRoot,
|
|
547
|
+
eventsPath: manifest.eventsPath, // I5: scratchpad metric events from the worker
|
|
547
548
|
attempt: i,
|
|
548
549
|
steeringFile: resolveRealContainedPath(`${manifest.artifactsRoot}/steering`, `${task.id}.jsonl`),
|
|
549
550
|
onSpawn: (pid) => {
|
|
@@ -41,7 +41,15 @@ export function persistSingleTaskUpdate(
|
|
|
41
41
|
checkpointPhase?: TaskCheckpointState["phase"],
|
|
42
42
|
skipCoalesce: boolean = false,
|
|
43
43
|
): TeamTaskState[] {
|
|
44
|
-
|
|
44
|
+
// H5 (2026-08-10): lowered from 100 → 10. Each retry does
|
|
45
|
+
// flushPendingAtomicWrites (global) + loadRunManifestById (stat + parse)
|
|
46
|
+
// + statSync, ~5ms each. The first attempt uses fallbackTasks directly
|
|
47
|
+
// (no disk read); retries only fire under real contention from best-effort
|
|
48
|
+
// writers that don't hold the run lock (async-notifier, crash-recovery).
|
|
49
|
+
// If 10 retries cannot converge, the system is in a pathological state
|
|
50
|
+
// where 100 would not help either — the explicit error below surfaces it
|
|
51
|
+
// instead of blocking the event loop for 500ms.
|
|
52
|
+
const MAX_CAS_ATTEMPTS = 10;
|
|
45
53
|
let baseMtime = 0;
|
|
46
54
|
try {
|
|
47
55
|
baseMtime = fs.statSync(manifest.tasksPath).mtimeMs;
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
appendEventBuffered,
|
|
18
18
|
appendEventFireAndForget,
|
|
19
19
|
flushEventLogBuffer,
|
|
20
|
+
readEvents,
|
|
20
21
|
} from "../state/event-log/event-log.ts";
|
|
21
22
|
import { hashArtifactContent as hashContent, writeArtifact } from "../state/stores/artifact-store.ts";
|
|
22
23
|
import { HealthStore } from "../state/stores/health-store.ts";
|
|
@@ -415,6 +416,27 @@ function runEffectivenessLines(
|
|
|
415
416
|
);
|
|
416
417
|
}
|
|
417
418
|
|
|
419
|
+
// I5 (plan): surface scratchpad adoption in the run summary — but ONLY when
|
|
420
|
+
// non-zero, so the 3 armed roles that never call it add no noise. Counts the
|
|
421
|
+
// metric events the workers appended to the run events log (fire-and-forget
|
|
422
|
+
// scratchpad.cell / scratchpad.restored). Silent when the feature is unused or
|
|
423
|
+
// the events path is missing (no throw).
|
|
424
|
+
function scratchpadSummaryLines(manifest: TeamRunManifest): string[] {
|
|
425
|
+
if (!manifest.eventsPath) return [];
|
|
426
|
+
const events = readEvents(manifest.eventsPath);
|
|
427
|
+
const cells = events.filter((e) => e.type === "scratchpad.cell");
|
|
428
|
+
const restores = events.filter((e) => e.type === "scratchpad.restored");
|
|
429
|
+
if (cells.length === 0 && restores.length === 0) return [];
|
|
430
|
+
return [
|
|
431
|
+
`## Scratchpad (RLM adoption) — I5 metric`,
|
|
432
|
+
`- cells executed: ${cells.length}`,
|
|
433
|
+
`- snapshot restores: ${restores.length}`,
|
|
434
|
+
...(cells.length > 0
|
|
435
|
+
? [`- total cell time: ${Math.round(cells.reduce((s, e) => s + ((e.data?.durationMs as number) ?? 0), 0))} ms`]
|
|
436
|
+
: []),
|
|
437
|
+
];
|
|
438
|
+
}
|
|
439
|
+
|
|
418
440
|
// P6 (perf): Cache the last-rendered progress content so we can skip the
|
|
419
441
|
// artifact write + redaction + atomic write + size/hash read when nothing
|
|
420
442
|
// material changed (rare between batches, but happens between idle heartbeats).
|
|
@@ -964,8 +986,22 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
964
986
|
);
|
|
965
987
|
manifest = updateRunStatus(manifest, "failed", `Unhandled error in team runner: ${message}`);
|
|
966
988
|
await saveRunManifestAsync(manifest);
|
|
967
|
-
} catch {
|
|
968
|
-
//
|
|
989
|
+
} catch (error) {
|
|
990
|
+
// H8 (2026-08-10): surface the silent failure. This is the
|
|
991
|
+
// unhandled-error recovery branch — if the state save ALSO fails
|
|
992
|
+
// (disk full, run-dir gone, permissions), the manifest stays in
|
|
993
|
+
// its pre-failure state (possibly still "running") and crash
|
|
994
|
+
// recovery will treat it as stale. Previously the failure was
|
|
995
|
+
// swallowed with no signal; now it lands in the internal-error
|
|
996
|
+
// channel so operators can see when recovery itself failed.
|
|
997
|
+
// We do NOT re-throw — the run must still be reported as failed
|
|
998
|
+
// to the caller (rejectRunPromise below) regardless of state-save
|
|
999
|
+
// outcome.
|
|
1000
|
+
logInternalError(
|
|
1001
|
+
"team-runner.recovery-save-failed",
|
|
1002
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
1003
|
+
`runId=${manifest.runId} — state may be inconsistent; crash-recovery will reconcile on next launch`,
|
|
1004
|
+
);
|
|
969
1005
|
}
|
|
970
1006
|
const result = { manifest, tasks };
|
|
971
1007
|
rejectRunPromise(manifest.runId, error instanceof Error ? error : new Error(message));
|
|
@@ -2260,6 +2296,7 @@ async function finalizeRun(ctx: SchedulerContext): Promise<{ manifest: TeamRunMa
|
|
|
2260
2296
|
"## Policy decisions",
|
|
2261
2297
|
...(manifest.policyDecisions?.length ? summarizePolicyDecisions(manifest.policyDecisions) : ["- (none)"]),
|
|
2262
2298
|
"",
|
|
2299
|
+
...scratchpadSummaryLines(manifest),
|
|
2263
2300
|
].join("\n"),
|
|
2264
2301
|
});
|
|
2265
2302
|
// Build the complete manifest BEFORE acquiring the lock so the artifacts array
|
package/src/state/contracts.ts
CHANGED
|
@@ -114,6 +114,9 @@ export const TEAM_EVENT_TYPES = [
|
|
|
114
114
|
"dwf.failed",
|
|
115
115
|
"dwf.trust_denied", // F-01: project .dwf.ts denied without PI_CREW_TRUST_PROJECT_DWF
|
|
116
116
|
"dwf.log",
|
|
117
|
+
// RLM/scratchpad adoption metrics (plan I5)
|
|
118
|
+
"scratchpad.cell",
|
|
119
|
+
"scratchpad.restored",
|
|
117
120
|
] as const;
|
|
118
121
|
export type TeamEventType = (typeof TEAM_EVENT_TYPES)[number];
|
|
119
122
|
|
|
@@ -357,6 +357,12 @@ function persistSequence(eventsPath: string, seq: number): void {
|
|
|
357
357
|
// makes assignment atomic (JS is single-threaded); persistSequence() keeps the
|
|
358
358
|
// sidecar durable for crash recovery across restarts.
|
|
359
359
|
const seqCounters = new Map<string, number>();
|
|
360
|
+
// H6 (2026-08-10): FIFO cap — mirrors appendCounters (APPEND_COUNTER_MAX_ENTRIES = 256)
|
|
361
|
+
// and agentEventSeqCache (cap at :434). Without this, a long-lived parent pi process
|
|
362
|
+
// that observes many runs (dev sessions with hundreds of background runs over a week)
|
|
363
|
+
// accumulates one entry per distinct eventsPath forever. Eviction is safe: the next
|
|
364
|
+
// append re-seeds from the `.seq` sidecar via reserveSequenceUnderLock.
|
|
365
|
+
const SEQ_COUNTERS_MAX_ENTRIES = 256;
|
|
360
366
|
|
|
361
367
|
/** Atomically reserve the next sequence number for `eventsPath`.
|
|
362
368
|
*
|
|
@@ -384,7 +390,18 @@ function reserveSequence(eventsPath: string): number {
|
|
|
384
390
|
* (e.g. baseMetadata.seq) so a later auto-assigned seq never collides with it. */
|
|
385
391
|
function advanceSequenceCounter(eventsPath: string, seq: number): void {
|
|
386
392
|
const last = seqCounters.get(eventsPath);
|
|
387
|
-
if (last === undefined || seq > last)
|
|
393
|
+
if (last === undefined || seq > last) {
|
|
394
|
+
seqCounters.set(eventsPath, seq);
|
|
395
|
+
enforceSeqCountersCap();
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** H6: FIFO eviction when the seqCounters map exceeds its bounded size. */
|
|
400
|
+
function enforceSeqCountersCap(): void {
|
|
401
|
+
if (seqCounters.size > SEQ_COUNTERS_MAX_ENTRIES) {
|
|
402
|
+
const oldest = seqCounters.keys().next().value;
|
|
403
|
+
if (oldest !== undefined) seqCounters.delete(oldest);
|
|
404
|
+
}
|
|
388
405
|
}
|
|
389
406
|
|
|
390
407
|
/** C-01: Reserve sequence INSIDE the cross-process lock. Reads the authoritative
|
|
@@ -401,6 +418,7 @@ function reserveSequenceUnderLock(eventsPath: string): number {
|
|
|
401
418
|
const last = Math.max(stored, inProcess);
|
|
402
419
|
const next = last + 1;
|
|
403
420
|
seqCounters.set(eventsPath, next);
|
|
421
|
+
enforceSeqCountersCap();
|
|
404
422
|
return next;
|
|
405
423
|
}
|
|
406
424
|
|
|
@@ -418,32 +436,6 @@ export function computeEventFingerprint(event: Pick<TeamEvent, "type" | "runId"
|
|
|
418
436
|
.slice(0, 16);
|
|
419
437
|
}
|
|
420
438
|
|
|
421
|
-
/**
|
|
422
|
-
* Check for sequence gaps between the sidecar file and the events file.
|
|
423
|
-
* This detects situations where the sidecar records a sequence number that has
|
|
424
|
-
* no corresponding event in the file (e.g., due to a crash between
|
|
425
|
-
* persistSequence and appendFile in older code, or other corruption).
|
|
426
|
-
*
|
|
427
|
-
* Returns an array of gap info: for each gap found, { missing: n } indicates
|
|
428
|
-
* sequence n is recorded in sidecar but has no corresponding event.
|
|
429
|
-
* An empty array means no gaps were found.
|
|
430
|
-
*/
|
|
431
|
-
export function checkSequenceGaps(eventsPath: string): { missing: number }[] {
|
|
432
|
-
if (!fs.existsSync(eventsPath)) return [];
|
|
433
|
-
const gaps: { missing: number }[] = [];
|
|
434
|
-
const storedSeq = readStoredSequence(eventsPath);
|
|
435
|
-
if (storedSeq === undefined) return [];
|
|
436
|
-
const maxInFile = scanSequence(eventsPath);
|
|
437
|
-
// If sidecar is ahead of file, report the missing sequences
|
|
438
|
-
// (sidecar stores the NEXT sequence to use, so storedSeq is the last written)
|
|
439
|
-
if (storedSeq > maxInFile) {
|
|
440
|
-
for (let i = maxInFile + 1; i <= storedSeq; i++) {
|
|
441
|
-
gaps.push({ missing: i });
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
return gaps;
|
|
445
|
-
}
|
|
446
|
-
|
|
447
439
|
/**
|
|
448
440
|
* @deprecated Prefer `appendEventAsync()` in async contexts. The sync lock uses
|
|
449
441
|
* `sleepSync` which blocks the Node.js event loop, preventing AbortSignal handlers
|
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Manage .gitignore entries for the
|
|
2
|
+
* Manage .gitignore entries for the crew state directory.
|
|
3
|
+
*
|
|
4
|
+
* Supports BOTH layouts that `projectCrewRoot` (src/utils/paths.ts) can
|
|
5
|
+
* resolve to:
|
|
6
|
+
* - `.crew/` (default, created when neither .crew/ nor .pi/ exists)
|
|
7
|
+
* - `.pi/teams/` (legacy layout, reused when .pi/ already exists)
|
|
8
|
+
*
|
|
9
|
+
* Before this fix (2026-08-10, improvement-plan G12), only `.crew/`
|
|
10
|
+
* entries were written — so projects on the `.pi/teams/` layout silently
|
|
11
|
+
* committed their run state, artifacts, and logs to git. The layout is
|
|
12
|
+
* detected from the repo root (parent dir of the .gitignore path).
|
|
13
|
+
*
|
|
3
14
|
* Only adds entries if not already present; preserves existing content.
|
|
4
15
|
*/
|
|
5
16
|
import * as fs from "node:fs";
|
|
17
|
+
import * as path from "node:path";
|
|
6
18
|
import { atomicWriteFile } from "./atomic-write.ts";
|
|
7
19
|
|
|
8
20
|
/**
|
|
9
|
-
* Entries to add to .gitignore for
|
|
21
|
+
* Entries to add to .gitignore for the `.crew/` layout.
|
|
10
22
|
*
|
|
11
23
|
* - `/.crew/` and `/.crew` ignore the core state directory.
|
|
12
|
-
* - Exceptions allow optional commit of artifacts/ and graphs
|
|
24
|
+
* - Exceptions allow optional commit of artifacts/ and graphs/.
|
|
13
25
|
*/
|
|
14
26
|
const CREW_GITIGNORE_ENTRIES = [
|
|
15
27
|
"/.crew/",
|
|
@@ -21,12 +33,54 @@ const CREW_GITIGNORE_ENTRIES = [
|
|
|
21
33
|
];
|
|
22
34
|
|
|
23
35
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
36
|
+
* Entries to add to .gitignore for the `.pi/teams/` legacy layout.
|
|
37
|
+
*
|
|
38
|
+
* `.pi/` is shared with other pi-crew-unrelated pi tooling, so we ignore
|
|
39
|
+
* ONLY the specific pi-crew state subdirs rather than the whole `.pi/teams/`
|
|
40
|
+
* tree. `artifacts/` and `graphs/` stay committable (mirroring the `.crew/`
|
|
41
|
+
* exception policy).
|
|
42
|
+
*/
|
|
43
|
+
const PI_TEAMS_GITIGNORE_ENTRIES = [
|
|
44
|
+
"/.pi/teams/state/",
|
|
45
|
+
"/.pi/teams/cache/",
|
|
46
|
+
"/.pi/teams/worktrees/",
|
|
47
|
+
"/.pi/teams/imports/",
|
|
48
|
+
"/.pi/teams/audit/",
|
|
49
|
+
"!/.pi/teams/artifacts/",
|
|
50
|
+
"!/.pi/teams/graphs/",
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve which gitignore entry set applies. The repo root is the parent
|
|
55
|
+
* directory of the `.gitignore` path. If `.crew/` exists there, use the
|
|
56
|
+
* `.crew/` entries; else if `.pi/` exists, use the `.pi/teams/` entries;
|
|
57
|
+
* else fall through to the default `.crew/` set (forward-looking for fresh
|
|
58
|
+
* projects — the crew-init step will create `.crew/`).
|
|
59
|
+
*/
|
|
60
|
+
function resolveEntries(gitignorePath: string): string[] {
|
|
61
|
+
const repoRoot = path.dirname(gitignorePath);
|
|
62
|
+
try {
|
|
63
|
+
if (fs.existsSync(path.join(repoRoot, ".crew"))) return CREW_GITIGNORE_ENTRIES;
|
|
64
|
+
if (fs.existsSync(path.join(repoRoot, ".pi"))) return PI_TEAMS_GITIGNORE_ENTRIES;
|
|
65
|
+
} catch {
|
|
66
|
+
// Filesystem error — fall through to default. The crew-init step
|
|
67
|
+
// creates .crew/ anyway, so the default entries are correct.
|
|
68
|
+
}
|
|
69
|
+
return CREW_GITIGNORE_ENTRIES;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Update .gitignore with the appropriate crew entries. Creates the file if it
|
|
74
|
+
* doesn't exist. Preserves all existing content.
|
|
75
|
+
*
|
|
76
|
+
* Layout detection: if `.pi/` exists at the repo root and `.crew/` does not,
|
|
77
|
+
* the `.pi/teams/` entries are written instead (mirrors `projectCrewRoot`).
|
|
26
78
|
*/
|
|
27
79
|
export async function updateGitignore(gitignorePath: string): Promise<void> {
|
|
80
|
+
const entries = resolveEntries(gitignorePath);
|
|
81
|
+
|
|
28
82
|
if (!fs.existsSync(gitignorePath)) {
|
|
29
|
-
atomicWriteFile(gitignorePath,
|
|
83
|
+
atomicWriteFile(gitignorePath, entries.join("\n") + "\n");
|
|
30
84
|
return;
|
|
31
85
|
}
|
|
32
86
|
|
|
@@ -34,7 +88,7 @@ export async function updateGitignore(gitignorePath: string): Promise<void> {
|
|
|
34
88
|
const existingLines = new Set(current.split("\n").map((line) => line.trim()));
|
|
35
89
|
|
|
36
90
|
let appended = "";
|
|
37
|
-
for (const entry of
|
|
91
|
+
for (const entry of entries) {
|
|
38
92
|
if (!existingLines.has(entry)) {
|
|
39
93
|
appended += `\n${entry}`;
|
|
40
94
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Glob matcher with ReDoS protection.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `src/extension/team-tool/api.ts` so the security-critical
|
|
5
|
+
* ReDoS guard (pattern length cap + path-safe wildcards) is testable in
|
|
6
|
+
* isolation without importing the heavy `team-tool/api` transitive graph
|
|
7
|
+
* (which pulls in `pi-coding-agent` peer deps unavailable in unit tests).
|
|
8
|
+
*
|
|
9
|
+
* Semantics (intentional, path-safe):
|
|
10
|
+
* - `*` matches a run of non-separator chars (does NOT cross `/`)
|
|
11
|
+
* - `?` matches a single non-separator char
|
|
12
|
+
* - all other regex metacharacters are escaped (literal match)
|
|
13
|
+
* - patterns longer than 200 chars are rejected outright (ReDoS backstop)
|
|
14
|
+
*
|
|
15
|
+
* @see test/unit/glob-match-redos.test.ts — the regression suite for the
|
|
16
|
+
* catastrophic-backtracking fix (MEDIUM #4).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// Prevent ReDoS: reject excessively long patterns.
|
|
20
|
+
const MAX_GLOB_PATTERN_LEN = 200;
|
|
21
|
+
|
|
22
|
+
export function globMatch(value: string, pattern: string): boolean {
|
|
23
|
+
if (pattern.length > MAX_GLOB_PATTERN_LEN) return false;
|
|
24
|
+
const regex = pattern
|
|
25
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape regex special chars
|
|
26
|
+
.replace(/\*/g, "[^/]*") // * matches non-slash characters only
|
|
27
|
+
.replace(/\?/g, "[^/]"); // ? matches single non-slash
|
|
28
|
+
return new RegExp(`^${regex}$`).test(value);
|
|
29
|
+
}
|
package/types/dwf.d.ts
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* import type { WorkflowCtx } from "pi-crew/workflow";
|
|
25
25
|
* ```
|
|
26
26
|
*
|
|
27
|
-
* These interfaces mirror the runtime types in `src/runtime/dynamic-workflow-context.ts`.
|
|
27
|
+
* These interfaces mirror the runtime types in `src/runtime/goal-workflow/dynamic-workflow-context.ts`.
|
|
28
28
|
* They are authoring-only (no runtime values); the real implementations live in the runner.
|
|
29
29
|
*
|
|
30
30
|
* ## Resume & Checkpoint (round-18 P2-3)
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Type imports from pi v0.77.0
|
|
3
|
-
*/
|
|
4
|
-
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
|
|
6
|
-
export type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
7
|
-
|
|
8
|
-
// Note: AgentEvent is not exported by pi-coding-agent v0.77.0
|
|
9
|
-
// Using AgentEndEvent and AgentStartEvent instead
|
|
10
|
-
|
|
11
|
-
// Type guards for pi-crew usage
|
|
12
|
-
/** @internal */
|
|
13
|
-
function isToolEvent(event: AgentSessionEvent): boolean {
|
|
14
|
-
return event.type === "tool_execution_start" || event.type === "tool_execution_update" || event.type === "tool_execution_end";
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** @internal */
|
|
18
|
-
function isAgentLifecycleEvent(event: AgentSessionEvent): boolean {
|
|
19
|
-
return event.type === "agent_start" || event.type === "agent_end";
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** @internal */
|
|
23
|
-
function isCompactionEvent(event: AgentSessionEvent): boolean {
|
|
24
|
-
return event.type === "compaction_start" || event.type === "compaction_end";
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** @internal */
|
|
28
|
-
function isRetryEvent(event: AgentSessionEvent): boolean {
|
|
29
|
-
return event.type === "auto_retry_start" || event.type === "auto_retry_end";
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** @internal */
|
|
33
|
-
function isQueueEvent(event: AgentSessionEvent): boolean {
|
|
34
|
-
return event.type === "queue_update";
|
|
35
|
-
}
|