@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,707 @@
1
+ /**
2
+ * Workspace configuration loading and validation.
3
+ *
4
+ * Detects workspace mode by checking for `.pi/orchid-workspace.yaml`.
5
+ * When the file is present, it must be valid — invalid files are fatal.
6
+ * When absent, `loadWorkspaceConfig()` returns null and `buildExecutionContext()`
7
+ * decides repo-mode eligibility (cwd must be a git repository).
8
+ *
9
+ * Validation order (deterministic, fail-fast):
10
+ * 1. File existence check → absent = repo mode (return null)
11
+ * 2. File read → WORKSPACE_FILE_READ_ERROR
12
+ * 3. YAML parse → WORKSPACE_FILE_PARSE_ERROR
13
+ * 4. Top-level schema → WORKSPACE_SCHEMA_INVALID
14
+ * 5. repos map non-empty → WORKSPACE_MISSING_REPOS
15
+ * 6. Per-repo validation (sorted key order):
16
+ * a. path present → WORKSPACE_REPO_PATH_MISSING
17
+ * b. path exists on disk → WORKSPACE_REPO_PATH_NOT_FOUND
18
+ * c. path is git repo → WORKSPACE_REPO_NOT_GIT
19
+ * 7. Duplicate repo paths → WORKSPACE_DUPLICATE_REPO_PATH
20
+ * 8. routing.tasks_root present → WORKSPACE_MISSING_TASKS_ROOT
21
+ * 9. routing.tasks_root exists → WORKSPACE_TASKS_ROOT_NOT_FOUND
22
+ * 10. routing.default_repo present → WORKSPACE_MISSING_DEFAULT_REPO
23
+ * 11. routing.default_repo valid → WORKSPACE_DEFAULT_REPO_NOT_FOUND
24
+ * 12. routing.task_packet_repo valid (or compat fallback) → WORKSPACE_TASK_PACKET_REPO_NOT_FOUND
25
+ * 13. routing.tasks_root inside packet-home repo → WORKSPACE_TASKS_ROOT_OUTSIDE_PACKET_REPO
26
+ *
27
+ * Path normalization rules:
28
+ * - Relative paths are resolved against workspaceRoot.
29
+ * - Existing paths are canonicalized via `fs.realpathSync.native()` to
30
+ * expand Windows 8.3 short names and resolve symlinks.
31
+ * - All paths are forward-slash normalized and lowercased for comparison.
32
+ * - This matches the precedent in `worktree.ts:normalizePath()`.
33
+ *
34
+ * Git repo validation:
35
+ * - Uses `git rev-parse --git-dir` run inside the repo path.
36
+ * - The path must be the repo root (not a subdirectory).
37
+ * We verify by checking that `git rev-parse --show-toplevel` matches
38
+ * the canonicalized path.
39
+ *
40
+ * @module orch/workspace
41
+ */
42
+ import { readFileSync, existsSync, realpathSync } from "fs";
43
+ import { resolve, relative, isAbsolute } from "path";
44
+ import { parse as yamlParse } from "yaml";
45
+
46
+ import { runGit } from "./git.ts";
47
+ import {
48
+ WorkspaceConfigError,
49
+ workspaceConfigPath,
50
+ pointerFilePath,
51
+ type WorkspaceConfig,
52
+ type WorkspaceRepoConfig,
53
+ type WorkspaceRoutingConfig,
54
+ type PointerResolution,
55
+ } from "./types.ts";
56
+
57
+ // ── Path Canonicalization ────────────────────────────────────────────
58
+
59
+ /**
60
+ * Canonicalize a filesystem path for comparison and storage.
61
+ *
62
+ * Reuses the normalization pattern from `worktree.ts:normalizePath()`:
63
+ * - `realpathSync.native()` expands Windows 8.3 short names when the path exists.
64
+ * - Falls back to `resolve()` for non-existent paths.
65
+ * - Forward-slash normalized and lowercased for platform-safe comparison.
66
+ *
67
+ * @param p - Path to canonicalize (absolute or relative)
68
+ * @param base - Base directory for resolving relative paths
69
+ * @returns Canonical absolute path (forward-slash, lowercased)
70
+ */
71
+ export function canonicalizePath(p: string, base: string): string {
72
+ const resolved = resolve(base, p);
73
+ let expanded: string;
74
+ try {
75
+ expanded = realpathSync.native(resolved);
76
+ } catch {
77
+ // Path doesn't exist yet — fall back to resolve()
78
+ expanded = resolved;
79
+ }
80
+ return expanded.replace(/\\/g, "/").toLowerCase();
81
+ }
82
+
83
+ /**
84
+ * Canonicalize a path for storage (absolute, native separators, resolved symlinks).
85
+ * Unlike canonicalizePath(), this preserves original case for display/config output.
86
+ *
87
+ * @param p - Path to resolve (absolute or relative)
88
+ * @param base - Base directory for resolving relative paths
89
+ * @returns Absolute resolved path (native separators preserved)
90
+ */
91
+ function resolveAbsolutePath(p: string, base: string): string {
92
+ const resolved = resolve(base, p);
93
+ try {
94
+ return realpathSync.native(resolved);
95
+ } catch {
96
+ return resolved;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * True when `childPath` is the same path as `parentPath` or contained within it.
102
+ * Uses canonicalized paths for cross-platform, case-insensitive comparison.
103
+ */
104
+ function isPathWithinContainer(childPath: string, parentPath: string): boolean {
105
+ const child = canonicalizePath(childPath, "");
106
+ const parent = canonicalizePath(parentPath, "");
107
+ return child === parent || child.startsWith(`${parent}/`);
108
+ }
109
+
110
+ // ── Pointer Resolution ───────────────────────────────────────────────
111
+
112
+ /**
113
+ * Resolve the workspace pointer file to find config and agent roots.
114
+ *
115
+ * The pointer file (`<workspace-root>/.pi/orchid-pointer.json`) tells
116
+ * OrchID where to find project config and agent overrides in workspace
117
+ * (polyrepo) mode. It's created by `orchid init` and is local-only
118
+ * (not committed to git).
119
+ *
120
+ * **Repo mode:** Returns null. The pointer is workspace-only — in repo
121
+ * mode it is never read, even if a file happens to exist on disk.
122
+ *
123
+ * **Workspace mode:** Reads and validates the pointer, then resolves
124
+ * config and agent roots. All failures are non-fatal:
125
+ * - Missing pointer file → warn + fallback
126
+ * - Malformed JSON → warn + fallback
127
+ * - Missing required fields → warn + fallback
128
+ * - Unknown config_repo (not in WorkspaceConfig.repos) → warn + fallback
129
+ * - Path traversal in config_path → warn + fallback
130
+ *
131
+ * Fallback paths: `<workspace-root>/.pi/` for config,
132
+ * `<workspace-root>/.pi/agents/` for agents.
133
+ *
134
+ * State/sidecar paths are NOT affected by the pointer and are not
135
+ * included in the return value — they always live at
136
+ * `<workspace-root>/.pi/` regardless.
137
+ *
138
+ * @param workspaceRoot - Absolute path to the workspace root directory
139
+ * @param workspaceConfig - Loaded workspace config (null = repo mode → returns null)
140
+ * @returns PointerResolution with resolved paths, or null in repo mode
141
+ */
142
+ export function resolvePointer(
143
+ workspaceRoot: string,
144
+ workspaceConfig: WorkspaceConfig | null,
145
+ ): PointerResolution | null {
146
+ // ── Repo mode: pointer is ignored entirely ───────────────────
147
+ if (workspaceConfig === null) {
148
+ return null;
149
+ }
150
+
151
+ const fallbackConfigRoot = resolve(workspaceRoot, ".pi");
152
+ const fallbackAgentRoot = resolve(workspaceRoot, ".pi", "agents");
153
+
154
+ const filePath = pointerFilePath(workspaceRoot);
155
+
156
+ // ── 1. File existence ────────────────────────────────────────
157
+ if (!existsSync(filePath)) {
158
+ return {
159
+ used: false,
160
+ configRoot: fallbackConfigRoot,
161
+ agentRoot: fallbackAgentRoot,
162
+ warning: `Pointer file not found: ${filePath}. Run 'orchid init' to create it.`,
163
+ };
164
+ }
165
+
166
+ // ── 2. Read file ─────────────────────────────────────────────
167
+ let rawContent: string;
168
+ try {
169
+ rawContent = readFileSync(filePath, "utf-8");
170
+ } catch (err: unknown) {
171
+ const msg = err instanceof Error ? err.message : String(err);
172
+ return {
173
+ used: false,
174
+ configRoot: fallbackConfigRoot,
175
+ agentRoot: fallbackAgentRoot,
176
+ warning: `Cannot read pointer file ${filePath}: ${msg}`,
177
+ };
178
+ }
179
+
180
+ // ── 3. Parse JSON ────────────────────────────────────────────
181
+ let parsed: unknown;
182
+ try {
183
+ parsed = JSON.parse(rawContent);
184
+ } catch {
185
+ return {
186
+ used: false,
187
+ configRoot: fallbackConfigRoot,
188
+ agentRoot: fallbackAgentRoot,
189
+ warning: `Pointer file ${filePath} contains invalid JSON.`,
190
+ };
191
+ }
192
+
193
+ // ── 4. Validate shape ────────────────────────────────────────
194
+ if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
195
+ return {
196
+ used: false,
197
+ configRoot: fallbackConfigRoot,
198
+ agentRoot: fallbackAgentRoot,
199
+ warning: `Pointer file ${filePath} must be a JSON object.`,
200
+ };
201
+ }
202
+
203
+ const doc = parsed as Record<string, unknown>;
204
+ const configRepo = doc.config_repo;
205
+ const configPath = doc.config_path;
206
+
207
+ if (!configRepo || typeof configRepo !== "string" || configRepo.trim() === "") {
208
+ return {
209
+ used: false,
210
+ configRoot: fallbackConfigRoot,
211
+ agentRoot: fallbackAgentRoot,
212
+ warning: `Pointer file ${filePath} is missing required field 'config_repo'.`,
213
+ };
214
+ }
215
+
216
+ if (!configPath || typeof configPath !== "string" || configPath.trim() === "") {
217
+ return {
218
+ used: false,
219
+ configRoot: fallbackConfigRoot,
220
+ agentRoot: fallbackAgentRoot,
221
+ warning: `Pointer file ${filePath} is missing required field 'config_path'.`,
222
+ };
223
+ }
224
+
225
+ // ── 5. Guard path traversal ──────────────────────────────────
226
+ const normalizedConfigPath = configPath.trim().replace(/\\/g, "/");
227
+
228
+ // Reject absolute paths (POSIX `/...` and Windows `C:/...`, `\\...`)
229
+ if (isAbsolute(normalizedConfigPath) || isAbsolute(configPath.trim())) {
230
+ return {
231
+ used: false,
232
+ configRoot: fallbackConfigRoot,
233
+ agentRoot: fallbackAgentRoot,
234
+ warning: `Pointer file ${filePath} has invalid config_path '${configPath}' (absolute paths not allowed).`,
235
+ };
236
+ }
237
+
238
+ // Reject traversal sequences
239
+ if (
240
+ normalizedConfigPath.startsWith("..") ||
241
+ normalizedConfigPath.includes("/../") ||
242
+ normalizedConfigPath.endsWith("/..")
243
+ ) {
244
+ return {
245
+ used: false,
246
+ configRoot: fallbackConfigRoot,
247
+ agentRoot: fallbackAgentRoot,
248
+ warning: `Pointer file ${filePath} has invalid config_path '${configPath}' (path traversal not allowed).`,
249
+ };
250
+ }
251
+
252
+ // ── 6. Resolve config_repo against workspace repos map ──────
253
+ const repoId = configRepo.trim();
254
+ const repoConfig = workspaceConfig.repos.get(repoId);
255
+ if (!repoConfig) {
256
+ const available = Array.from(workspaceConfig.repos.keys()).join(", ");
257
+ return {
258
+ used: false,
259
+ configRoot: fallbackConfigRoot,
260
+ agentRoot: fallbackAgentRoot,
261
+ warning: `Pointer file ${filePath}: config_repo '${repoId}' not found in workspace repos. Available repos: ${available}`,
262
+ };
263
+ }
264
+
265
+ // ── 7. Build resolved paths + containment check ──────────────
266
+ const resolvedConfigRoot = resolve(repoConfig.path, normalizedConfigPath);
267
+
268
+ // Verify the resolved path is within the repo root (defense-in-depth)
269
+ const rel = relative(repoConfig.path, resolvedConfigRoot);
270
+ if (rel.startsWith("..") || isAbsolute(rel)) {
271
+ return {
272
+ used: false,
273
+ configRoot: fallbackConfigRoot,
274
+ agentRoot: fallbackAgentRoot,
275
+ warning: `Pointer file ${filePath} has invalid config_path '${configPath}' (resolved path escapes config repo root).`,
276
+ };
277
+ }
278
+
279
+ const resolvedAgentRoot = resolve(resolvedConfigRoot, "agents");
280
+
281
+ return {
282
+ used: true,
283
+ configRoot: resolvedConfigRoot,
284
+ agentRoot: resolvedAgentRoot,
285
+ };
286
+ }
287
+
288
+ // ── Workspace Config Loading ─────────────────────────────────────────
289
+
290
+ /**
291
+ * Load and validate workspace configuration from `.pi/orchid-workspace.yaml`.
292
+ *
293
+ * Mode determination rules:
294
+ * 1. No config file → return null (repo mode, non-fatal, silent).
295
+ * 2. Config file present + invalid → throw WorkspaceConfigError (fatal).
296
+ * 3. Config file present + valid → return WorkspaceConfig (workspace mode).
297
+ *
298
+ * @param workspaceRoot - Absolute path to the workspace root directory
299
+ * @returns WorkspaceConfig if workspace mode, null if repo mode
300
+ * @throws WorkspaceConfigError when config file is present but invalid
301
+ */
302
+ export function loadWorkspaceConfig(workspaceRoot: string): WorkspaceConfig | null {
303
+ const configFile = workspaceConfigPath(workspaceRoot);
304
+
305
+ // ── 1. File existence check ──────────────────────────────────
306
+ if (!existsSync(configFile)) {
307
+ return null;
308
+ }
309
+
310
+ // ── 2. File read ─────────────────────────────────────────────
311
+ let rawContent: string;
312
+ try {
313
+ rawContent = readFileSync(configFile, "utf-8");
314
+ } catch (err: unknown) {
315
+ const msg = err instanceof Error ? err.message : String(err);
316
+ throw new WorkspaceConfigError(
317
+ "WORKSPACE_FILE_READ_ERROR",
318
+ `Cannot read workspace config file: ${msg}`,
319
+ undefined,
320
+ configFile,
321
+ );
322
+ }
323
+
324
+ // ── 3. YAML parse ────────────────────────────────────────────
325
+ let parsed: unknown;
326
+ try {
327
+ parsed = yamlParse(rawContent);
328
+ } catch (err: unknown) {
329
+ const msg = err instanceof Error ? err.message : String(err);
330
+ throw new WorkspaceConfigError(
331
+ "WORKSPACE_FILE_PARSE_ERROR",
332
+ `Invalid YAML in workspace config: ${msg}`,
333
+ undefined,
334
+ configFile,
335
+ );
336
+ }
337
+
338
+ // ── 4. Top-level schema validation ───────────────────────────
339
+ if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
340
+ throw new WorkspaceConfigError(
341
+ "WORKSPACE_SCHEMA_INVALID",
342
+ "Workspace config must be a YAML mapping (object), not a scalar or sequence.",
343
+ undefined,
344
+ configFile,
345
+ );
346
+ }
347
+ const doc = parsed as Record<string, unknown>;
348
+
349
+ if (!doc.repos || typeof doc.repos !== "object" || Array.isArray(doc.repos)) {
350
+ throw new WorkspaceConfigError(
351
+ "WORKSPACE_SCHEMA_INVALID",
352
+ "Workspace config must contain a 'repos' mapping.",
353
+ undefined,
354
+ configFile,
355
+ );
356
+ }
357
+ if (!doc.routing || typeof doc.routing !== "object" || Array.isArray(doc.routing)) {
358
+ throw new WorkspaceConfigError(
359
+ "WORKSPACE_SCHEMA_INVALID",
360
+ "Workspace config must contain a 'routing' mapping.",
361
+ undefined,
362
+ configFile,
363
+ );
364
+ }
365
+
366
+ // ── 5. Repos map non-empty ───────────────────────────────────
367
+ const rawRepos = doc.repos as Record<string, unknown>;
368
+ const repoKeys = Object.keys(rawRepos).sort(); // deterministic order
369
+ if (repoKeys.length === 0) {
370
+ throw new WorkspaceConfigError(
371
+ "WORKSPACE_MISSING_REPOS",
372
+ "Workspace config must define at least one repo under 'repos'.",
373
+ undefined,
374
+ configFile,
375
+ );
376
+ }
377
+
378
+ // ── 6. Per-repo validation ───────────────────────────────────
379
+ const repos = new Map<string, WorkspaceRepoConfig>();
380
+ const normalizedPaths = new Map<string, string>(); // normalized → repoId (for duplicate detection)
381
+
382
+ for (const repoId of repoKeys) {
383
+ const rawRepo = rawRepos[repoId];
384
+ if (rawRepo == null || typeof rawRepo !== "object" || Array.isArray(rawRepo)) {
385
+ throw new WorkspaceConfigError(
386
+ "WORKSPACE_SCHEMA_INVALID",
387
+ `Repo '${repoId}' must be a YAML mapping with at least a 'path' field.`,
388
+ repoId,
389
+ configFile,
390
+ );
391
+ }
392
+ const repoEntry = rawRepo as Record<string, unknown>;
393
+
394
+ // 6a. path present and non-empty
395
+ const rawPath = repoEntry.path;
396
+ if (!rawPath || typeof rawPath !== "string" || rawPath.trim() === "") {
397
+ throw new WorkspaceConfigError(
398
+ "WORKSPACE_REPO_PATH_MISSING",
399
+ `Repo '${repoId}' is missing a 'path' field.`,
400
+ repoId,
401
+ configFile,
402
+ );
403
+ }
404
+
405
+ // 6b. path exists on disk
406
+ const absolutePath = resolveAbsolutePath(rawPath.trim(), workspaceRoot);
407
+ const normalizedPath = canonicalizePath(rawPath.trim(), workspaceRoot);
408
+ if (!existsSync(absolutePath)) {
409
+ throw new WorkspaceConfigError(
410
+ "WORKSPACE_REPO_PATH_NOT_FOUND",
411
+ `Repo '${repoId}' path does not exist: ${absolutePath}`,
412
+ repoId,
413
+ absolutePath,
414
+ );
415
+ }
416
+
417
+ // 6c. path is a git repo root
418
+ const gitDirCheck = runGit(["rev-parse", "--git-dir"], absolutePath);
419
+ if (!gitDirCheck.ok) {
420
+ throw new WorkspaceConfigError(
421
+ "WORKSPACE_REPO_NOT_GIT",
422
+ `Repo '${repoId}' path is not a git repository: ${absolutePath}`,
423
+ repoId,
424
+ absolutePath,
425
+ );
426
+ }
427
+ // Verify we're at the root, not a subdirectory
428
+ const toplevelCheck = runGit(["rev-parse", "--show-toplevel"], absolutePath);
429
+ if (toplevelCheck.ok) {
430
+ const toplevelNormalized = canonicalizePath(toplevelCheck.stdout.trim(), "");
431
+ if (toplevelNormalized !== normalizedPath) {
432
+ throw new WorkspaceConfigError(
433
+ "WORKSPACE_REPO_NOT_GIT",
434
+ `Repo '${repoId}' path is a subdirectory of a git repo, not the repo root. Expected root: ${toplevelCheck.stdout.trim()}, got: ${absolutePath}`,
435
+ repoId,
436
+ absolutePath,
437
+ );
438
+ }
439
+ }
440
+
441
+ // 7. Collect for duplicate detection (checked after loop)
442
+ if (normalizedPaths.has(normalizedPath)) {
443
+ throw new WorkspaceConfigError(
444
+ "WORKSPACE_DUPLICATE_REPO_PATH",
445
+ `Repos '${normalizedPaths.get(normalizedPath)}' and '${repoId}' share the same path: ${absolutePath}`,
446
+ repoId,
447
+ absolutePath,
448
+ );
449
+ }
450
+ normalizedPaths.set(normalizedPath, repoId);
451
+
452
+ // Build repo config
453
+ const defaultBranch =
454
+ typeof repoEntry.default_branch === "string" && repoEntry.default_branch.trim()
455
+ ? repoEntry.default_branch.trim()
456
+ : undefined;
457
+
458
+ repos.set(repoId, {
459
+ id: repoId,
460
+ path: absolutePath,
461
+ defaultBranch,
462
+ });
463
+ }
464
+
465
+ // ── 8–11. Routing validation ─────────────────────────────────
466
+ const rawRouting = doc.routing as Record<string, unknown>;
467
+
468
+ // 8. routing.tasks_root present
469
+ const rawTasksRoot = rawRouting.tasks_root;
470
+ if (!rawTasksRoot || typeof rawTasksRoot !== "string" || rawTasksRoot.trim() === "") {
471
+ throw new WorkspaceConfigError(
472
+ "WORKSPACE_MISSING_TASKS_ROOT",
473
+ "Workspace config 'routing.tasks_root' is missing or empty.",
474
+ undefined,
475
+ configFile,
476
+ );
477
+ }
478
+
479
+ // 9. routing.tasks_root exists on disk
480
+ const tasksRootAbsolute = resolveAbsolutePath(rawTasksRoot.trim(), workspaceRoot);
481
+ if (!existsSync(tasksRootAbsolute)) {
482
+ throw new WorkspaceConfigError(
483
+ "WORKSPACE_TASKS_ROOT_NOT_FOUND",
484
+ `routing.tasks_root path does not exist: ${tasksRootAbsolute}`,
485
+ undefined,
486
+ tasksRootAbsolute,
487
+ );
488
+ }
489
+
490
+ // 10. routing.default_repo present
491
+ const rawDefaultRepo = rawRouting.default_repo;
492
+ if (!rawDefaultRepo || typeof rawDefaultRepo !== "string" || rawDefaultRepo.trim() === "") {
493
+ throw new WorkspaceConfigError(
494
+ "WORKSPACE_MISSING_DEFAULT_REPO",
495
+ "Workspace config 'routing.default_repo' is missing or empty.",
496
+ undefined,
497
+ configFile,
498
+ );
499
+ }
500
+
501
+ // 11. routing.default_repo references a valid repo ID
502
+ const defaultRepoId = rawDefaultRepo.trim();
503
+ if (!repos.has(defaultRepoId)) {
504
+ throw new WorkspaceConfigError(
505
+ "WORKSPACE_DEFAULT_REPO_NOT_FOUND",
506
+ `routing.default_repo '${defaultRepoId}' does not match any repo ID. Available repos: ${Array.from(repos.keys()).join(", ")}`,
507
+ undefined,
508
+ configFile,
509
+ );
510
+ }
511
+
512
+ // 12. routing.task_packet_repo (required by v1 contract)
513
+ // Compatibility policy: if omitted, default to routing.default_repo and
514
+ // emit a warning so legacy configs remain deterministic.
515
+ const hasTaskPacketRepo = Object.prototype.hasOwnProperty.call(rawRouting, "task_packet_repo");
516
+ const rawTaskPacketRepo = rawRouting.task_packet_repo;
517
+ let taskPacketRepoId = defaultRepoId;
518
+
519
+ if (hasTaskPacketRepo) {
520
+ if (typeof rawTaskPacketRepo !== "string" || rawTaskPacketRepo.trim() === "") {
521
+ throw new WorkspaceConfigError(
522
+ "WORKSPACE_SCHEMA_INVALID",
523
+ "Workspace config 'routing.task_packet_repo' must be a non-empty string when provided.",
524
+ undefined,
525
+ configFile,
526
+ );
527
+ }
528
+ taskPacketRepoId = rawTaskPacketRepo.trim();
529
+ } else {
530
+ console.error(
531
+ `[orchid] workspace compatibility: 'routing.task_packet_repo' is missing in ${configFile}; defaulting to routing.default_repo ('${defaultRepoId}'). Add 'routing.task_packet_repo' explicitly.`,
532
+ );
533
+ }
534
+
535
+ if (!repos.has(taskPacketRepoId)) {
536
+ throw new WorkspaceConfigError(
537
+ "WORKSPACE_TASK_PACKET_REPO_NOT_FOUND",
538
+ `routing.task_packet_repo '${taskPacketRepoId}' does not match any repo ID. Available repos: ${Array.from(repos.keys()).join(", ")}`,
539
+ undefined,
540
+ configFile,
541
+ );
542
+ }
543
+
544
+ // 13. tasks_root must be inside repos[task_packet_repo].path
545
+ const packetRepoPath = repos.get(taskPacketRepoId)!.path;
546
+ if (!isPathWithinContainer(tasksRootAbsolute, packetRepoPath)) {
547
+ throw new WorkspaceConfigError(
548
+ "WORKSPACE_TASKS_ROOT_OUTSIDE_PACKET_REPO",
549
+ `routing.tasks_root '${tasksRootAbsolute}' must be inside packet-home repo '${taskPacketRepoId}' (${packetRepoPath}). Update routing.tasks_root or routing.task_packet_repo.`,
550
+ undefined,
551
+ tasksRootAbsolute,
552
+ );
553
+ }
554
+
555
+ // ── 14. routing.strict (optional boolean, default false) ─────
556
+ const rawStrict = rawRouting.strict;
557
+ if (rawStrict !== undefined) {
558
+ // null (from bare `strict:` or `strict: null` in YAML) is rejected
559
+ // to prevent fail-open: governance controls must be explicit.
560
+ if (rawStrict === null || typeof rawStrict !== "boolean") {
561
+ throw new WorkspaceConfigError(
562
+ "WORKSPACE_SCHEMA_INVALID",
563
+ `routing.strict must be a boolean (true/false)${rawStrict === null ? ", got null (use true or false explicitly)" : `, got ${typeof rawStrict}: ${JSON.stringify(rawStrict)}`}`,
564
+ undefined,
565
+ configFile,
566
+ );
567
+ }
568
+ }
569
+ const strict = rawStrict === true;
570
+
571
+ // ── Build routing config ─────────────────────────────────────
572
+ const routing: WorkspaceRoutingConfig = {
573
+ tasksRoot: tasksRootAbsolute,
574
+ defaultRepo: defaultRepoId,
575
+ taskPacketRepo: taskPacketRepoId,
576
+ ...(strict ? { strict: true } : {}),
577
+ };
578
+
579
+ // ── Build and return WorkspaceConfig ─────────────────────────
580
+ return {
581
+ mode: "workspace",
582
+ repos,
583
+ routing,
584
+ configPath: configFile,
585
+ };
586
+ }
587
+
588
+ // ── Cross-Config Validation ─────────────────────────────────────────
589
+
590
+ /**
591
+ * Enforce that every configured task area resolves inside workspace routing.tasksRoot.
592
+ *
593
+ * This is a cross-config invariant and therefore runs after both workspace and
594
+ * task-runner configs are loaded.
595
+ */
596
+ export function validateTaskAreasWithinTasksRoot(
597
+ workspaceRoot: string,
598
+ workspaceConfig: WorkspaceConfig,
599
+ taskRunnerConfig: import("./types.ts").TaskRunnerConfig,
600
+ ): void {
601
+ const tasksRoot = workspaceConfig.routing.tasksRoot;
602
+ const areaEntries = Object.entries(taskRunnerConfig.task_areas ?? {}).sort((a, b) =>
603
+ a[0].localeCompare(b[0]),
604
+ );
605
+
606
+ for (const [areaName, area] of areaEntries) {
607
+ const areaPathRaw = (area?.path ?? "").trim();
608
+ const areaAbsolute = resolveAbsolutePath(areaPathRaw, workspaceRoot);
609
+ if (!isPathWithinContainer(areaAbsolute, tasksRoot)) {
610
+ throw new WorkspaceConfigError(
611
+ "WORKSPACE_TASK_AREA_OUTSIDE_TASKS_ROOT",
612
+ `Task area '${areaName}' path '${areaAbsolute}' must be inside routing.tasks_root '${tasksRoot}'. Move the area under tasks_root or update task_areas.${areaName}.path.`,
613
+ undefined,
614
+ areaAbsolute,
615
+ );
616
+ }
617
+ }
618
+ }
619
+
620
+ // ── Execution Context Builder ────────────────────────────────────────
621
+
622
+ /**
623
+ * Build an ExecutionContext from the current working directory.
624
+ *
625
+ * This is the top-level entry point for Step 2 (wire orchestrator startup).
626
+ * It loads all configs, detects workspace mode, and returns a unified context.
627
+ *
628
+ * @param cwd - Current working directory
629
+ * @param loadOrchConfig - Orchestrator config loader (for testability)
630
+ * @param loadTaskConfig - Task runner config loader (for testability)
631
+ * @returns ExecutionContext ready for orchestrator consumption
632
+ * @throws WorkspaceConfigError if workspace config is present but invalid,
633
+ * or when no workspace config exists and `cwd` is not a git repository.
634
+ */
635
+ function isInsideGitRepo(cwd: string): boolean {
636
+ const probe = runGit(["rev-parse", "--is-inside-work-tree"], cwd);
637
+ return probe.ok && probe.stdout.trim() === "true";
638
+ }
639
+
640
+ export function buildExecutionContext(
641
+ cwd: string,
642
+ loadOrchConfig: (
643
+ root: string,
644
+ pointerConfigRoot?: string,
645
+ ) => import("./types.ts").OrchestratorConfig,
646
+ loadTaskConfig: (
647
+ root: string,
648
+ pointerConfigRoot?: string,
649
+ ) => import("./types.ts").TaskRunnerConfig,
650
+ ): import("./types.ts").ExecutionContext {
651
+ const workspaceConfig = loadWorkspaceConfig(cwd);
652
+
653
+ if (workspaceConfig === null) {
654
+ // Deterministic mode guard: without workspace config, repo mode is only
655
+ // valid when cwd is a git repository.
656
+ if (!isInsideGitRepo(cwd)) {
657
+ const wsConfigFile = workspaceConfigPath(cwd);
658
+ throw new WorkspaceConfigError(
659
+ "WORKSPACE_SETUP_REQUIRED",
660
+ `No workspace config found at ${wsConfigFile}, and current directory is not a git repository: ${cwd}. ` +
661
+ `Run OrchID from a git repository, or create ${wsConfigFile} (orchid init) to use workspace mode.`,
662
+ undefined,
663
+ cwd,
664
+ );
665
+ }
666
+
667
+ // Repo mode: pointer is ignored entirely. Config loads from cwd.
668
+ const orchestratorConfig = loadOrchConfig(cwd);
669
+ const taskRunnerConfig = loadTaskConfig(cwd);
670
+
671
+ return {
672
+ workspaceRoot: cwd,
673
+ repoRoot: cwd,
674
+ mode: "repo",
675
+ workspaceConfig: null,
676
+ orchestratorConfig,
677
+ taskRunnerConfig,
678
+ pointer: null,
679
+ };
680
+ }
681
+
682
+ // Workspace mode: resolve pointer once, pass configRoot to config loaders.
683
+ const pointer = resolvePointer(cwd, workspaceConfig);
684
+
685
+ // Log pointer warning once at startup (non-fatal).
686
+ if (pointer && pointer.warning) {
687
+ console.error(`[orchid] pointer warning: ${pointer.warning}`);
688
+ }
689
+
690
+ const pointerConfigRoot = pointer?.configRoot;
691
+ const orchestratorConfig = loadOrchConfig(cwd, pointerConfigRoot);
692
+ const taskRunnerConfig = loadTaskConfig(cwd, pointerConfigRoot);
693
+
694
+ // Cross-config invariant: every task-area path must live under routing.tasks_root.
695
+ validateTaskAreasWithinTasksRoot(cwd, workspaceConfig, taskRunnerConfig);
696
+
697
+ const defaultRepo = workspaceConfig.repos.get(workspaceConfig.routing.defaultRepo)!;
698
+ return {
699
+ workspaceRoot: cwd,
700
+ repoRoot: defaultRepo.path,
701
+ mode: "workspace",
702
+ workspaceConfig,
703
+ orchestratorConfig,
704
+ taskRunnerConfig,
705
+ pointer,
706
+ };
707
+ }