pi-crew 0.9.66 → 0.9.68

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 (52) hide show
  1. package/CHANGELOG.md +109 -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 +4451 -4092
  7. package/package.json +12 -8
  8. package/scripts/analyze-run.mjs +1333 -0
  9. package/scripts/resource-sampler.mjs +482 -0
  10. package/skills/real-test-pi-crew/SKILL.md +10 -9
  11. package/src/agents/agent-config.ts +35 -14
  12. package/src/config/role-tools.ts +2 -2
  13. package/src/extension/knowledge-injection.ts +17 -0
  14. package/src/extension/pi-api.ts +0 -16
  15. package/src/extension/team-tool/api/agent-control.ts +358 -0
  16. package/src/extension/team-tool/api/handler-context.ts +57 -0
  17. package/src/extension/team-tool/api/heartbeat.ts +75 -0
  18. package/src/extension/team-tool/api/mailbox.ts +242 -0
  19. package/src/extension/team-tool/api/plan-approval.ts +190 -0
  20. package/src/extension/team-tool/api/read.ts +443 -0
  21. package/src/extension/team-tool/api/task-claims.ts +207 -0
  22. package/src/extension/team-tool/api.ts +56 -1301
  23. package/src/extension/team-tool/cancel.ts +15 -4
  24. package/src/extension/team-tool/goal-wrap.ts +2 -2
  25. package/src/extension/team-tool/goal.ts +4 -4
  26. package/src/extension/team-tool/lifecycle-actions.ts +5 -5
  27. package/src/extension/team-tool/parallel-dispatch.ts +2 -2
  28. package/src/extension/team-tool/respond.ts +11 -3
  29. package/src/extension/team-tool/run-intent.ts +357 -0
  30. package/src/extension/team-tool/run.ts +8 -285
  31. package/src/extension/team-tool/status.ts +56 -21
  32. package/src/extension/team-tool.ts +10 -8
  33. package/src/prompt/scratchpad-lifecycle.ts +80 -5
  34. package/src/runtime/child-pi/child-pi-spawn.ts +13 -7
  35. package/src/runtime/child-pi/child-pi.ts +22 -144
  36. package/src/runtime/child-pi/mock-fixtures.ts +171 -0
  37. package/src/runtime/crew-agent-records.ts +18 -1
  38. package/src/runtime/scratchpad/README.md +11 -0
  39. package/src/runtime/scratchpad/guest.ts +157 -7
  40. package/src/runtime/scratchpad/protocol.ts +13 -1
  41. package/src/runtime/scratchpad/transform.ts +206 -12
  42. package/src/runtime/supervisor-contact.ts +0 -16
  43. package/src/runtime/task-runner/child-executor.ts +1 -0
  44. package/src/runtime/task-runner/state-helpers.ts +9 -1
  45. package/src/runtime/team-runner.ts +39 -2
  46. package/src/state/contracts.ts +3 -0
  47. package/src/state/event-log/event-log.ts +19 -27
  48. package/src/state/gitignore-manager.ts +61 -7
  49. package/src/utils/glob-match.ts +29 -0
  50. package/types/dwf.d.ts +1 -1
  51. package/src/runtime/scratchpad/snapshot-hmac.ts +0 -161
  52. package/src/types/new-api-types.ts +0 -35
