pi-crew 0.8.14 → 0.9.1

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 (86) hide show
  1. package/CHANGELOG.md +366 -0
  2. package/README.md +112 -2
  3. package/docs/FEATURE_INTAKE.md +1 -1
  4. package/docs/HARNESS.md +20 -19
  5. package/docs/PROJECT_REVIEW.md +132 -133
  6. package/docs/PROJECT_REVIEW_FIXES.md +130 -131
  7. package/docs/actions-reference.md +127 -121
  8. package/docs/architecture.md +1 -1
  9. package/docs/code-review-2026-05-11.md +134 -134
  10. package/docs/commands-reference.md +108 -106
  11. package/docs/comparison-pi-subagents-vs-pi-crew.md +105 -105
  12. package/docs/deep-review-report.md +1 -1
  13. package/docs/dynamic-workflows.md +90 -0
  14. package/docs/fixes/BATCH_A_H1_H2.md +17 -17
  15. package/docs/fixes/bug-007-async-notifier-stale-ctx.md +23 -23
  16. package/docs/followup-plan-2026-05-12.md +135 -135
  17. package/docs/followup-review-2026-05-12.md +86 -86
  18. package/docs/followup-review-round3-2026-05-12.md +123 -123
  19. package/docs/goals.md +59 -0
  20. package/docs/implementation-plan-top3.md +4 -4
  21. package/docs/issue-29-analysis.md +2 -2
  22. package/docs/oh-my-pi-research.md +154 -154
  23. package/docs/optimization-plan.md +2 -0
  24. package/docs/perf/baseline-2026-05.md +9 -9
  25. package/docs/perf/final-report-2026-05.md +2 -2
  26. package/docs/perf/sprint-1-report.md +2 -2
  27. package/docs/perf/sprint-2-report.md +1 -1
  28. package/docs/perf/upgrade-plan-2026-05.md +72 -72
  29. package/docs/pi-crew-bugs.md +230 -230
  30. package/docs/pi-crew-investigation-report.md +102 -102
  31. package/docs/pi-crew-test-round5.md +4 -4
  32. package/docs/runtime-analysis-child-vs-live.md +57 -57
  33. package/docs/runtime-migration-in-process-analysis.md +97 -97
  34. package/package.json +2 -4
  35. package/skills/orchestration/SKILL.md +11 -11
  36. package/src/agents/agent-config.ts +4 -0
  37. package/src/config/config.ts +39 -0
  38. package/src/config/types.ts +11 -0
  39. package/src/extension/action-suggestions.ts +2 -1
  40. package/src/extension/async-notifier.ts +10 -0
  41. package/src/extension/help.ts +14 -0
  42. package/src/extension/registration/commands.ts +27 -0
  43. package/src/extension/team-tool/destructive-gate.ts +1 -1
  44. package/src/extension/team-tool/goal-wrap.ts +288 -0
  45. package/src/extension/team-tool/goal.ts +405 -0
  46. package/src/extension/team-tool/run.ts +103 -4
  47. package/src/extension/team-tool/workflow-manage.ts +194 -0
  48. package/src/extension/team-tool.ts +20 -0
  49. package/src/hooks/types.ts +3 -1
  50. package/src/runtime/async-runner.ts +27 -2
  51. package/src/runtime/background-runner.ts +68 -19
  52. package/src/runtime/child-pi.ts +9 -1
  53. package/src/runtime/completion-guard.ts +1 -1
  54. package/src/runtime/dynamic-workflow-context.ts +450 -0
  55. package/src/runtime/dynamic-workflow-runner.ts +180 -0
  56. package/src/runtime/global-worker-cap.ts +96 -0
  57. package/src/runtime/goal-evaluator.ts +294 -0
  58. package/src/runtime/goal-loop-runner.ts +612 -0
  59. package/src/runtime/goal-state-store.ts +209 -0
  60. package/src/runtime/iteration-hooks.ts +2 -1
  61. package/src/runtime/pi-args.ts +10 -2
  62. package/src/runtime/post-checks.ts +2 -1
  63. package/src/runtime/result-extractor.ts +32 -0
  64. package/src/runtime/team-runner.ts +11 -1
  65. package/src/runtime/verification-gates.ts +88 -5
  66. package/src/runtime/verification-integrity.ts +110 -0
  67. package/src/runtime/verification-worktree.ts +136 -0
  68. package/src/runtime/workspace-lock.ts +448 -0
  69. package/src/schema/config-schema.ts +26 -0
  70. package/src/schema/team-tool-schema.ts +39 -4
  71. package/src/state/atomic-write.ts +9 -0
  72. package/src/state/contracts.ts +14 -0
  73. package/src/state/crew-init.ts +18 -5
  74. package/src/state/event-log.ts +7 -1
  75. package/src/state/state-store.ts +2 -0
  76. package/src/state/types.ts +82 -0
  77. package/src/state/worker-atomic-writer.ts +190 -0
  78. package/src/utils/env-allowlist.ts +30 -0
  79. package/src/utils/redaction.ts +104 -24
  80. package/src/utils/safe-paths.ts +55 -14
  81. package/src/workflows/discover-workflows.ts +25 -1
  82. package/src/workflows/workflow-config.ts +13 -0
  83. package/src/worktree/cleanup.ts +2 -1
  84. package/src/worktree/worktree-manager.ts +4 -3
  85. package/teams/parallel-research.team.md +1 -1
  86. package/workflows/examples/hello.dwf.ts +24 -0
