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/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 { HARNESSES, detectHarnesses, parseHarnessList } from "./detect.js";
8
- import { CLASS_MODELS, DEFAULT_MODELS, DEFAULT_PROFILE, MODEL_ALIASES, MODEL_CLASSES, PROFILES, assertValidModelId, parseModelsSpec, parseProfile, rolesForProfile, } from "./models.js";
9
- import { detectProvider, loadOpencodeCatalog, resolveAlias, resolveOpencodeModels, } from "./opencode.js";
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
- async function promptHarnesses(detected, installed) {
32
- const known = [...new Set([...detected, ...installed])];
33
- const preselected = known.length > 0 ? known : ["claude"];
34
- const { harnesses } = await inquirer.prompt([
35
- {
36
- type: "checkbox",
37
- name: "harnesses",
38
- message: "Install adapters for which harnesses?",
39
- choices: HARNESSES.map((harness) => ({
40
- name: harness + (detected.includes(harness) ? " (detected)" : ""),
41
- value: harness,
42
- checked: preselected.includes(harness),
43
- })),
44
- validate: (selection) => selection.length > 0 || "Select at least one harness",
45
- },
46
- ]);
47
- return harnesses;
48
- }
49
- async function promptProfile(base) {
50
- // Labels are derived from rolesForProfile so a future role addition (like
51
- // the advisor role) shows up here automatically instead of silently
52
- // falling out of sync with the roles the profile actually installs.
53
- const fullRoles = rolesForProfile("full").join(", ");
54
- const minimalRoles = rolesForProfile("minimal").join(", ");
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
- async function promptModels(base, roles) {
76
- const models = { ...base };
77
- for (const role of roles) {
78
- const { choice } = await inquirer.prompt([
79
- {
80
- type: "list",
81
- name: "choice",
82
- message: `Model for the ${role} subagent:`,
83
- default: models[role],
84
- choices: [
85
- ...MODEL_ALIASES.map((alias) => ({
86
- name: alias === DEFAULT_MODELS[role] ? `${alias} (default)` : alias,
87
- value: alias,
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
- let harnesses;
161
- if (opts.harness) {
162
- harnesses = parseHarnessList(opts.harness);
163
- }
164
- else {
165
- const installed = previous?.harnesses ?? [];
166
- const fallback = [...new Set([...detected, ...installed])];
167
- harnesses = interactive
168
- ? await promptHarnesses(detected, installed)
169
- : fallback.length > 0
170
- ? fallback
171
- : ["claude"];
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} installed for: ${harnesses.join(", ")} (profile: ${profile}, tiers: ${tiers})`);
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;