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.
Files changed (54) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +2 -3
  3. package/agents/executor.md +1 -1
  4. package/agents/test-engineer.md +1 -1
  5. package/agents/verifier.md +1 -1
  6. package/dist/index.mjs +4526 -4100
  7. package/package.json +7 -4
  8. package/skills/real-test-pi-crew/SKILL.md +10 -9
  9. package/src/config/config.ts +19 -3
  10. package/src/config/role-tools.ts +2 -2
  11. package/src/config/types.ts +2 -0
  12. package/src/extension/knowledge-injection.ts +17 -0
  13. package/src/extension/pi-api.ts +0 -16
  14. package/src/extension/team-tool/api/agent-control.ts +358 -0
  15. package/src/extension/team-tool/api/handler-context.ts +57 -0
  16. package/src/extension/team-tool/api/heartbeat.ts +75 -0
  17. package/src/extension/team-tool/api/mailbox.ts +242 -0
  18. package/src/extension/team-tool/api/plan-approval.ts +190 -0
  19. package/src/extension/team-tool/api/read.ts +443 -0
  20. package/src/extension/team-tool/api/task-claims.ts +207 -0
  21. package/src/extension/team-tool/api.ts +56 -1301
  22. package/src/extension/team-tool/cancel.ts +49 -4
  23. package/src/extension/team-tool/dispatch/manage.ts +7 -4
  24. package/src/extension/team-tool/explain.ts +3 -1
  25. package/src/extension/team-tool/goal-wrap.ts +2 -2
  26. package/src/extension/team-tool/goal.ts +4 -4
  27. package/src/extension/team-tool/lifecycle-actions.ts +9 -6
  28. package/src/extension/team-tool/parallel-dispatch.ts +2 -2
  29. package/src/extension/team-tool/respond.ts +11 -3
  30. package/src/extension/team-tool/run-intent.ts +357 -0
  31. package/src/extension/team-tool/run.ts +8 -285
  32. package/src/extension/team-tool/status.ts +56 -21
  33. package/src/extension/team-tool-types.ts +2 -0
  34. package/src/extension/team-tool.ts +10 -8
  35. package/src/prompt/scratchpad-lifecycle.ts +80 -5
  36. package/src/runtime/child-pi/child-pi-spawn.ts +13 -7
  37. package/src/runtime/child-pi/child-pi.ts +22 -144
  38. package/src/runtime/child-pi/mock-fixtures.ts +171 -0
  39. package/src/runtime/crew-agent-records.ts +18 -1
  40. package/src/runtime/output/output-validator.ts +34 -6
  41. package/src/runtime/scheduling/scheduler.ts +67 -19
  42. package/src/runtime/scratchpad/README.md +6 -0
  43. package/src/runtime/scratchpad/guest.ts +54 -5
  44. package/src/runtime/scratchpad/snapshot-hmac.ts +7 -1
  45. package/src/runtime/supervisor-contact.ts +0 -16
  46. package/src/runtime/task-runner/child-executor.ts +1 -0
  47. package/src/runtime/task-runner/state-helpers.ts +9 -1
  48. package/src/runtime/team-runner.ts +39 -2
  49. package/src/state/contracts.ts +3 -0
  50. package/src/state/event-log/event-log.ts +19 -27
  51. package/src/state/gitignore-manager.ts +61 -7
  52. package/src/utils/glob-match.ts +29 -0
  53. package/types/dwf.d.ts +1 -1
  54. 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
- function nextCronDate(spec: string, from: Date): Date | { error: string } | null {
233
- const parts = spec.split(/\s+/);
234
- if (parts.length < 5) return { error: "Invalid cron expression" };
235
- const [minStr, hourStr, domStr, monthStr, dowStr] = parts;
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
- function matchField(value: number, str: string, min: number, max: number): boolean {
238
- if (str === "*") return true;
239
- const n = parseInt(str, 10);
240
- if (!Number.isNaN(n) && n >= min && n <= max && n === value) return true;
241
- if (/^\d+-\d+$/.test(str)) {
242
- const [a, b] = str.split("-").map(Number);
243
- return value >= a && value <= b;
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
- if (str.includes(",")) {
246
- return str.split(",").some((part) => matchField(value, part.trim(), min, max));
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
- matchField(min, minStr, 0, 59) &&
265
- matchField(hour, hourStr, 0, 23) &&
266
- matchField(dom, domStr, 1, 31) &&
267
- matchField(month, monthStr, 1, 12) &&
268
- matchField(dow, dowStr, 0, 6)
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. The spike has no host bridge, so
183
- // nothing is installed — but the hook is kept (and re-run after restore) for
184
- // fidelity with the port plan: a later phase wires real bindings here.
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
- // Spike: no host bridge, no Bun — nothing to mount.
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. The spike installs nothing, but the hook is kept for fidelity.
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
- * docs/runtime/scratchpad/README.md:120 ("v8.deserialize of restore content
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
- const MAX_CAS_ATTEMPTS = 100;
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
- // Best-effort — state write may also fail
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
@@ -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) seqCounters.set(eventsPath, seq);
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 .crew directory.
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 .crew directory management.
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/ (including their .gitkeep).
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
- * Update .gitignore with .crew entries. Creates the file if it doesn't exist.
25
- * Preserves all existing content.
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, CREW_GITIGNORE_ENTRIES.join("\n") + "\n");
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 CREW_GITIGNORE_ENTRIES) {
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
- }