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