@pi-agents/orchid 0.1.0-beta.0

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 (163) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/LICENSE +21 -0
  3. package/README.md +246 -0
  4. package/agents/AGENTS-MANIFEST.md +42 -0
  5. package/agents/brain.md +42 -0
  6. package/agents/context-builder.md +46 -0
  7. package/agents/delegate.md +12 -0
  8. package/agents/dev-1.md +42 -0
  9. package/agents/oracle.md +73 -0
  10. package/agents/planner.md +55 -0
  11. package/agents/researcher.md +52 -0
  12. package/agents/reviewer.md +79 -0
  13. package/agents/scout.md +50 -0
  14. package/agents/tester.md +45 -0
  15. package/agents/worker.md +55 -0
  16. package/extensions/ralph.ts +1 -0
  17. package/extensions/reviewer-extension.ts +125 -0
  18. package/extensions/task-orchestrator.ts +28 -0
  19. package/package.json +63 -0
  20. package/prompts/gather-context-and-clarify.md +13 -0
  21. package/prompts/parallel-cleanup.md +59 -0
  22. package/prompts/parallel-context-build.md +53 -0
  23. package/prompts/parallel-handoff-plan.md +59 -0
  24. package/prompts/parallel-research.md +50 -0
  25. package/prompts/parallel-review.md +54 -0
  26. package/prompts/review-loop.md +41 -0
  27. package/skills/orchid/SKILL.md +214 -0
  28. package/skills/orchid/orchid-cleanup/SKILL.md +122 -0
  29. package/skills/orchid/orchid-converge/SKILL.md +124 -0
  30. package/skills/orchid/orchid-decompose/SKILL.md +201 -0
  31. package/skills/orchid/orchid-doctor/SKILL.md +162 -0
  32. package/skills/orchid/orchid-investigate/SKILL.md +102 -0
  33. package/skills/orchid/orchid-launch/SKILL.md +147 -0
  34. package/skills/ralph/SKILL.md +73 -0
  35. package/skills/subagents/pi-subagents/SKILL.md +813 -0
  36. package/src/index.ts +7 -0
  37. package/src/orchestrator/abort.ts +534 -0
  38. package/src/orchestrator/agent-bridge-extension.ts +1020 -0
  39. package/src/orchestrator/agent-host.ts +954 -0
  40. package/src/orchestrator/cleanup.ts +776 -0
  41. package/src/orchestrator/config-loader.ts +1412 -0
  42. package/src/orchestrator/config-schema.ts +690 -0
  43. package/src/orchestrator/config.ts +81 -0
  44. package/src/orchestrator/context-window.ts +66 -0
  45. package/src/orchestrator/diagnostic-reports.ts +475 -0
  46. package/src/orchestrator/diagnostics.ts +394 -0
  47. package/src/orchestrator/discovery.ts +1833 -0
  48. package/src/orchestrator/engine-worker.ts +415 -0
  49. package/src/orchestrator/engine.ts +5940 -0
  50. package/src/orchestrator/execution.ts +3104 -0
  51. package/src/orchestrator/extension.ts +5934 -0
  52. package/src/orchestrator/formatting.ts +785 -0
  53. package/src/orchestrator/git.ts +88 -0
  54. package/src/orchestrator/index.ts +28 -0
  55. package/src/orchestrator/lane-runner.ts +1787 -0
  56. package/src/orchestrator/mailbox.ts +780 -0
  57. package/src/orchestrator/merge.ts +3414 -0
  58. package/src/orchestrator/messages.ts +1062 -0
  59. package/src/orchestrator/migrations.ts +278 -0
  60. package/src/orchestrator/naming.ts +117 -0
  61. package/src/orchestrator/path-resolver.ts +275 -0
  62. package/src/orchestrator/persistence.ts +2625 -0
  63. package/src/orchestrator/process-registry.ts +452 -0
  64. package/src/orchestrator/quality-gate.ts +1085 -0
  65. package/src/orchestrator/resume.ts +3488 -0
  66. package/src/orchestrator/sessions.ts +57 -0
  67. package/src/orchestrator/settings-loader.ts +136 -0
  68. package/src/orchestrator/settings-tui.ts +2208 -0
  69. package/src/orchestrator/sidecar-telemetry.ts +267 -0
  70. package/src/orchestrator/supervisor.ts +4548 -0
  71. package/src/orchestrator/task-executor-core.ts +675 -0
  72. package/src/orchestrator/tmux-compat.ts +37 -0
  73. package/src/orchestrator/tool-allowlist-constants.ts +37 -0
  74. package/src/orchestrator/types.ts +4465 -0
  75. package/src/orchestrator/verification.ts +547 -0
  76. package/src/orchestrator/waves.ts +1564 -0
  77. package/src/orchestrator/workspace.ts +707 -0
  78. package/src/orchestrator/worktree.ts +2725 -0
  79. package/src/ralph/index.ts +825 -0
  80. package/src/subagents/agents/agent-management.ts +648 -0
  81. package/src/subagents/agents/agent-scope.ts +6 -0
  82. package/src/subagents/agents/agent-selection.ts +23 -0
  83. package/src/subagents/agents/agent-serializer.ts +86 -0
  84. package/src/subagents/agents/agents.ts +832 -0
  85. package/src/subagents/agents/chain-serializer.ts +137 -0
  86. package/src/subagents/agents/frontmatter.ts +29 -0
  87. package/src/subagents/agents/identity.ts +30 -0
  88. package/src/subagents/agents/skills.ts +632 -0
  89. package/src/subagents/extension/config.ts +16 -0
  90. package/src/subagents/extension/control-notices.ts +92 -0
  91. package/src/subagents/extension/doctor.ts +199 -0
  92. package/src/subagents/extension/fanout-child.ts +170 -0
  93. package/src/subagents/extension/index.ts +573 -0
  94. package/src/subagents/extension/schemas.ts +168 -0
  95. package/src/subagents/intercom/intercom-bridge.ts +379 -0
  96. package/src/subagents/intercom/result-intercom.ts +377 -0
  97. package/src/subagents/runs/background/async-execution.ts +712 -0
  98. package/src/subagents/runs/background/async-job-tracker.ts +310 -0
  99. package/src/subagents/runs/background/async-resume.ts +345 -0
  100. package/src/subagents/runs/background/async-status.ts +325 -0
  101. package/src/subagents/runs/background/completion-dedupe.ts +63 -0
  102. package/src/subagents/runs/background/notify.ts +108 -0
  103. package/src/subagents/runs/background/parallel-groups.ts +45 -0
  104. package/src/subagents/runs/background/result-watcher.ts +307 -0
  105. package/src/subagents/runs/background/run-id-resolver.ts +83 -0
  106. package/src/subagents/runs/background/run-status.ts +269 -0
  107. package/src/subagents/runs/background/stale-run-reconciler.ts +336 -0
  108. package/src/subagents/runs/background/subagent-runner.ts +1808 -0
  109. package/src/subagents/runs/background/top-level-async.ts +13 -0
  110. package/src/subagents/runs/foreground/chain-clarify.ts +1333 -0
  111. package/src/subagents/runs/foreground/chain-execution.ts +938 -0
  112. package/src/subagents/runs/foreground/execution.ts +918 -0
  113. package/src/subagents/runs/foreground/subagent-executor.ts +2527 -0
  114. package/src/subagents/runs/shared/completion-guard.ts +147 -0
  115. package/src/subagents/runs/shared/long-running-guard.ts +175 -0
  116. package/src/subagents/runs/shared/mcp-direct-tool-allowlist.ts +365 -0
  117. package/src/subagents/runs/shared/model-fallback.ts +103 -0
  118. package/src/subagents/runs/shared/nested-events.ts +819 -0
  119. package/src/subagents/runs/shared/nested-path.ts +52 -0
  120. package/src/subagents/runs/shared/nested-render.ts +115 -0
  121. package/src/subagents/runs/shared/parallel-utils.ts +109 -0
  122. package/src/subagents/runs/shared/pi-args.ts +220 -0
  123. package/src/subagents/runs/shared/pi-spawn.ts +115 -0
  124. package/src/subagents/runs/shared/run-history.ts +60 -0
  125. package/src/subagents/runs/shared/single-output.ts +164 -0
  126. package/src/subagents/runs/shared/subagent-control.ts +226 -0
  127. package/src/subagents/runs/shared/subagent-prompt-runtime.ts +170 -0
  128. package/src/subagents/runs/shared/worktree.ts +577 -0
  129. package/src/subagents/shared/artifacts.ts +98 -0
  130. package/src/subagents/shared/atomic-json.ts +16 -0
  131. package/src/subagents/shared/file-coalescer.ts +40 -0
  132. package/src/subagents/shared/fork-context.ts +76 -0
  133. package/src/subagents/shared/formatters.ts +133 -0
  134. package/src/subagents/shared/jsonl-writer.ts +81 -0
  135. package/src/subagents/shared/model-info.ts +78 -0
  136. package/src/subagents/shared/post-exit-stdio-guard.ts +85 -0
  137. package/src/subagents/shared/session-identity.ts +10 -0
  138. package/src/subagents/shared/session-tokens.ts +44 -0
  139. package/src/subagents/shared/settings.ts +397 -0
  140. package/src/subagents/shared/status-format.ts +49 -0
  141. package/src/subagents/shared/types.ts +822 -0
  142. package/src/subagents/shared/utils.ts +450 -0
  143. package/src/subagents/slash/prompt-template-bridge.ts +397 -0
  144. package/src/subagents/slash/slash-bridge.ts +174 -0
  145. package/src/subagents/slash/slash-commands.ts +528 -0
  146. package/src/subagents/slash/slash-live-state.ts +292 -0
  147. package/src/subagents/tui/render-helpers.ts +80 -0
  148. package/src/subagents/tui/render.ts +1358 -0
  149. package/templates/agents/local/supervisor.md +33 -0
  150. package/templates/agents/local/task-merger.md +27 -0
  151. package/templates/agents/local/task-reviewer.md +30 -0
  152. package/templates/agents/local/task-worker.md +34 -0
  153. package/templates/agents/supervisor-routing.md +92 -0
  154. package/templates/agents/supervisor.md +229 -0
  155. package/templates/agents/task-merger.md +214 -0
  156. package/templates/agents/task-reviewer.md +260 -0
  157. package/templates/agents/task-worker-segment.md +44 -0
  158. package/templates/agents/task-worker.md +557 -0
  159. package/templates/tasks/CONTEXT.md +30 -0
  160. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -0
  161. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -0
  162. package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -0
  163. package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -0
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Additive Upgrade Migrations for OrchID
3
+ *
4
+ * Provides a lightweight migration runner that applies additive-only
5
+ * changes (e.g., creating missing scaffold files) when extensions load
6
+ * or `/orch` starts. Migrations never overwrite existing files.
7
+ *
8
+ * Migration state is tracked in `.pi/orchid.json` under the
9
+ * `migrations` key, preserving all existing version-tracker fields.
10
+ *
11
+ * @module migrations
12
+ * @since TP-063
13
+ */
14
+
15
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from "fs";
16
+ import { join, dirname } from "path";
17
+ import { fileURLToPath } from "url";
18
+
19
+ // ── Types ────────────────────────────────────────────────────────────
20
+
21
+ /**
22
+ * Metadata for a single additive migration.
23
+ */
24
+ export interface Migration {
25
+ /** Unique, stable identifier (e.g., "add-supervisor-local-template-v1") */
26
+ id: string;
27
+ /** Human-readable description for logs */
28
+ description: string;
29
+ /**
30
+ * Execute the migration. Should only create files that don't exist.
31
+ *
32
+ * @param projectRoot - Project root directory
33
+ * @param packageRoot - OrchID package root (for template resolution)
34
+ * @param configRoot - Config root directory (e.g., ".pi" in repo mode, "shared-libs/.orchid" in workspace mode)
35
+ * @returns A short message describing what was created, or null if skipped (already exists)
36
+ * @throws If the migration cannot complete (e.g., missing template source)
37
+ */
38
+ run(projectRoot: string, packageRoot: string, configRoot: string): string | null;
39
+ }
40
+
41
+ /**
42
+ * Record of a single applied migration in `.pi/orchid.json`.
43
+ */
44
+ export interface AppliedMigration {
45
+ /** ISO timestamp when the migration was applied */
46
+ appliedAt: string;
47
+ }
48
+
49
+ /**
50
+ * The `migrations` section within `.pi/orchid.json`.
51
+ */
52
+ export interface MigrationState {
53
+ applied: Record<string, AppliedMigration>;
54
+ }
55
+
56
+ /**
57
+ * Shape of `.pi/orchid.json` (partial — only fields we read/write).
58
+ * Other fields (version, installedAt, lastUpgraded, components) are
59
+ * preserved as-is during read-modify-write.
60
+ */
61
+ export interface TaskplaneMeta {
62
+ [key: string]: unknown;
63
+ migrations?: MigrationState;
64
+ }
65
+
66
+ /**
67
+ * Result of running migrations.
68
+ */
69
+ export interface MigrationRunResult {
70
+ /** Migration IDs that were applied in this run */
71
+ applied: string[];
72
+ /** Migration IDs that were skipped (already applied or target exists) */
73
+ skipped: string[];
74
+ /** Migrations that failed with errors (non-fatal — logged and skipped) */
75
+ errors: Array<{ id: string; error: string }>;
76
+ /** Human-readable messages for each applied migration */
77
+ messages: string[];
78
+ }
79
+
80
+ // ── Meta File Helpers ────────────────────────────────────────────────
81
+
82
+ const ORCHID_META_FILENAME = ".orchid.json";
83
+
84
+ /**
85
+ * Load `.pi/orchid.json`, returning its content or an empty object
86
+ * if the file doesn't exist or is malformed.
87
+ *
88
+ * Never throws — returns `{}` for any read/parse error.
89
+ */
90
+ export function loadOrchidMeta(projectRoot: string): TaskplaneMeta {
91
+ const metaPath = join(projectRoot, ".pi", ORCHID_META_FILENAME);
92
+ try {
93
+ if (!existsSync(metaPath)) return {};
94
+ const raw = readFileSync(metaPath, "utf-8");
95
+ const parsed = JSON.parse(raw);
96
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
97
+ return parsed as TaskplaneMeta;
98
+ } catch {
99
+ return {};
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Save `.pi/orchid.json`, merging the provided meta with any
105
+ * existing content. Creates the `.pi/` directory if needed.
106
+ *
107
+ * Performs a shallow merge at the top level — existing keys not in
108
+ * `meta` are preserved. The `migrations` key is always taken from
109
+ * the provided `meta` object (deep replacement).
110
+ */
111
+ export function saveOrchidMeta(projectRoot: string, meta: TaskplaneMeta): void {
112
+ const piDir = join(projectRoot, ".pi");
113
+ mkdirSync(piDir, { recursive: true });
114
+
115
+ const metaPath = join(piDir, ORCHID_META_FILENAME);
116
+
117
+ // Read existing content to preserve version-tracker fields
118
+ let existing: TaskplaneMeta = {};
119
+ try {
120
+ if (existsSync(metaPath)) {
121
+ const raw = readFileSync(metaPath, "utf-8");
122
+ const parsed = JSON.parse(raw);
123
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
124
+ existing = parsed as TaskplaneMeta;
125
+ }
126
+ }
127
+ } catch {
128
+ // Existing file unreadable — start fresh but we'll overwrite only our keys
129
+ }
130
+
131
+ // Merge: existing fields preserved, our fields override
132
+ const merged = { ...existing, ...meta };
133
+ writeFileSync(metaPath, JSON.stringify(merged, null, 2) + "\n", "utf-8");
134
+ }
135
+
136
+ // ── Package Root Resolution ──────────────────────────────────────────
137
+
138
+ /**
139
+ * Resolve the.orchid package root directory.
140
+ *
141
+ * Uses ESM `import.meta.url` to compute the path deterministically.
142
+ * The package root is two levels up from this file:
143
+ * `<package-root>/extensions.orchid/migrations.ts`
144
+ *
145
+ * @param importMetaUrl - Pass `import.meta.url` from the calling module
146
+ * @returns Absolute path to the package root
147
+ */
148
+ export function resolvePackageRoot(importMetaUrl?: string): string {
149
+ const url = importMetaUrl ?? import.meta.url;
150
+ const thisDir = dirname(fileURLToPath(url));
151
+ // extensions.orchid/ → extensions/ → package root
152
+ return join(thisDir, "..", "..");
153
+ }
154
+
155
+ // ── Migration Registry ──────────────────────────────────────────────
156
+
157
+ /**
158
+ * Registry of all additive migrations, ordered by creation date.
159
+ *
160
+ * New migrations are appended to this array. Each migration must:
161
+ * - Have a unique, stable `id` (never renamed after release)
162
+ * - Only create files that don't exist (additive-only)
163
+ * - Throw on unrecoverable errors (e.g., missing template source)
164
+ * - Return null if the target already exists (skip)
165
+ */
166
+ export const MIGRATION_REGISTRY: Migration[] = [
167
+ {
168
+ id: "add-supervisor-local-template-v1",
169
+ description: "Create agents/supervisor.md from template if missing",
170
+ run(projectRoot: string, packageRoot: string, configRoot: string): string | null {
171
+ const targetPath = join(configRoot, "agents", "supervisor.md");
172
+
173
+ // Skip if file already exists — never overwrite
174
+ if (existsSync(targetPath)) {
175
+ return null;
176
+ }
177
+
178
+ // Resolve template source
179
+ const templatePath = join(packageRoot, "templates", "agents", "local", "supervisor.md");
180
+ if (!existsSync(templatePath)) {
181
+ throw new Error(
182
+ `Migration template not found: ${templatePath}. ` +
183
+ `This may indicate a packaging issue with the.orchid package.`,
184
+ );
185
+ }
186
+
187
+ // Create target directory and copy template
188
+ mkdirSync(dirname(targetPath), { recursive: true });
189
+ copyFileSync(templatePath, targetPath);
190
+
191
+ return `Created ${targetPath} from template`;
192
+ },
193
+ },
194
+ ];
195
+
196
+ // ── Migration Runner ─────────────────────────────────────────────────
197
+
198
+ /**
199
+ * Run all pending additive migrations.
200
+ *
201
+ * Loads migration state from `.pi/orchid.json`, runs only unapplied
202
+ * migrations from the registry, and persists applied IDs + timestamps.
203
+ *
204
+ * Each migration is individually try/caught:
205
+ * - Success → recorded as applied, message logged
206
+ * - Skip (returns null) → recorded as applied (target already exists)
207
+ * - Error → logged and skipped (NOT recorded — will be retried next time)
208
+ *
209
+ * @param projectRoot - Project root directory
210
+ * @param packageRoot - OrchID package root (for template resolution).
211
+ * If omitted, resolved from import.meta.url.
212
+ * @returns Migration run result with applied/skipped/error details
213
+ */
214
+ export function runMigrations(
215
+ projectRoot: string,
216
+ packageRoot?: string,
217
+ configRoot?: string,
218
+ ): MigrationRunResult {
219
+ const pkgRoot = packageRoot ?? resolvePackageRoot();
220
+ const cfgRoot = configRoot ?? join(projectRoot, ".pi");
221
+ const result: MigrationRunResult = {
222
+ applied: [],
223
+ skipped: [],
224
+ errors: [],
225
+ messages: [],
226
+ };
227
+
228
+ // Load current state
229
+ const meta = loadOrchidMeta(projectRoot);
230
+ const migrationState: MigrationState = meta.migrations ?? { applied: {} };
231
+
232
+ let stateChanged = false;
233
+
234
+ for (const migration of MIGRATION_REGISTRY) {
235
+ // Skip already-applied migrations
236
+ if (migrationState.applied[migration.id]) {
237
+ result.skipped.push(migration.id);
238
+ continue;
239
+ }
240
+
241
+ try {
242
+ const message = migration.run(projectRoot, pkgRoot, cfgRoot);
243
+
244
+ // Record as applied (whether it created something or skipped)
245
+ migrationState.applied[migration.id] = {
246
+ appliedAt: new Date().toISOString(),
247
+ };
248
+ stateChanged = true;
249
+
250
+ if (message) {
251
+ result.applied.push(migration.id);
252
+ result.messages.push(`📦 Migration: ${message}`);
253
+ } else {
254
+ // Target already existed — still mark as applied so we don't recheck
255
+ result.skipped.push(migration.id);
256
+ }
257
+ } catch (err: unknown) {
258
+ const errMsg = err instanceof Error ? err.message : String(err);
259
+ result.errors.push({ id: migration.id, error: errMsg });
260
+ // NOT recorded as applied — will be retried next time
261
+ }
262
+ }
263
+
264
+ // Persist state if anything changed
265
+ if (stateChanged) {
266
+ try {
267
+ saveOrchidMeta(projectRoot, { ...meta, migrations: migrationState });
268
+ } catch (err: unknown) {
269
+ const errMsg = err instanceof Error ? err.message : String(err);
270
+ result.errors.push({
271
+ id: "__state_save",
272
+ error: `Failed to persist migration state: ${errMsg}`,
273
+ });
274
+ }
275
+ }
276
+
277
+ return result;
278
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Naming contract helpers for team-scale collision resistance.
3
+ *
4
+ * Provides deterministic, human-readable identifiers for lane session IDs,
5
+ * worktree directories, git branches, and merge artifacts. All naming
6
+ * components are sanitized for safe use in filesystem paths, git refs,
7
+ * and tmux-compatible session IDs.
8
+ *
9
+ * @module orch/naming
10
+ */
11
+ import { basename, resolve } from "path";
12
+ import { userInfo } from "os";
13
+
14
+ import type { OrchestratorConfig } from "./types.ts";
15
+
16
+ // ── Sanitization ─────────────────────────────────────────────────────
17
+
18
+ /**
19
+ * Sanitize a raw string into a safe naming component.
20
+ *
21
+ * Rules:
22
+ * - Lowercase
23
+ * - Replace non-alphanumeric characters (except hyphens) with hyphens
24
+ * - Collapse consecutive hyphens
25
+ * - Trim leading/trailing hyphens
26
+ * - Truncate to `maxLen` characters
27
+ *
28
+ * Safe for use in: lane session IDs, git branch refs, filesystem paths.
29
+ *
30
+ * @param raw - Raw input string
31
+ * @param maxLen - Maximum length (default: 16)
32
+ * @returns Sanitized string, or empty string if input sanitizes to nothing
33
+ */
34
+ export function sanitizeNameComponent(raw: string, maxLen: number = 16): string {
35
+ return raw
36
+ .toLowerCase()
37
+ .replace(/[^a-z0-9-]/g, "-")
38
+ .replace(/-+/g, "-")
39
+ .replace(/^-+|-+$/g, "")
40
+ .slice(0, maxLen);
41
+ }
42
+
43
+ // ── Operator ID ──────────────────────────────────────────────────────
44
+
45
+ /**
46
+ * Resolve the operator identifier from available sources.
47
+ *
48
+ * Resolution order (first non-empty wins):
49
+ * 1. `TASKPLANE_OPERATOR_ID` environment variable
50
+ * 2. `operator_id` field in OrchestratorConfig
51
+ * 3. Current OS username via `os.userInfo().username`
52
+ * 4. Fallback: `"op"`
53
+ *
54
+ * The resolved value is sanitized and truncated to 12 characters.
55
+ *
56
+ * @param config - Orchestrator configuration (may contain operator_id)
57
+ * @param env - Environment variables (defaults to process.env)
58
+ * @returns Sanitized operator identifier (never empty)
59
+ */
60
+ export function resolveOperatorId(
61
+ config: OrchestratorConfig,
62
+ env: Record<string, string | undefined> = process.env,
63
+ ): string {
64
+ const FALLBACK = "op";
65
+ const MAX_LEN = 12;
66
+
67
+ // 1. Environment variable
68
+ const envValue = env.TASKPLANE_OPERATOR_ID;
69
+ if (envValue && envValue.trim()) {
70
+ const sanitized = sanitizeNameComponent(envValue.trim(), MAX_LEN);
71
+ if (sanitized) return sanitized;
72
+ }
73
+
74
+ // 2. Config field
75
+ const configValue = config.orchestrator.operator_id;
76
+ if (configValue && configValue.trim()) {
77
+ const sanitized = sanitizeNameComponent(configValue.trim(), MAX_LEN);
78
+ if (sanitized) return sanitized;
79
+ }
80
+
81
+ // 3. OS username
82
+ try {
83
+ const username = userInfo().username;
84
+ if (username && username.trim()) {
85
+ const sanitized = sanitizeNameComponent(username.trim(), MAX_LEN);
86
+ if (sanitized) return sanitized;
87
+ }
88
+ } catch {
89
+ // userInfo() can throw on some platforms
90
+ }
91
+
92
+ // 4. Fallback
93
+ return FALLBACK;
94
+ }
95
+
96
+ // ── Repo Slug ────────────────────────────────────────────────────────
97
+
98
+ /**
99
+ * Derive a repo slug from the repository root directory name.
100
+ *
101
+ * Provides cross-repo disambiguation when multiple repos share the
102
+ * same machine. Used in lane session IDs and worktree paths where
103
+ * names must be globally unique on the machine.
104
+ *
105
+ * @param repoRoot - Absolute path to the repository root
106
+ * @returns Sanitized repo slug (never empty; falls back to "repo")
107
+ */
108
+ export function resolveRepoSlug(repoRoot: string): string {
109
+ const FALLBACK = "repo";
110
+ const MAX_LEN = 16;
111
+
112
+ const dirName = basename(resolve(repoRoot));
113
+ if (!dirName) return FALLBACK;
114
+
115
+ const sanitized = sanitizeNameComponent(dirName, MAX_LEN);
116
+ return sanitized || FALLBACK;
117
+ }
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Path Resolver — Consolidated npm global root detection and package/tool path resolution.
3
+ *
4
+ * This module is the single source of truth for resolving paths to globally-installed
5
+ * npm packages (OrchID and the pi coding agent CLI). It was created to eliminate
6
+ * three duplicate implementations that previously existed in execution.ts, agent-host.ts,
7
+ * and agent-bridge-extension.ts.
8
+ *
9
+ * ## Why this module exists
10
+ *
11
+ * macOS-specific bugs (#472, #474) were caused by hardcoded path lists that missed
12
+ * Homebrew (`/opt/homebrew`) and contained ESM-unsafe `require()` calls. Each fix had
13
+ * to be applied to multiple files, risking future drift. A single module eliminates that.
14
+ *
15
+ * ## Platform coverage
16
+ *
17
+ * - **Windows** — npm global root is typically `%APPDATA%\npm\node_modules` or a custom
18
+ * prefix. Static fallbacks cover both the APPDATA env var path and the HOME-relative
19
+ * equivalent (`AppData\Roaming\npm\node_modules`).
20
+ *
21
+ * - **macOS** — Multiple valid npm setups are covered:
22
+ * - System Node via Homebrew: `/opt/homebrew/lib/node_modules`
23
+ * - System Node (legacy): `/usr/local/lib/node_modules`
24
+ * - nvm, volta, or custom prefix: resolved dynamically via `npm root -g`
25
+ * - Custom global prefix: `~/.npm-global/lib/node_modules`
26
+ *
27
+ * - **Linux** — System Node (`/usr/local/lib/node_modules`), nvm, volta, and custom
28
+ * prefixes are all covered dynamically via `npm root -g`.
29
+ *
30
+ * ## Resolution strategy
31
+ *
32
+ * `npm root -g` is the **primary** resolution path because it covers every npm setup
33
+ * (nvm, Homebrew, volta, pnpm global, and any custom `--prefix`) with a single call.
34
+ * Static fallbacks exist only for environments where `npm` is not on PATH, which is
35
+ * uncommon but can happen in certain CI containers or restricted environments.
36
+ *
37
+ * The `npm root -g` result is module-level cached because:
38
+ * 1. It is called from multiple callsites per process.
39
+ * 2. The result never changes within a process lifetime.
40
+ * 3. Spawning a subprocess on every call would be expensive.
41
+ *
42
+ * @module orchid/path-resolver
43
+ * @since TP-157
44
+ */
45
+
46
+ import { spawnSync } from "child_process";
47
+ import { existsSync } from "fs";
48
+ import { join, resolve } from "path";
49
+
50
+ // ── Module-level cache ──────────────────────────────────────────────
51
+
52
+ /**
53
+ * Cached result of `npm root -g`.
54
+ * `null` = not yet resolved; `""` = resolution failed.
55
+ */
56
+ let _npmGlobalRoot: string | null = null;
57
+
58
+ // ── Exported functions ──────────────────────────────────────────────
59
+
60
+ /**
61
+ * Get the global npm root directory via `npm root -g`.
62
+ *
63
+ * The result is cached at module level for the process lifetime, so repeated
64
+ * calls are free after the first.
65
+ *
66
+ * @returns Absolute path to the npm global `node_modules` directory,
67
+ * or `""` if the call fails (npm not on PATH, subprocess error, etc.).
68
+ * Never throws.
69
+ *
70
+ * @platform Windows — `shell: true` is required because `npm` resolves to
71
+ * `npm.cmd`, a Windows batch script that cannot be spawned without a shell.
72
+ */
73
+ export function getNpmGlobalRoot(): string {
74
+ if (_npmGlobalRoot !== null) return _npmGlobalRoot;
75
+ try {
76
+ const result = spawnSync("npm", ["root", "-g"], {
77
+ encoding: "utf-8",
78
+ timeout: 5000,
79
+ // shell: true is mandatory on Windows — npm resolves to npm.cmd
80
+ shell: true,
81
+ });
82
+ _npmGlobalRoot = result.stdout?.trim() || "";
83
+ } catch {
84
+ _npmGlobalRoot = "";
85
+ }
86
+ return _npmGlobalRoot;
87
+ }
88
+
89
+ /**
90
+ * Pi CLI npm package scopes that OrchID resolves at runtime, ordered with
91
+ * the canonical (current) scope FIRST and legacy scopes after for backward
92
+ * compatibility. Issue #560: the Pi coding agent was renamed from
93
+ * `@mariozechner/pi-coding-agent` to `@earendil-works/pi-coding-agent` in
94
+ * Pi v0.74.0. Pi's own extension loader bundles BOTH scope aliases at runtime
95
+ * for in-process module imports, but spawn-side path resolution (this file)
96
+ * has to look on disk under whichever scope was actually installed.
97
+ *
98
+ * Order matters: the new scope is preferred so a system that has BOTH
99
+ * installed (e.g., during a transition window) picks up the current Pi.
100
+ */
101
+ const PI_PACKAGE_SCOPES = ["@earendil-works", "@mariozechner"] as const;
102
+
103
+ /**
104
+ * Resolve the absolute path to the Pi coding agent CLI entrypoint (`cli.js`).
105
+ *
106
+ * The Pi CLI is installed under one of two npm scopes:
107
+ * - `@earendil-works/pi-coding-agent` (current, as of Pi v0.74.0)
108
+ * - `@mariozechner/pi-coding-agent` (legacy)
109
+ *
110
+ * On Windows, invoking `pi` directly executes a `.CMD` shim that cannot be
111
+ * spawned with `shell: false`. This function locates the underlying
112
+ * `dist/cli.js` so callers can spawn it with `node` directly, without a shell
113
+ * intermediary.
114
+ *
115
+ * Resolution order: the cross product of base directories × package scopes,
116
+ * with each base directory tried for the new scope before any base directory
117
+ * is tried for the legacy scope. (Equivalently: scope is the inner loop, base
118
+ * is the outer loop.)
119
+ *
120
+ * Base directories (outer loop):
121
+ * 1. `npm root -g` result (dynamic — covers all setups: nvm, Homebrew, volta, etc.)
122
+ * 2. `%APPDATA%\npm\node_modules\...` (Windows, APPDATA env var)
123
+ * 3. `%USERPROFILE%\AppData\Roaming\npm\node_modules\...` (Windows, HOME-relative)
124
+ * 4. `~/.npm-global/lib/node_modules/...` (macOS/Linux custom global prefix)
125
+ * 5. `/usr/local/lib/node_modules/...` (macOS system Node, Linux)
126
+ * 6. `/opt/homebrew/lib/node_modules/...` (macOS Homebrew)
127
+ *
128
+ * Scopes per base (inner loop):
129
+ * a. `@earendil-works/pi-coding-agent/dist/cli.js`
130
+ * b. `@mariozechner/pi-coding-agent/dist/cli.js`
131
+ *
132
+ * @returns Absolute path to a Pi CLI `dist/cli.js` (under whichever scope was found).
133
+ * @throws {Error} If the CLI entrypoint cannot be found under any base × scope
134
+ * combination. The error message includes the `npm root -g` value
135
+ * AND lists both scopes searched, for operator diagnosis.
136
+ */
137
+ export function resolvePiCliPath(): string {
138
+ const bases: string[] = [];
139
+
140
+ // 1. Dynamic: npm root -g (covers nvm, Homebrew, volta, custom npm prefix, etc.)
141
+ const npmRoot = getNpmGlobalRoot();
142
+ if (npmRoot) bases.push(npmRoot);
143
+
144
+ // 2-3. Static Windows fallbacks
145
+ const home = process.env.HOME || process.env.USERPROFILE || "";
146
+ if (process.env.APPDATA) {
147
+ bases.push(join(process.env.APPDATA, "npm", "node_modules"));
148
+ }
149
+ if (home) {
150
+ bases.push(join(home, "AppData", "Roaming", "npm", "node_modules"));
151
+ // 4. macOS/Linux custom global prefix
152
+ bases.push(join(home, ".npm-global", "lib", "node_modules"));
153
+ }
154
+ // 5. macOS system Node / Linux
155
+ bases.push(join("/usr", "local", "lib", "node_modules"));
156
+ // 6. macOS Homebrew
157
+ bases.push(join("/opt", "homebrew", "lib", "node_modules"));
158
+
159
+ // Cross product: scope is the inner loop so a single base directory is
160
+ // fully exhausted (new scope, then legacy scope) before falling back to
161
+ // the next base. This matches operator intuition ("check the most likely
162
+ // install location for either scope first").
163
+ for (const base of bases) {
164
+ for (const scope of PI_PACKAGE_SCOPES) {
165
+ const candidate = join(base, scope, "pi-coding-agent", "dist", "cli.js");
166
+ if (existsSync(candidate)) return candidate;
167
+ }
168
+ }
169
+
170
+ throw new Error(
171
+ "Cannot find Pi CLI entrypoint (pi-coding-agent/dist/cli.js) under any known npm scope " +
172
+ `(${PI_PACKAGE_SCOPES.join(" or ")}). ` +
173
+ "Install via 'npm install -g @earendil-works/pi-coding-agent' " +
174
+ "(or, for legacy installs, 'npm install -g @mariozechner/pi-coding-agent'). " +
175
+ `npm root -g returned: ${npmRoot || "(empty — npm may not be on PATH)"}`,
176
+ );
177
+ }
178
+
179
+ /**
180
+ * Resolve the path to a file within the OrchID npm package.
181
+ *
182
+ * This handles both local development (running from the OrchID repo itself)
183
+ * and the installed-package case (OrchID installed globally via npm).
184
+ *
185
+ * Resolution order:
186
+ * 1. `join(repoRoot, relPath)` — local development (OrchID's own repo)
187
+ * 2. `npm root -g` result: `{npmGlobalRoot}/@pi-agents/orchid/{relPath}` (dynamic, all setups)
188
+ * 3. `{APPDATA}/npm/node_modules/@pi-agents/orchid/{relPath}` (Windows)
189
+ * 4. `{HOME}/AppData/Roaming/npm/node_modules/@pi-agents/orchid/{relPath}` (Windows alt)
190
+ * 5. `{HOME}/.npm-global/lib/node_modules/@pi-agents/orchid/{relPath}` (macOS/Linux custom prefix)
191
+ * 6. `/usr/local/lib/node_modules/@pi-agents/orchid/{relPath}` (macOS system Node, Linux)
192
+ * 7. `/opt/homebrew/lib/node_modules/@claude-code-s-we/orchid/{relPath}` (macOS Homebrew)
193
+ * 8. Peer of pi's package (adjacent to `process.argv[1]`)
194
+ *
195
+ * @param repoRoot - Absolute path to the project root (used for local dev check)
196
+ * @param relPath - Relative path within the OrchID package, e.g.
197
+ * `"extensions/task-orchestrator.ts"` or `"templates/agents/task-worker.md"`
198
+ * @returns Absolute path to the resolved file. If not found in any location,
199
+ * returns the local path (`join(repoRoot, relPath)`) as a fallback — callers
200
+ * will fail at use time with a clear "file not found" error.
201
+ */
202
+ export function resolveTaskplanePackageFile(repoRoot: string, relPath: string): string {
203
+ // 1. Local development — OrchID's own repo
204
+ const localPath = join(resolve(repoRoot), relPath);
205
+ if (existsSync(localPath)) return localPath;
206
+
207
+ const candidates: string[] = [];
208
+
209
+ // 2. Dynamic: npm root -g (covers ALL npm setups: nvm, Homebrew, volta, etc.)
210
+ const npmRoot = getNpmGlobalRoot();
211
+ if (npmRoot) {
212
+ candidates.push(join(npmRoot, "@pi-agents/orchid", relPath));
213
+ }
214
+
215
+ // 3-7. Well-known static paths
216
+ const home = process.env.HOME || process.env.USERPROFILE || "";
217
+ if (process.env.APPDATA) {
218
+ candidates.push(join(process.env.APPDATA, "npm", "node_modules", "@pi-agents", "orchid", relPath));
219
+ }
220
+ if (home) {
221
+ candidates.push(join(home, "AppData", "Roaming", "npm", "node_modules", "@pi-agents", "orchid", relPath));
222
+ candidates.push(join(home, ".npm-global", "lib", "node_modules", "@pi-agents", "orchid", relPath));
223
+ }
224
+ candidates.push(join("/usr", "local", "lib", "node_modules", "@pi-agents", "orchid", relPath));
225
+ candidates.push(join("/opt", "homebrew", "lib", "node_modules", "@pi-agents", "orchid", relPath));
226
+
227
+ // 8. Peer of pi's package (look adjacent to pi's CLI entrypoint).
228
+ // pi is at: <npmRoot>/<scope>/pi-coding-agent/dist/cli.js (where <scope> is
229
+ // @earendil-works (current) or @mariozechner (legacy)).
230
+ // so piPkgDir = <npmRoot>/<scope>/pi-coding-agent (resolve up 2 levels from cli.js).
231
+ // Then go up TWO more levels to reach <npmRoot>, then into @pi-agents/orchid/.
232
+ // This works regardless of which scope Pi is installed under because we
233
+ // only walk up the directory tree — we never name the scope explicitly.
234
+ try {
235
+ const piPath = process.argv[1] || "";
236
+ const piPkgDir = resolve(piPath, "..", ".."); // <npmRoot>/<scope>/pi-coding-agent
237
+ const npmRootFromPi = resolve(piPkgDir, "..", ".."); // <npmRoot>
238
+ candidates.push(join(npmRootFromPi, "@pi-agents", "orchid", relPath));
239
+ } catch {
240
+ /* ignore — process.argv[1] may be undefined in test contexts */
241
+ }
242
+
243
+ for (const candidate of candidates) {
244
+ if (existsSync(candidate)) return candidate;
245
+ }
246
+
247
+ // Fallback: return the local path. Callers will fail with a clear error at use time.
248
+ return localPath;
249
+ }
250
+
251
+ /**
252
+ * Resolve the path to an OrchID agent template file.
253
+ *
254
+ * Convenience wrapper around {@link resolveTaskplanePackageFile} for the
255
+ * common case of locating a file in `templates/agents/`.
256
+ *
257
+ * Used by `loadBaseAgentPrompt` (execution.ts) and `loadReviewerPrompt`
258
+ * (agent-bridge-extension.ts) to locate the base agent prompt templates
259
+ * that ship with the OrchID package.
260
+ *
261
+ * @param agentName - Agent template name without extension, e.g. `"task-worker"`,
262
+ * `"task-reviewer"`, `"task-merger"`
263
+ * @returns Absolute path to `templates/agents/{agentName}.md` within the
264
+ * resolved OrchID package root.
265
+ *
266
+ * @example
267
+ * ```ts
268
+ * const templatePath = resolveTaskplaneAgentTemplate("task-worker");
269
+ * // → "/usr/local/lib/node_modules/@pi-agents/orchid/templates/agents/task-worker.md"
270
+ * // (or local dev path, or any other resolved location)
271
+ * ```
272
+ */
273
+ export function resolveTaskplaneAgentTemplate(agentName: string): string {
274
+ return resolveTaskplanePackageFile(process.cwd(), join("templates", "agents", `${agentName}.md`));
275
+ }