pi-crew 0.9.20 → 0.9.22

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.
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Lightweight token counter for estimating token counts in text.
3
+ *
4
+ * Provides a more accurate estimate than the naive char/4 heuristic by
5
+ * distinguishing word characters from punctuation. Real LLM tokenizers
6
+ * (BPE) typically count alphanumeric runs as ~1 token per ~4 characters,
7
+ * while individual punctuation characters are often separate tokens.
8
+ *
9
+ * Performance: O(n) single-pass, ~1ms for 10KB text, no external deps.
10
+ */
11
+
12
+ function isWhitespace(c: number): boolean {
13
+ return c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d;
14
+ }
15
+
16
+ function isAlphanumeric(c: number): boolean {
17
+ return (
18
+ (c >= 0x30 && c <= 0x39) || // 0-9
19
+ (c >= 0x41 && c <= 0x5a) || // A-Z
20
+ (c >= 0x61 && c <= 0x7a) || // a-z
21
+ c === 0x5f // _
22
+ );
23
+ }
24
+
25
+ /**
26
+ * Estimate token count for a string using a single-pass O(n) scan.
27
+ *
28
+ * Algorithm:
29
+ * 1. Walk text char-by-char with charCodeAt (fast, no allocations).
30
+ * 2. Count alphabetic chars (each ~1 token per 4 chars, like BPE prose).
31
+ * 3. Count punctuation chars separately (each = 1 token, since operators
32
+ * and punctuation typically tokenize as separate units in BPE).
33
+ * 4. Estimate: ceil(alpha / 4) + punct
34
+ *
35
+ * Why this beats char/4:
36
+ * - char/4 undercounts operators in code-heavy content (treats `=>` as ~3
37
+ * chars/token when BPE gives ~1-2 tokens per operator).
38
+ * - char/4 also miscounts short punctuation-only segments.
39
+ * - This formula weights punctuation at 1 token each, matching BPE's
40
+ * tendency to tokenize operators, brackets, and symbols individually.
41
+ *
42
+ * Accuracy: typically within ±10-15% of actual BPE token counts for both
43
+ * English prose and code, beating the char/4 heuristic (which can be
44
+ * 30%+ off for code-heavy content).
45
+ *
46
+ * @param text Input text to estimate tokens for.
47
+ * @returns Estimated token count.
48
+ */
49
+ export function countTokens(text: string): number {
50
+ if (!text || text.length === 0) return 0;
51
+
52
+ let alpha = 0;
53
+ let punct = 0;
54
+ const len = text.length;
55
+
56
+ for (let i = 0; i < len; i++) {
57
+ const c = text.charCodeAt(i);
58
+ if (isWhitespace(c)) continue;
59
+ if (isAlphanumeric(c)) {
60
+ alpha++;
61
+ } else {
62
+ punct++;
63
+ }
64
+ }
65
+
66
+ return Math.ceil(alpha / 4) + punct;
67
+ }
@@ -1,7 +1,8 @@
1
- import { execFileSync, spawnSync } from "node:child_process";
1
+ import { execFile, execFileSync, spawnSync } from "node:child_process";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
+ import { promisify } from "node:util";
5
6
  import { loadConfig } from "../config/config.ts";
6
7
  import { DEFAULT_PATHS } from "../config/defaults.ts";
7
8
  import { writeArtifact } from "../state/artifact-store.ts";
@@ -20,6 +21,8 @@ export interface PreparedTaskWorkspace {
20
21
  syntheticPaths?: string[];
21
22
  }
22
23
 
24
+ const execFileAsync = promisify(execFile);
25
+
23
26
  export interface WorktreeDiffStat {
24
27
  filesChanged: number;
25
28
  insertions: number;
@@ -69,6 +72,51 @@ function git(cwd: string, args: string[]): string {
69
72
  }).trim();
70
73
  }
71
74
 