@@ -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,161 +0,0 @@
1
- /**
2
- * Snapshot HMAC helper — opt-in integrity for scratchpad snapshots.
3
- *
4
- * 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
6
- * is unauthenticated (no HMAC)"). The threat model — a same-uid attacker
7
- * plants a crafted V8 blob at the snapshot path to run deserialize gadgets
8
- * in the guest — does not cross the existing same-uid boundary, but HMAC
9
- * hardening is required before snapshots ever land in a shared/networked
10
- * store.
11
- *
12
- * Migration window (see docs/decisions/2026-08-10-scratchpad-snapshot-hmac.md):
13
- *
14
- * Phase 1 (this module): HMAC sign-on-write + verify-on-read are OPT-IN
15
- * via PI_CREW_SNAPSHOT_HMAC_KEY. When the key is unset, behaviour is
16
- * unchanged (snapshots remain unsigned). When the key is set, writes
17
- * attach a signature and reads verify it; an unsigned/failed snapshot
18
- * is ACCEPTED with a warning so existing snapshots remain readable.
19
- * Phase 2 (after one release): unsigned snapshots are REJECTED when the
20
- * key is set (configurable via PI_CREW_SNAPSHOT_HMAC_STRICT=1).
21
- * Phase 3 (after snapshots move to a shared store): the key becomes
22
- * required and unsigned snapshots are always rejected.
23
- *
24
- * Wire-up into the actual write/read paths is intentionally deferred to a
25
- * follow-up that audits the snapshot envelope format (V8 base64 vs raw
26
- * bytes) and the writeArtifact redaction interaction. See ADR for the plan.
27
- */
28
- import { createHmac, timingSafeEqual } from "node:crypto";
29
-
30
- /**
31
- * Env var holding the HMAC secret. When unset, HMAC is disabled (Phase 0
32
- * behaviour — snapshots unsigned). When set, sign-on-write and
33
- * verify-on-read are enabled.
34
- */
35
- export const SNAPSHOT_HMAC_KEY_ENV = "PI_CREW_SNAPSHOT_HMAC_KEY";
36
-
37
- /**
38
- * Opt-in strict mode (Phase 2): when the key is set AND this is "1",
39
- * unsigned or signature-mismatched snapshots are REJECTED on read instead
40
- * of accepted with a warning.
41
- */
42
- export const SNAPSHOT_HMAC_STRICT_ENV = "PI_CREW_SNAPSHOT_HMAC_STRICT";
43
-
44
- /**
45
- * Header prefix for an inline signature. When snapshots are signed, the
46
- * signature is prepended to the blob as `PI_CREW_SIG=<hex>\n` so a single
47
- * read yields both the signature and the payload. (Sidecar files would
48
- * require writeArtifact coordination that the envelope format does not
49
- * currently support.)
50
- */
51
- export const SNAPSHOT_SIG_PREFIX = "PI_CREW_SIG=";
52
-
53
- export function getSnapshotHmacKey(env: NodeJS.ProcessEnv = process.env): Buffer | undefined {
54
- const raw = env[SNAPSHOT_HMAC_KEY_ENV];
55
- if (!raw) return undefined;
56
- // Accept hex-encoded keys directly; otherwise encode the string as utf8.
57
- // A key shorter than 32 bytes is rejected to prevent trivial brute-force.
58
- const buf = /^[0-9a-fA-F]+$/.test(raw) && raw.length % 2 === 0 ? Buffer.from(raw, "hex") : Buffer.from(raw, "utf8");
59
- if (buf.length < 32) {
60
- throw new Error(
61
- `${SNAPSHOT_HMAC_KEY_ENV} must be at least 32 bytes (got ${buf.length}); use a longer key or generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`,
62
- );
63
- }
64
- return buf;
65
- }
66
-
67
- export function isSnapshotHmacStrict(env: NodeJS.ProcessEnv = process.env): boolean {
68
- return env[SNAPSHOT_HMAC_STRICT_ENV] === "1" || env[SNAPSHOT_HMAC_STRICT_ENV] === "true";
69
- }
70
-
71
- /**
72
- * Compute the HMAC-SHA256 signature of `content` under `key`. Returns a
73
- * lowercase hex string.
74
- */
75
- export function signSnapshot(content: Buffer | string, key: Buffer): string {
76
- return createHmac("sha256", key).update(content).digest("hex");
77
- }
78
-
79
- /**
80
- * Constant-time signature comparison. Both signatures must be lowercase
81
- * hex of the same length; anything else returns false without throwing.
82
- */
83
- export function snapshotSignatureMatches(content: Buffer | string, signature: string, key: Buffer): boolean {
84
- const expected = signSnapshot(content, key);
85
- const a = Buffer.from(expected);
86
- const b = Buffer.from(signature);
87
- if (a.length !== b.length) return false;
88
- return timingSafeEqual(a, b);
89
- }
90
-
91
- /**
92
- * Outcome of {@link verifySnapshotPayload}. Callers decide how to react
93
- * based on the strict-mode flag.
94
- */
95
- export type SnapshotVerifyOutcome =
96
- | { kind: "unsigned"; strict: boolean }
97
- | { kind: "verified" }
98
- | { kind: "mismatch"; strict: boolean }
99
- | { kind: "hmac-disabled" };
100
-
101
- /**
102
- * Verify a snapshot payload that may or may not carry an inline signature.
103
- *
104
- * @param payload The raw bytes read from disk (possibly including the
105
- * `PI_CREW_SIG=<hex>\n` prefix).
106
- * @param key The HMAC key, or `undefined` when HMAC is disabled.
107
- * @param strict When true, unsigned/mismatched payloads are reported as
108
- * rejectable. When false (the Phase 1 default), the caller is expected
109
- * to accept the payload with a warning.
110
- */
111
- export function verifySnapshotPayload(payload: Buffer, key: Buffer | undefined, strict: boolean): SnapshotVerifyOutcome {
112
- if (!key) return { kind: "hmac-disabled" };
113
- const prefixStr = SNAPSHOT_SIG_PREFIX;
114
- if (payload.length < prefixStr.length + 1 || payload.subarray(0, prefixStr.length).toString("utf8") !== prefixStr) {
115
- return { kind: "unsigned", strict };
116
- }
117
- const newlineIdx = payload.indexOf(0x0a, prefixStr.length);
118
- if (newlineIdx < 0) return { kind: "unsigned", strict };
119
- const sigHex = payload.subarray(prefixStr.length, newlineIdx).toString("utf8");
120
- const body = payload.subarray(newlineIdx + 1);
121
- return snapshotSignatureMatches(body, sigHex, key) ? { kind: "verified" } : { kind: "mismatch", strict };
122
- }
123
-
124
- /**
125
- * Strip the inline signature prefix and return the bare payload. Returns
126
- * the original buffer when no prefix is present. Used by read paths that
127
- * have already called {@link verifySnapshotPayload} and decided to accept.
128
- */
129
- export function stripSnapshotSignature(payload: Buffer): Buffer {
130
- if (payload.length < SNAPSHOT_SIG_PREFIX.length) return payload;
131
- if (payload.subarray(0, SNAPSHOT_SIG_PREFIX.length).toString("utf8") !== SNAPSHOT_SIG_PREFIX) return payload;
132
- const newlineIdx = payload.indexOf(0x0a, SNAPSHOT_SIG_PREFIX.length);
133
- if (newlineIdx < 0) return payload;
134
- return payload.subarray(newlineIdx + 1);
135
- }
136
-
137
- /**
138
- * Attach an inline signature to a payload. Returns a new buffer shaped as
139
- * `PI_CREW_SIG=<hex>\n<payload>`. Used by write paths that have HMAC
140
- * enabled.
141
- */
142
- export function attachSnapshotSignature(payload: Buffer, key: Buffer): Buffer {
143
- const sig = signSnapshot(payload, key);
144
- return Buffer.concat([Buffer.from(`${SNAPSHOT_SIG_PREFIX}${sig}\n`, "utf8"), payload]);
145
- }
146
-
147
- /**
148
- * Should the caller reject the snapshot based on the verify outcome?
149
- * Centralises the strict-vs-migration decision so callers stay simple.
150
- */
151
- export function shouldRejectSnapshot(outcome: SnapshotVerifyOutcome): boolean {
152
- switch (outcome.kind) {
153
- case "hmac-disabled":
154
- return false;
155
- case "verified":
156
- return false;
157
- case "unsigned":
158
- case "mismatch":
159
- return outcome.strict;
160
- }
161
- }
@@ -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
- }