@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.
- package/CHANGELOG.md +41 -0
- package/LICENSE +21 -0
- package/README.md +246 -0
- package/agents/AGENTS-MANIFEST.md +42 -0
- package/agents/brain.md +42 -0
- package/agents/context-builder.md +46 -0
- package/agents/delegate.md +12 -0
- package/agents/dev-1.md +42 -0
- package/agents/oracle.md +73 -0
- package/agents/planner.md +55 -0
- package/agents/researcher.md +52 -0
- package/agents/reviewer.md +79 -0
- package/agents/scout.md +50 -0
- package/agents/tester.md +45 -0
- package/agents/worker.md +55 -0
- package/extensions/ralph.ts +1 -0
- package/extensions/reviewer-extension.ts +125 -0
- package/extensions/task-orchestrator.ts +28 -0
- package/package.json +63 -0
- package/prompts/gather-context-and-clarify.md +13 -0
- package/prompts/parallel-cleanup.md +59 -0
- package/prompts/parallel-context-build.md +53 -0
- package/prompts/parallel-handoff-plan.md +59 -0
- package/prompts/parallel-research.md +50 -0
- package/prompts/parallel-review.md +54 -0
- package/prompts/review-loop.md +41 -0
- package/skills/orchid/SKILL.md +214 -0
- package/skills/orchid/orchid-cleanup/SKILL.md +122 -0
- package/skills/orchid/orchid-converge/SKILL.md +124 -0
- package/skills/orchid/orchid-decompose/SKILL.md +201 -0
- package/skills/orchid/orchid-doctor/SKILL.md +162 -0
- package/skills/orchid/orchid-investigate/SKILL.md +102 -0
- package/skills/orchid/orchid-launch/SKILL.md +147 -0
- package/skills/ralph/SKILL.md +73 -0
- package/skills/subagents/pi-subagents/SKILL.md +813 -0
- package/src/index.ts +7 -0
- package/src/orchestrator/abort.ts +534 -0
- package/src/orchestrator/agent-bridge-extension.ts +1020 -0
- package/src/orchestrator/agent-host.ts +954 -0
- package/src/orchestrator/cleanup.ts +776 -0
- package/src/orchestrator/config-loader.ts +1412 -0
- package/src/orchestrator/config-schema.ts +690 -0
- package/src/orchestrator/config.ts +81 -0
- package/src/orchestrator/context-window.ts +66 -0
- package/src/orchestrator/diagnostic-reports.ts +475 -0
- package/src/orchestrator/diagnostics.ts +394 -0
- package/src/orchestrator/discovery.ts +1833 -0
- package/src/orchestrator/engine-worker.ts +415 -0
- package/src/orchestrator/engine.ts +5940 -0
- package/src/orchestrator/execution.ts +3104 -0
- package/src/orchestrator/extension.ts +5934 -0
- package/src/orchestrator/formatting.ts +785 -0
- package/src/orchestrator/git.ts +88 -0
- package/src/orchestrator/index.ts +28 -0
- package/src/orchestrator/lane-runner.ts +1787 -0
- package/src/orchestrator/mailbox.ts +780 -0
- package/src/orchestrator/merge.ts +3414 -0
- package/src/orchestrator/messages.ts +1062 -0
- package/src/orchestrator/migrations.ts +278 -0
- package/src/orchestrator/naming.ts +117 -0
- package/src/orchestrator/path-resolver.ts +275 -0
- package/src/orchestrator/persistence.ts +2625 -0
- package/src/orchestrator/process-registry.ts +452 -0
- package/src/orchestrator/quality-gate.ts +1085 -0
- package/src/orchestrator/resume.ts +3488 -0
- package/src/orchestrator/sessions.ts +57 -0
- package/src/orchestrator/settings-loader.ts +136 -0
- package/src/orchestrator/settings-tui.ts +2208 -0
- package/src/orchestrator/sidecar-telemetry.ts +267 -0
- package/src/orchestrator/supervisor.ts +4548 -0
- package/src/orchestrator/task-executor-core.ts +675 -0
- package/src/orchestrator/tmux-compat.ts +37 -0
- package/src/orchestrator/tool-allowlist-constants.ts +37 -0
- package/src/orchestrator/types.ts +4465 -0
- package/src/orchestrator/verification.ts +547 -0
- package/src/orchestrator/waves.ts +1564 -0
- package/src/orchestrator/workspace.ts +707 -0
- package/src/orchestrator/worktree.ts +2725 -0
- package/src/ralph/index.ts +825 -0
- package/src/subagents/agents/agent-management.ts +648 -0
- package/src/subagents/agents/agent-scope.ts +6 -0
- package/src/subagents/agents/agent-selection.ts +23 -0
- package/src/subagents/agents/agent-serializer.ts +86 -0
- package/src/subagents/agents/agents.ts +832 -0
- package/src/subagents/agents/chain-serializer.ts +137 -0
- package/src/subagents/agents/frontmatter.ts +29 -0
- package/src/subagents/agents/identity.ts +30 -0
- package/src/subagents/agents/skills.ts +632 -0
- package/src/subagents/extension/config.ts +16 -0
- package/src/subagents/extension/control-notices.ts +92 -0
- package/src/subagents/extension/doctor.ts +199 -0
- package/src/subagents/extension/fanout-child.ts +170 -0
- package/src/subagents/extension/index.ts +573 -0
- package/src/subagents/extension/schemas.ts +168 -0
- package/src/subagents/intercom/intercom-bridge.ts +379 -0
- package/src/subagents/intercom/result-intercom.ts +377 -0
- package/src/subagents/runs/background/async-execution.ts +712 -0
- package/src/subagents/runs/background/async-job-tracker.ts +310 -0
- package/src/subagents/runs/background/async-resume.ts +345 -0
- package/src/subagents/runs/background/async-status.ts +325 -0
- package/src/subagents/runs/background/completion-dedupe.ts +63 -0
- package/src/subagents/runs/background/notify.ts +108 -0
- package/src/subagents/runs/background/parallel-groups.ts +45 -0
- package/src/subagents/runs/background/result-watcher.ts +307 -0
- package/src/subagents/runs/background/run-id-resolver.ts +83 -0
- package/src/subagents/runs/background/run-status.ts +269 -0
- package/src/subagents/runs/background/stale-run-reconciler.ts +336 -0
- package/src/subagents/runs/background/subagent-runner.ts +1808 -0
- package/src/subagents/runs/background/top-level-async.ts +13 -0
- package/src/subagents/runs/foreground/chain-clarify.ts +1333 -0
- package/src/subagents/runs/foreground/chain-execution.ts +938 -0
- package/src/subagents/runs/foreground/execution.ts +918 -0
- package/src/subagents/runs/foreground/subagent-executor.ts +2527 -0
- package/src/subagents/runs/shared/completion-guard.ts +147 -0
- package/src/subagents/runs/shared/long-running-guard.ts +175 -0
- package/src/subagents/runs/shared/mcp-direct-tool-allowlist.ts +365 -0
- package/src/subagents/runs/shared/model-fallback.ts +103 -0
- package/src/subagents/runs/shared/nested-events.ts +819 -0
- package/src/subagents/runs/shared/nested-path.ts +52 -0
- package/src/subagents/runs/shared/nested-render.ts +115 -0
- package/src/subagents/runs/shared/parallel-utils.ts +109 -0
- package/src/subagents/runs/shared/pi-args.ts +220 -0
- package/src/subagents/runs/shared/pi-spawn.ts +115 -0
- package/src/subagents/runs/shared/run-history.ts +60 -0
- package/src/subagents/runs/shared/single-output.ts +164 -0
- package/src/subagents/runs/shared/subagent-control.ts +226 -0
- package/src/subagents/runs/shared/subagent-prompt-runtime.ts +170 -0
- package/src/subagents/runs/shared/worktree.ts +577 -0
- package/src/subagents/shared/artifacts.ts +98 -0
- package/src/subagents/shared/atomic-json.ts +16 -0
- package/src/subagents/shared/file-coalescer.ts +40 -0
- package/src/subagents/shared/fork-context.ts +76 -0
- package/src/subagents/shared/formatters.ts +133 -0
- package/src/subagents/shared/jsonl-writer.ts +81 -0
- package/src/subagents/shared/model-info.ts +78 -0
- package/src/subagents/shared/post-exit-stdio-guard.ts +85 -0
- package/src/subagents/shared/session-identity.ts +10 -0
- package/src/subagents/shared/session-tokens.ts +44 -0
- package/src/subagents/shared/settings.ts +397 -0
- package/src/subagents/shared/status-format.ts +49 -0
- package/src/subagents/shared/types.ts +822 -0
- package/src/subagents/shared/utils.ts +450 -0
- package/src/subagents/slash/prompt-template-bridge.ts +397 -0
- package/src/subagents/slash/slash-bridge.ts +174 -0
- package/src/subagents/slash/slash-commands.ts +528 -0
- package/src/subagents/slash/slash-live-state.ts +292 -0
- package/src/subagents/tui/render-helpers.ts +80 -0
- package/src/subagents/tui/render.ts +1358 -0
- package/templates/agents/local/supervisor.md +33 -0
- package/templates/agents/local/task-merger.md +27 -0
- package/templates/agents/local/task-reviewer.md +30 -0
- package/templates/agents/local/task-worker.md +34 -0
- package/templates/agents/supervisor-routing.md +92 -0
- package/templates/agents/supervisor.md +229 -0
- package/templates/agents/task-merger.md +214 -0
- package/templates/agents/task-reviewer.md +260 -0
- package/templates/agents/task-worker-segment.md +44 -0
- package/templates/agents/task-worker.md +557 -0
- package/templates/tasks/CONTEXT.md +30 -0
- package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -0
- package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -0
- package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -0
- 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
|
+
}
|