75
+ /** Build the sanitized env object shared by all git operations. */
76
+ function gitEnv(): Record<string, string> {
77
+ return {
78
+ ...sanitizeEnvSecrets(process.env, {
79
+ allowList: [
80
+ "PATH",
81
+ "HOME",
82
+ "USER",
83
+ ...WINDOWS_ESSENTIAL_ENV_VARS,
84
+ "SHELL",
85
+ "TERM",
86
+ "LANG",
87
+ "LC_ALL",
88
+ "LC_COLLATE",
89
+ "LC_CTYPE",
90
+ "LC_MESSAGES",
91
+ "XDG_CONFIG_HOME",
92
+ "XDG_DATA_HOME",
93
+ "XDG_CACHE_HOME",
94
+ "NVM_BIN",
95
+ "NVM_DIR",
96
+ "NODE_PATH",
97
+ "GIT_CONFIG_GLOBAL",
98
+ "GIT_CONFIG_SYSTEM",
99
+ "GIT_AUTHOR_NAME",
100
+ "GIT_AUTHOR_EMAIL",
101
+ "GIT_COMMITTER_NAME",
102
+ "GIT_COMMITTER_EMAIL",
103
+ ],
104
+ }),
105
+ LANG: "en_US.UTF-8",
106
+ LC_ALL: "en_US.UTF-8",
107
+ };
108
+ }
109
+
110
+ async function gitAsync(cwd: string, args: string[]): Promise<string> {
111
+ const { stdout } = await execFileAsync("git", args, {
112
+ cwd,
113
+ encoding: "utf-8",
114
+ env: gitEnv(),
115
+ windowsHide: true,
116
+ });
117
+ return stdout.trim();
118
+ }
119
+
72
120
  // Dots are removed from branch names since they are used in path construction,
73
121
  // and dots could cause ambiguity with relative path handling on some platforms.
74
122
  // Branch names themselves support dots in git, but we strip them for safe path use.
@@ -92,6 +140,41 @@ export function assertCleanLeader(repoRoot: string): void {
92
140
  }
93
141
  }
94
142
 
143
+ // --- Async versions ---
144
+
145
+ /** Cache for findGitRoot results keyed by cwd. Cleared per-run. */
146
+ const _gitRootCache = new Map<string, string>();
147
+
148
+ /** Clear the findGitRoot cache. Call at the start of each team run. */
149
+ export function clearGitRootCache(): void {
150
+ _gitRootCache.clear();
151
+ }
152
+
153
+ export async function findGitRootAsync(cwd: string): Promise<string> {
154
+ const cached = _gitRootCache.get(cwd);
155
+ if (cached) return cached;
156
+ const root = await gitAsync(cwd, ["rev-parse", "--show-toplevel"]);
157
+ _gitRootCache.set(cwd, root);
158
+ return root;
159
+ }
160
+
161
+ /** Cache for assertCleanLeader results keyed by repoRoot. Cleared per-run. */
162
+ const _cleanLeaderCache = new Set<string>();
163
+
164
+ /** Clear the assertCleanLeader cache. Call at the start of each team run. */
165
+ export function clearCleanLeaderCache(): void {
166
+ _cleanLeaderCache.clear();
167
+ }
168
+
169
+ export async function assertCleanLeaderAsync(repoRoot: string): Promise<void> {
170
+ if (_cleanLeaderCache.has(repoRoot)) return;
171
+ const status = await gitAsync(repoRoot, ["status", "--porcelain"]);
172
+ if (status.trim()) {
173
+ throw new Error("Worktree mode requires a clean leader repository. Commit/stash changes or use workspaceMode: 'single'.");
174
+ }
175
+ _cleanLeaderCache.add(repoRoot);
176
+ }
177
+
95
178
  function linkNodeModulesIfPresent(repoRoot: string, worktreePath: string): boolean {
96
179
  const source = path.join(repoRoot, "node_modules");
97
180
  const target = path.join(worktreePath, "node_modules");
@@ -348,6 +431,39 @@ function pruneStaleWorktrees(repoRoot: string): void {
348
431
  }
349
432
  }
350
433
 