@@ -72,6 +72,12 @@ export const TeamToolParams = Type.Object({
72
72
  Type.Literal("anchor"),
73
73
  Type.Literal("auto-summarize"),
74
74
  Type.Literal("auto_boomerang"),
75
+ Type.Literal("goal"),
76
+ Type.Literal("workflow-create"),
77
+ Type.Literal("workflow-get"),
78
+ Type.Literal("workflow-list"),
79
+ Type.Literal("workflow-save"),
80
+ Type.Literal("workflow-delete"),
75
81
  ],
76
82
  { description: "Team action. Defaults to 'list' when omitted." },
77
83
  ),
@@ -244,8 +250,14 @@ export const TeamToolParams = Type.Object({
244
250
  budgetTotal: Type.Optional(
245
251
  Type.Number({
246
252
  description:
247
- "Total token budget for the run. When set, enables budget tracking with default 80% warning and 95% abort thresholds.",
248
- minimum: 1,
253
+ "Total token budget for the run. When set, enables budget tracking with default 80% warning and 95% abort thresholds. Minimum 1000 — this is a MISCONFIGURATION GUARD (catches typos / silent-abort configs like budgetTotal:1, which would abort on turn 1), NOT a usefulness guarantee; a productive multi-turn goal needs far more than 1000 tokens.",
254
+ minimum: 1000,
255
+ }),
256
+ ),
257
+ budgetUnlimited: Type.Optional(
258
+ Type.Boolean({
259
+ description:
260
+ "When true, skip budget enforcement entirely (explicit opt-out). Goal-start validation requires budgetTotal>=1000 OR budgetUnlimited:true; audit-logged when set. The validation itself is enforced in a later integration task.",
249
261
  }),
250
262
  ),
251
263
  budgetWarning: Type.Optional(
@@ -264,6 +276,19 @@ export const TeamToolParams = Type.Object({
264
276
  maximum: 1,
265
277
  }),
266
278
  ),
279
+ runKind: Type.Optional(
280
+ Type.Union(
281
+ [
282
+ Type.Literal("team-run"),
283
+ Type.Literal("goal-loop"),
284
+ Type.Literal("dynamic-workflow"),
285
+ ],
286
+ {
287
+ description:
288
+ "Background dispatch discriminator. Default \"team-run\" runs the normal executeTeamRun workflow; \"goal-loop\" (P0/P1) and \"dynamic-workflow\" (P2/P3) dispatch to their respective background runners. Absent = \"team-run\" for backward compatibility.",
289
+ },
290
+ ),
291
+ ),
267
292
  });
268
293
 
