orchestrator-workflow 0.25.0 → 0.26.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 +161 -0
- package/INSTALL-AGENT.md +44 -4
- package/README.md +122 -4
- package/assets/agents-md-section.md +7 -3
- package/assets/skill/SKILL.md +41 -6
- package/assets/templates/00-goal.md +1 -0
- package/dist/cli-inputs.d.ts +71 -0
- package/dist/cli-inputs.js +253 -0
- package/dist/cli.js +883 -183
- package/dist/detect.d.ts +11 -0
- package/dist/detect.js +30 -0
- package/dist/doctor.d.ts +222 -0
- package/dist/doctor.js +411 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/init.d.ts +35 -0
- package/dist/init.js +106 -6
- package/dist/operator-manifest.d.ts +277 -0
- package/dist/operator-manifest.js +538 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
import inquirer from "inquirer";
|
|
6
6
|
import { PACKAGE_VERSION } from "./assets.js";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import { readInstalledManifest, runInit } from "./init.js";
|
|
7
|
+
import { resolveInitInputs } from "./cli-inputs.js";
|
|
8
|
+
import { HARNESSES, detectHarnesses } from "./detect.js";
|
|
9
|
+
import { DEFAULT_MODELS, PROFILES } from "./models.js";
|
|
10
|
+
import { MANIFEST_PATH, readInstalledManifest, runInit } from "./init.js";
|
|
11
|
+
import { OPERATOR_MANIFEST_FILENAME, OperatorManifestLockTimeoutError, applyRegistrationFailureMessage, createOperatorManifest, operatorManifestState, readOperatorManifest, resolveOperatorHome, safeRealpath, updateOperatorManifest, upsertOperatorTarget, } from "./operator-manifest.js";
|
|
11
12
|
import { runUninstall } from "./uninstall.js";
|
|
13
|
+
import { adoptExitCodeForStatus, adoptJsonExtras, inspectTarget, runDoctor, statOrClassify, suppressSuccessLine, targetReportToJson, } from "./doctor.js";
|
|
12
14
|
function isInteractive() {
|
|
13
15
|
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
14
16
|
}
|
|
@@ -19,6 +21,17 @@ function showPaths(label, paths) {
|
|
|
19
21
|
for (const path of paths)
|
|
20
22
|
console.log(` ${path}`);
|
|
21
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Formats the "installed for: ..." clause of `init`/`apply`'s final summary
|
|
26
|
+
* line. An empty `harnesses` list is templates-only mode (`--harness none`):
|
|
27
|
+
* "installed for: " with nothing after the colon reads as broken output, so
|
|
28
|
+
* that case prints "templates only" instead.
|
|
29
|
+
*/
|
|
30
|
+
function installedForClause(harnesses) {
|
|
31
|
+
return harnesses.length > 0
|
|
32
|
+
? `installed for: ${harnesses.join(", ")}`
|
|
33
|
+
: "templates only";
|
|
34
|
+
}
|
|
22
35
|
function requireDirectory(dir) {
|
|
23
36
|
const targetDir = resolve(dir);
|
|
24
37
|
if (!existsSync(targetDir) || !statSync(targetDir).isDirectory()) {
|
|
@@ -28,92 +41,45 @@ function requireDirectory(dir) {
|
|
|
28
41
|
}
|
|
29
42
|
return targetDir;
|
|
30
43
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const { profile } = await inquirer.prompt([
|
|
56
|
-
{
|
|
57
|
-
type: "list",
|
|
58
|
-
name: "profile",
|
|
59
|
-
message: "Which subagent roles should be installed?",
|
|
60
|
-
default: base,
|
|
61
|
-
choices: [
|
|
62
|
-
{
|
|
63
|
-
name: `full — ${fullRoles} (default)`,
|
|
64
|
-
value: "full",
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
name: `minimal — ${minimalRoles} only (reviewer is never optional)`,
|
|
68
|
-
value: "minimal",
|
|
69
|
-
},
|
|
70
|
-
],
|
|
71
|
-
},
|
|
72
|
-
]);
|
|
73
|
-
return profile;
|
|
44
|
+
/**
|
|
45
|
+
* `resolveInitInputs` was extracted from `init` and typed against `init`'s
|
|
46
|
+
* per-repo `Manifest` (kit/version/files/installedAt included), since that
|
|
47
|
+
* is the only "previous install" shape it existed to read before this
|
|
48
|
+
* command. `setup` has no repository and no such record; it only has the
|
|
49
|
+
* operator manifest's `defaults` (harnesses/profile/models/tiers). The
|
|
50
|
+
* function only ever reads those four fields off `previous`, so this maps
|
|
51
|
+
* `defaults` into a `Manifest`-shaped value with harmless placeholders for
|
|
52
|
+
* the unused fields, rather than changing `resolveInitInputs`'s signature
|
|
53
|
+
* or semantics.
|
|
54
|
+
*/
|
|
55
|
+
function defaultsAsManifest(defaults) {
|
|
56
|
+
if (!defaults)
|
|
57
|
+
return undefined;
|
|
58
|
+
return {
|
|
59
|
+
kit: "orchestrator-workflow",
|
|
60
|
+
version: PACKAGE_VERSION,
|
|
61
|
+
harnesses: defaults.harnesses,
|
|
62
|
+
models: { ...DEFAULT_MODELS, ...defaults.models },
|
|
63
|
+
profile: defaults.profile,
|
|
64
|
+
tiers: defaults.tiers,
|
|
65
|
+
files: {},
|
|
66
|
+
installedAt: "",
|
|
67
|
+
};
|
|
74
68
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
{ name: "custom model id", value: "__custom__" },
|
|
90
|
-
],
|
|
91
|
-
},
|
|
92
|
-
]);
|
|
93
|
-
if (choice === "__custom__") {
|
|
94
|
-
const { custom } = await inquirer.prompt([
|
|
95
|
-
{
|
|
96
|
-
type: "input",
|
|
97
|
-
name: "custom",
|
|
98
|
-
message: `Custom model id for ${role}:`,
|
|
99
|
-
validate: (value) => {
|
|
100
|
-
try {
|
|
101
|
-
assertValidModelId(value.trim());
|
|
102
|
-
return true;
|
|
103
|
-
}
|
|
104
|
-
catch (error) {
|
|
105
|
-
return error instanceof Error ? error.message : String(error);
|
|
106
|
-
}
|
|
107
|
-
},
|
|
108
|
-
},
|
|
109
|
-
]);
|
|
110
|
-
models[role] = custom.trim();
|
|
111
|
-
}
|
|
112
|
-
else {
|
|
113
|
-
models[role] = choice;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
return models;
|
|
69
|
+
/**
|
|
70
|
+
* Order- and completeness-insensitive comparison of two operator defaults
|
|
71
|
+
* sets, used to decide whether `setup` needs to rewrite the manifest at
|
|
72
|
+
* all (a plain re-run that resolves to the same values must not touch
|
|
73
|
+
* `updatedAt` or the file).
|
|
74
|
+
*/
|
|
75
|
+
function defaultsEqual(a, b) {
|
|
76
|
+
const normalize = (d) => JSON.stringify({
|
|
77
|
+
harnesses: [...d.harnesses].sort(),
|
|
78
|
+
profile: d.profile,
|
|
79
|
+
tiers: d.tiers,
|
|
80
|
+
models: Object.fromEntries(Object.entries(d.models).sort(([x], [y]) => x.localeCompare(y))),
|
|
81
|
+
});
|
|
82
|
+
return normalize(a) === normalize(b);
|
|
117
83
|
}
|
|
118
84
|
const program = new Command();
|
|
119
85
|
program
|
|
@@ -126,7 +92,7 @@ program
|
|
|
126
92
|
.argument("[dir]", "target repository directory", ".")
|
|
127
93
|
.option("-y, --yes", "accept all defaults and skip prompts")
|
|
128
94
|
.option("-f, --force", "overwrite kit-owned files that have local edits")
|
|
129
|
-
.option("--harness <list>", `comma-separated harnesses (${HARNESSES.join(", ")}); default: detected`)
|
|
95
|
+
.option("--harness <list>", `comma-separated harnesses (${HARNESSES.join(", ")}), or "none" alone for templates-only mode (.ai/workflow/** and .ai/runs/.gitkeep only, no AGENTS.md/CLAUDE.md/harness files); default: detected`)
|
|
130
96
|
.option("--models <spec>", 'per-role model overrides, e.g. "implementer=sonnet,reviewer=opus"')
|
|
131
97
|
.option("--profile <profile>", `subagent role profile (${PROFILES.join(", ")}); default: full, or the previously installed profile on a re-run`)
|
|
132
98
|
.option("--opencode-provider <id>", "opencode provider id for alias resolution (e.g. github-copilot); auto-detected when omitted")
|
|
@@ -157,97 +123,19 @@ program
|
|
|
157
123
|
: "none recorded";
|
|
158
124
|
console.log(`Found existing install (${version.startsWith("unknown") ? version : `v${version}`}, harnesses: ${installedFor}, profile: ${previous.profile}, tiers: ${previous.tiers})`);
|
|
159
125
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
// Explicit --profile always overrides; a plain re-run keeps the
|
|
174
|
-
// profile from the previous install (same override-vs-persist rule as
|
|
175
|
-
// --harness/--models above); a fresh install with no prior manifest
|
|
176
|
-
// defaults to full.
|
|
177
|
-
let profile;
|
|
178
|
-
if (opts.profile) {
|
|
179
|
-
profile = parseProfile(opts.profile);
|
|
180
|
-
}
|
|
181
|
-
else {
|
|
182
|
-
profile = previous?.profile ?? DEFAULT_PROFILE;
|
|
183
|
-
if (interactive)
|
|
184
|
-
profile = await promptProfile(profile);
|
|
185
|
-
}
|
|
186
|
-
let models = {
|
|
187
|
-
...DEFAULT_MODELS,
|
|
188
|
-
...(previous?.models ?? {}),
|
|
189
|
-
};
|
|
190
|
-
if (opts.models)
|
|
191
|
-
models = parseModelsSpec(opts.models, models);
|
|
192
|
-
if (interactive && !opts.models)
|
|
193
|
-
models = await promptModels(models, rolesForProfile(profile));
|
|
194
|
-
// Explicit --tiers/--no-tiers always override; a plain re-run (neither
|
|
195
|
-
// flag passed) keeps whatever the previous install had (default false
|
|
196
|
-
// for a fresh install), same override-vs-persist rule as
|
|
197
|
-
// --profile/--models above. commander's negatable-option pairing
|
|
198
|
-
// (--tiers / --no-tiers declared under the same "tiers" option name)
|
|
199
|
-
// resolves opts.tiers to `true` when --tiers is passed, `false` when
|
|
200
|
-
// --no-tiers is passed, and `undefined` when neither is passed; the
|
|
201
|
-
// CLI re-run test below verifies this against the installed commander
|
|
202
|
-
// version rather than assuming it. No interactive prompt: tiers is
|
|
203
|
-
// opt-in/off via the flags only.
|
|
204
|
-
const tiers = opts.tiers ?? previous?.tiers ?? false;
|
|
205
|
-
// Resolve opencode model aliases against the live catalog when the opencode
|
|
206
|
-
// harness is selected. The shell-out stays here in the CLI so runInit
|
|
207
|
-
// remains pure.
|
|
208
|
-
let opencodeModels;
|
|
209
|
-
let opencodeClassModels;
|
|
210
|
-
if (harnesses.includes("opencode")) {
|
|
211
|
-
const catalog = loadOpencodeCatalog();
|
|
212
|
-
const { resolved, warnings } = resolveOpencodeModels(models, {
|
|
213
|
-
catalog,
|
|
214
|
-
explicitProvider: opts.opencodeProvider,
|
|
215
|
-
});
|
|
216
|
-
opencodeModels = resolved;
|
|
217
|
-
for (const w of warnings) {
|
|
218
|
-
process.stderr.write(`Warning: ${w}\n`);
|
|
219
|
-
}
|
|
220
|
-
if (tiers) {
|
|
221
|
-
const providerResult = detectProvider({
|
|
222
|
-
catalog,
|
|
223
|
-
explicit: opts.opencodeProvider,
|
|
224
|
-
});
|
|
225
|
-
opencodeClassModels = {};
|
|
226
|
-
for (const modelClass of MODEL_CLASSES) {
|
|
227
|
-
const alias = CLASS_MODELS[modelClass];
|
|
228
|
-
const resolved = providerResult.provider
|
|
229
|
-
? resolveAlias(providerResult.provider, alias, catalog)
|
|
230
|
-
: undefined;
|
|
231
|
-
opencodeClassModels[modelClass] = resolved;
|
|
232
|
-
if (resolved !== undefined)
|
|
233
|
-
continue;
|
|
234
|
-
// One warning per unresolved model class: without it, every
|
|
235
|
-
// effort-tier variant keyed to this class is silently skipped
|
|
236
|
-
// (init.ts skips the variant write entirely when the class
|
|
237
|
-
// model is unresolved), with nothing on stderr saying why.
|
|
238
|
-
const reason = providerResult.provider
|
|
239
|
-
? `provider "${providerResult.provider}" has no "${alias}" model in the catalog`
|
|
240
|
-
: providerResult.ambiguous
|
|
241
|
-
? `multiple providers offer Claude models in the live catalog; cannot auto-detect`
|
|
242
|
-
: `no provider offering Claude models found in the live catalog`;
|
|
243
|
-
// States the real effect (no variant file at all, not just a
|
|
244
|
-
// missing model: line, since init.ts skips the write entirely
|
|
245
|
-
// when the class never resolves) and the real scope (opencode
|
|
246
|
-
// only: Claude Code variants resolve model: from a plain alias
|
|
247
|
-
// and need no live catalog lookup, so they are unaffected).
|
|
248
|
-
process.stderr.write(`Warning: Tier model class "${modelClass}" (alias "${alias}") could not be resolved to an opencode model id (${reason}); no opencode effort-tier variant files will be rendered for this class (Claude Code variants are unaffected).\n`);
|
|
249
|
-
}
|
|
250
|
-
}
|
|
126
|
+
const { harnesses, profile, models, tiers, opencodeModels, opencodeClassModels, warnings, } = await resolveInitInputs({
|
|
127
|
+
detected,
|
|
128
|
+
interactive,
|
|
129
|
+
previous,
|
|
130
|
+
opts,
|
|
131
|
+
// `previous` here is `readInstalledManifest(targetDir)` (undefined,
|
|
132
|
+
// or the target's own actually-recorded manifest), unlike `apply`'s
|
|
133
|
+
// synthetic operator-defaults "floor" object: an empty harnesses
|
|
134
|
+
// array is a real recorded --harness none install here.
|
|
135
|
+
previousIsRecordedManifest: true,
|
|
136
|
+
});
|
|
137
|
+
for (const w of warnings) {
|
|
138
|
+
process.stderr.write(`${w}\n`);
|
|
251
139
|
}
|
|
252
140
|
const report = runInit({
|
|
253
141
|
targetDir,
|
|
@@ -265,7 +153,112 @@ program
|
|
|
265
153
|
showPaths("Conflicts (local edits kept, re-run with --force to overwrite)", report.conflicted);
|
|
266
154
|
for (const note of report.notes)
|
|
267
155
|
console.log(note);
|
|
268
|
-
console.log(`\norchestrator-workflow v${PACKAGE_VERSION}
|
|
156
|
+
console.log(`\norchestrator-workflow v${PACKAGE_VERSION} ${installedForClause(harnesses)} (profile: ${profile}, tiers: ${tiers})`);
|
|
157
|
+
});
|
|
158
|
+
program
|
|
159
|
+
.command("setup")
|
|
160
|
+
.description("Write or update this operator's default install options (harnesses, profile, models, tiers), used as the baseline for future installs; touches no repository")
|
|
161
|
+
.option("-y, --yes", "accept all defaults and skip prompts")
|
|
162
|
+
.option("--harness <list>", `comma-separated harnesses (${HARNESSES.join(", ")}); default: previously stored, or claude`)
|
|
163
|
+
.option("--models <spec>", 'per-role model overrides, e.g. "implementer=sonnet,reviewer=opus"')
|
|
164
|
+
.option("--profile <profile>", `subagent role profile (${PROFILES.join(", ")}); default: full, or the previously stored profile on a re-run`)
|
|
165
|
+
.option("--opencode-provider <id>", "opencode provider id for alias resolution (e.g. github-copilot); auto-detected when omitted")
|
|
166
|
+
.option("--tiers", "select per-role effort-tier subagent variants by default; default: off, or the previously stored value on a re-run")
|
|
167
|
+
.option("--no-tiers", "explicitly turn effort-tier subagent variants off, overriding a previously stored --tiers value")
|
|
168
|
+
.action(async (opts) => {
|
|
169
|
+
const interactive = !opts.yes && isInteractive();
|
|
170
|
+
const home = resolveOperatorHome();
|
|
171
|
+
console.log(`Operator home: ${home}`);
|
|
172
|
+
const existing = readOperatorManifest(home);
|
|
173
|
+
if (existing) {
|
|
174
|
+
const installedFor = existing.defaults.harnesses.length > 0
|
|
175
|
+
? existing.defaults.harnesses.join(", ")
|
|
176
|
+
: "none recorded";
|
|
177
|
+
console.log(`Found existing operator defaults (harnesses: ${installedFor}, profile: ${existing.defaults.profile}, tiers: ${existing.defaults.tiers})`);
|
|
178
|
+
}
|
|
179
|
+
// No target repository exists to detect harnesses against; "claude"
|
|
180
|
+
// is the same shipped fallback `init` uses for a fresh install with
|
|
181
|
+
// nothing detected and nothing previously recorded.
|
|
182
|
+
const { harnesses, profile, models, tiers, warnings } = await resolveInitInputs({
|
|
183
|
+
// No target directory exists to detect against: the stored
|
|
184
|
+
// harnesses are the baseline, and only a first-ever setup falls
|
|
185
|
+
// back to claude, so a codex-only default is not widened silently.
|
|
186
|
+
detected: existing?.defaults.harnesses.length
|
|
187
|
+
? existing.defaults.harnesses
|
|
188
|
+
: ["claude"],
|
|
189
|
+
interactive,
|
|
190
|
+
previous: defaultsAsManifest(existing?.defaults),
|
|
191
|
+
opts,
|
|
192
|
+
});
|
|
193
|
+
for (const w of warnings) {
|
|
194
|
+
process.stderr.write(`${w}\n`);
|
|
195
|
+
}
|
|
196
|
+
const newDefaults = {
|
|
197
|
+
harnesses,
|
|
198
|
+
profile,
|
|
199
|
+
tiers,
|
|
200
|
+
models,
|
|
201
|
+
};
|
|
202
|
+
// The prompts and resolution above ran against `existing`, an
|
|
203
|
+
// unlocked read taken before this point; another process (an
|
|
204
|
+
// `apply` registering a target, most plausibly) may have already
|
|
205
|
+
// written a newer manifest by the time this reaches the lock. The
|
|
206
|
+
// mutate callback below re-reads inside the lock (`current`) and
|
|
207
|
+
// merges only the newly computed defaults onto that fresh copy, so
|
|
208
|
+
// `current.targets`/`current.createdAt` are what survive into the
|
|
209
|
+
// write, never `existing`'s (`updateOperatorManifest` is this
|
|
210
|
+
// module's sole write path; nothing here calls the raw writer
|
|
211
|
+
// directly). The unchanged/created/updated decision is likewise
|
|
212
|
+
// made against `current.defaults`, not `existing.defaults`. A
|
|
213
|
+
// manifest that re-reads as `unreadable` inside the lock (damaged or
|
|
214
|
+
// hand-edited since the unlocked `existing` read above) must not be
|
|
215
|
+
// silently replaced with a fresh one: that would discard whatever
|
|
216
|
+
// targets survive in the damaged file. `mutate` returns `undefined`
|
|
217
|
+
// in that case (no write), and the caller below reports it and exits
|
|
218
|
+
// non-zero instead of ever printing a created/updated status.
|
|
219
|
+
const result = updateOperatorManifest(home, (current, state) => {
|
|
220
|
+
if (state.kind === "unreadable") {
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
if (!current) {
|
|
224
|
+
return createOperatorManifest(newDefaults);
|
|
225
|
+
}
|
|
226
|
+
if (defaultsEqual(current.defaults, newDefaults)) {
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
// `updatedAt` is no longer set here: `updateOperatorManifest`
|
|
230
|
+
// stamps it centrally on any write that refreshes an existing
|
|
231
|
+
// manifest (`current` is truthy in this branch), the same way it
|
|
232
|
+
// now does for `apply`'s and `adopt`'s own refreshes (fix-round,
|
|
233
|
+
// review finding L10).
|
|
234
|
+
const manifest = {
|
|
235
|
+
...current,
|
|
236
|
+
defaults: newDefaults,
|
|
237
|
+
};
|
|
238
|
+
return manifest;
|
|
239
|
+
});
|
|
240
|
+
if (result.state.kind === "unreadable") {
|
|
241
|
+
process.stderr.write(`Operator manifest at ${join(home, "manifest.json")} is unreadable; back it up and repair it (any recorded targets would be lost by overwriting it), or remove it and run setup again.\n`);
|
|
242
|
+
process.exitCode = 1;
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const status = !result.written
|
|
246
|
+
? "unchanged"
|
|
247
|
+
: result.state.kind === "ok"
|
|
248
|
+
? "updated"
|
|
249
|
+
: "created";
|
|
250
|
+
console.log(`Harnesses: ${harnesses.join(", ")}`);
|
|
251
|
+
console.log(`Profile: ${profile}`);
|
|
252
|
+
console.log(`Models: ${Object.entries(models)
|
|
253
|
+
.map(([role, model]) => `${role}=${model}`)
|
|
254
|
+
.join(", ")}`);
|
|
255
|
+
console.log(`Tiers: ${tiers}`);
|
|
256
|
+
console.log(status === "created"
|
|
257
|
+
? "Created operator defaults."
|
|
258
|
+
: status === "updated"
|
|
259
|
+
? "Updated operator defaults."
|
|
260
|
+
: "Unchanged.");
|
|
261
|
+
console.log("Next: orchestrator-workflow apply --target <repo>");
|
|
269
262
|
});
|
|
270
263
|
program
|
|
271
264
|
.command("uninstall")
|
|
@@ -313,6 +306,713 @@ program
|
|
|
313
306
|
console.log(note);
|
|
314
307
|
console.log(`\norchestrator-workflow uninstalled from ${targetDir}`);
|
|
315
308
|
});
|
|
309
|
+
/**
|
|
310
|
+
* Detects a repo manifest whose raw JSON carries a `pin` key that
|
|
311
|
+
* `readInstalledManifest` (init.ts) silently dropped rather than surfacing:
|
|
312
|
+
* a non-string value, or a string that is empty or whitespace-only after
|
|
313
|
+
* trimming. `readInstalledManifest` already re-parses the file itself and
|
|
314
|
+
* applies this exact degradation rule for `pin` specifically; this helper
|
|
315
|
+
* duplicates just that one check against a second, independent parse of
|
|
316
|
+
* the same file, rather than changing `readInstalledManifest`'s return
|
|
317
|
+
* shape to also report which fields it dropped. Read failures (missing
|
|
318
|
+
* file, invalid JSON, non-object) are not this helper's concern: they are
|
|
319
|
+
* already `readInstalledManifest`'s "no record" case, with no pin to warn
|
|
320
|
+
* about either way.
|
|
321
|
+
*/
|
|
322
|
+
function repoManifestHasMalformedPin(targetDir) {
|
|
323
|
+
const path = join(targetDir, ".ai", "workflow", "manifest.json");
|
|
324
|
+
if (!existsSync(path))
|
|
325
|
+
return false;
|
|
326
|
+
let raw;
|
|
327
|
+
try {
|
|
328
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
if (typeof raw !== "object" || raw === null)
|
|
334
|
+
return false;
|
|
335
|
+
const candidate = raw;
|
|
336
|
+
if (!("pin" in candidate))
|
|
337
|
+
return false;
|
|
338
|
+
return !(typeof candidate.pin === "string" && candidate.pin.trim() !== "");
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Resolves `apply`'s harnesses ahead of `resolveInitInputs`, per the
|
|
342
|
+
* fallback chain an explicit `--harness` does not need: the target's own
|
|
343
|
+
* recorded harnesses, else the operator manifest's default harnesses, else
|
|
344
|
+
* (only when both are empty) `detectHarnesses(targetDir)` or `["claude"]`,
|
|
345
|
+
* the same shipped fallback `init` uses. The result is handed to
|
|
346
|
+
* `resolveInitInputs` as `detected`, and mirrored into the synthetic
|
|
347
|
+
* `previous.harnesses` field built by `buildApplyPrevious` below, so
|
|
348
|
+
* `resolveInitInputs`'s own union-of-detected-and-installed fallback
|
|
349
|
+
* resolves to exactly this value rather than widening it further.
|
|
350
|
+
*/
|
|
351
|
+
function resolveApplyHarnesses(targetDir, repoManifest, operatorDefaults) {
|
|
352
|
+
if (repoManifest && repoManifest.harnesses.length > 0) {
|
|
353
|
+
return repoManifest.harnesses;
|
|
354
|
+
}
|
|
355
|
+
if (operatorDefaults.harnesses.length > 0) {
|
|
356
|
+
return operatorDefaults.harnesses;
|
|
357
|
+
}
|
|
358
|
+
const detected = detectHarnesses(targetDir);
|
|
359
|
+
return detected.length > 0 ? detected : ["claude"];
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Builds the synthetic `previous` manifest handed to `resolveInitInputs`,
|
|
363
|
+
* implementing `apply`'s profile/tiers/models precedence: operator defaults
|
|
364
|
+
* are the floor; when the target has its own recorded manifest, that
|
|
365
|
+
* recording overrides the operator default for profile/tiers/models UNLESS
|
|
366
|
+
* `--sync` is passed, in which case the operator default overrides the
|
|
367
|
+
* recording instead (an explicit CLI flag still wins over either, handled
|
|
368
|
+
* inside `resolveInitInputs` itself). `harnesses` on the returned value is
|
|
369
|
+
* always the target's own recorded harnesses (never the operator default),
|
|
370
|
+
* since `--sync` only affects profile/tiers/models per the rule above; the
|
|
371
|
+
* full harnesses fallback chain is `resolveApplyHarnesses`'s job, not this
|
|
372
|
+
* function's.
|
|
373
|
+
*/
|
|
374
|
+
function buildApplyPrevious(repoManifest, operatorDefaults, sync) {
|
|
375
|
+
const harnesses = repoManifest?.harnesses ?? [];
|
|
376
|
+
const models = sync
|
|
377
|
+
? { ...DEFAULT_MODELS, ...operatorDefaults.models }
|
|
378
|
+
: {
|
|
379
|
+
...DEFAULT_MODELS,
|
|
380
|
+
...operatorDefaults.models,
|
|
381
|
+
...repoManifest?.models,
|
|
382
|
+
};
|
|
383
|
+
const profile = sync
|
|
384
|
+
? operatorDefaults.profile
|
|
385
|
+
: (repoManifest?.profile ?? operatorDefaults.profile);
|
|
386
|
+
const tiers = sync
|
|
387
|
+
? operatorDefaults.tiers
|
|
388
|
+
: (repoManifest?.tiers ?? operatorDefaults.tiers);
|
|
389
|
+
return {
|
|
390
|
+
kit: "orchestrator-workflow",
|
|
391
|
+
version: PACKAGE_VERSION,
|
|
392
|
+
harnesses,
|
|
393
|
+
models,
|
|
394
|
+
profile,
|
|
395
|
+
tiers,
|
|
396
|
+
files: {},
|
|
397
|
+
installedAt: "",
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
program
|
|
401
|
+
.command("apply")
|
|
402
|
+
.description("Project this operator's install onto a target repository, sourced from the operator manifest's defaults and the target's previously recorded settings; registers the target in the operator manifest")
|
|
403
|
+
.requiredOption("--target <repo>", "target repository directory to apply the kit to")
|
|
404
|
+
.option("-y, --yes", "accept all defaults and skip prompts")
|
|
405
|
+
.option("-f, --force", "overwrite kit-owned files that have local edits")
|
|
406
|
+
.option("--harness <list>", `comma-separated harnesses (${HARNESSES.join(", ")}); default: the target's recorded harnesses, else the operator defaults, else detected`)
|
|
407
|
+
.option("--models <spec>", 'per-role model overrides, e.g. "implementer=sonnet,reviewer=opus"')
|
|
408
|
+
.option("--profile <profile>", `subagent role profile (${PROFILES.join(", ")}); default: the target's recorded profile, else the operator default`)
|
|
409
|
+
.option("--opencode-provider <id>", "opencode provider id for alias resolution (e.g. github-copilot); auto-detected when omitted")
|
|
410
|
+
.option("--tiers", "also render per-role effort-tier subagent variants (<role>-<tier>.md); default: the target's recorded value, else the operator default")
|
|
411
|
+
.option("--no-tiers", "explicitly turn effort-tier subagent variants off, overriding a recorded or operator-default --tiers value")
|
|
412
|
+
.option("--sync", "let the operator defaults for profile/tiers/models override the target's own recorded values, instead of the other way around")
|
|
413
|
+
.option("--force-pin", "proceed past an existing pin that differs from this operator install's version, advancing it to this version; has no effect on a target with no pin recorded (it stays unpinned)")
|
|
414
|
+
.option("--pin <version>", "set or replace the target's recorded kit-version pin and apply this operator install regardless of any existing pin")
|
|
415
|
+
.option("--unpin", "clear the target's recorded kit-version pin and apply this operator install")
|
|
416
|
+
.action(async (opts) => {
|
|
417
|
+
// --pin and --unpin express opposite intents (set a pin vs clear it);
|
|
418
|
+
// accepting both silently would make the effective pin depend on
|
|
419
|
+
// internal option-resolution order, so this is a usage error rather
|
|
420
|
+
// than an implicit precedence rule.
|
|
421
|
+
if (opts.pin !== undefined && opts.unpin) {
|
|
422
|
+
console.error("--pin and --unpin cannot be used together");
|
|
423
|
+
process.exitCode = 2;
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
let pinArg;
|
|
427
|
+
if (opts.pin !== undefined) {
|
|
428
|
+
const trimmed = opts.pin.trim();
|
|
429
|
+
if (trimmed === "" || /\s/.test(trimmed)) {
|
|
430
|
+
console.error(`Invalid --pin value: ${JSON.stringify(opts.pin)}; must be non-empty with no internal whitespace`);
|
|
431
|
+
process.exitCode = 2;
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
pinArg = trimmed;
|
|
435
|
+
}
|
|
436
|
+
const home = resolveOperatorHome();
|
|
437
|
+
// `state.manifest` (the "early copy") is used only for the pin-gate
|
|
438
|
+
// guard just below and for `chosenHarnesses`/`previous`'s defaults;
|
|
439
|
+
// the upsert at the end re-reads the manifest again immediately
|
|
440
|
+
// before writing, rather than reusing this copy, so a target another
|
|
441
|
+
// concurrent `apply` registered in between is not lost. See that
|
|
442
|
+
// re-read's own comment for why the window is narrowed, not closed.
|
|
443
|
+
const state = operatorManifestState(home);
|
|
444
|
+
if (state.kind === "unreadable") {
|
|
445
|
+
console.error(`Operator manifest at ${join(home, "manifest.json")} is unreadable; back it up and repair it, or remove it and run \`orchestrator-workflow setup\` again.`);
|
|
446
|
+
process.exitCode = 1;
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (state.kind === "absent") {
|
|
450
|
+
console.error("No operator setup found; run `orchestrator-workflow setup` first.");
|
|
451
|
+
process.exitCode = 1;
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const operatorManifest = state.manifest;
|
|
455
|
+
const targetDir = requireDirectory(opts.target);
|
|
456
|
+
if (!targetDir)
|
|
457
|
+
return;
|
|
458
|
+
const interactive = !opts.yes && isInteractive();
|
|
459
|
+
const repoManifest = readInstalledManifest(targetDir);
|
|
460
|
+
if (repoManifest) {
|
|
461
|
+
const version = repoManifest.version || "unknown version";
|
|
462
|
+
const installedFor = repoManifest.harnesses.length > 0
|
|
463
|
+
? repoManifest.harnesses.join(", ")
|
|
464
|
+
: "none recorded";
|
|
465
|
+
console.log(`Found existing install (${version.startsWith("unknown") ? version : `v${version}`}, harnesses: ${installedFor}, profile: ${repoManifest.profile}, tiers: ${repoManifest.tiers})`);
|
|
466
|
+
}
|
|
467
|
+
// A hand-written or damaged repo manifest may carry a `pin` key that
|
|
468
|
+
// `readInstalledManifest` silently dropped (non-string, or
|
|
469
|
+
// empty/whitespace after trimming) rather than throwing, the same
|
|
470
|
+
// per-field-degradation style it uses for every other field; that
|
|
471
|
+
// also means the pin gate just below never sees it. Warn once so the
|
|
472
|
+
// operator knows the gate did not run rather than concluding the
|
|
473
|
+
// target is simply unpinned.
|
|
474
|
+
if (repoManifestHasMalformedPin(targetDir)) {
|
|
475
|
+
process.stderr.write(`Ignoring a malformed pin in ${join(targetDir, ".ai", "workflow", "manifest.json")}; the pin gate did not run\n`);
|
|
476
|
+
}
|
|
477
|
+
// Pin gate: a recorded pin that differs from this operator install's
|
|
478
|
+
// kit version blocks a plain apply (the repo asked to stay put)
|
|
479
|
+
// unless the operator explicitly overrides it, either by advancing to
|
|
480
|
+
// the current kit version (--force-pin) or by setting an explicit pin
|
|
481
|
+
// decision of its own (--pin/--unpin); either override is itself an
|
|
482
|
+
// explicit instruction to proceed, so it takes priority over the gate.
|
|
483
|
+
const repoPin = repoManifest?.pin;
|
|
484
|
+
const pinOverridden = Boolean(opts.forcePin || pinArg !== undefined || opts.unpin);
|
|
485
|
+
if (repoPin && repoPin !== PACKAGE_VERSION && !pinOverridden) {
|
|
486
|
+
console.log(`Repository is pinned at ${repoPin}; this operator install is v${PACKAGE_VERSION}. Skipping.`);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
// Say where files will land only once it is certain the apply is
|
|
490
|
+
// actually going to run (the pin gate above may have already
|
|
491
|
+
// returned): an accidental cwd read as `--target` is the most likely
|
|
492
|
+
// operator mistake, and a skipped run must not claim an install is
|
|
493
|
+
// starting. Mirrors `init`'s own "Installing into"/git-root note.
|
|
494
|
+
console.log(`Installing into ${targetDir}`);
|
|
495
|
+
if (!existsSync(join(targetDir, ".git"))) {
|
|
496
|
+
console.log("Note: the target is not a git repository root. Pass a different --target if this is not the repo you meant.");
|
|
497
|
+
}
|
|
498
|
+
const chosenHarnesses = resolveApplyHarnesses(targetDir, repoManifest, operatorManifest.defaults);
|
|
499
|
+
const previous = buildApplyPrevious(repoManifest, operatorManifest.defaults, Boolean(opts.sync));
|
|
500
|
+
const { harnesses, profile, models, tiers, opencodeModels, opencodeClassModels, warnings, } = await resolveInitInputs({
|
|
501
|
+
detected: chosenHarnesses,
|
|
502
|
+
interactive,
|
|
503
|
+
previous,
|
|
504
|
+
opts,
|
|
505
|
+
});
|
|
506
|
+
for (const w of warnings) {
|
|
507
|
+
process.stderr.write(`${w}\n`);
|
|
508
|
+
}
|
|
509
|
+
// `--unpin` clears; an explicit `--pin <version>` sets or replaces
|
|
510
|
+
// (even when it equals the target's existing pin but differs from
|
|
511
|
+
// PACKAGE_VERSION, since the operator asked for this kit version
|
|
512
|
+
// explicitly, the pin gate above already let this call through);
|
|
513
|
+
// `--force-pin` advances the pin to this operator install's version,
|
|
514
|
+
// but only when `repoPin` already held one: it is a "proceed past the
|
|
515
|
+
// gate and catch this target up" instruction, not a "pin this target
|
|
516
|
+
// for the first time" one, so on an unpinned target it must leave the
|
|
517
|
+
// target unpinned rather than pinning it to PACKAGE_VERSION as a side
|
|
518
|
+
// effect; otherwise the pin carries forward unchanged (runInit's own
|
|
519
|
+
// `undefined` semantics).
|
|
520
|
+
const pin = opts.unpin
|
|
521
|
+
? null
|
|
522
|
+
: pinArg !== undefined
|
|
523
|
+
? pinArg
|
|
524
|
+
: opts.forcePin && repoPin
|
|
525
|
+
? PACKAGE_VERSION
|
|
526
|
+
: undefined;
|
|
527
|
+
const report = runInit({
|
|
528
|
+
targetDir,
|
|
529
|
+
harnesses,
|
|
530
|
+
models,
|
|
531
|
+
profile,
|
|
532
|
+
force: opts.force,
|
|
533
|
+
opencodeModels,
|
|
534
|
+
tiers,
|
|
535
|
+
opencodeClassModels,
|
|
536
|
+
pin,
|
|
537
|
+
});
|
|
538
|
+
showPaths("Created", report.written);
|
|
539
|
+
showPaths("Updated", report.updated);
|
|
540
|
+
showPaths("Unchanged", report.skipped);
|
|
541
|
+
showPaths("Conflicts (local edits kept, re-run with --force to overwrite)", report.conflicted);
|
|
542
|
+
for (const note of report.notes)
|
|
543
|
+
console.log(note);
|
|
544
|
+
console.log(`\norchestrator-workflow v${PACKAGE_VERSION} ${installedForClause(harnesses)} (profile: ${profile}, tiers: ${tiers})`);
|
|
545
|
+
// The re-read, upsert, and write below all run inside
|
|
546
|
+
// `updateOperatorManifest`'s single locked critical section, so no
|
|
547
|
+
// other locked writer against this same `home` can interleave its
|
|
548
|
+
// own read-modify-write in between: that closes the operator-
|
|
549
|
+
// manifest lost-update window. The re-read itself is still
|
|
550
|
+
// necessary even under the lock: `operatorManifest` (the copy read
|
|
551
|
+
// at the top, before `runInit` did its file writes, and before this
|
|
552
|
+
// lock was even acquired) may already be stale by the time the lock
|
|
553
|
+
// is granted, since a previous holder's own locked write could have
|
|
554
|
+
// landed in between. `resolvedTargetPath` and `alreadyRegistered`
|
|
555
|
+
// are captured from inside the `mutate` callback (it only returns
|
|
556
|
+
// an `OperatorManifest | undefined`) since both are needed for the
|
|
557
|
+
// messages printed after the lock is released.
|
|
558
|
+
let resolvedTargetPath = "";
|
|
559
|
+
let alreadyRegistered = false;
|
|
560
|
+
let result;
|
|
561
|
+
try {
|
|
562
|
+
result = updateOperatorManifest(home, (current, state) => {
|
|
563
|
+
if (state.kind !== "ok" || !current) {
|
|
564
|
+
return undefined;
|
|
565
|
+
}
|
|
566
|
+
// A run with local edits that conflicted still registers the
|
|
567
|
+
// target and records PACKAGE_VERSION here: the apply itself ran
|
|
568
|
+
// (the conflicting files were left as the operator's local
|
|
569
|
+
// edits, not skipped or aborted), so the registry should
|
|
570
|
+
// reflect that a vPACKAGE_VERSION apply was attempted against
|
|
571
|
+
// this target, the same as any other non-force-pin-gated run.
|
|
572
|
+
// Only the pin gate above returns before reaching this point
|
|
573
|
+
// without registering.
|
|
574
|
+
const upserted = upsertOperatorTarget(current, targetDir, PACKAGE_VERSION, new Date().toISOString());
|
|
575
|
+
resolvedTargetPath = safeRealpath(targetDir);
|
|
576
|
+
alreadyRegistered = upserted.alreadyRegistered;
|
|
577
|
+
return upserted.manifest;
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
catch (error) {
|
|
581
|
+
if (error instanceof OperatorManifestLockTimeoutError) {
|
|
582
|
+
const manifestPath = join(home, "manifest.json");
|
|
583
|
+
console.error(`Could not lock the operator manifest at ${manifestPath} (another orchestrator-workflow command holds it); the kit was installed but the target was not registered. Re-run \`apply\` to register it.`);
|
|
584
|
+
process.exitCode = 1;
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
throw error;
|
|
588
|
+
}
|
|
589
|
+
if (result.state.kind !== "ok") {
|
|
590
|
+
const manifestPath = join(home, "manifest.json");
|
|
591
|
+
console.error(applyRegistrationFailureMessage(result.state.kind, manifestPath, targetDir));
|
|
592
|
+
process.exitCode = 1;
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
console.log(alreadyRegistered
|
|
596
|
+
? `Refreshed the registry entry for ${resolvedTargetPath}`
|
|
597
|
+
: `Registered ${resolvedTargetPath} in the operator manifest`);
|
|
598
|
+
});
|
|
599
|
+
program
|
|
600
|
+
.command("doctor")
|
|
601
|
+
.description("Report each operator-registered target's status: clean, divergent from the operator defaults, version-lagging, hash-drifted, missing, without a repo manifest, or unverifiable (could not be checked at all)")
|
|
602
|
+
.option("--json", "print a single JSON report to stdout and suppress human output")
|
|
603
|
+
.option("--prune", "remove missing and no-manifest targets from the operator registry before reporting; rewrites the whole manifest file in its normalized form (a hand-edited or legacy entry that readOperatorManifest could not parse is dropped from the file, not just left alone)")
|
|
604
|
+
.action(async (opts) => {
|
|
605
|
+
const home = resolveOperatorHome();
|
|
606
|
+
// Test-only escape hatch: shrinks `--prune`'s lock-acquire timeout so a
|
|
607
|
+
// test can force `OperatorManifestLockTimeoutError` (a foreign holder
|
|
608
|
+
// sitting on the lock) without waiting out the production
|
|
609
|
+
// `DEFAULT_LOCK_TIMEOUT_MS`. Unset in every real invocation, and
|
|
610
|
+
// effective only when `--prune` is also passed (only `--prune` ever
|
|
611
|
+
// takes the operator-manifest lock).
|
|
612
|
+
const testLockTimeoutMs = process.env.OW_DOCTOR_TEST_LOCK_TIMEOUT_MS;
|
|
613
|
+
const lockOptions = opts.prune && testLockTimeoutMs
|
|
614
|
+
? { timeoutMs: Number(testLockTimeoutMs), pollMs: 10 }
|
|
615
|
+
: undefined;
|
|
616
|
+
let report;
|
|
617
|
+
try {
|
|
618
|
+
report = runDoctor(home, { prune: opts.prune, lockOptions });
|
|
619
|
+
}
|
|
620
|
+
catch (error) {
|
|
621
|
+
// `runDoctor`'s `--prune` path runs its read-modify-write through
|
|
622
|
+
// `updateOperatorManifest`, which can throw instead of returning:
|
|
623
|
+
// `OperatorManifestLockTimeoutError` when another
|
|
624
|
+
// orchestrator-workflow command already holds the operator-manifest
|
|
625
|
+
// lock past the timeout, or any other error raised while acquiring
|
|
626
|
+
// it (most commonly `EACCES` creating the lock directory itself,
|
|
627
|
+
// e.g. a read-only operator home). Either way the manifest was left
|
|
628
|
+
// untouched; report the failure directly instead of letting an
|
|
629
|
+
// uncaught exception crash the CLI with a raw stack trace.
|
|
630
|
+
const isLockTimeout = error instanceof OperatorManifestLockTimeoutError;
|
|
631
|
+
const doctorError = isLockTimeout
|
|
632
|
+
? "operator-manifest-locked"
|
|
633
|
+
: "operator-manifest-write-failed";
|
|
634
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
635
|
+
if (opts.json) {
|
|
636
|
+
console.log(JSON.stringify({
|
|
637
|
+
operatorHome: home,
|
|
638
|
+
operatorVersion: PACKAGE_VERSION,
|
|
639
|
+
targets: [],
|
|
640
|
+
pruned: [],
|
|
641
|
+
exitCode: 2,
|
|
642
|
+
error: doctorError,
|
|
643
|
+
message,
|
|
644
|
+
}));
|
|
645
|
+
}
|
|
646
|
+
else {
|
|
647
|
+
console.error(isLockTimeout
|
|
648
|
+
? `Could not acquire the operator manifest lock at ${home} (another orchestrator-workflow command holds it): ${message}`
|
|
649
|
+
: `Could not update the operator manifest at ${home}: ${message}`);
|
|
650
|
+
}
|
|
651
|
+
process.exitCode = 2;
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
if (opts.json) {
|
|
655
|
+
console.log(JSON.stringify({
|
|
656
|
+
operatorHome: report.operatorHome,
|
|
657
|
+
operatorVersion: report.operatorVersion,
|
|
658
|
+
targets: report.targets.map(targetReportToJson),
|
|
659
|
+
pruned: report.pruned,
|
|
660
|
+
exitCode: report.exitCode,
|
|
661
|
+
unvalidatedDropped: report.unvalidatedDropped,
|
|
662
|
+
...(report.error ? { error: report.error } : {}),
|
|
663
|
+
}));
|
|
664
|
+
process.exitCode = report.exitCode;
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
if (report.error === "no-operator-manifest") {
|
|
668
|
+
console.error("No operator setup found; run `orchestrator-workflow setup` first.");
|
|
669
|
+
process.exitCode = report.exitCode;
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
if (report.error === "operator-manifest-unreadable") {
|
|
673
|
+
const manifestPath = join(report.operatorHome, OPERATOR_MANIFEST_FILENAME);
|
|
674
|
+
console.error(`Operator manifest at ${manifestPath} is unreadable; back it up and repair it, or remove it and run \`orchestrator-workflow setup\` again.`);
|
|
675
|
+
process.exitCode = report.exitCode;
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
console.log(`Operator home: ${report.operatorHome} (kit v${report.operatorVersion})`);
|
|
679
|
+
const counts = new Map();
|
|
680
|
+
for (const target of report.targets) {
|
|
681
|
+
printTargetDetail(target, report.operatorVersion);
|
|
682
|
+
counts.set(target.status, (counts.get(target.status) ?? 0) + 1);
|
|
683
|
+
}
|
|
684
|
+
const summary = [...counts.entries()]
|
|
685
|
+
.map(([status, count]) => `${count} ${status}`)
|
|
686
|
+
.join(", ");
|
|
687
|
+
const targetCount = report.targets.length;
|
|
688
|
+
console.log(`${targetCount} target${targetCount === 1 ? "" : "s"}: ${summary === "" ? "none" : summary}`);
|
|
689
|
+
if (opts.prune) {
|
|
690
|
+
console.log(`pruned: ${report.pruned.length > 0 ? report.pruned.join(", ") : "(none)"}`);
|
|
691
|
+
// Printed only when the file actually held a raw target entry the
|
|
692
|
+
// parser could not validate (fix-round-2, review finding M3): the
|
|
693
|
+
// note used to print unconditionally whenever anything at all was
|
|
694
|
+
// pruned, even when every dropped entry was a validly-shaped
|
|
695
|
+
// missing/no-manifest target and the file held no unvalidatable
|
|
696
|
+
// entry to report.
|
|
697
|
+
if (report.unvalidatedDropped > 0) {
|
|
698
|
+
console.log(`note: the operator manifest was rewritten in normalized form; ${report.unvalidatedDropped} raw target ${report.unvalidatedDropped === 1 ? "entry" : "entries"} the parser could not validate ${report.unvalidatedDropped === 1 ? "was" : "were"} dropped from the file along with the pruned targets above.`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
process.exitCode = report.exitCode;
|
|
702
|
+
});
|
|
703
|
+
/**
|
|
704
|
+
* Prints one target's doctor-style status line and detail lines. Factored
|
|
705
|
+
* out of `doctor`'s own per-target loop above so `adopt` below can print
|
|
706
|
+
* the exact same format for the single target it just registered, rather
|
|
707
|
+
* than hand-duplicating it; `doctor`'s own output is unchanged (same
|
|
708
|
+
* lines, same order, same content), only the printing code moved into
|
|
709
|
+
* this function. (A function declaration, not a `const`, so it is hoisted
|
|
710
|
+
* above its one call site inside `doctor`'s action, further up this file.)
|
|
711
|
+
*/
|
|
712
|
+
function printTargetDetail(target, operatorVersion) {
|
|
713
|
+
console.log(`${target.status} ${target.path}`);
|
|
714
|
+
if (target.status === "unverifiable" && target.reason) {
|
|
715
|
+
console.log(` ${target.reason}`);
|
|
716
|
+
}
|
|
717
|
+
// Divergence and version-lag detail lines print for both `divergent`
|
|
718
|
+
// and `drift` status lines (fix-round-2, review finding L6): a drift
|
|
719
|
+
// target can also be divergent and/or version-lagging (see
|
|
720
|
+
// doctor.ts's status-precedence doc comment), and before this fix
|
|
721
|
+
// its `divergent`/`version-lag` facts were silently dropped from the
|
|
722
|
+
// human output whenever `drift` won the status field.
|
|
723
|
+
if ((target.status === "divergent" || target.status === "drift") &&
|
|
724
|
+
target.divergence) {
|
|
725
|
+
if (target.divergence.profile) {
|
|
726
|
+
console.log(` profile: repo=${target.repoProfile}, operator=${target.operatorProfile}`);
|
|
727
|
+
}
|
|
728
|
+
if (target.divergence.tiers) {
|
|
729
|
+
console.log(` tiers: repo=${target.repoTiers}, operator=${target.operatorTiers}`);
|
|
730
|
+
}
|
|
731
|
+
if (target.divergence.models) {
|
|
732
|
+
console.log(` models: ${target.divergentModelRoles.join(", ")}`);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
const showsVersionLagDetail = (target.status === "version-lag" ||
|
|
736
|
+
((target.status === "divergent" || target.status === "drift") &&
|
|
737
|
+
target.versionLag)) &&
|
|
738
|
+
target.installedVersion !== null;
|
|
739
|
+
if (showsVersionLagDetail) {
|
|
740
|
+
// A pinned target that is still version-lag (the pin no longer
|
|
741
|
+
// matches the installed version, see doctor.ts's `versionLag`)
|
|
742
|
+
// shows what it is pinned at instead of the operator's running
|
|
743
|
+
// version, which is not the relevant comparison for a pinned
|
|
744
|
+
// target.
|
|
745
|
+
console.log(target.pin
|
|
746
|
+
? ` installed ${target.installedVersion}, pinned at ${target.pin}`
|
|
747
|
+
: ` installed ${target.installedVersion}, operator ${operatorVersion}`);
|
|
748
|
+
}
|
|
749
|
+
if (target.status === "drift" && target.driftFiles) {
|
|
750
|
+
for (const file of target.driftFiles) {
|
|
751
|
+
console.log(` ${file}`);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
if (target.pin && !showsVersionLagDetail) {
|
|
755
|
+
console.log(` pinned at ${target.pin}`);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Builds this operator's bootstrap defaults from a target's own recorded
|
|
760
|
+
* manifest, used only when `adopt` finds no operator manifest at all: the
|
|
761
|
+
* freshly created operator manifest's `defaults` become exactly what this
|
|
762
|
+
* repository was already installed with (harnesses/profile/tiers/models),
|
|
763
|
+
* rather than the shipped defaults `setup` would otherwise fall back to.
|
|
764
|
+
*/
|
|
765
|
+
function operatorDefaultsFromRepoManifest(repoManifest) {
|
|
766
|
+
return {
|
|
767
|
+
harnesses: repoManifest.harnesses,
|
|
768
|
+
profile: repoManifest.profile,
|
|
769
|
+
tiers: repoManifest.tiers,
|
|
770
|
+
models: { ...repoManifest.models },
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* True when the already-parsed repo manifest `raw` is a JSON object whose
|
|
775
|
+
* `kit` field is a string that is not `"orchestrator-workflow"`: a manifest
|
|
776
|
+
* written by some other tool at this well-known path, not a damaged or
|
|
777
|
+
* hand-edited orchestrator-workflow one. Takes the already-parsed value
|
|
778
|
+
* (rather than re-reading the file itself) so its caller controls exactly
|
|
779
|
+
* how a read failure on that file is classified (fix-round-2: reading the
|
|
780
|
+
* file here too, and swallowing any error into "not foreign", previously
|
|
781
|
+
* folded an `EACCES` on the manifest file into the reinstall-advising
|
|
782
|
+
* `unreadable-repo-manifest` branch instead of `unverifiable-repo-manifest`).
|
|
783
|
+
* `readInstalledManifest` (init.ts) already treats any `kit` mismatch,
|
|
784
|
+
* missing or otherwise, as "no record" (`undefined`); this needs its own
|
|
785
|
+
* independent check to tell a genuinely foreign manifest apart from
|
|
786
|
+
* `adopt`'s other `!repoManifest` causes (missing file, invalid JSON, a
|
|
787
|
+
* manifest with `kit` absent or some other invalid field), the same way
|
|
788
|
+
* `repoManifestHasMalformedPin` above re-parses independently for its own,
|
|
789
|
+
* different question (fix-round, review finding L8).
|
|
790
|
+
*/
|
|
791
|
+
function repoManifestIsForeign(raw) {
|
|
792
|
+
if (typeof raw !== "object" || raw === null)
|
|
793
|
+
return false;
|
|
794
|
+
const candidate = raw;
|
|
795
|
+
return (typeof candidate.kit === "string" &&
|
|
796
|
+
candidate.kit !== "orchestrator-workflow");
|
|
797
|
+
}
|
|
798
|
+
program
|
|
799
|
+
.command("adopt")
|
|
800
|
+
.description("Register an already-installed repository into the operator manifest verbatim, touching nothing in the repository; bootstraps the operator manifest from the repository's own recorded settings when none exists yet, then prints this one target's doctor report")
|
|
801
|
+
.argument("[dir]", "target repository directory", ".")
|
|
802
|
+
.option("--json", "print a single JSON report to stdout and suppress human output")
|
|
803
|
+
.action(async (dir, opts) => {
|
|
804
|
+
// `targetDir` is only `resolve(dir)` here, not yet verified: unlike
|
|
805
|
+
// every other command, `adopt` cannot reuse `requireDirectory` for this
|
|
806
|
+
// precondition, since that helper is `--json`-unaware (`console.error`
|
|
807
|
+
// and a bare `process.exitCode = 1`) and every other failure this
|
|
808
|
+
// command reports goes through `reportUsageError` at exit code 2, not
|
|
809
|
+
// 1 (fix-round, review finding M1). `init`/`uninstall`/`apply` keep
|
|
810
|
+
// using `requireDirectory` unchanged.
|
|
811
|
+
const targetDir = resolve(dir);
|
|
812
|
+
const home = resolveOperatorHome();
|
|
813
|
+
// Every failure this command can report before it has anything to put
|
|
814
|
+
// in a `TargetReportJson` is a usage/precondition error, and the
|
|
815
|
+
// decisions this task is scoped to fix that at exit code 2 (contrast
|
|
816
|
+
// `apply`, which uses 1 for its own precondition failures); this
|
|
817
|
+
// helper centralizes that one shape for both `--json` and human mode
|
|
818
|
+
// rather than repeating it at each of this action's several failure
|
|
819
|
+
// points.
|
|
820
|
+
function reportUsageError(error, message) {
|
|
821
|
+
if (opts.json) {
|
|
822
|
+
console.log(JSON.stringify({
|
|
823
|
+
operatorHome: home,
|
|
824
|
+
operatorVersion: PACKAGE_VERSION,
|
|
825
|
+
targetDir,
|
|
826
|
+
target: null,
|
|
827
|
+
registered: null,
|
|
828
|
+
bootstrapped: null,
|
|
829
|
+
error,
|
|
830
|
+
message,
|
|
831
|
+
exitCode: 2,
|
|
832
|
+
}));
|
|
833
|
+
}
|
|
834
|
+
else {
|
|
835
|
+
console.error(message);
|
|
836
|
+
}
|
|
837
|
+
process.exitCode = 2;
|
|
838
|
+
}
|
|
839
|
+
const targetStat = statOrClassify(targetDir);
|
|
840
|
+
if (targetStat.kind !== "ok" || !targetStat.stat.isDirectory()) {
|
|
841
|
+
reportUsageError("target-not-a-directory", `Target is not a directory: ${targetDir}`);
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
const repoManifest = readInstalledManifest(targetDir);
|
|
845
|
+
if (!repoManifest) {
|
|
846
|
+
const repoManifestPath = join(targetDir, MANIFEST_PATH);
|
|
847
|
+
// `statOrClassify` distinguishes "no such file" from every other
|
|
848
|
+
// stat failure (most commonly `EACCES` on `.ai/workflow` itself), the
|
|
849
|
+
// same distinction `doctor.ts`'s own `inspectTarget` relies on:
|
|
850
|
+
// plain `existsSync` swallows both alike and reports `false` either
|
|
851
|
+
// way, which previously misreported an inaccessible-but-installed
|
|
852
|
+
// repo as `no-repo-manifest` and advised `init`/`apply`, which would
|
|
853
|
+
// have overwritten it (fix-round, review finding M2).
|
|
854
|
+
const manifestStat = statOrClassify(repoManifestPath);
|
|
855
|
+
if (manifestStat.kind === "enoent") {
|
|
856
|
+
reportUsageError("no-repo-manifest", `No orchestrator-workflow install found in ${targetDir}; run 'orchestrator-workflow init' or 'orchestrator-workflow apply --target ${targetDir}' first.`);
|
|
857
|
+
}
|
|
858
|
+
else if (manifestStat.kind === "error") {
|
|
859
|
+
reportUsageError("unverifiable-repo-manifest", `Could not verify the repository manifest at ${repoManifestPath} (its directory is not accessible); check its permissions and try again.`);
|
|
860
|
+
}
|
|
861
|
+
else {
|
|
862
|
+
// `manifestStat.kind === "ok"`: the manifest file itself stats
|
|
863
|
+
// fine (stat only needs search access on its ancestor directories,
|
|
864
|
+
// not read access on the file), so a mode-000 manifest file lands
|
|
865
|
+
// here too. Read its bytes once, ourselves, so an `EACCES`/`EPERM`
|
|
866
|
+
// (or any other non-`ENOENT` read failure) is told apart from a
|
|
867
|
+
// parse failure: `readInstalledManifest` and the old
|
|
868
|
+
// `repoManifestIsForeign` each re-read this same file and swallow
|
|
869
|
+
// that distinction, which previously folded an unreadable file into
|
|
870
|
+
// the reinstall-advising `unreadable-repo-manifest` branch instead
|
|
871
|
+
// of `unverifiable-repo-manifest` (fix-round-2).
|
|
872
|
+
let bytes;
|
|
873
|
+
try {
|
|
874
|
+
bytes = readFileSync(repoManifestPath, "utf8");
|
|
875
|
+
}
|
|
876
|
+
catch (error) {
|
|
877
|
+
const code = error.code;
|
|
878
|
+
if (code !== "ENOENT") {
|
|
879
|
+
reportUsageError("unverifiable-repo-manifest", `Could not verify the repository manifest at ${repoManifestPath} (its directory is not accessible); check its permissions and try again.`);
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
let raw;
|
|
884
|
+
let parseError = bytes === undefined;
|
|
885
|
+
if (bytes !== undefined) {
|
|
886
|
+
try {
|
|
887
|
+
raw = JSON.parse(bytes);
|
|
888
|
+
}
|
|
889
|
+
catch {
|
|
890
|
+
parseError = true;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
if (!parseError && repoManifestIsForeign(raw)) {
|
|
894
|
+
// Distinct from the generic "unreadable" case below (fix-round,
|
|
895
|
+
// review finding L8): this is not a damaged or hand-edited
|
|
896
|
+
// orchestrator-workflow manifest to repair, it is a different
|
|
897
|
+
// tool's manifest that happens to live at the same well-known
|
|
898
|
+
// path, and "repair it, or reinstall" is the wrong advice either
|
|
899
|
+
// way.
|
|
900
|
+
reportUsageError("foreign-manifest", `${repoManifestPath} is not an orchestrator-workflow manifest; nothing was registered.`);
|
|
901
|
+
}
|
|
902
|
+
else {
|
|
903
|
+
reportUsageError("unreadable-repo-manifest", `Unreadable repository manifest at ${repoManifestPath}; repair it, or run \`orchestrator-workflow apply --target ${targetDir}\` to reinstall.`);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
// `resolvedTargetPath`, `alreadyRegistered`, and `bootstrapped` are
|
|
909
|
+
// captured from inside the `mutate` callback (mirroring `apply`'s own
|
|
910
|
+
// capture of `resolvedTargetPath`/`alreadyRegistered`) since `mutate`
|
|
911
|
+
// can only return an `OperatorManifest | undefined`. `mutate` re-reads
|
|
912
|
+
// `current`/`state` fresh inside the lock rather than relying on any
|
|
913
|
+
// earlier unlocked read (there is none here: unlike `apply`, `adopt`
|
|
914
|
+
// never reads the operator manifest before this call), so a concurrent
|
|
915
|
+
// writer's own read-modify-write cannot be lost. The manifest object
|
|
916
|
+
// itself is read back from `result.manifest` below rather than a
|
|
917
|
+
// `mutate`-captured local (fix-round-2): `updateOperatorManifest` may
|
|
918
|
+
// re-stamp `updatedAt` on the value `mutate` returned before writing it
|
|
919
|
+
// (its "refreshing write" case), so `mutate`'s own return value can be
|
|
920
|
+
// stale by the time the lock releases; `result.manifest` is always the
|
|
921
|
+
// bytes actually written.
|
|
922
|
+
let resolvedTargetPath = "";
|
|
923
|
+
let alreadyRegistered = false;
|
|
924
|
+
let bootstrapped = false;
|
|
925
|
+
let result;
|
|
926
|
+
try {
|
|
927
|
+
result = updateOperatorManifest(home, (current, state) => {
|
|
928
|
+
if (state.kind === "unreadable") {
|
|
929
|
+
return undefined;
|
|
930
|
+
}
|
|
931
|
+
const appliedAt = new Date().toISOString();
|
|
932
|
+
const base = current ??
|
|
933
|
+
createOperatorManifest(operatorDefaultsFromRepoManifest(repoManifest), appliedAt);
|
|
934
|
+
bootstrapped = !current;
|
|
935
|
+
const upserted = upsertOperatorTarget(base, targetDir, repoManifest.version, appliedAt);
|
|
936
|
+
resolvedTargetPath = safeRealpath(targetDir);
|
|
937
|
+
alreadyRegistered = upserted.alreadyRegistered;
|
|
938
|
+
return upserted.manifest;
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
catch (error) {
|
|
942
|
+
const manifestPath = join(home, OPERATOR_MANIFEST_FILENAME);
|
|
943
|
+
const isLockTimeout = error instanceof OperatorManifestLockTimeoutError;
|
|
944
|
+
const message = isLockTimeout
|
|
945
|
+
? `Could not lock the operator manifest at ${manifestPath} (another orchestrator-workflow command holds it); nothing was changed. Re-run \`orchestrator-workflow adopt\` to register ${targetDir}.`
|
|
946
|
+
: `Could not update the operator manifest at ${manifestPath}: ${error instanceof Error ? error.message : String(error)}`;
|
|
947
|
+
reportUsageError(isLockTimeout
|
|
948
|
+
? "operator-manifest-locked"
|
|
949
|
+
: "operator-manifest-write-failed", message);
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
const writtenManifest = result.manifest;
|
|
953
|
+
if (result.state.kind === "unreadable" || !writtenManifest) {
|
|
954
|
+
const manifestPath = join(home, OPERATOR_MANIFEST_FILENAME);
|
|
955
|
+
reportUsageError("operator-manifest-unreadable", `Operator manifest at ${manifestPath} is unreadable; back it up and repair it, or remove it and run \`orchestrator-workflow setup\` again.`);
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
const registeredTarget = writtenManifest.targets.find((candidate) => candidate.path === resolvedTargetPath);
|
|
959
|
+
if (!registeredTarget) {
|
|
960
|
+
// Unreachable in practice: `upsertOperatorTarget` always writes an
|
|
961
|
+
// entry at `safeRealpath(targetDir)`, which is exactly
|
|
962
|
+
// `resolvedTargetPath`. Reported rather than assumed away, matching
|
|
963
|
+
// the "never guess" posture the rest of this command takes with
|
|
964
|
+
// every other unreachable-in-practice branch.
|
|
965
|
+
reportUsageError("target-not-registered", `Internal error: ${resolvedTargetPath} was not found in the operator manifest immediately after registering it.`);
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
const targetReport = inspectTarget(registeredTarget, writtenManifest, PACKAGE_VERSION);
|
|
969
|
+
const registered = alreadyRegistered
|
|
970
|
+
? "refreshed"
|
|
971
|
+
: "new";
|
|
972
|
+
// The target directory and its manifest were just read successfully
|
|
973
|
+
// above, so `missing`/`no-manifest`/`unverifiable` should not recur a
|
|
974
|
+
// moment later; if one nonetheless does (a race with something else
|
|
975
|
+
// removing or damaging the target in between), that is reported as an
|
|
976
|
+
// error rather than folded into the normal 0/1 exit-code contract. The
|
|
977
|
+
// mapping itself is `doctor.ts`'s exported `adoptExitCodeForStatus`
|
|
978
|
+
// (fix-round, review findings M3/L5), not an inline ternary chain here,
|
|
979
|
+
// so all seven statuses are pinned by a direct unit test rather than
|
|
980
|
+
// only the subset a live `adopt` run can actually reach.
|
|
981
|
+
const exitCode = adoptExitCodeForStatus(targetReport.status);
|
|
982
|
+
const unexpectedStatus = suppressSuccessLine(targetReport.status);
|
|
983
|
+
if (opts.json) {
|
|
984
|
+
console.log(JSON.stringify({
|
|
985
|
+
operatorHome: home,
|
|
986
|
+
operatorVersion: PACKAGE_VERSION,
|
|
987
|
+
target: targetReportToJson(targetReport),
|
|
988
|
+
registered,
|
|
989
|
+
bootstrapped,
|
|
990
|
+
exitCode,
|
|
991
|
+
// Only this genuinely-unreachable-in-practice case gets an
|
|
992
|
+
// `error` key (`doctor.ts`'s exported, unit-tested
|
|
993
|
+
// `adoptJsonExtras`); a `--json` consumer previously had no way
|
|
994
|
+
// to tell this apart from a normal (if unlucky) result at the
|
|
995
|
+
// same exit code (fix-round, review finding M3).
|
|
996
|
+
...adoptJsonExtras(targetReport.status),
|
|
997
|
+
}));
|
|
998
|
+
process.exitCode = exitCode;
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
if (unexpectedStatus) {
|
|
1002
|
+
// The success line ("Adopted ...") must not print here: the target
|
|
1003
|
+
// was not cleanly adopted, only registered before an unexplained
|
|
1004
|
+
// status turned up immediately after (fix-round, review finding M3;
|
|
1005
|
+
// before this fix the success line printed unconditionally, ahead of
|
|
1006
|
+
// the detail lines and the stderr note below).
|
|
1007
|
+
printTargetDetail(targetReport, PACKAGE_VERSION);
|
|
1008
|
+
console.error(`Unexpected status ${targetReport.status} for a target whose directory and manifest were just verified; treat this as a bug.`);
|
|
1009
|
+
process.exitCode = exitCode;
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
console.log(`Adopted ${resolvedTargetPath} (registered: ${registered}; operator defaults ${bootstrapped ? "bootstrapped from this repository" : "kept"})`);
|
|
1013
|
+
printTargetDetail(targetReport, PACKAGE_VERSION);
|
|
1014
|
+
process.exitCode = exitCode;
|
|
1015
|
+
});
|
|
316
1016
|
program.parseAsync(process.argv).catch((error) => {
|
|
317
1017
|
console.error(error instanceof Error ? error.message : error);
|
|
318
1018
|
process.exitCode = 1;
|