434
+ async function branchExistsAsync(repoRoot: string, branch: string): Promise<{ local: boolean; remoteOnly: boolean }> {
435
+ let local = false;
436
+ try {
437
+ await gitAsync(repoRoot, ["rev-parse", "--verify", `refs/heads/${branch}`]);
438
+ local = true;
439
+ } catch {}
440
+ if (local) return { local: true, remoteOnly: false };
441
+ // Check remote-tracking branch
442
+ try {
443
+ const out = (
444
+ await execFileAsync("git", ["for-each-ref", "--format=%(refname)", `refs/remotes/*/${branch}`], {
445
+ cwd: repoRoot,
446
+ encoding: "utf-8",
447
+ windowsHide: true,
448
+ })
449
+ ).stdout.trim();
450
+ return { local: false, remoteOnly: out.length > 0 };
451
+ } catch {
452
+ return { local: false, remoteOnly: false };
453
+ }
454
+ }
455
+
456
+ async function pruneStaleWorktreesAsync(repoRoot: string): Promise<void> {
457
+ try {
458
+ await execFileAsync("git", ["worktree", "prune"], {
459
+ cwd: repoRoot,
460
+ windowsHide: true,
461
+ });
462
+ } catch {
463
+ /* best-effort */
464
+ }
465
+ }
466
+
351
467
  /**
352
468
  * Normalize and validate seed paths — ensure all paths stay within repoRoot.
353
469
  * Rejects path traversal (../) and absolute paths.
@@ -582,6 +698,144 @@ export function prepareTaskWorkspace(manifest: TeamRunManifest, task: TeamTaskSt
582
698
  };
583
699
  }
584
700
 
701
+ /** Async version of prepareTaskWorkspace — yields the event loop during git operations. */
702
+ export async function prepareTaskWorkspaceAsync(
703
+ manifest: TeamRunManifest,
704
+ task: TeamTaskState,
705
+ stepSeedPaths?: string[],
706
+ ): Promise<PreparedTaskWorkspace> {
707
+ if (manifest.workspaceMode !== "worktree") return { cwd: task.cwd };
708
+ const repoRoot = await findGitRootAsync(manifest.cwd);
709
+ const loadedConfig = loadConfig(manifest.cwd);
710
+ if (loadedConfig.config.requireCleanWorktreeLeader !== false) await assertCleanLeaderAsync(repoRoot);
711
+ const sanitizedRunId = manifest.runId.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/^-+|-+$/g, "") || "run";
712
+ const worktreeRoot = path.join(projectCrewRoot(manifest.cwd), DEFAULT_PATHS.state.worktreesSubdir, sanitizedRunId);
713
+ fs.mkdirSync(worktreeRoot, { recursive: true });
714
+ let resolvedWorktreeRoot = worktreeRoot;
715
+ try {
716
+ const r = fs.realpathSync.native(worktreeRoot);
717
+ resolvedWorktreeRoot = r.startsWith("\\\\?\\") ? r.slice(4) : r;
718
+ } catch {
719
+ try {
720
+ resolvedWorktreeRoot = fs.realpathSync(worktreeRoot);
721
+ } catch {
722
+ /* keep as-is */
723
+ }
724
+ }
725
+ const sanitizedTaskId = sanitizeBranchPart(task.id);
726
+ const worktreePath = path.join(resolvedWorktreeRoot, sanitizedTaskId);
727
+ const branch = `pi-crew/${sanitizeBranchPart(manifest.runId)}/${sanitizeBranchPart(task.id)}`;
728
+ let worktreeExists = false;
729
+ try {
730
+ const worktreeList = await gitAsync(repoRoot, ["worktree", "list", "--porcelain"]);
731
+ const normalizedWtPath =
732
+ process.platform === "win32"
733
+ ? (() => {
734
+ try {
735
+ const r = fs.realpathSync.native(worktreePath);
736
+ return r.startsWith("\\\\?\\") ? r.slice(4) : r;
737
+ } catch {
738
+ return worktreePath;
739
+ }
740
+ })()
741
+ .replace(/\\/g, "/")
742
+ .toLowerCase()
743
+ : worktreePath;
744
+ worktreeExists = worktreeList.split("\n").some((line) => {
745
+ const trimmed = line.trim();
746
+ const matchPath = trimmed.startsWith("worktree ") ? trimmed.slice(9) : trimmed;
747
+ if (process.platform === "win32") {
748
+ return matchPath.replace(/\\/g, "/").toLowerCase() === normalizedWtPath;
749
+ }
750
+ return matchPath === worktreePath;
751
+ });
752
+ } catch {
753
+ worktreeExists = false;
754
+ }
755
+ if (worktreeExists) {
756
+ let currentBranch: string;
757
+ try {
758
+ currentBranch = await gitAsync(worktreePath, ["rev-parse", "--abbrev-ref", "HEAD"]);
759
+ } catch (gitError) {
760
+ throw new Error(
761
+ `Existing worktree at ${worktreePath} is not a valid git repository; cannot verify branch: ${gitError instanceof Error ? gitError.message : String(gitError)}`,
762
+ );
763
+ }
764
+ if (currentBranch !== branch) {
765
+ throw new Error(`Existing worktree branch mismatch at ${worktreePath}: expected '${branch}', got '${currentBranch}'.`);
766
+ }
767
+ const dirtyStatus = await gitAsync(worktreePath, ["status", "--porcelain"]);
768
+ if (dirtyStatus.trim()) {
769
+ logInternalError(
770
+ "worktree.reused.dirty",
771
+ new Error(`Discarding uncommitted changes in reused worktree at ${worktreePath}`),
772
+ `runId=${manifest.runId}, taskId=${task.id}, dirtyStatus=${dirtyStatus.trim()}`,
773
+ );
774
+ await gitAsync(worktreePath, ["checkout", "--", "."]);
775
+ await gitAsync(worktreePath, ["clean", "-fd"]);
776
+ }
777
+ const globalSeedPaths = loadedConfig.config.worktree?.seedPaths ?? [];
778
+ const mergedReused = normalizeSeedPaths([...globalSeedPaths, ...(stepSeedPaths ?? [])], repoRoot);
779
+ if (mergedReused.length > 0) {
780
+ overlaySeedPaths(repoRoot, worktreePath, mergedReused);
781
+ }
782
+ // Re-validate leader is still clean before reusing
783
+ // Note: this intentionally skips the cache to re-check the live state.
784
+ _cleanLeaderCache.delete(repoRoot);
785
+ await assertCleanLeaderAsync(repoRoot);
786
+ return { cwd: worktreePath, worktreePath, branch, reused: true };
787
+ }
788
+ await pruneStaleWorktreesAsync(repoRoot);
789
+ const exists = await branchExistsAsync(repoRoot, branch);
790
+ let worktreeCreated = false;
791
+ try {
792
+ if (exists.local) {
793
+ await gitAsync(repoRoot, ["worktree", "add", worktreePath, branch]);
794
+ } else {
795
+ if (exists.remoteOnly) {
796
+ logInternalError(
797
+ "worktree.branchRemoteOnly",
798
+ new Error(`Branch '${branch}' exists only on remote; creating local from HEAD instead of tracking remote.`),
799
+ `branch=${branch}`,
800
+ );
801
+ }
802
+ await gitAsync(repoRoot, ["worktree", "add", "-b", branch, worktreePath, "HEAD"]);
803
+ }
804
+ worktreeCreated = true;
805
+ } catch (error) {
806
+ if (fs.existsSync(worktreePath)) {
807
+ try {
808
+ fs.rmSync(worktreePath, { recursive: true, force: true });
809
+ } catch {
810
+ /* best-effort cleanup */
811
+ }
812
+ }
813
+ const msg = error instanceof Error ? error.message : String(error);
814
+ if (/already checked out|is already used by worktree/i.test(msg)) {
815
+ throw new Error(
816
+ `Branch '${branch}' is checked out at another worktree. Run \`team cleanup runId=${manifest.runId} force=true\` or manually remove the conflicting worktree.`,
817
+ );
818
+ }
819
+ throw error;
820
+ }
821
+ const syntheticPaths = runSetupHook(manifest, task, repoRoot, worktreePath, branch);
822
+ const nodeModulesLinked =
823
+ loadedConfig.config.worktree?.linkNodeModules === true ? linkNodeModulesIfPresent(repoRoot, worktreePath) : false;
824
+ const globalSeedPaths = loadedConfig.config.worktree?.seedPaths ?? [];
825
+ const merged = normalizeSeedPaths([...globalSeedPaths, ...(stepSeedPaths ?? [])], repoRoot);
826
+ if (merged.length > 0) {
827
+ overlaySeedPaths(repoRoot, worktreePath, merged);
828
+ }
829
+ return {
830
+ cwd: worktreePath,
831
+ worktreePath,
832
+ branch,
833
+ reused: false,
834
+ nodeModulesLinked,
835
+ syntheticPaths,
836
+ };
837
+ }
838
+
585
839
  export function captureWorktreeDiffStat(worktreePath: string): WorktreeDiffStat {
586
840
  try {
587
841
  const diffStat = git(worktreePath, ["diff", "--stat"]);
@@ -601,6 +855,25 @@ export function captureWorktreeDiffStat(worktreePath: string): WorktreeDiffStat
601
855
  }
602
856
  }