269
294
  export interface TeamToolParamsValue {
@@ -312,7 +337,13 @@ export interface TeamToolParamsValue {
312
337
  | "search"
313
338
  | "orchestrate"
314
339
  | "schedule"
315
- | "scheduled";
340
+ | "scheduled"
341
+ | "goal"
342
+ | "workflow-create"
343
+ | "workflow-get"
344
+ | "workflow-list"
345
+ | "workflow-save"
346
+ | "workflow-delete";
316
347
  resource?: "agent" | "team" | "workflow";
317
348
  team?: string;
318
349
  workflow?: string;
@@ -352,10 +383,14 @@ export interface TeamToolParamsValue {
352
383
  once?: string | number;
353
384
  /** Mark certain bash commands as excludeFromContext to reduce context tokens (default: false). */
354
385
  excludeContextBash?: boolean;
355
- /** Total token budget for the run. When set, enables budget tracking. */
386
+ /** Total token budget for the run. When set, enables budget tracking (minimum 1000). */
356
387
  budgetTotal?: number;
388
+ /** When true, skip budget enforcement entirely (explicit opt-out). */
389
+ budgetUnlimited?: boolean;
357
390
  /** Budget warning threshold as a fraction (0-1). Default: 0.8. */
358
391
  budgetWarning?: number;
359
392
  /** Budget abort threshold as a fraction (0-1). Default: 0.95. */
360
393
  budgetAbort?: number;
394
+ /** Background dispatch discriminator. Default "team-run". "goal-loop"/"dynamic-workflow" dispatch to their runners (P0/P2). */
395
+ runKind?: "team-run" | "goal-loop" | "dynamic-workflow";
361
396
  }
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import { logInternalError } from "../utils/internal-error.ts";
6
+ import { isWorkerAtomicWriterEnabled, atomicWriteFileViaWorker } from "./worker-atomic-writer.ts";
6
7
  import { sleepSync } from "../utils/sleep.ts";
7
8
 
8
9
  function hashContent(content: string): string {
@@ -380,6 +381,14 @@ export function atomicWriteFile(filePath: string, content: string, expectedHash?
380
381
 
381
382
 
382
383
  export async function atomicWriteFileAsync(filePath: string, content: string): Promise<void> {
384
+ // Phase 1.5 (RFC 15): when the worker-thread atomic writer is enabled
385
+ // (PI_CREW_WORKER_ATOMIC_WRITER=1), dispatch to a dedicated worker thread
386
+ // that performs SYNC fs ops with no internal yields. Mitigates the
387
+ // non-deterministic V8/libuv crash during event-loop yields in multi-step
388
+ // goal-wrapped workflows.
389
+ if (isWorkerAtomicWriterEnabled()) {
390
+ return atomicWriteFileViaWorker(filePath, content);
391
+ }
383
392
  if (!isSymlinkSafePath(filePath)) throw new Error(`Refusing to write: target is a symlink or inside untrusted directory: ${filePath}`);
384
393
  await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
385
394
  const tempPath = `${filePath}.${crypto.randomUUID()}.tmp`;
@@ -77,6 +77,20 @@ const TEAM_EVENT_TYPES = [
77
77
  "phase.completed",
78
78
  "phase.skipped",
79
79
  "phase.failed",
80
+ // Goal loop events (P0/P1) — autonomous goal-loop coordinator.
81
+ "goal.loop_start",
82
+ "goal.turn_start",
83
+ "goal.turn_evaluated",
84
+ "goal.budget_warning",
85
+ "goal.loop_end",
86
+ "goal.feedback_steered",
87
+ "goal.state_changed",
88
+ // Dynamic workflow events (P2) — script-driven orchestration.
89
+ "dwf.started",
90
+ "dwf.phase_started",
91
+ "dwf.phase_completed",
92
+ "dwf.completed",
93
+ "dwf.failed",
80
94
  ] as const;
81
95
  export type TeamEventType = typeof TEAM_EVENT_TYPES[number];
82
96
 
@@ -22,8 +22,19 @@ import { updateGitignore } from "./gitignore-manager.ts";
22
22
  // Re-export updateGitignore for backwards compatibility with tests.
23
23
  export { updateGitignore };
24
24
 
25
- /** README content for the .crew directory. */
26
- const CREW_README = `# .crew pi-crew Runtime Directory
25
+ /**
26
+ * README content for the .crew directory.
27
+ *
28
+ * Defined as a function (not a `const`) to avoid the Temporal Dead Zone race
29
+ * documented in issue #28 + RFC 17. When this module is loaded via
30
+ * `jiti.import()` (pi's extension loader) wrapped in an async function, a
31
+ * `const` initializer can be hit in TDZ by functions hoisted above it (the
32
+ * same pattern that bit `crewInitPromise` in team-tool/run.ts — see commit
33
+ * fixing it). A function declaration is hoisted with its body available
34
+ * immediately, so callers always get the fully-built string.
35
+ */
36
+ function buildCrewReadme(): string {
37
+ return `# .crew — pi-crew Runtime Directory
27
38
 
28
39
  This directory contains pi-crew runtime state and artifacts.
29
40
 
@@ -50,7 +61,7 @@ To clear cache:
50
61
  team action='cache' action='clear'
51
62
  \`\`\`
52
63
  `;
53
-
64
+ }
54
65
  /**
55
66
  * Find the project root by walking up from start directory.
56
67
  * Inline implementation to avoid module dependency on paths.ts.
@@ -249,13 +260,15 @@ export async function ensureCrewDirectory(cwd: string): Promise<void> {
249
260
  }
250
261
 
251
262
  // 3. Write README.md (always overwrite to keep it current)
252
- fs.writeFileSync(safeJoin(crewRoot, "README.md"), CREW_README, "utf-8");
263
+ fs.writeFileSync(safeJoin(crewRoot, "README.md"), buildCrewReadme(), "utf-8");
253
264
 
254
265
  // 4. Update .gitignore at project root
255
266
  const repoRoot = findProjectRoot(cwd);
256
267
  if (repoRoot) {
257
268
  const gitignorePath = safeJoin(repoRoot, ".gitignore");
258
- await updateGitignore(gitignorePath);
269
+ // LAZY: dodge the jiti ESM/CJS interop TDZ race on the static `import { updateGitignore }` above (issue #28, RFC 17). At this point the module body has fully evaluated, so the dynamic import resolves to a live binding.
270
+ const { updateGitignore: updateGitignoreFn } = await import("./gitignore-manager.ts");
271
+ await updateGitignoreFn(gitignorePath);
259
272
  }
260
273
  }
261
274
 
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
+ import { isWorkerAtomicWriterEnabled, appendFileViaWorker } from "./worker-atomic-writer.ts";
4
5
  import { DEFAULT_EVENT_LOG } from "../config/defaults.ts";
5
6
  import { atomicWriteFile } from "./atomic-write.ts";
6
7
  import { errors } from "../errors.ts";
@@ -443,7 +444,12 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
443
444
 
444
445
  if (!skippedDueToSize) {
445
446
  const line = JSON.stringify(redactSecrets(fullEvent)) + "\n";
446
- await fs.promises.appendFile(eventsPath, line, { encoding: "utf-8", flag: "a" });
447
+ // Phase 1.5: when worker atomic writer is enabled, append via worker.
448
+ if (isWorkerAtomicWriterEnabled()) {
449
+ await appendFileViaWorker(eventsPath, line);
450
+ } else {
451
+ await fs.promises.appendFile(eventsPath, line, { encoding: "utf-8", flag: "a" });
452
+ }
447
453
  // FIX: fsync to ensure event content is flushed to disk before persisting
448
454
  // the sequence number. This closes the crash window between appendFile and
449
455
  // persistSequence where sequence reuse could occur on restart.
@@ -227,6 +227,7 @@ export function createRunManifest(params: {
227
227
  goal: string;
228
228
  workspaceMode?: "single" | "worktree";
229
229
  ownerSessionId?: string;
230
+ runKind?: "team-run" | "goal-loop" | "dynamic-workflow";
230
231
  }): { manifest: TeamRunManifest; tasks: TeamTaskState[]; paths: RunPaths } {
231
232
  const paths = createRunPaths(params.cwd);
232
233
  const now = new Date().toISOString();
@@ -249,6 +250,7 @@ export function createRunManifest(params: {
249
250
  eventsPath: paths.eventsPath,
250
251
  artifacts: [],
251
252
  ...(params.ownerSessionId ? { ownerSessionId: params.ownerSessionId } : {}),
253
+ runKind: params.runKind ?? "team-run",
252
254
  };
253
255
  fs.mkdirSync(paths.stateRoot, { recursive: true });
254
256
  fs.mkdirSync(paths.artifactsRoot, { recursive: true });
@@ -183,6 +183,8 @@ export interface TeamRunManifest {
183
183
  runtimeResolution?: RuntimeResolutionState;
184
184
  /** Effective run config snapshot used by async background workers. Optional for backward compatibility. */
185
185
  runConfig?: unknown;
186
+ /** Background dispatch discriminator. Default "team-run" runs executeTeamRun; "goal-loop" / "dynamic-workflow" dispatch to their respective runners. Absent = "team-run" for backward compatibility. */
187
+ runKind?: "team-run" | "goal-loop" | "dynamic-workflow";
186
188
  summary?: string;
187
189
  policyDecisions?: PolicyDecision[];
188
190
  }
@@ -196,6 +198,86 @@ export interface UsageState {
196
198
  turns?: number;
197
199
  }
198
200
 
201
+ // ───────────────────────────────────────────────────────────────────────────
202
+ // Goal loop types (P0/P1 — autonomous goal loop, Claude-Code-style /goal).
203
+ // Spec: research-findings/goal-workflow/00-SPEC.md §2.3; plan 07-PLAN.md v3 §0b G2 + §0c.
204
+ // ───────────────────────────────────────────────────────────────────────────
205
+
206
+ /**
207
+ * Outer-state lifecycle of a goal loop. Inner per-turn state lives on each turn's TeamRunManifest.
208
+ *
209
+ * P1b (RFC v0.5 §P1b): `"stuck"` is NON-TERMINAL and RE-HINTABLE. Legal transitions:
210
+ * running → stuck (only by the background loop, after the oscillation detector fires)
211
+ * stuck → running (only by `goal resume`, atomically via GoalStore.compareAndSetStatus)
212
+ * stuck → cancelled (by the idle-timeout sweeper OR `goal stop`)
213
+ */
214
+ export type GoalLoopStatus =
215
+ | "running"
216
+ | "paused"
217
+ | "stuck"
218
+ | "achieved"
219
+ | "max_turns"
220
+ | "budget_exceeded"
221
+ | "blocked"
222
+ | "cancelled";
223
+
224
+ /** One evaluation by the goal-judge model after a turn. */
225
+ export interface GoalVerdict {
226
+ turn: number;
227
+ achieved: boolean;
228
+ /** "achieved: all tests pass" | "not-achieved: 2/8 tests failing" | "BLOCKED: <reason>" (BLOCKED: prefix → status='blocked'). */
229
+ reason: string;
230
+ evidenceRefs?: string[];
231
+ evaluatorModel: string;
232
+ evaluatedAt: string;
233
+ }
234
+
235
+ /** Persisted at <crewRoot>/state/goals/<goalId>.json by GoalStore. Survives session restart. */
236
+ export interface GoalLoopState {
237
+ goalId: string;
238
+ ownerSessionId: string;
239
+ objective: string;
240
+ scope?: string;
241
+ /** Acceptance conditions as shell commands (exit 0 = pass). Reuses VerificationContract semantics. */
242
+ verification?: { commands: string[]; allowManualEvidence?: boolean };
243
+ state: GoalLoopStatus;
244
+ maxTurns: number;
245
+ turnsUsed: number;
246
+ budgetTotal?: number;
247
+ /** P1d (RFC v0.5 §P1d): when true, budget enforcement is skipped (explicit opt-out; audit-logged at start). */
248
+ budgetUnlimited?: boolean;
249
+ budgetWarning?: number;
250
+ budgetAbort?: number;
251
+ budgetUsed: number;
252
+ /**
253
+ * P1a (RFC v0.5 §P1a): bookend integrity snapshot of project-manifest files
254
+ * taken at goal start (only when verification.commands is declared). The
255
+ * goal-loop-runner re-hashes before (T_snap) and after (T_verify_done) each
256
+ * verification command to detect persistent manifest tampering. The literal
257
+ * `"none-text-only"` marks goals started in text-only verification mode
258
+ * (no objective oracle → no snapshot taken).
259
+ */
260
+ verificationIntegrity?:
261
+ | { snapshot: Record<string, string>; takenAt: string }
262
+ | "none-text-only";
263
+ evaluatorModel: string;
264
+ workerModel?: string;
265
+ /** subagent_type / agent name for worker turns (default "executor"). */
266
+ workerAgent?: string;
267
+ team?: string;
268
+ cwd: string;
269
+ /** Feedback from turn N's verdict, prepended into turn N+1's manifest.goal (G1). */
270
+ nextTurnFeedback?: string;
271
+ /** The team-run of the current in-flight turn (for cancel/steer). */
272
+ currentRunId?: string;
273
+ verdicts: GoalVerdict[];
274
+ history: { runId: string; outcome: string; learnedAt: string; turn: number }[];
275
+ createdAt: string;
276
+ updatedAt: string;
277
+ /** Mirror of manifest.async for PID-liveness checks (cf. AsyncRunState). */
278
+ async?: { pid: number; logPath: string; spawnedAt: string };
279
+ }
280
+
199
281
  export interface ModelAttemptState {
200
282
  model: string;
201
283
  success: boolean;
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Phase 1.5 worker-thread atomic writer (RFC: 15-PHASE1.5-WORKER-WRITER-RFC.md).
3
+ *
4
+ * Background: multi-step goal-wrapped workflows crash silently and
5
+ * non-deterministically during batch transitions. The crash point moves to
6
+ * every `await` yield in the write path (mkdir, open, stat, rename,
7
+ * appendEvent). Sync replacements regress. Hypothesis: V8/libuv-level race
8
+ * during event-loop yields. Mitigation: route writes through a dedicated
9
+ * worker thread that performs SYNC fs operations with no internal yields.
10
+ *
11
+ * Opt-in via `PI_CREW_WORKER_ATOMIC_WRITER=1`. When disabled, callers fall
12
+ * back to the regular async path. Safe to ship behind a flag.
13
+ *
14
+ * Protocol (main → worker):
15
+ * { kind: "write", id, filePath, content }
16
+ * { kind: "mkdir", id, dirPath }
17
+ * { kind: "append", id, filePath, content }
18
+ *
19
+ * Protocol (worker → main):
20
+ * { kind: "done", id }
21
+ * { kind: "error", id, message }
22
+ */
23
+ import { Worker } from "node:worker_threads";
24
+ import * as path from "node:path";
25
+ import * as fs from "node:fs";
26
+ import { createRequire } from "node:module";
27
+
28
+ const require = createRequire(import.meta.url);
29
+
30
+ let worker: Worker | undefined;
31
+ let nextRequestId = 1;
32
+ const pending = new Map<number, { resolve: () => void; reject: (e: Error) => void }>();
33
+
34
+ /** Worker script source — runs SYNC fs ops with no internal yields. */
35
+ const WORKER_SOURCE = `
36
+ const { parentPort, workerData } = require("node:worker_threads");
37
+ const fs = require("node:fs");
38
+ const path = require("node:path");
39
+ const crypto = require("node:crypto");
40
+
41
+ function isSymlinkSafePath(filePath) {
42
+ try {
43
+ // Safe roots: the system temp dir (resolved through any symlinks, e.g.
44
+ // macOS /var → /private/var) and the current project cwd.
45
+ var tmpReal;
46
+ try { tmpReal = fs.realpathSync(require("node:os").tmpdir()); } catch (e2) { tmpReal = require("node:os").tmpdir(); }
47
+ var cwd = process.cwd();
48
+ // Accept a symlink ancestor as safe if the resolved target (a) IS a safe
49
+ // root, (b) is UNDER a safe root (target is a descendant), or (c) is an
50
+ // ANCESTOR of a safe root (target is a parent dir of a safe root).
51
+ // Case (c) is essential on macOS: /var → /private/var is an ancestor of
52
+ // the tmpdir /private/var/folders/…, so it must be allowed.
53
+ var isSafeAncestor = function (real) {
54
+ var roots = [tmpReal, cwd];
55
+ return roots.some(function (root) {
56
+ return real === root
57
+ || real.indexOf(root + path.sep) === 0
58
+ || root.indexOf(real + path.sep) === 0;
59
+ });
60
+ };
61
+ var currentPath = filePath;
62
+ while (currentPath !== path.dirname(currentPath)) {
63
+ var dir = path.dirname(currentPath);
64
+ try {
65
+ var stat = fs.lstatSync(dir);
66
+ if (stat.isSymbolicLink()) {
67
+ var real = fs.realpathSync(dir);
68
+ if (!isSafeAncestor(real)) return false;
69
+ }
70
+ } catch (e3) { /* not found — fine */ }
71
+ currentPath = dir;
72
+ }
73
+ return true;
74
+ } catch (e) { return true; }
75
+ }
76
+
77
+ function syncAtomicWriteFile(filePath, content) {
78
+ if (!isSymlinkSafePath(filePath)) throw new Error("Refusing to write: unsafe path: " + filePath);
79
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
80
+ const tempPath = filePath + "." + crypto.randomUUID() + ".tmp";
81
+ try {
82
+ const fd = fs.openSync(tempPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
83
+ try {
84
+ fs.writeFileSync(fd, content, "utf-8");
85
+ } finally {
86
+ fs.closeSync(fd);
87
+ }
88
+ fs.renameSync(tempPath, filePath);
89
+ } catch (error) {
90
+ try { fs.rmSync(tempPath, { force: true }); } catch {}
91
+ // If rename raced with another writer that produced identical content, swallow.
92
+ if (error && error.code === "EEXIST") {
93
+ try {
94
+ const existing = fs.readFileSync(filePath, "utf-8");
95
+ if (existing === content) return;
96
+ } catch {}
97
+ }
98
+ throw error;
99
+ }
100
+ }
101
+
102
+ function syncAppend(filePath, content) {
103
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
104
+ fs.appendFileSync(filePath, content, "utf-8");
105
+ }
106
+
107
+ parentPort.on("message", (msg) => {
108
+ try {
109
+ if (msg.kind === "write") syncAtomicWriteFile(msg.filePath, msg.content);
110
+ else if (msg.kind === "mkdir") fs.mkdirSync(msg.dirPath, { recursive: true });
111
+ else if (msg.kind === "append") syncAppend(msg.filePath, msg.content);
112
+ else throw new Error("worker-atomic-writer: unknown message kind: " + msg.kind);
113
+ parentPort.postMessage({ kind: "done", id: msg.id });
114
+ } catch (error) {
115
+ parentPort.postMessage({ kind: "error", id: msg.id, message: error && error.message ? error.message : String(error) });
116
+ }
117
+ });
118
+ `;
119
+
120
+ function getWorker(): Worker {
121
+ if (worker) return worker;
122
+ // Write worker source to a temp file (so Worker can load it as CJS via
123
+ // require() inside the worker, which always has CommonJS available).
124
+ const os = require("node:os");
125
+ const tmpPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "pi-crew-waw-")), "worker.cjs");
126
+ fs.writeFileSync(tmpPath, WORKER_SOURCE, "utf-8");
127
+ worker = new Worker(tmpPath);
128
+ worker.on("message", (msg: { kind: string; id: number; message?: string }) => {
129
+ const entry = pending.get(msg.id);
130
+ if (!entry) return;
131
+ pending.delete(msg.id);
132
+ if (msg.kind === "done") entry.resolve();
133
+ else entry.reject(new Error(msg.message ?? "worker-atomic-writer: unknown error"));
134
+ });
135
+ worker.on("error", (error: Error) => {
136
+ // Reject ALL pending requests — worker died.
137
+ for (const [, entry] of pending) entry.reject(error);
138
+ pending.clear();
139
+ });
140
+ worker.unref(); // don't keep event loop alive (unless tests request it)
141
+ if (keepRefForTests) worker.ref();
142
+ return worker;
143
+ }
144
+
145
+ function dispatch(kind: "write" | "mkdir" | "append", payload: Record<string, unknown>): Promise<void> {
146
+ return new Promise((resolve, reject) => {
147
+ const id = nextRequestId++;
148
+ pending.set(id, { resolve, reject });
149
+ try {
150
+ getWorker().postMessage({ kind, id, ...payload });
151
+ } catch (error) {
152
+ pending.delete(id);
153
+ reject(error instanceof Error ? error : new Error(String(error)));
154
+ }
155
+ });
156
+ }
157
+
158
+ /** Whether the worker writer is enabled (env var opt-in). */
159
+ export function isWorkerAtomicWriterEnabled(): boolean {
160
+ return process.env.PI_CREW_WORKER_ATOMIC_WRITER === "1" || process.env.PI_TEAMS_WORKER_ATOMIC_WRITER === "1";
161
+ }
162
+
163
+ /** Atomic-write a file via the worker thread. Sync fs ops inside worker. */
164
+ export function atomicWriteFileViaWorker(filePath: string, content: string): Promise<void> {
165
+ return dispatch("write", { filePath, content });
166
+ }
167
+
168
+ /** Append to a file via the worker thread (used by event-log). */
169
+ export function appendFileViaWorker(filePath: string, content: string): Promise<void> {
170
+ return dispatch("append", { filePath, content });
171
+ }
172
+
173
+ /** Terminate the worker (for tests / cleanup). */
174
+ export function terminateWorkerAtomicWriter(): void {
175
+ if (worker) {
176
+ const w = worker;
177
+ worker = undefined;
178
+ w.terminate().catch(() => { /* ignore */ });
179
+ }
180
+ for (const [, entry] of pending) entry.reject(new Error("worker terminated"));
181
+ pending.clear();
182
+ }
183
+
184
+ /** Tests-only knob: keep the worker ref'd so the test runner doesn't exit
185
+ * before promises resolve. Production code leaves this false (worker is
186
+ * unref'd to avoid blocking process exit). */
187
+ let keepRefForTests = false;
188
+ export function __setKeepWorkerRefForTests(value: boolean): void {
189
+ keepRefForTests = value;
190
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Windows-essential environment variables that MUST be present in every
3
+ * subprocess env allowlist in pi-crew.
4
+ *
5
+ * Without them, child processes on Windows cannot locate the npm-global prefix
6
+ * (`%APPDATA%\npm`), the user profile, system DLLs (SystemRoot), cmd.exe
7
+ * (ComSpec), or a writable temp dir.
8
+ *
9
+ * Regression root cause (v0.9.0): env allowlists stripped these vars, so child
10
+ * pi/npm resolved the npm-global prefix via the literal `%APPDATA%` (cmd) /
11
+ * `${APPDATA}` (bash) expansion — but APPDATA was missing from the env, so the
12
+ * shell left the literal `${APPDATA}` in place. A phantom `${APPDATA}/npm`
13
+ * directory appeared in the project root and a literal `${APPDATA}` line leaked
14
+ * into `.gitignore`.
15
+ *
16
+ * USAGE: spread this constant into every `allowList` array:
17
+ * `allowList: ["PATH", "HOME", ...WINDOWS_ESSENTIAL_ENV_VARS, ...]`
18
+ *
19
+ * The regression test `test/unit/env-allowlist.test.ts` enforces that no
20
+ * `src/` file hardcodes these vars inline — all sites MUST use this constant.
21
+ */
22
+ export const WINDOWS_ESSENTIAL_ENV_VARS: readonly string[] = [
23
+ "APPDATA",
24
+ "LOCALAPPDATA",
25
+ "USERPROFILE",
26
+ "SystemRoot",
27
+ "ComSpec",
28
+ "TEMP",
29
+ "TMP",
30
+ ];