@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,1412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified config loader for orchid-config.json with YAML fallback.
|
|
3
|
+
*
|
|
4
|
+
* Effective precedence:
|
|
5
|
+
* 1. Schema defaults (internal)
|
|
6
|
+
* 2. Global preferences (`~/.pi/agent.orchid/preferences.json`)
|
|
7
|
+
* 3. Project overrides (`orchid-config.json` or YAML fallback)
|
|
8
|
+
*
|
|
9
|
+
* Project config is treated as sparse overrides. Missing project fields
|
|
10
|
+
* fall through to global preferences, then schema defaults.
|
|
11
|
+
*
|
|
12
|
+
* Global preferences parsing is allowlist-based. Unknown top-level keys are
|
|
13
|
+
* ignored, and malformed preferences fall back to defaults silently.
|
|
14
|
+
*
|
|
15
|
+
* Path resolution:
|
|
16
|
+
* Resolves config paths relative to `configRoot`. Callers should pass
|
|
17
|
+
* the project root (or TASKPLANE_WORKSPACE_ROOT fallback) as `configRoot`.
|
|
18
|
+
*
|
|
19
|
+
* All returned objects are deep-cloned from defaults — no cross-call mutation.
|
|
20
|
+
*
|
|
21
|
+
* @module config/loader
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "fs";
|
|
25
|
+
import { join } from "path";
|
|
26
|
+
import { homedir } from "os";
|
|
27
|
+
import { parse as yamlParse } from "yaml";
|
|
28
|
+
import { resolvePointer, loadWorkspaceConfig } from "./workspace.ts";
|
|
29
|
+
import type { PointerResolution } from "./types.ts";
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
CONFIG_VERSION,
|
|
33
|
+
PROJECT_CONFIG_FILENAME,
|
|
34
|
+
DEFAULT_PROJECT_CONFIG,
|
|
35
|
+
DEFAULT_GLOBAL_PREFERENCES,
|
|
36
|
+
DEFAULT_BOOTSTRAP_GLOBAL_PREFERENCES,
|
|
37
|
+
GLOBAL_PREFERENCES_FILENAME,
|
|
38
|
+
GLOBAL_PREFERENCES_SUBDIR,
|
|
39
|
+
} from "./config-schema.ts";
|
|
40
|
+
import type {
|
|
41
|
+
TaskplaneConfig,
|
|
42
|
+
TaskRunnerSection,
|
|
43
|
+
OrchestratorSection,
|
|
44
|
+
WorkspaceSectionConfig,
|
|
45
|
+
GlobalPreferences,
|
|
46
|
+
DeepPartial,
|
|
47
|
+
} from "./config-schema.ts";
|
|
48
|
+
|
|
49
|
+
// ── Error Types ──────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Error codes for config loading failures.
|
|
53
|
+
*
|
|
54
|
+
* - CONFIG_JSON_MALFORMED: File exists but is not valid JSON
|
|
55
|
+
* - CONFIG_VERSION_UNSUPPORTED: configVersion is not supported by this version
|
|
56
|
+
* - CONFIG_VERSION_MISSING: configVersion field is missing from JSON
|
|
57
|
+
* - CONFIG_LEGACY_FIELD: removed TMUX-era field/value detected; migration required
|
|
58
|
+
*/
|
|
59
|
+
export type ConfigLoadErrorCode =
|
|
60
|
+
| "CONFIG_JSON_MALFORMED"
|
|
61
|
+
| "CONFIG_VERSION_UNSUPPORTED"
|
|
62
|
+
| "CONFIG_VERSION_MISSING"
|
|
63
|
+
| "CONFIG_LEGACY_FIELD";
|
|
64
|
+
|
|
65
|
+
export class ConfigLoadError extends Error {
|
|
66
|
+
code: ConfigLoadErrorCode;
|
|
67
|
+
|
|
68
|
+
constructor(code: ConfigLoadErrorCode, message: string) {
|
|
69
|
+
super(message);
|
|
70
|
+
this.name = "ConfigLoadError";
|
|
71
|
+
this.code = code;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Deep Clone Helper ────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
/** Deep clone a config object to avoid cross-call mutation. */
|
|
78
|
+
function deepClone<T>(obj: T): T {
|
|
79
|
+
return JSON.parse(JSON.stringify(obj));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Deep Merge Helper ────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Deep merge `source` into `target`. Arrays are replaced, not merged.
|
|
86
|
+
* Only merges plain objects (not arrays, dates, etc).
|
|
87
|
+
* Returns `target` for chaining.
|
|
88
|
+
*/
|
|
89
|
+
function deepMerge<T extends Record<string, any>>(target: T, source: Record<string, any>): T {
|
|
90
|
+
for (const key of Object.keys(source)) {
|
|
91
|
+
const srcVal = source[key];
|
|
92
|
+
const tgtVal = (target as any)[key];
|
|
93
|
+
if (
|
|
94
|
+
srcVal !== null &&
|
|
95
|
+
srcVal !== undefined &&
|
|
96
|
+
typeof srcVal === "object" &&
|
|
97
|
+
!Array.isArray(srcVal) &&
|
|
98
|
+
tgtVal !== null &&
|
|
99
|
+
tgtVal !== undefined &&
|
|
100
|
+
typeof tgtVal === "object" &&
|
|
101
|
+
!Array.isArray(tgtVal)
|
|
102
|
+
) {
|
|
103
|
+
deepMerge(tgtVal, srcVal);
|
|
104
|
+
} else if (srcVal !== undefined) {
|
|
105
|
+
(target as any)[key] = srcVal;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return target;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function hasOwn(obj: unknown, key: string): boolean {
|
|
112
|
+
return !!obj && typeof obj === "object" && Object.prototype.hasOwnProperty.call(obj, key);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizeInheritAlias(value: string): string {
|
|
116
|
+
return value.trim().toLowerCase() === "inherit" ? "" : value;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Normalize explicit "inherit" aliases to empty-string inheritance semantics.
|
|
121
|
+
*
|
|
122
|
+
* Empty string is the canonical value meaning "inherit from active session"
|
|
123
|
+
* for per-agent model/thinking overrides.
|
|
124
|
+
*/
|
|
125
|
+
function normalizeInheritanceAliases(config: TaskplaneConfig): void {
|
|
126
|
+
const normalizeField = (obj: Record<string, any>, key: string) => {
|
|
127
|
+
if (typeof obj[key] === "string") {
|
|
128
|
+
obj[key] = normalizeInheritAlias(obj[key]);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
normalizeField(config.taskRunner.worker as Record<string, any>, "model");
|
|
133
|
+
normalizeField(config.taskRunner.worker as Record<string, any>, "thinking");
|
|
134
|
+
normalizeField(config.taskRunner.reviewer as Record<string, any>, "model");
|
|
135
|
+
normalizeField(config.taskRunner.reviewer as Record<string, any>, "thinking");
|
|
136
|
+
normalizeField(config.orchestrator.merge as Record<string, any>, "model");
|
|
137
|
+
normalizeField(config.orchestrator.merge as Record<string, any>, "thinking");
|
|
138
|
+
normalizeField(config.orchestrator.supervisor as Record<string, any>, "model");
|
|
139
|
+
normalizeField(config.taskRunner.qualityGate as Record<string, any>, "reviewModel");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// throwLegacyFieldError removed — replaced by auto-migration functions that fix config in-place
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Auto-migrate legacy TMUX fields in project config.
|
|
146
|
+
* Renames fields in-place and writes back to disk instead of crashing.
|
|
147
|
+
* @returns true if any migrations were applied
|
|
148
|
+
*/
|
|
149
|
+
/** Track whether project config migration has already run for this load cycle. */
|
|
150
|
+
let _projectMigrationDone = false;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Auto-migrate legacy TMUX fields in global preferences.
|
|
154
|
+
*
|
|
155
|
+
* Same precedence: new key wins if both exist.
|
|
156
|
+
* Writes back atomically (tmp + rename).
|
|
157
|
+
*
|
|
158
|
+
* @returns true if any migrations were applied
|
|
159
|
+
*/
|
|
160
|
+
function migrateGlobalPreferences(raw: Record<string, any>, prefsPath: string): boolean {
|
|
161
|
+
let migrated = false;
|
|
162
|
+
if (hasOwn(raw, "tmuxPrefix")) {
|
|
163
|
+
if (!hasOwn(raw, "sessionPrefix") || raw.sessionPrefix === undefined) {
|
|
164
|
+
raw.sessionPrefix = raw.tmuxPrefix;
|
|
165
|
+
}
|
|
166
|
+
delete raw.tmuxPrefix;
|
|
167
|
+
console.error(`[orchid] Auto-migrated global preference: tmuxPrefix → sessionPrefix`);
|
|
168
|
+
migrated = true;
|
|
169
|
+
}
|
|
170
|
+
if (raw.spawnMode === "tmux") {
|
|
171
|
+
raw.spawnMode = "subprocess";
|
|
172
|
+
console.error(`[orchid] Auto-migrated global preference: spawnMode "tmux" → "subprocess"`);
|
|
173
|
+
migrated = true;
|
|
174
|
+
}
|
|
175
|
+
if (raw.orchestrator?.orchestrator?.spawnMode === "tmux") {
|
|
176
|
+
raw.orchestrator.orchestrator.spawnMode = "subprocess";
|
|
177
|
+
console.error(
|
|
178
|
+
`[orchid] Auto-migrated global preference: orchestrator.orchestrator.spawnMode "tmux" → "subprocess"`,
|
|
179
|
+
);
|
|
180
|
+
migrated = true;
|
|
181
|
+
}
|
|
182
|
+
if (raw.taskRunner?.worker?.spawnMode === "tmux") {
|
|
183
|
+
raw.taskRunner.worker.spawnMode = "subprocess";
|
|
184
|
+
console.error(
|
|
185
|
+
`[orchid] Auto-migrated global preference: taskRunner.worker.spawnMode "tmux" → "subprocess"`,
|
|
186
|
+
);
|
|
187
|
+
migrated = true;
|
|
188
|
+
}
|
|
189
|
+
if (migrated) {
|
|
190
|
+
try {
|
|
191
|
+
const tmpPath = prefsPath + ".migration-tmp";
|
|
192
|
+
writeFileSync(tmpPath, JSON.stringify(raw, null, 2) + "\n");
|
|
193
|
+
renameSync(tmpPath, prefsPath);
|
|
194
|
+
console.error(`[orchid] Preferences file updated: ${prefsPath}`);
|
|
195
|
+
} catch (err) {
|
|
196
|
+
console.error(
|
|
197
|
+
`[orchid] Warning: could not persist preferences migration to disk: ${err instanceof Error ? err.message : err}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return migrated;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Reset migration guard (for testing). @internal */
|
|
205
|
+
export function _resetMigrationGuard(): void {
|
|
206
|
+
_projectMigrationDone = false;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── YAML snake_case → camelCase Mapping ──────────────────────────────
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Convert a snake_case key to camelCase.
|
|
213
|
+
* e.g., "max_worker_iterations" → "maxWorkerIterations"
|
|
214
|
+
*/
|
|
215
|
+
function snakeToCamel(s: string): string {
|
|
216
|
+
return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Convert structural keys from snake_case to camelCase, recursively.
|
|
221
|
+
* Used for sections where ALL keys are structural schema keys (no
|
|
222
|
+
* user-defined dictionary keys).
|
|
223
|
+
*/
|
|
224
|
+
function convertStructuralKeys(obj: any): any {
|
|
225
|
+
if (obj === null || obj === undefined) return obj;
|
|
226
|
+
if (Array.isArray(obj)) return obj.map(convertStructuralKeys);
|
|
227
|
+
if (typeof obj !== "object") return obj;
|
|
228
|
+
|
|
229
|
+
const result: Record<string, any> = {};
|
|
230
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
231
|
+
const camelKey = snakeToCamel(key);
|
|
232
|
+
if (val !== null && typeof val === "object" && !Array.isArray(val)) {
|
|
233
|
+
result[camelKey] = convertStructuralKeys(val);
|
|
234
|
+
} else if (Array.isArray(val)) {
|
|
235
|
+
result[camelKey] = val.map(convertStructuralKeys);
|
|
236
|
+
} else {
|
|
237
|
+
result[camelKey] = val;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Convert a record/dictionary section where outer keys are user-defined
|
|
245
|
+
* identifiers (preserve verbatim) but inner keys are structural (convert).
|
|
246
|
+
*/
|
|
247
|
+
function convertRecordSection(obj: any): any {
|
|
248
|
+
if (obj === null || obj === undefined) return obj;
|
|
249
|
+
if (typeof obj !== "object" || Array.isArray(obj)) return obj;
|
|
250
|
+
|
|
251
|
+
const result: Record<string, any> = {};
|
|
252
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
253
|
+
// Preserve user-defined key verbatim, convert structural inner keys
|
|
254
|
+
if (val !== null && typeof val === "object" && !Array.isArray(val)) {
|
|
255
|
+
result[key] = convertStructuralKeys(val);
|
|
256
|
+
} else {
|
|
257
|
+
result[key] = val;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return result;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Convert a flat record/dictionary where both keys and values are
|
|
265
|
+
* user-defined (preserve everything verbatim). Used for sections like
|
|
266
|
+
* `reference_docs`, `self_doc_targets`, `testing.commands` where
|
|
267
|
+
* keys are identifiers and values are strings.
|
|
268
|
+
*/
|
|
269
|
+
function preserveRecord(obj: any): any {
|
|
270
|
+
if (obj === null || obj === undefined) return obj;
|
|
271
|
+
if (typeof obj !== "object" || Array.isArray(obj)) return obj;
|
|
272
|
+
return { ...obj };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── Section-aware YAML mapping ───────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Map a raw task-runner YAML object to the camelCase TaskRunnerSection shape.
|
|
279
|
+
*
|
|
280
|
+
* Knows which sections contain user-defined record keys vs. structural keys:
|
|
281
|
+
* - Structural-only: project, paths, worker, reviewer, context, standards
|
|
282
|
+
* - Record with structural inner keys: task_areas, standards_overrides
|
|
283
|
+
* - Flat record (preserve all keys): testing.commands, reference_docs,
|
|
284
|
+
* self_doc_targets
|
|
285
|
+
* - Array (preserve): never_load, protected_docs
|
|
286
|
+
*/
|
|
287
|
+
function mapTaskRunnerYaml(raw: any): Partial<TaskRunnerSection> {
|
|
288
|
+
const result: any = {};
|
|
289
|
+
|
|
290
|
+
// Structural sections — all keys are schema-defined
|
|
291
|
+
if (raw.project) result.project = convertStructuralKeys(raw.project);
|
|
292
|
+
if (raw.paths) result.paths = convertStructuralKeys(raw.paths);
|
|
293
|
+
if (raw.worker) result.worker = convertStructuralKeys(raw.worker);
|
|
294
|
+
if (raw.reviewer) result.reviewer = convertStructuralKeys(raw.reviewer);
|
|
295
|
+
if (raw.context) result.context = convertStructuralKeys(raw.context);
|
|
296
|
+
if (raw.standards) result.standards = convertStructuralKeys(raw.standards);
|
|
297
|
+
|
|
298
|
+
// Testing: commands is a flat user-defined record
|
|
299
|
+
if (raw.testing) {
|
|
300
|
+
result.testing = {};
|
|
301
|
+
if (raw.testing.commands) {
|
|
302
|
+
result.testing.commands = preserveRecord(raw.testing.commands);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Record sections with structural inner keys
|
|
307
|
+
if (raw.task_areas) result.taskAreas = convertRecordSection(raw.task_areas);
|
|
308
|
+
if (raw.standards_overrides)
|
|
309
|
+
result.standardsOverrides = convertRecordSection(raw.standards_overrides);
|
|
310
|
+
|
|
311
|
+
// Flat record sections (keys are identifiers, values are strings)
|
|
312
|
+
if (raw.reference_docs) result.referenceDocs = preserveRecord(raw.reference_docs);
|
|
313
|
+
if (raw.self_doc_targets) result.selfDocTargets = preserveRecord(raw.self_doc_targets);
|
|
314
|
+
|
|
315
|
+
// Array sections (preserve verbatim)
|
|
316
|
+
if (raw.never_load) result.neverLoad = [...raw.never_load];
|
|
317
|
+
if (raw.protected_docs) result.protectedDocs = [...raw.protected_docs];
|
|
318
|
+
|
|
319
|
+
// Quality gate (structural — all keys are schema-defined)
|
|
320
|
+
if (raw.quality_gate) result.qualityGate = convertStructuralKeys(raw.quality_gate);
|
|
321
|
+
|
|
322
|
+
// Model fallback (scalar — "inherit" or "fail")
|
|
323
|
+
if (raw.model_fallback) result.modelFallback = raw.model_fallback;
|
|
324
|
+
|
|
325
|
+
return result;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Map a raw orchestrator YAML object to the camelCase OrchestratorSection shape.
|
|
330
|
+
*
|
|
331
|
+
* Knows which sections contain user-defined record keys:
|
|
332
|
+
* - Structural: orchestrator, dependencies, merge, failure, monitoring
|
|
333
|
+
* - Record with structural inner keys: (none)
|
|
334
|
+
* - Flat record (preserve keys): pre_warm.commands, assignment.size_weights
|
|
335
|
+
*/
|
|
336
|
+
function mapOrchestratorYaml(raw: any): Partial<OrchestratorSection> {
|
|
337
|
+
const result: any = {};
|
|
338
|
+
|
|
339
|
+
// Structural sections
|
|
340
|
+
if (raw.orchestrator) result.orchestrator = convertStructuralKeys(raw.orchestrator);
|
|
341
|
+
if (raw.dependencies) result.dependencies = convertStructuralKeys(raw.dependencies);
|
|
342
|
+
if (raw.merge) result.merge = convertStructuralKeys(raw.merge);
|
|
343
|
+
if (raw.failure) result.failure = convertStructuralKeys(raw.failure);
|
|
344
|
+
if (raw.monitoring) result.monitoring = convertStructuralKeys(raw.monitoring);
|
|
345
|
+
|
|
346
|
+
// assignment: strategy is structural, size_weights is a user-defined record
|
|
347
|
+
if (raw.assignment) {
|
|
348
|
+
result.assignment = {};
|
|
349
|
+
if (raw.assignment.strategy !== undefined) result.assignment.strategy = raw.assignment.strategy;
|
|
350
|
+
if (raw.assignment.size_weights)
|
|
351
|
+
result.assignment.sizeWeights = preserveRecord(raw.assignment.size_weights);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// pre_warm: auto_detect is structural, commands is user-defined, always is array
|
|
355
|
+
if (raw.pre_warm) {
|
|
356
|
+
result.preWarm = {};
|
|
357
|
+
if (raw.pre_warm.auto_detect !== undefined) result.preWarm.autoDetect = raw.pre_warm.auto_detect;
|
|
358
|
+
if (raw.pre_warm.commands) result.preWarm.commands = preserveRecord(raw.pre_warm.commands);
|
|
359
|
+
if (raw.pre_warm.always) result.preWarm.always = [...raw.pre_warm.always];
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// verification: all keys are structural (TP-032)
|
|
363
|
+
if (raw.verification) result.verification = convertStructuralKeys(raw.verification);
|
|
364
|
+
|
|
365
|
+
// supervisor: all keys are structural (TP-041)
|
|
366
|
+
if (raw.supervisor) result.supervisor = convertStructuralKeys(raw.supervisor);
|
|
367
|
+
|
|
368
|
+
return result;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Normalize a workspace section loaded from JSON/YAML into camelCase shape.
|
|
373
|
+
*
|
|
374
|
+
* Compatibility: if `routing.taskPacketRepo` is missing, defaults to
|
|
375
|
+
* `routing.defaultRepo` and emits a warning message.
|
|
376
|
+
*/
|
|
377
|
+
function normalizeWorkspaceSection(
|
|
378
|
+
rawWorkspace: any,
|
|
379
|
+
sourcePath: string,
|
|
380
|
+
): WorkspaceSectionConfig | undefined {
|
|
381
|
+
if (!rawWorkspace || typeof rawWorkspace !== "object" || Array.isArray(rawWorkspace)) {
|
|
382
|
+
return undefined;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const rawRepos = rawWorkspace.repos;
|
|
386
|
+
if (!rawRepos || typeof rawRepos !== "object" || Array.isArray(rawRepos)) {
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const rawRouting = rawWorkspace.routing;
|
|
391
|
+
if (!rawRouting || typeof rawRouting !== "object" || Array.isArray(rawRouting)) {
|
|
392
|
+
return undefined;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const repos: WorkspaceSectionConfig["repos"] = {};
|
|
396
|
+
for (const [repoId, repoVal] of Object.entries(rawRepos as Record<string, any>)) {
|
|
397
|
+
if (!repoVal || typeof repoVal !== "object" || Array.isArray(repoVal)) continue;
|
|
398
|
+
const repoObj = repoVal as Record<string, any>;
|
|
399
|
+
if (typeof repoObj.path !== "string" || repoObj.path.trim() === "") continue;
|
|
400
|
+
repos[repoId] = {
|
|
401
|
+
path: repoObj.path,
|
|
402
|
+
...(typeof repoObj.defaultBranch === "string" && repoObj.defaultBranch.trim()
|
|
403
|
+
? { defaultBranch: repoObj.defaultBranch }
|
|
404
|
+
: {}),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const defaultRepo =
|
|
409
|
+
typeof rawRouting.defaultRepo === "string" ? rawRouting.defaultRepo.trim() : "";
|
|
410
|
+
const tasksRoot = typeof rawRouting.tasksRoot === "string" ? rawRouting.tasksRoot.trim() : "";
|
|
411
|
+
let taskPacketRepo =
|
|
412
|
+
typeof rawRouting.taskPacketRepo === "string" ? rawRouting.taskPacketRepo.trim() : "";
|
|
413
|
+
|
|
414
|
+
if (!taskPacketRepo && defaultRepo) {
|
|
415
|
+
taskPacketRepo = defaultRepo;
|
|
416
|
+
console.error(
|
|
417
|
+
`[orchid] config compatibility: workspace.routing.taskPacketRepo is missing in ${sourcePath}; defaulting to workspace.routing.defaultRepo ('${defaultRepo}'). Add workspace.routing.taskPacketRepo explicitly.`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (!tasksRoot || !defaultRepo || !taskPacketRepo) {
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const strict = rawRouting.strict === true;
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
repos,
|
|
429
|
+
routing: {
|
|
430
|
+
tasksRoot,
|
|
431
|
+
defaultRepo,
|
|
432
|
+
taskPacketRepo,
|
|
433
|
+
...(strict ? { strict: true } : {}),
|
|
434
|
+
},
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ── Config File Path Resolution ──────────────────────────────────────
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Resolve the path to a config file under the given root.
|
|
442
|
+
*
|
|
443
|
+
* Supports two directory layouts:
|
|
444
|
+
* 1. Standard layout: `<root>/.pi/<filename>` — used by repo mode and
|
|
445
|
+
* workspace root, where config files live under the `.pi/` subdirectory.
|
|
446
|
+
* 2. Flat layout: `<root>/<filename>` — used by pointer-resolved config
|
|
447
|
+
* roots (e.g., `<configRepo>/.orchid/task-runner.yaml`), where
|
|
448
|
+
* `orchid init` scaffolds files directly in the config path.
|
|
449
|
+
*
|
|
450
|
+
* Standard layout is checked first for backward compatibility. If neither
|
|
451
|
+
* exists, returns the standard-layout path (callers check existence).
|
|
452
|
+
*/
|
|
453
|
+
function resolveConfigFilePath(configRoot: string, filename: string): string {
|
|
454
|
+
const standardPath = join(configRoot, ".pi", filename);
|
|
455
|
+
if (existsSync(standardPath)) return standardPath;
|
|
456
|
+
|
|
457
|
+
const flatPath = join(configRoot, filename);
|
|
458
|
+
if (existsSync(flatPath)) return flatPath;
|
|
459
|
+
|
|
460
|
+
// Default to standard path — callers handle non-existence
|
|
461
|
+
return standardPath;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ── JSON Loading ─────────────────────────────────────────────────────
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Attempt to load and validate `orchid-config.json`.
|
|
468
|
+
*
|
|
469
|
+
* Checks both standard layout (`<root>/.pi/orchid-config.json`) and
|
|
470
|
+
* flat layout (`<root>/orchid-config.json`) — see `resolveConfigFilePath`.
|
|
471
|
+
*
|
|
472
|
+
* Returns the parsed config or null if the file doesn't exist.
|
|
473
|
+
* Throws ConfigLoadError for malformed JSON or unsupported versions.
|
|
474
|
+
*/
|
|
475
|
+
function loadJsonConfig(configRoot: string): DeepPartial<TaskplaneConfig> | null {
|
|
476
|
+
const jsonPath = resolveConfigFilePath(configRoot, PROJECT_CONFIG_FILENAME);
|
|
477
|
+
if (!existsSync(jsonPath)) return null;
|
|
478
|
+
|
|
479
|
+
let raw: string;
|
|
480
|
+
try {
|
|
481
|
+
raw = readFileSync(jsonPath, "utf-8");
|
|
482
|
+
} catch {
|
|
483
|
+
return null; // Can't read file — treat as absent
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
let parsed: any;
|
|
487
|
+
try {
|
|
488
|
+
parsed = JSON.parse(raw);
|
|
489
|
+
} catch (e: any) {
|
|
490
|
+
throw new ConfigLoadError(
|
|
491
|
+
"CONFIG_JSON_MALFORMED",
|
|
492
|
+
`Failed to parse ${jsonPath}: ${e.message ?? "invalid JSON"}`,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Validate configVersion
|
|
497
|
+
if (parsed.configVersion === undefined || parsed.configVersion === null) {
|
|
498
|
+
throw new ConfigLoadError(
|
|
499
|
+
"CONFIG_VERSION_MISSING",
|
|
500
|
+
`${jsonPath} is missing required field "configVersion". ` +
|
|
501
|
+
`Expected configVersion: ${CONFIG_VERSION}.`,
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (parsed.configVersion !== CONFIG_VERSION) {
|
|
506
|
+
throw new ConfigLoadError(
|
|
507
|
+
"CONFIG_VERSION_UNSUPPORTED",
|
|
508
|
+
`${jsonPath} has configVersion ${parsed.configVersion}, but this version of OrchID ` +
|
|
509
|
+
`only supports configVersion ${CONFIG_VERSION}. Please upgrade OrchID.`,
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const overrides: DeepPartial<TaskplaneConfig> = {};
|
|
514
|
+
if (
|
|
515
|
+
parsed.taskRunner &&
|
|
516
|
+
typeof parsed.taskRunner === "object" &&
|
|
517
|
+
!Array.isArray(parsed.taskRunner)
|
|
518
|
+
) {
|
|
519
|
+
overrides.taskRunner = deepClone(parsed.taskRunner);
|
|
520
|
+
}
|
|
521
|
+
if (
|
|
522
|
+
parsed.orchestrator &&
|
|
523
|
+
typeof parsed.orchestrator === "object" &&
|
|
524
|
+
!Array.isArray(parsed.orchestrator)
|
|
525
|
+
) {
|
|
526
|
+
overrides.orchestrator = deepClone(parsed.orchestrator);
|
|
527
|
+
}
|
|
528
|
+
if (parsed.workspace) {
|
|
529
|
+
const normalizedWorkspace = normalizeWorkspaceSection(parsed.workspace, jsonPath);
|
|
530
|
+
if (normalizedWorkspace) {
|
|
531
|
+
overrides.workspace = normalizedWorkspace;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return overrides;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ── YAML Loading ─────────────────────────────────────────────────────
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Load task-runner settings from `task-runner.yaml`.
|
|
542
|
+
*
|
|
543
|
+
* Checks both standard layout (`<root>/.pi/task-runner.yaml`) and
|
|
544
|
+
* flat layout (`<root>/task-runner.yaml`) — see `resolveConfigFilePath`.
|
|
545
|
+
* Maps snake_case YAML keys to the camelCase TaskRunnerSection shape.
|
|
546
|
+
* Uses section-aware mapping that preserves user-defined record keys.
|
|
547
|
+
* Returns sparse overrides (empty object when missing/malformed).
|
|
548
|
+
*/
|
|
549
|
+
function loadTaskRunnerYaml(configRoot: string): Partial<TaskRunnerSection> {
|
|
550
|
+
const yamlPath = resolveConfigFilePath(configRoot, "task-runner.yaml");
|
|
551
|
+
if (!existsSync(yamlPath)) return {};
|
|
552
|
+
|
|
553
|
+
try {
|
|
554
|
+
const raw = readFileSync(yamlPath, "utf-8");
|
|
555
|
+
const loaded = yamlParse(raw) as any;
|
|
556
|
+
if (!loaded || typeof loaded !== "object") return {};
|
|
557
|
+
|
|
558
|
+
// Section-aware mapping: structural keys → camelCase, record keys → preserved
|
|
559
|
+
const mapped = mapTaskRunnerYaml(loaded);
|
|
560
|
+
|
|
561
|
+
// Post-process taskAreas: trim repoId, drop whitespace-only values
|
|
562
|
+
// (matches legacy loadTaskRunnerConfig behavior from config.ts)
|
|
563
|
+
if (mapped.taskAreas) {
|
|
564
|
+
for (const area of Object.values(mapped.taskAreas)) {
|
|
565
|
+
if (area.repoId !== undefined) {
|
|
566
|
+
const trimmed = typeof area.repoId === "string" ? area.repoId.trim() : "";
|
|
567
|
+
if (trimmed) {
|
|
568
|
+
area.repoId = trimmed;
|
|
569
|
+
} else {
|
|
570
|
+
delete area.repoId;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return mapped;
|
|
577
|
+
} catch {
|
|
578
|
+
return {};
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Load orchestrator settings from `task-orchestrator.yaml`.
|
|
584
|
+
*
|
|
585
|
+
* Checks both standard layout (`<root>/.pi/task-orchestrator.yaml`) and
|
|
586
|
+
* flat layout (`<root>/task-orchestrator.yaml`) — see `resolveConfigFilePath`.
|
|
587
|
+
* Maps snake_case YAML keys to the camelCase OrchestratorSection shape.
|
|
588
|
+
* Uses section-aware mapping that preserves user-defined record keys.
|
|
589
|
+
* Returns sparse overrides (empty object when missing/malformed).
|
|
590
|
+
*/
|
|
591
|
+
function loadOrchestratorYaml(configRoot: string): Partial<OrchestratorSection> {
|
|
592
|
+
const yamlPath = resolveConfigFilePath(configRoot, "task-orchestrator.yaml");
|
|
593
|
+
if (!existsSync(yamlPath)) return {};
|
|
594
|
+
|
|
595
|
+
try {
|
|
596
|
+
const raw = readFileSync(yamlPath, "utf-8");
|
|
597
|
+
const loaded = yamlParse(raw) as any;
|
|
598
|
+
if (!loaded || typeof loaded !== "object") return {};
|
|
599
|
+
|
|
600
|
+
// Section-aware mapping: structural keys → camelCase, record keys → preserved
|
|
601
|
+
return mapOrchestratorYaml(loaded);
|
|
602
|
+
} catch {
|
|
603
|
+
return {};
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Load optional workspace routing config from legacy `orchid-workspace.yaml`.
|
|
609
|
+
*
|
|
610
|
+
* This file is fallback-only for workspace metadata when JSON `workspace`
|
|
611
|
+
* section is not present. Malformed files are ignored here — strict validation
|
|
612
|
+
* still happens in workspace runtime loading (`workspace.ts`).
|
|
613
|
+
*/
|
|
614
|
+
function loadWorkspaceYaml(configRoot: string): WorkspaceSectionConfig | undefined {
|
|
615
|
+
const yamlPath = resolveConfigFilePath(configRoot, "orchid-workspace.yaml");
|
|
616
|
+
if (!existsSync(yamlPath)) return undefined;
|
|
617
|
+
|
|
618
|
+
try {
|
|
619
|
+
const raw = readFileSync(yamlPath, "utf-8");
|
|
620
|
+
const loaded = yamlParse(raw) as any;
|
|
621
|
+
if (!loaded || typeof loaded !== "object") return undefined;
|
|
622
|
+
|
|
623
|
+
const converted = convertStructuralKeys(loaded);
|
|
624
|
+
return normalizeWorkspaceSection(converted, yamlPath);
|
|
625
|
+
} catch {
|
|
626
|
+
return undefined;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ── Global Preferences (Layer 2) ─────────────────────────────────────
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Resolve the absolute path to the global preferences file.
|
|
634
|
+
*
|
|
635
|
+
* Resolution order:
|
|
636
|
+
* 1. `PI_CODING_AGENT_DIR` env → `<value>.orchid/preferences.json`
|
|
637
|
+
* 2. `os.homedir()/.pi/agent.orchid/preferences.json`
|
|
638
|
+
*
|
|
639
|
+
* Uses `os.homedir()` for cross-platform home resolution
|
|
640
|
+
* (USERPROFILE on Windows, HOME on Unix) and `path.join()` for separators.
|
|
641
|
+
*/
|
|
642
|
+
export function resolveGlobalPreferencesPath(): string {
|
|
643
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR;
|
|
644
|
+
if (agentDir) {
|
|
645
|
+
return join(agentDir, GLOBAL_PREFERENCES_SUBDIR, GLOBAL_PREFERENCES_FILENAME);
|
|
646
|
+
}
|
|
647
|
+
return join(homedir(), ".pi", "agent", GLOBAL_PREFERENCES_SUBDIR, GLOBAL_PREFERENCES_FILENAME);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** Result envelope for global preferences loading. */
|
|
651
|
+
export interface GlobalPreferencesLoadResult {
|
|
652
|
+
preferences: GlobalPreferences;
|
|
653
|
+
wasBootstrapped: boolean;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Persist preferences JSON atomically (temp file + rename). */
|
|
657
|
+
function writePreferencesAtomically(prefsPath: string, prefs: GlobalPreferences): void {
|
|
658
|
+
const tmpPath = `${prefsPath}.tmp-${process.pid}-${Date.now()}`;
|
|
659
|
+
writeFileSync(tmpPath, JSON.stringify(prefs, null, 2) + "\n", "utf-8");
|
|
660
|
+
renameSync(tmpPath, prefsPath);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Write first-install bootstrap preferences to disk and return the in-memory seed.
|
|
665
|
+
*/
|
|
666
|
+
function bootstrapGlobalPreferencesFile(prefsPath: string): GlobalPreferences {
|
|
667
|
+
const bootstrapPrefs = deepClone(DEFAULT_BOOTSTRAP_GLOBAL_PREFERENCES);
|
|
668
|
+
try {
|
|
669
|
+
const dir = join(prefsPath, "..");
|
|
670
|
+
mkdirSync(dir, { recursive: true });
|
|
671
|
+
writePreferencesAtomically(prefsPath, bootstrapPrefs);
|
|
672
|
+
} catch {
|
|
673
|
+
// Best-effort; if we can't create, still return bootstrap defaults in-memory.
|
|
674
|
+
}
|
|
675
|
+
return bootstrapPrefs;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Load global preferences plus bootstrap metadata.
|
|
680
|
+
*
|
|
681
|
+
* Behavior:
|
|
682
|
+
* - If file doesn't exist: bootstrap preferences on disk and mark bootstrapped
|
|
683
|
+
* - If file is empty/malformed/invalid: re-bootstrap preferences and mark bootstrapped
|
|
684
|
+
* - Unknown keys are silently ignored (allowlist extraction)
|
|
685
|
+
*/
|
|
686
|
+
export function loadGlobalPreferencesWithMeta(): GlobalPreferencesLoadResult {
|
|
687
|
+
const prefsPath = resolveGlobalPreferencesPath();
|
|
688
|
+
|
|
689
|
+
if (!existsSync(prefsPath)) {
|
|
690
|
+
return {
|
|
691
|
+
preferences: bootstrapGlobalPreferencesFile(prefsPath),
|
|
692
|
+
wasBootstrapped: true,
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
let raw: string;
|
|
697
|
+
try {
|
|
698
|
+
raw = readFileSync(prefsPath, "utf-8");
|
|
699
|
+
} catch {
|
|
700
|
+
return { preferences: deepClone(DEFAULT_GLOBAL_PREFERENCES), wasBootstrapped: false };
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (!raw.trim()) {
|
|
704
|
+
return {
|
|
705
|
+
preferences: bootstrapGlobalPreferencesFile(prefsPath),
|
|
706
|
+
wasBootstrapped: true,
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
let parsed: any;
|
|
711
|
+
try {
|
|
712
|
+
parsed = JSON.parse(raw);
|
|
713
|
+
} catch {
|
|
714
|
+
return {
|
|
715
|
+
preferences: bootstrapGlobalPreferencesFile(prefsPath),
|
|
716
|
+
wasBootstrapped: true,
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (
|
|
721
|
+
!parsed ||
|
|
722
|
+
typeof parsed !== "object" ||
|
|
723
|
+
Array.isArray(parsed) ||
|
|
724
|
+
Object.keys(parsed).length === 0
|
|
725
|
+
) {
|
|
726
|
+
return {
|
|
727
|
+
preferences: bootstrapGlobalPreferencesFile(prefsPath),
|
|
728
|
+
wasBootstrapped: true,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
return {
|
|
733
|
+
preferences: extractAllowlistedPreferences(parsed, prefsPath),
|
|
734
|
+
wasBootstrapped: false,
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Load global preferences from `~/.pi/agent.orchid/preferences.json`.
|
|
740
|
+
*
|
|
741
|
+
* @returns Parsed GlobalPreferences (only recognized fields)
|
|
742
|
+
*/
|
|
743
|
+
export function loadGlobalPreferences(): GlobalPreferences {
|
|
744
|
+
return loadGlobalPreferencesWithMeta().preferences;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Extract only recognized/allowlisted fields from a raw parsed object.
|
|
749
|
+
* Unknown keys are silently dropped — this is the Layer 2 boundary guardrail.
|
|
750
|
+
*/
|
|
751
|
+
function normalizePreferenceThinkingMode(value: unknown): string {
|
|
752
|
+
const cleaned = String(value ?? "")
|
|
753
|
+
.trim()
|
|
754
|
+
.toLowerCase();
|
|
755
|
+
if (!cleaned || cleaned === "inherit") return "";
|
|
756
|
+
if (cleaned === "on") return "high";
|
|
757
|
+
if (["off", "minimal", "low", "medium", "high", "xhigh"].includes(cleaned)) {
|
|
758
|
+
return cleaned;
|
|
759
|
+
}
|
|
760
|
+
return "";
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function extractInitAgentDefaults(
|
|
764
|
+
rawInitDefaults: unknown,
|
|
765
|
+
): GlobalPreferences["initAgentDefaults"] | undefined {
|
|
766
|
+
if (!rawInitDefaults || typeof rawInitDefaults !== "object" || Array.isArray(rawInitDefaults)) {
|
|
767
|
+
return undefined;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const raw = rawInitDefaults as Record<string, unknown>;
|
|
771
|
+
const extracted: NonNullable<GlobalPreferences["initAgentDefaults"]> = {};
|
|
772
|
+
|
|
773
|
+
if (typeof raw.workerModel === "string") extracted.workerModel = raw.workerModel;
|
|
774
|
+
if (typeof raw.reviewerModel === "string") extracted.reviewerModel = raw.reviewerModel;
|
|
775
|
+
if (typeof raw.mergeModel === "string") extracted.mergeModel = raw.mergeModel;
|
|
776
|
+
if (raw.workerThinking !== undefined)
|
|
777
|
+
extracted.workerThinking = normalizePreferenceThinkingMode(raw.workerThinking);
|
|
778
|
+
if (raw.reviewerThinking !== undefined)
|
|
779
|
+
extracted.reviewerThinking = normalizePreferenceThinkingMode(raw.reviewerThinking);
|
|
780
|
+
if (raw.mergeThinking !== undefined)
|
|
781
|
+
extracted.mergeThinking = normalizePreferenceThinkingMode(raw.mergeThinking);
|
|
782
|
+
|
|
783
|
+
return Object.keys(extracted).length > 0 ? extracted : undefined;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function extractConfigOverrideSection(rawSection: unknown): Record<string, any> | undefined {
|
|
787
|
+
if (!rawSection || typeof rawSection !== "object" || Array.isArray(rawSection)) {
|
|
788
|
+
return undefined;
|
|
789
|
+
}
|
|
790
|
+
return deepClone(rawSection as Record<string, any>);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function extractAllowlistedPreferences(
|
|
794
|
+
raw: Record<string, any>,
|
|
795
|
+
prefsPath: string,
|
|
796
|
+
): GlobalPreferences {
|
|
797
|
+
migrateGlobalPreferences(raw, prefsPath);
|
|
798
|
+
|
|
799
|
+
const prefs: GlobalPreferences = {};
|
|
800
|
+
|
|
801
|
+
const taskRunnerOverrides = extractConfigOverrideSection(raw.taskRunner);
|
|
802
|
+
if (taskRunnerOverrides) {
|
|
803
|
+
prefs.taskRunner = taskRunnerOverrides as GlobalPreferences["taskRunner"];
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const orchestratorOverrides = extractConfigOverrideSection(raw.orchestrator);
|
|
807
|
+
if (orchestratorOverrides) {
|
|
808
|
+
prefs.orchestrator = orchestratorOverrides as GlobalPreferences["orchestrator"];
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const workspaceOverrides = extractConfigOverrideSection(raw.workspace);
|
|
812
|
+
if (workspaceOverrides) {
|
|
813
|
+
prefs.workspace = workspaceOverrides as GlobalPreferences["workspace"];
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Legacy flat aliases (backward compatibility for existing preferences.json files)
|
|
817
|
+
if (typeof raw.operatorId === "string") prefs.operatorId = raw.operatorId;
|
|
818
|
+
if (typeof raw.sessionPrefix === "string") {
|
|
819
|
+
prefs.sessionPrefix = raw.sessionPrefix;
|
|
820
|
+
}
|
|
821
|
+
if (raw.spawnMode === "subprocess") {
|
|
822
|
+
prefs.spawnMode = "subprocess";
|
|
823
|
+
}
|
|
824
|
+
if (typeof raw.workerModel === "string") prefs.workerModel = raw.workerModel;
|
|
825
|
+
if (typeof raw.reviewerModel === "string") prefs.reviewerModel = raw.reviewerModel;
|
|
826
|
+
if (typeof raw.mergeModel === "string") prefs.mergeModel = raw.mergeModel;
|
|
827
|
+
if (typeof raw.mergeThinking === "string") prefs.mergeThinking = raw.mergeThinking;
|
|
828
|
+
if (typeof raw.supervisorModel === "string") prefs.supervisorModel = raw.supervisorModel;
|
|
829
|
+
|
|
830
|
+
// Preferences-only fields (intentionally not merged into runtime config)
|
|
831
|
+
if (typeof raw.dashboardPort === "number" && Number.isFinite(raw.dashboardPort)) {
|
|
832
|
+
prefs.dashboardPort = raw.dashboardPort;
|
|
833
|
+
}
|
|
834
|
+
const initAgentDefaults = extractInitAgentDefaults(raw.initAgentDefaults);
|
|
835
|
+
if (initAgentDefaults) {
|
|
836
|
+
prefs.initAgentDefaults = initAgentDefaults;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
return prefs;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Apply global preferences (Layer 2) onto a project config (Layer 1).
|
|
844
|
+
*
|
|
845
|
+
* Merge order inside Layer 2:
|
|
846
|
+
* 1. Legacy flat aliases (for backward compatibility)
|
|
847
|
+
* 2. Config-shaped nested overrides (`taskRunner` / `orchestrator` / `workspace`)
|
|
848
|
+
* Nested overrides intentionally win when both styles are present.
|
|
849
|
+
*
|
|
850
|
+
* Preferences-only fields (`dashboardPort`, `initAgentDefaults`) are preserved
|
|
851
|
+
* in `GlobalPreferences` but intentionally not merged into runtime config.
|
|
852
|
+
*/
|
|
853
|
+
export function applyGlobalPreferences(
|
|
854
|
+
config: TaskplaneConfig,
|
|
855
|
+
prefs: GlobalPreferences,
|
|
856
|
+
): TaskplaneConfig {
|
|
857
|
+
// Helper: only apply non-empty string values
|
|
858
|
+
const applyStr = (val: string | undefined, setter: (v: string) => void) => {
|
|
859
|
+
if (val !== undefined && val !== "") setter(val);
|
|
860
|
+
};
|
|
861
|
+
|
|
862
|
+
// 1) Legacy flat aliases
|
|
863
|
+
applyStr(prefs.operatorId, (v) => {
|
|
864
|
+
config.orchestrator.orchestrator.operatorId = v;
|
|
865
|
+
});
|
|
866
|
+
applyStr(prefs.sessionPrefix, (v) => {
|
|
867
|
+
config.orchestrator.orchestrator.sessionPrefix = v;
|
|
868
|
+
});
|
|
869
|
+
applyStr(prefs.workerModel, (v) => {
|
|
870
|
+
config.taskRunner.worker.model = v;
|
|
871
|
+
});
|
|
872
|
+
applyStr(prefs.reviewerModel, (v) => {
|
|
873
|
+
config.taskRunner.reviewer.model = v;
|
|
874
|
+
});
|
|
875
|
+
applyStr(prefs.mergeModel, (v) => {
|
|
876
|
+
config.orchestrator.merge.model = v;
|
|
877
|
+
});
|
|
878
|
+
applyStr(prefs.mergeThinking, (v) => {
|
|
879
|
+
config.orchestrator.merge.thinking = v;
|
|
880
|
+
});
|
|
881
|
+
applyStr(prefs.supervisorModel, (v) => {
|
|
882
|
+
config.orchestrator.supervisor.model = v;
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
// spawnMode: enum — apply if defined (not a string-empty check)
|
|
886
|
+
// TP-195: dropped dead `prefs.spawnMode === "tmux"` migration check.
|
|
887
|
+
// `prefs.spawnMode` is typed as `"subprocess"` only (see
|
|
888
|
+
// `GlobalPreferences.spawnMode` in config-schema.ts). Raw input is
|
|
889
|
+
// migrated upstream at line ~169 BEFORE assignment to the typed
|
|
890
|
+
// `prefs` object, so by this point the value is already "subprocess"
|
|
891
|
+
// or undefined — the comparison can never be true. Behavior-neutral.
|
|
892
|
+
if (prefs.spawnMode !== undefined) {
|
|
893
|
+
config.orchestrator.orchestrator.spawnMode = prefs.spawnMode;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// 2) Config-shaped nested overrides
|
|
897
|
+
if (prefs.taskRunner) {
|
|
898
|
+
deepMerge(config.taskRunner as Record<string, any>, prefs.taskRunner as Record<string, any>);
|
|
899
|
+
}
|
|
900
|
+
if (prefs.orchestrator) {
|
|
901
|
+
deepMerge(config.orchestrator as Record<string, any>, prefs.orchestrator as Record<string, any>);
|
|
902
|
+
}
|
|
903
|
+
if (prefs.workspace) {
|
|
904
|
+
if (!config.workspace || typeof config.workspace !== "object") {
|
|
905
|
+
config.workspace = {} as TaskplaneConfig["workspace"];
|
|
906
|
+
}
|
|
907
|
+
deepMerge(config.workspace as Record<string, any>, prefs.workspace as Record<string, any>);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Runtime safety: nested legacy values may arrive through config-shaped overrides.
|
|
911
|
+
if ((config.orchestrator.orchestrator as Record<string, any>).spawnMode === "tmux") {
|
|
912
|
+
config.orchestrator.orchestrator.spawnMode = "subprocess";
|
|
913
|
+
console.error(
|
|
914
|
+
`[orchid] Auto-migrated runtime global preference: orchestrator.orchestrator.spawnMode "tmux" → "subprocess"`,
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
if ((config.taskRunner.worker as Record<string, any>).spawnMode === "tmux") {
|
|
918
|
+
config.taskRunner.worker.spawnMode = "subprocess";
|
|
919
|
+
console.error(
|
|
920
|
+
`[orchid] Auto-migrated runtime global preference: taskRunner.worker.spawnMode "tmux" → "subprocess"`,
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
return config;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// ── Unified Loader ───────────────────────────────────────────────────
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Check whether any config files exist under the given root.
|
|
931
|
+
*
|
|
932
|
+
* Supports both standard layout (`<root>/.pi/<file>`) and flat layout
|
|
933
|
+
* (`<root>/<file>`). Returns true if any recognized config file is found
|
|
934
|
+
* in either location. This allows pointer-resolved roots (e.g.,
|
|
935
|
+
* `<configRepo>/.orchid/`) where files are scaffolded directly
|
|
936
|
+
* without a `.pi/` subdirectory.
|
|
937
|
+
*
|
|
938
|
+
* Includes optional workspace YAML (`orchid-workspace.yaml`) so
|
|
939
|
+
* workspace-only roots participate in config-root resolution.
|
|
940
|
+
*/
|
|
941
|
+
export function hasConfigFiles(root: string): boolean {
|
|
942
|
+
// Check for actual project config files (not workspace YAML — that's a
|
|
943
|
+
// coordination file, not a project config). Without this distinction,
|
|
944
|
+
// workspace root's .pi/orchid-workspace.yaml causes resolveConfigRoot
|
|
945
|
+
// to short-circuit before checking the pointer-resolved config root (#424).
|
|
946
|
+
const files = [PROJECT_CONFIG_FILENAME, "task-runner.yaml", "task-orchestrator.yaml"];
|
|
947
|
+
for (const f of files) {
|
|
948
|
+
if (existsSync(join(root, ".pi", f)) || existsSync(join(root, f))) return true;
|
|
949
|
+
}
|
|
950
|
+
return false;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Resolve the config root directory.
|
|
955
|
+
*
|
|
956
|
+
* In workspace mode, workers run in repo worktrees — not the workspace root.
|
|
957
|
+
* TASKPLANE_WORKSPACE_ROOT tells us where config files actually live.
|
|
958
|
+
* The pointer file (`orchid-pointer.json`) can redirect config loading
|
|
959
|
+
* to a specific repo's config path.
|
|
960
|
+
*
|
|
961
|
+
* Resolution order:
|
|
962
|
+
* 1. If `cwd` has actual config files → use cwd (local override wins)
|
|
963
|
+
* 2. If `pointerConfigRoot` is set and has config files → use it (pointer redirect)
|
|
964
|
+
* 3. If TASKPLANE_WORKSPACE_ROOT is set and has config files → use it (legacy fallback)
|
|
965
|
+
* 4. Fall back to cwd (loaders will return defaults)
|
|
966
|
+
*
|
|
967
|
+
* We check for actual config files — not just the `.pi/` directory —
|
|
968
|
+
* because worktrees may have a sidecar `.pi` without config files.
|
|
969
|
+
*
|
|
970
|
+
* @param cwd - Current working directory (project root or worktree)
|
|
971
|
+
* @param pointerConfigRoot - Resolved config root from pointer file (optional, workspace mode only)
|
|
972
|
+
*/
|
|
973
|
+
export function resolveConfigRoot(cwd: string, pointerConfigRoot?: string): string {
|
|
974
|
+
// Prefer cwd if it has actual config files (local override always wins)
|
|
975
|
+
if (hasConfigFiles(cwd)) return cwd;
|
|
976
|
+
|
|
977
|
+
// Pointer-resolved config root — workspace mode with valid pointer
|
|
978
|
+
if (pointerConfigRoot && hasConfigFiles(pointerConfigRoot)) return pointerConfigRoot;
|
|
979
|
+
|
|
980
|
+
// Workspace mode fallback — check for actual config files at workspace root
|
|
981
|
+
const wsRoot = process.env.TASKPLANE_WORKSPACE_ROOT;
|
|
982
|
+
if (wsRoot && hasConfigFiles(wsRoot)) return wsRoot;
|
|
983
|
+
|
|
984
|
+
// Fall back to cwd even without config files — loaders will return defaults
|
|
985
|
+
return cwd;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function mergeProjectOverrides(
|
|
989
|
+
config: TaskplaneConfig,
|
|
990
|
+
overrides: DeepPartial<TaskplaneConfig>,
|
|
991
|
+
): void {
|
|
992
|
+
if (overrides.taskRunner) {
|
|
993
|
+
deepMerge(config.taskRunner as Record<string, any>, overrides.taskRunner as Record<string, any>);
|
|
994
|
+
}
|
|
995
|
+
if (overrides.orchestrator) {
|
|
996
|
+
deepMerge(
|
|
997
|
+
config.orchestrator as Record<string, any>,
|
|
998
|
+
overrides.orchestrator as Record<string, any>,
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
if (overrides.workspace) {
|
|
1002
|
+
if (!config.workspace || typeof config.workspace !== "object") {
|
|
1003
|
+
config.workspace = {} as TaskplaneConfig["workspace"];
|
|
1004
|
+
}
|
|
1005
|
+
deepMerge(config.workspace as Record<string, any>, overrides.workspace as Record<string, any>);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// TP-195: switched parameter to `DeepPartial<TaskplaneConfig>` to match the
|
|
1010
|
+
// nested-section partial shape produced by `loadProjectOverrides` (the YAML
|
|
1011
|
+
// loaders return `Partial<TaskRunnerSection>` etc., which `Partial<TaskplaneConfig>`
|
|
1012
|
+
// rejects — it makes top-level fields optional but inner sections stay full).
|
|
1013
|
+
function migrateProjectOverrides(
|
|
1014
|
+
overrides: DeepPartial<TaskplaneConfig>,
|
|
1015
|
+
configRoot: string,
|
|
1016
|
+
): boolean {
|
|
1017
|
+
if (_projectMigrationDone) return false;
|
|
1018
|
+
|
|
1019
|
+
let migrated = false;
|
|
1020
|
+
// TP-195: 2-step `as unknown as` widening. The structurally-typed
|
|
1021
|
+
// `OrchestratorCoreConfig` is being treated as a property bag for
|
|
1022
|
+
// migration purposes (legacy `tmuxPrefix` -> `sessionPrefix`,
|
|
1023
|
+
// `spawnMode "tmux"` -> `"subprocess"`). Both source and target
|
|
1024
|
+
// types are object-shaped at runtime; the cast is structurally
|
|
1025
|
+
// legitimate, just outside the narrow set of conversions TS allows
|
|
1026
|
+
// in a single step.
|
|
1027
|
+
const orchestratorCore = overrides.orchestrator?.orchestrator as unknown as
|
|
1028
|
+
| Record<string, unknown>
|
|
1029
|
+
| undefined;
|
|
1030
|
+
if (orchestratorCore && hasOwn(orchestratorCore, "tmuxPrefix")) {
|
|
1031
|
+
const currentPrefix = orchestratorCore.sessionPrefix;
|
|
1032
|
+
const isDefault = currentPrefix === undefined || currentPrefix === "orch";
|
|
1033
|
+
if (isDefault) {
|
|
1034
|
+
(orchestratorCore as any).sessionPrefix = orchestratorCore.tmuxPrefix;
|
|
1035
|
+
}
|
|
1036
|
+
delete orchestratorCore.tmuxPrefix;
|
|
1037
|
+
console.error(`[orchid] Auto-migrated: orchestrator.orchestrator.tmuxPrefix → sessionPrefix`);
|
|
1038
|
+
migrated = true;
|
|
1039
|
+
}
|
|
1040
|
+
if (orchestratorCore?.spawnMode === "tmux") {
|
|
1041
|
+
(orchestratorCore as any).spawnMode = "subprocess";
|
|
1042
|
+
console.error(
|
|
1043
|
+
`[orchid] Auto-migrated: orchestrator.orchestrator.spawnMode "tmux" → "subprocess"`,
|
|
1044
|
+
);
|
|
1045
|
+
migrated = true;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// TP-195: 2-step `as unknown as` widening (same rationale as the
|
|
1049
|
+
// orchestratorCore cast above).
|
|
1050
|
+
const workerConfig = overrides.taskRunner?.worker as unknown as
|
|
1051
|
+
| Record<string, unknown>
|
|
1052
|
+
| undefined;
|
|
1053
|
+
if (workerConfig?.spawnMode === "tmux") {
|
|
1054
|
+
(workerConfig as any).spawnMode = "subprocess";
|
|
1055
|
+
console.error(`[orchid] Auto-migrated: taskRunner.worker.spawnMode "tmux" → "subprocess"`);
|
|
1056
|
+
migrated = true;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
if (migrated) {
|
|
1060
|
+
try {
|
|
1061
|
+
const jsonPath = resolveConfigFilePath(configRoot, PROJECT_CONFIG_FILENAME);
|
|
1062
|
+
if (existsSync(jsonPath)) {
|
|
1063
|
+
const raw = JSON.parse(readFileSync(jsonPath, "utf-8"));
|
|
1064
|
+
if (raw.orchestrator?.orchestrator?.tmuxPrefix !== undefined) {
|
|
1065
|
+
const rawPrefix = raw.orchestrator.orchestrator.sessionPrefix;
|
|
1066
|
+
if (rawPrefix === undefined || rawPrefix === "orch") {
|
|
1067
|
+
raw.orchestrator.orchestrator.sessionPrefix = raw.orchestrator.orchestrator.tmuxPrefix;
|
|
1068
|
+
}
|
|
1069
|
+
delete raw.orchestrator.orchestrator.tmuxPrefix;
|
|
1070
|
+
}
|
|
1071
|
+
if (raw.orchestrator?.orchestrator?.spawnMode === "tmux") {
|
|
1072
|
+
raw.orchestrator.orchestrator.spawnMode = "subprocess";
|
|
1073
|
+
}
|
|
1074
|
+
if (raw.taskRunner?.worker?.spawnMode === "tmux") {
|
|
1075
|
+
raw.taskRunner.worker.spawnMode = "subprocess";
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const tmpPath = jsonPath + ".migration-tmp";
|
|
1079
|
+
writeFileSync(tmpPath, JSON.stringify(raw, null, 2) + "\n");
|
|
1080
|
+
renameSync(tmpPath, jsonPath);
|
|
1081
|
+
console.error(`[orchid] Config file updated: ${jsonPath}`);
|
|
1082
|
+
}
|
|
1083
|
+
} catch (err) {
|
|
1084
|
+
console.error(
|
|
1085
|
+
`[orchid] Warning: could not persist config migration to disk: ${err instanceof Error ? err.message : err}`,
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
_projectMigrationDone = true;
|
|
1091
|
+
return migrated;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// TP-195: return type widened from `Partial<TaskplaneConfig>` to
|
|
1095
|
+
// `DeepPartial<TaskplaneConfig>` so the nested `Partial<TaskRunnerSection>` /
|
|
1096
|
+
// `Partial<OrchestratorSection>` returned by the YAML loaders are
|
|
1097
|
+
// assignable. `Partial<TaskplaneConfig>` only relaxes top-level optionality
|
|
1098
|
+
// while keeping inner sections fully required.
|
|
1099
|
+
export function loadProjectOverrides(configRoot: string): DeepPartial<TaskplaneConfig> {
|
|
1100
|
+
const jsonOverrides = loadJsonConfig(configRoot);
|
|
1101
|
+
if (jsonOverrides !== null) {
|
|
1102
|
+
return jsonOverrides;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
const taskRunner = loadTaskRunnerYaml(configRoot);
|
|
1106
|
+
const orchestrator = loadOrchestratorYaml(configRoot);
|
|
1107
|
+
const workspace = loadWorkspaceYaml(configRoot);
|
|
1108
|
+
|
|
1109
|
+
const overrides: DeepPartial<TaskplaneConfig> = {};
|
|
1110
|
+
if (Object.keys(taskRunner).length > 0) overrides.taskRunner = taskRunner;
|
|
1111
|
+
if (Object.keys(orchestrator).length > 0) overrides.orchestrator = orchestrator;
|
|
1112
|
+
if (workspace) overrides.workspace = workspace;
|
|
1113
|
+
return overrides;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Load the unified project configuration.
|
|
1118
|
+
*
|
|
1119
|
+
* Precedence (layered):
|
|
1120
|
+
* 1. Schema defaults
|
|
1121
|
+
* 2. Global preferences (`~/.pi/agent.orchid/preferences.json`)
|
|
1122
|
+
* 3. Project overrides (`orchid-config.json` or YAML fallback)
|
|
1123
|
+
*
|
|
1124
|
+
* Project config is treated as sparse overrides. Missing fields in project
|
|
1125
|
+
* config fall through to global preferences, then schema defaults.
|
|
1126
|
+
*/
|
|
1127
|
+
export function loadProjectConfig(cwd: string, pointerConfigRoot?: string): TaskplaneConfig {
|
|
1128
|
+
const configRoot = resolveConfigRoot(cwd, pointerConfigRoot);
|
|
1129
|
+
const config = deepClone(DEFAULT_PROJECT_CONFIG);
|
|
1130
|
+
|
|
1131
|
+
// Layer 2 baseline: global preferences on top of defaults
|
|
1132
|
+
const prefs = loadGlobalPreferences();
|
|
1133
|
+
applyGlobalPreferences(config, prefs);
|
|
1134
|
+
|
|
1135
|
+
// Layer 1 project overrides: sparse config merged on top
|
|
1136
|
+
const overrides = loadProjectOverrides(configRoot);
|
|
1137
|
+
_projectMigrationDone = false;
|
|
1138
|
+
migrateProjectOverrides(overrides, configRoot);
|
|
1139
|
+
mergeProjectOverrides(config, overrides);
|
|
1140
|
+
|
|
1141
|
+
normalizeInheritanceAliases(config);
|
|
1142
|
+
return config;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* Load project overrides merged with schema defaults, without applying
|
|
1147
|
+
* global preferences. Used by settings write-back code paths that must
|
|
1148
|
+
* avoid embedding global baseline values into project config.
|
|
1149
|
+
*/
|
|
1150
|
+
export function loadLayer1Config(cwd: string, pointerConfigRoot?: string): TaskplaneConfig {
|
|
1151
|
+
const configRoot = resolveConfigRoot(cwd, pointerConfigRoot);
|
|
1152
|
+
const config = deepClone(DEFAULT_PROJECT_CONFIG);
|
|
1153
|
+
const overrides = loadProjectOverrides(configRoot);
|
|
1154
|
+
|
|
1155
|
+
_projectMigrationDone = false;
|
|
1156
|
+
migrateProjectOverrides(overrides, configRoot);
|
|
1157
|
+
mergeProjectOverrides(config, overrides);
|
|
1158
|
+
|
|
1159
|
+
normalizeInheritanceAliases(config);
|
|
1160
|
+
return config;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// ── Backward-Compatible Adapters ─────────────────────────────────────
|
|
1164
|
+
|
|
1165
|
+
// The following adapter functions convert the unified camelCase config
|
|
1166
|
+
// back to the snake_case shapes expected by existing consumers.
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* Adapter: produce the legacy `OrchestratorConfig` (snake_case) from unified config.
|
|
1170
|
+
*
|
|
1171
|
+
* Uses explicit field mapping instead of generic recursive key conversion
|
|
1172
|
+
* to preserve record/dictionary keys verbatim (e.g., sizeWeights S/M/L,
|
|
1173
|
+
* preWarm.commands keys, etc.).
|
|
1174
|
+
*/
|
|
1175
|
+
export function toOrchestratorConfig(
|
|
1176
|
+
config: TaskplaneConfig,
|
|
1177
|
+
): import("./types.ts").OrchestratorConfig {
|
|
1178
|
+
const o = config.orchestrator;
|
|
1179
|
+
return {
|
|
1180
|
+
orchestrator: {
|
|
1181
|
+
max_lanes: o.orchestrator.maxLanes,
|
|
1182
|
+
worktree_location: o.orchestrator.worktreeLocation,
|
|
1183
|
+
worktree_prefix: o.orchestrator.worktreePrefix,
|
|
1184
|
+
batch_id_format: o.orchestrator.batchIdFormat,
|
|
1185
|
+
spawn_mode: o.orchestrator.spawnMode,
|
|
1186
|
+
sessionPrefix: o.orchestrator.sessionPrefix,
|
|
1187
|
+
operator_id: o.orchestrator.operatorId,
|
|
1188
|
+
integration: o.orchestrator.integration,
|
|
1189
|
+
},
|
|
1190
|
+
dependencies: {
|
|
1191
|
+
source: o.dependencies.source,
|
|
1192
|
+
cache: o.dependencies.cache,
|
|
1193
|
+
},
|
|
1194
|
+
assignment: {
|
|
1195
|
+
strategy: o.assignment.strategy,
|
|
1196
|
+
// Preserve dictionary keys verbatim (S, M, L, XL, etc.)
|
|
1197
|
+
size_weights: { ...o.assignment.sizeWeights },
|
|
1198
|
+
},
|
|
1199
|
+
pre_warm: {
|
|
1200
|
+
auto_detect: o.preWarm.autoDetect,
|
|
1201
|
+
// Preserve user-defined command keys verbatim
|
|
1202
|
+
commands: { ...o.preWarm.commands },
|
|
1203
|
+
always: [...o.preWarm.always],
|
|
1204
|
+
},
|
|
1205
|
+
merge: {
|
|
1206
|
+
model: o.merge.model,
|
|
1207
|
+
tools: o.merge.tools,
|
|
1208
|
+
thinking: o.merge.thinking,
|
|
1209
|
+
verify: [...o.merge.verify],
|
|
1210
|
+
order: o.merge.order,
|
|
1211
|
+
timeout_minutes: o.merge.timeoutMinutes ?? 90,
|
|
1212
|
+
exclude_extensions: [...(o.merge.excludeExtensions ?? [])],
|
|
1213
|
+
},
|
|
1214
|
+
failure: {
|
|
1215
|
+
on_task_failure: o.failure.onTaskFailure,
|
|
1216
|
+
on_merge_failure: o.failure.onMergeFailure,
|
|
1217
|
+
stall_timeout: o.failure.stallTimeout,
|
|
1218
|
+
max_worker_minutes: o.failure.maxWorkerMinutes,
|
|
1219
|
+
abort_grace_period: o.failure.abortGracePeriod,
|
|
1220
|
+
},
|
|
1221
|
+
monitoring: {
|
|
1222
|
+
poll_interval: o.monitoring.pollInterval,
|
|
1223
|
+
},
|
|
1224
|
+
verification: {
|
|
1225
|
+
enabled: o.verification.enabled,
|
|
1226
|
+
mode: o.verification.mode,
|
|
1227
|
+
flaky_reruns: o.verification.flakyReruns,
|
|
1228
|
+
},
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* Adapter: produce the legacy `TaskRunnerConfig` (snake_case subset) from unified config.
|
|
1234
|
+
*
|
|
1235
|
+
* The orchestrator's `TaskRunnerConfig` is a subset: { task_areas, reference_docs }.
|
|
1236
|
+
* This adapter maps the unified shape back to that contract.
|
|
1237
|
+
*
|
|
1238
|
+
* Special handling for `repoId`: whitespace-only values are treated as undefined,
|
|
1239
|
+
* and non-empty values are trimmed — matching the original YAML loader behavior.
|
|
1240
|
+
*/
|
|
1241
|
+
export function toTaskRunnerConfig(config: TaskplaneConfig): import("./types.ts").TaskRunnerConfig {
|
|
1242
|
+
// task_areas needs snake_case keys inside each area too (repoId → repo_id)
|
|
1243
|
+
const taskAreas: Record<string, import("./types.ts").TaskArea> = {};
|
|
1244
|
+
for (const [name, area] of Object.entries(config.taskRunner.taskAreas)) {
|
|
1245
|
+
const ta: import("./types.ts").TaskArea = {
|
|
1246
|
+
path: area.path,
|
|
1247
|
+
prefix: area.prefix,
|
|
1248
|
+
context: area.context,
|
|
1249
|
+
};
|
|
1250
|
+
// repoId: only set if non-empty after trim (matches original YAML loader)
|
|
1251
|
+
if (area.repoId && typeof area.repoId === "string" && area.repoId.trim()) {
|
|
1252
|
+
ta.repoId = area.repoId.trim();
|
|
1253
|
+
}
|
|
1254
|
+
taskAreas[name] = ta;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// Include testing_commands for baseline fingerprinting (TP-032).
|
|
1258
|
+
// Only set the field when there are actual commands configured.
|
|
1259
|
+
const testingCommands = config.taskRunner.testing?.commands;
|
|
1260
|
+
const hasTestingCommands = testingCommands && Object.keys(testingCommands).length > 0;
|
|
1261
|
+
|
|
1262
|
+
return {
|
|
1263
|
+
task_areas: taskAreas,
|
|
1264
|
+
reference_docs: { ...config.taskRunner.referenceDocs },
|
|
1265
|
+
...(hasTestingCommands ? { testing_commands: { ...testingCommands } } : {}),
|
|
1266
|
+
worker: {
|
|
1267
|
+
model: config.taskRunner.worker.model,
|
|
1268
|
+
thinking: config.taskRunner.worker.thinking,
|
|
1269
|
+
tools: config.taskRunner.worker.tools,
|
|
1270
|
+
excludeExtensions: [...(config.taskRunner.worker.excludeExtensions ?? [])],
|
|
1271
|
+
},
|
|
1272
|
+
model_fallback: config.taskRunner.modelFallback ?? "inherit",
|
|
1273
|
+
reviewer: {
|
|
1274
|
+
model: config.taskRunner.reviewer.model,
|
|
1275
|
+
thinking: config.taskRunner.reviewer.thinking,
|
|
1276
|
+
tools: config.taskRunner.reviewer.tools,
|
|
1277
|
+
excludeExtensions: [...(config.taskRunner.reviewer.excludeExtensions ?? [])],
|
|
1278
|
+
},
|
|
1279
|
+
workerExcludeExtensions: [...(config.taskRunner.worker.excludeExtensions ?? [])],
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/**
|
|
1284
|
+
* Adapter: produce the legacy task-runner `TaskConfig` (snake_case) from unified config.
|
|
1285
|
+
*
|
|
1286
|
+
* The task-runner extension has its own `TaskConfig` interface with snake_case keys.
|
|
1287
|
+
* This adapter maps the unified shape back to that contract.
|
|
1288
|
+
*/
|
|
1289
|
+
export function toTaskConfig(config: TaskplaneConfig): {
|
|
1290
|
+
project: { name: string; description: string };
|
|
1291
|
+
paths: { tasks: string; architecture?: string };
|
|
1292
|
+
testing: { commands: Record<string, string> };
|
|
1293
|
+
standards: { docs: string[]; rules: string[] };
|
|
1294
|
+
standards_overrides: Record<string, { docs?: string[]; rules?: string[] }>;
|
|
1295
|
+
task_areas: Record<string, { path: string; [key: string]: any }>;
|
|
1296
|
+
worker: { model: string; tools: string; thinking: string; spawn_mode?: "subprocess" };
|
|
1297
|
+
reviewer: { model: string; tools: string; thinking: string };
|
|
1298
|
+
context: {
|
|
1299
|
+
worker_context_window: number;
|
|
1300
|
+
warn_percent: number;
|
|
1301
|
+
kill_percent: number;
|
|
1302
|
+
max_worker_iterations: number;
|
|
1303
|
+
max_review_cycles: number;
|
|
1304
|
+
no_progress_limit: number;
|
|
1305
|
+
max_worker_minutes?: number;
|
|
1306
|
+
};
|
|
1307
|
+
quality_gate: {
|
|
1308
|
+
enabled: boolean;
|
|
1309
|
+
review_model: string;
|
|
1310
|
+
max_review_cycles: number;
|
|
1311
|
+
max_fix_cycles: number;
|
|
1312
|
+
pass_threshold: "no_critical" | "no_important" | "all_clear";
|
|
1313
|
+
};
|
|
1314
|
+
} {
|
|
1315
|
+
const tr = config.taskRunner;
|
|
1316
|
+
|
|
1317
|
+
// Build standards_overrides with snake_case outer structure
|
|
1318
|
+
const stdOverrides: Record<string, { docs?: string[]; rules?: string[] }> = {};
|
|
1319
|
+
for (const [key, val] of Object.entries(tr.standardsOverrides)) {
|
|
1320
|
+
stdOverrides[key] = { docs: val.docs, rules: val.rules };
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// Build task_areas
|
|
1324
|
+
const taskAreas: Record<string, { path: string; [key: string]: any }> = {};
|
|
1325
|
+
for (const [key, val] of Object.entries(tr.taskAreas)) {
|
|
1326
|
+
taskAreas[key] = { path: val.path, prefix: val.prefix, context: val.context };
|
|
1327
|
+
if (val.repoId) (taskAreas[key] as any).repo_id = val.repoId;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
return {
|
|
1331
|
+
project: { ...tr.project },
|
|
1332
|
+
paths: { ...tr.paths },
|
|
1333
|
+
testing: { commands: { ...tr.testing.commands } },
|
|
1334
|
+
standards: { docs: [...tr.standards.docs], rules: [...tr.standards.rules] },
|
|
1335
|
+
standards_overrides: stdOverrides,
|
|
1336
|
+
task_areas: taskAreas,
|
|
1337
|
+
worker: {
|
|
1338
|
+
model: tr.worker.model,
|
|
1339
|
+
tools: tr.worker.tools,
|
|
1340
|
+
thinking: tr.worker.thinking,
|
|
1341
|
+
spawn_mode: tr.worker.spawnMode,
|
|
1342
|
+
},
|
|
1343
|
+
reviewer: { model: tr.reviewer.model, tools: tr.reviewer.tools, thinking: tr.reviewer.thinking },
|
|
1344
|
+
context: {
|
|
1345
|
+
worker_context_window: tr.context.workerContextWindow,
|
|
1346
|
+
warn_percent: tr.context.warnPercent,
|
|
1347
|
+
kill_percent: tr.context.killPercent,
|
|
1348
|
+
max_worker_iterations: tr.context.maxWorkerIterations,
|
|
1349
|
+
max_review_cycles: tr.context.maxReviewCycles,
|
|
1350
|
+
no_progress_limit: tr.context.noProgressLimit,
|
|
1351
|
+
max_worker_minutes: tr.context.maxWorkerMinutes,
|
|
1352
|
+
},
|
|
1353
|
+
quality_gate: {
|
|
1354
|
+
enabled: tr.qualityGate.enabled,
|
|
1355
|
+
review_model: tr.qualityGate.reviewModel,
|
|
1356
|
+
max_review_cycles: tr.qualityGate.maxReviewCycles,
|
|
1357
|
+
max_fix_cycles: tr.qualityGate.maxFixCycles,
|
|
1358
|
+
pass_threshold: tr.qualityGate.passThreshold,
|
|
1359
|
+
},
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// ── Task Runner Config Loader ───────────────────────────────────────────
|
|
1364
|
+
//
|
|
1365
|
+
// loadConfig and _resetPointerWarning for task execution consumers.
|
|
1366
|
+
|
|
1367
|
+
/** Track whether a pointer warning has been logged this session (log once). */
|
|
1368
|
+
let _pointerWarningLogged = false;
|
|
1369
|
+
|
|
1370
|
+
/**
|
|
1371
|
+
* Resolve the workspace pointer for config and agent path redirection.
|
|
1372
|
+
* Returns null in repo mode (TASKPLANE_WORKSPACE_ROOT not set).
|
|
1373
|
+
*/
|
|
1374
|
+
function resolveTaskRunnerPointer(): PointerResolution | null {
|
|
1375
|
+
const wsRoot = process.env.TASKPLANE_WORKSPACE_ROOT;
|
|
1376
|
+
if (!wsRoot) return null;
|
|
1377
|
+
try {
|
|
1378
|
+
const wsConfig = loadWorkspaceConfig(wsRoot);
|
|
1379
|
+
const result = resolvePointer(wsRoot, wsConfig);
|
|
1380
|
+
if (result?.warning && !_pointerWarningLogged) {
|
|
1381
|
+
_pointerWarningLogged = true;
|
|
1382
|
+
console.error(`[task-runner] pointer: ${result.warning}`);
|
|
1383
|
+
}
|
|
1384
|
+
return result;
|
|
1385
|
+
} catch {
|
|
1386
|
+
return null;
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
/** Reset pointer warning state (for testing only). */
|
|
1391
|
+
export function _resetPointerWarning(): void {
|
|
1392
|
+
_pointerWarningLogged = false;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/**
|
|
1396
|
+
* Load task-runner config via the unified config loader.
|
|
1397
|
+
*
|
|
1398
|
+
* Returns the legacy snake_case TaskConfig shape. Wraps loadProjectConfig
|
|
1399
|
+
* with pointer resolution and error handling.
|
|
1400
|
+
*/
|
|
1401
|
+
export function loadConfig(cwd: string): ReturnType<typeof toTaskConfig> {
|
|
1402
|
+
try {
|
|
1403
|
+
const pointer = resolveTaskRunnerPointer();
|
|
1404
|
+
const unified = loadProjectConfig(cwd, pointer?.configRoot);
|
|
1405
|
+
return toTaskConfig(unified);
|
|
1406
|
+
} catch (err: unknown) {
|
|
1407
|
+
if (err instanceof ConfigLoadError && err.code === "CONFIG_LEGACY_FIELD") {
|
|
1408
|
+
throw err;
|
|
1409
|
+
}
|
|
1410
|
+
return toTaskConfig(deepClone(DEFAULT_PROJECT_CONFIG));
|
|
1411
|
+
}
|
|
1412
|
+
}
|