603
857
 
858
+ export async function captureWorktreeDiffStatAsync(worktreePath: string): Promise<WorktreeDiffStat> {
859
+ try {
860
+ const diffStat = await gitAsync(worktreePath, ["diff", "--stat"]);
861
+ const numstat = await gitAsync(worktreePath, ["diff", "--numstat"]);
862
+ let filesChanged = 0;
863
+ let insertions = 0;
864
+ let deletions = 0;
865
+ for (const line of numstat.split(/\r?\n/).filter(Boolean)) {
866
+ const [add, del] = line.split(/\s+/);
867
+ filesChanged += 1;
868
+ insertions += Number(add) || 0;
869
+ deletions += Number(del) || 0;
870
+ }
871
+ return { filesChanged, insertions, deletions, diffStat };
872
+ } catch {
873
+ return { filesChanged: 0, insertions: 0, deletions: 0, diffStat: "" };
874
+ }
875
+ }
876
+
604
877
  export function captureWorktreeDiff(worktreePath: string): string {
605
878
  try {
606
879
  return git(worktreePath, ["diff", "--stat"]) + "\n\n" + git(worktreePath, ["diff"]);
@@ -610,6 +883,16 @@ export function captureWorktreeDiff(worktreePath: string): string {
610
883
  }
611
884
  }
612
885
 
886
+ export async function captureWorktreeDiffAsync(worktreePath: string): Promise<string> {
887
+ try {
888
+ const [stat, full] = await Promise.all([gitAsync(worktreePath, ["diff", "--stat"]), gitAsync(worktreePath, ["diff"])]);
889
+ return stat + "\n\n" + full;
890
+ } catch (error) {
891
+ const message = error instanceof Error ? error.message : String(error);
892
+ return `Failed to capture worktree diff: ${message}`;
893
+ }
894
+ }
895
+
613
896
  /**
614
897
  * round-17 P2-4: Create an isolated git worktree for a single DWF agent call.
615
898
  *
@@ -644,6 +927,29 @@ export function prepareAgentWorktree(manifest: TeamRunManifest, agentId: string)
644
927
  }
645
928
  }
646
929
 
930
+ /** Async version of prepareAgentWorktree — yields the event loop during git operations. */
931
+ export async function prepareAgentWorktreeAsync(manifest: TeamRunManifest, agentId: string): Promise<PreparedTaskWorkspace | undefined> {
932
+ try {
933
+ const repoRoot = await findGitRootAsync(manifest.cwd);
934
+ const loadedConfig = loadConfig(manifest.cwd);
935
+ if (loadedConfig.config.requireCleanWorktreeLeader !== false) await assertCleanLeaderAsync(repoRoot);
936
+ const sanitizedRunId = manifest.runId.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/^-+|-+$/g, "") || "run";
937
+ const worktreeRoot = path.join(projectCrewRoot(manifest.cwd), DEFAULT_PATHS.state.worktreesSubdir, sanitizedRunId);
938
+ fs.mkdirSync(worktreeRoot, { recursive: true });
939
+ const sanitizedAgentId = sanitizeBranchPart(agentId);
940
+ const worktreePath = path.join(worktreeRoot, sanitizedAgentId);
941
+ const branch = `pi-crew/${sanitizedRunId}/${sanitizedAgentId}`;
942
+ await pruneStaleWorktreesAsync(repoRoot);
943
+ await gitAsync(repoRoot, ["worktree", "add", "-b", branch, worktreePath, "HEAD"]);
944
+ const nodeModulesLinked =
945
+ loadedConfig.config.worktree?.linkNodeModules === true ? linkNodeModulesIfPresent(repoRoot, worktreePath) : false;
946
+ return { cwd: worktreePath, worktreePath, branch, nodeModulesLinked };
947
+ } catch {
948
+ // Graceful fallback: no git repo, dirty leader, or git error → run normally.
949
+ return undefined;
950
+ }
951
+ }
952
+
647
953
  /**
648
954
  * round-17 P2-4: Remove a DWF agent worktree after the agent completes.
649
955
  *
@@ -697,3 +1003,59 @@ export function cleanupAgentWorktree(manifest: TeamRunManifest, worktreePath: st
697
1003
  logInternalError("worktree.agent-cleanup.prune", error, `worktreePath=${worktreePath}`);
698
1004
  }
699
1005
  }
1006
+
1007
+ /** Async version of cleanupAgentWorktree — caches findGitRoot and yields during git operations. */
1008
+ export async function cleanupAgentWorktreeAsync(manifest: TeamRunManifest, worktreePath: string, branch?: string): Promise<void> {
1009
+ // Capture diff as artifact (best-effort).
1010
+ try {
1011
+ const diff = await captureWorktreeDiffAsync(worktreePath);
1012
+ if (diff.trim() && !diff.startsWith("Failed to capture worktree diff")) {
1013
+ writeArtifact(manifest.artifactsRoot, {
1014
+ kind: "diff",
1015
+ relativePath: `wf/worktree-diff-${Date.now()}-${randomBytes(2).toString("hex")}.diff`,
1016
+ content: diff,
1017
+ producer: "dynamic-workflow",
1018
+ });
1019
+ }
1020
+ } catch (error) {
1021
+ logInternalError("worktree.agent-cleanup.diff", error, `worktreePath=${worktreePath}`);
1022
+ }
1023
+ // Resolve repoRoot once and reuse (cache optimization).
1024
+ let repoRoot: string | undefined;
1025
+ try {
1026
+ repoRoot = await findGitRootAsync(manifest.cwd);
1027
+ } catch {
1028
+ // Cannot resolve repoRoot — fall back to fs.rm for the worktree.
1029
+ try {
1030
+ fs.rmSync(worktreePath, { recursive: true, force: true });
1031
+ } catch (rmError) {
1032
+ logInternalError("worktree.agent-cleanup.rm", rmError, `worktreePath=${worktreePath}`);
1033
+ }
1034
+ return;
1035
+ }
1036
+ // Remove worktree (best-effort). Try git first, then fall back to fs.rm.
1037
+ try {
1038
+ await gitAsync(repoRoot, ["worktree", "remove", "--force", worktreePath]);
1039
+ } catch (error) {
1040
+ logInternalError("worktree.agent-cleanup.remove", error, `worktreePath=${worktreePath}`);
1041
+ try {
1042
+ fs.rmSync(worktreePath, { recursive: true, force: true });
1043
+ } catch (rmError) {
1044
+ logInternalError("worktree.agent-cleanup.rm", rmError, `worktreePath=${worktreePath}`);
1045
+ }
1046
+ }
1047
+ // Delete the ephemeral agent branch (best-effort).
1048
+ if (branch) {
1049
+ try {
1050
+ await gitAsync(repoRoot, ["branch", "-D", branch]);
1051
+ } catch (error) {
1052
+ logInternalError("worktree.agent-cleanup.branch", error, `branch=${branch}`);
1053
+ }
1054
+ }
1055
+ // Prune stale worktree refs (best-effort).
1056
+ try {
1057
+ await gitAsync(repoRoot, ["worktree", "prune"]);
1058
+ } catch (error) {
1059
+ logInternalError("worktree.agent-cleanup.prune", error, `worktreePath=${worktreePath}`);
1060
+ }
1061
+ }