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/detect.d.ts CHANGED
@@ -7,3 +7,14 @@ export declare const HARNESSES: Harness[];
7
7
  */
8
8
  export declare function detectHarnesses(dir: string): Harness[];
9
9
  export declare function parseHarnessList(list: string): Harness[];
10
+ /**
11
+ * Parses a `--harness` option value, additionally accepting the literal
12
+ * `none` (templates-only mode: install `.ai/workflow/**` and
13
+ * `.ai/runs/.gitkeep` only, no AGENTS.md/CLAUDE.md/harness directories, and
14
+ * an empty `harnesses` array in the manifest). `none` must be the only entry
15
+ * in the list; combining it with a real harness name (`none,claude`) is
16
+ * ambiguous about intent, so it is rejected with a clear message instead of
17
+ * silently picking one interpretation. Every other value delegates to
18
+ * `parseHarnessList` unchanged.
19
+ */
20
+ export declare function parseHarnessOption(list: string): Harness[];
package/dist/detect.js CHANGED
@@ -37,3 +37,33 @@ export function parseHarnessList(list) {
37
37
  }
38
38
  return parsed;
39
39
  }
40
+ /**
41
+ * Parses a `--harness` option value, additionally accepting the literal
42
+ * `none` (templates-only mode: install `.ai/workflow/**` and
43
+ * `.ai/runs/.gitkeep` only, no AGENTS.md/CLAUDE.md/harness directories, and
44
+ * an empty `harnesses` array in the manifest). `none` must be the only entry
45
+ * in the list; combining it with a real harness name (`none,claude`) is
46
+ * ambiguous about intent, so it is rejected with a clear message instead of
47
+ * silently picking one interpretation. Every other value delegates to
48
+ * `parseHarnessList` unchanged.
49
+ */
50
+ export function parseHarnessOption(list) {
51
+ // Deduplicated before the arity check below so a repeated "none"
52
+ // (`none,none`) is recognized as the same single-entry intent as a
53
+ // plain "none", rather than tripping the "cannot be combined with other
54
+ // harnesses" error meant for an actually different entry like
55
+ // "none,claude".
56
+ const entries = [
57
+ ...new Set(list
58
+ .split(",")
59
+ .map((entry) => entry.trim().toLowerCase())
60
+ .filter((entry) => entry !== "")),
61
+ ];
62
+ if (entries.includes("none")) {
63
+ if (entries.length > 1) {
64
+ throw new Error(`--harness none selects templates-only mode and cannot be combined with other harnesses; got "${list}"`);
65
+ }
66
+ return [];
67
+ }
68
+ return parseHarnessList(list);
69
+ }
@@ -0,0 +1,222 @@
1
+ import type { Stats } from "node:fs";
2
+ import type { Profile, Role } from "./models.js";
3
+ import type { OperatorManifest, OperatorManifestLockOptions, OperatorTarget } from "./operator-manifest.js";
4
+ /**
5
+ * A target's status against the operator's registry. `drift` takes
6
+ * precedence over `divergent`/`version-lag` (a target can carry both a
7
+ * profile/tiers/models divergence and a hash drift at the same time; the
8
+ * drift is the more actionable fact, so it wins the status field while the
9
+ * divergence is still reported on the target). `divergent` in turn takes
10
+ * precedence over `version-lag`: a target can be both divergent and
11
+ * version-lagging (see `versionLag` below), and both facts are reported,
12
+ * but the status is `divergent`. `unverifiable` is separate from both
13
+ * `missing` and `no-manifest`: the target directory or its repo manifest
14
+ * could not be checked at all (a stat failure other than ENOENT, most
15
+ * commonly EACCES on an ancestor directory, or a manifest file present but
16
+ * unreadable/unparseable), so nothing is actually known about this
17
+ * target's real state. Unlike `missing`/`no-manifest`, `--prune` never
18
+ * removes an `unverifiable` target (review round 2, M1): an unreadable
19
+ * target might still be perfectly fine, and dropping its registry row on
20
+ * that basis would be an unrecoverable guess.
21
+ */
22
+ export type TargetStatus = "clean" | "divergent" | "version-lag" | "drift" | "missing" | "no-manifest" | "unverifiable";
23
+ export interface TargetDivergence {
24
+ profile: boolean;
25
+ tiers: boolean;
26
+ models: boolean;
27
+ }
28
+ /**
29
+ * Per-target report. Only `path`, `status`, `installedVersion`, `pin`,
30
+ * `divergence`, `driftFiles`, `versionLag`, and `reason` are part of the
31
+ * `--json` contract (`targetReportToJson` below picks exactly those); the
32
+ * remaining fields exist to let the human-output printer in `cli.ts`
33
+ * render detail lines without recomputing values `inspectTarget` already
34
+ * worked out.
35
+ */
36
+ export interface TargetReport {
37
+ path: string;
38
+ status: TargetStatus;
39
+ installedVersion: string | null;
40
+ pin: string | null;
41
+ divergence: TargetDivergence | null;
42
+ driftFiles: string[] | null;
43
+ /** Human-output-only: the repo's own profile, or null when unknown. */
44
+ repoProfile: Profile | null;
45
+ /** Human-output-only: the operator default profile, for the comparison line. */
46
+ operatorProfile: Profile;
47
+ /** Human-output-only: the repo's own tiers flag, or null when unknown. */
48
+ repoTiers: boolean | null;
49
+ /** Human-output-only: the operator default tiers flag, for the comparison line. */
50
+ operatorTiers: boolean;
51
+ /** Human-output-only: roles whose resolved model differs from the operator default. */
52
+ divergentModelRoles: Role[];
53
+ /**
54
+ * Whether this target is lagging the running kit version (no pin
55
+ * recorded, and the installed version differs from the kit version),
56
+ * independent of the final `status` field. Lets the printer show the
57
+ * "installed X, operator Y" line for a target whose status was
58
+ * overridden to `divergent` or `drift` because it is also lagging.
59
+ * Part of the `--json` contract since fix-round-2 (review finding L6):
60
+ * a `--json` consumer previously had no way to see version-lag on a
61
+ * target whose status field reads `divergent` or `drift`.
62
+ */
63
+ versionLag: boolean;
64
+ /**
65
+ * `null` for every status except `unverifiable`, where it explains what
66
+ * could not be checked: `"directory not accessible"` (the target
67
+ * directory, or a directory on the path to its repo manifest, failed to
68
+ * stat for a reason other than ENOENT) or `"manifest unreadable"` (the
69
+ * directory itself stats fine and the manifest file exists, but it could
70
+ * not be parsed or read). Part of the `--json` contract (review finding
71
+ * M1) so a `--json` consumer can distinguish the two causes without
72
+ * re-deriving them.
73
+ */
74
+ reason: string | null;
75
+ }
76
+ /** The subset of `TargetReport` that is part of the `--json` contract. */
77
+ export interface TargetReportJson {
78
+ path: string;
79
+ status: TargetStatus;
80
+ installedVersion: string | null;
81
+ pin: string | null;
82
+ divergence: TargetDivergence | null;
83
+ driftFiles: string[] | null;
84
+ versionLag: boolean;
85
+ reason: string | null;
86
+ }
87
+ export declare function targetReportToJson(report: TargetReport): TargetReportJson;
88
+ /**
89
+ * Maps a single target's status to `adopt`'s single-target exit-code
90
+ * contract: 0 for `clean`/`divergent`/`version-lag`, 1 for `drift`, 2 for
91
+ * `missing`/`no-manifest`/`unverifiable`. A pure function, exported and
92
+ * unit-testable directly against all seven {@link TargetStatus} values,
93
+ * rather than left as the inline ternary chain `cli.ts`'s `adopt` action
94
+ * used to carry (fix-round, review findings M3/L5): a live `adopt` run can
95
+ * only ever exercise `clean`/`divergent`/`version-lag`/`drift` in practice
96
+ * (the directory and manifest were just read successfully immediately
97
+ * before this status is computed), so the `missing`/`no-manifest`/
98
+ * `unverifiable` branches were previously pinned by nothing at all.
99
+ */
100
+ export declare function adoptExitCodeForStatus(status: TargetStatus): 0 | 1 | 2;
101
+ /**
102
+ * Whether `adopt`'s human-mode success line ("Adopted ...") must be
103
+ * suppressed for a target report whose status is `status`: true for exactly
104
+ * the three statuses {@link adoptExitCodeForStatus} maps to exit code 2
105
+ * (`missing`, `no-manifest`, `unverifiable`), statuses that should not
106
+ * occur for a target whose directory and manifest were just verified
107
+ * immediately before this status was computed, so printing the success line
108
+ * ahead of the stderr bug note would be misleading. A pure function,
109
+ * exported and unit-tested directly against all seven {@link TargetStatus}
110
+ * values, rather than left as `cli.ts`'s inline `exitCode === 2` check
111
+ * (fix-round-2), the same reasoning that already pulled
112
+ * `adoptExitCodeForStatus` itself out of an inline ternary chain.
113
+ */
114
+ export declare function suppressSuccessLine(status: TargetStatus): boolean;
115
+ export interface DoctorReport {
116
+ operatorHome: string;
117
+ operatorVersion: string;
118
+ targets: TargetReport[];
119
+ pruned: string[];
120
+ exitCode: 0 | 1 | 2;
121
+ /**
122
+ * The count of raw `targets` array entries in the on-disk operator
123
+ * manifest that `readOperatorManifest`'s own per-entry validation
124
+ * silently dropped (wrong shape, a missing field, ...), distinct from
125
+ * the targets named in `pruned` above (which were validly-shaped but
126
+ * `missing`/`no-manifest`). Always `0` unless `--prune` both ran and
127
+ * actually wrote (`pruned.length > 0`); `cli.ts`'s human-output prune
128
+ * note prints only when this is greater than zero, naming the count
129
+ * (fix-round-2 review finding M3: the note used to print unconditionally
130
+ * whenever anything at all was pruned, even when the file held no
131
+ * unvalidatable raw entry to report).
132
+ */
133
+ unvalidatedDropped: number;
134
+ /**
135
+ * Set only when no operator manifest was evaluated; `targets` is then
136
+ * `[]`. `no-operator-manifest`: no file exists at
137
+ * `<operatorHome>/manifest.json`. `operator-manifest-unreadable`: the
138
+ * file exists but `readOperatorManifest` could not parse or validate it
139
+ * (corrupt JSON, or an envelope that does not match this kit).
140
+ */
141
+ error?: "no-operator-manifest" | "operator-manifest-unreadable";
142
+ }
143
+ /**
144
+ * Stats `path`, distinguishing "does not exist" (`ENOENT`) from every other
145
+ * stat failure (most commonly `EACCES` on an ancestor directory). Plain
146
+ * `existsSync` cannot make this distinction: it swallows every error alike
147
+ * and returns `false` either way (review round 2, M2), which is exactly
148
+ * what let an inaccessible-but-present target get misreported as
149
+ * `missing`/`no-manifest` and then pruned out of the registry on a guess.
150
+ */
151
+ export declare function statOrClassify(path: string): {
152
+ kind: "ok";
153
+ stat: Stats;
154
+ } | {
155
+ kind: "enoent";
156
+ } | {
157
+ kind: "error";
158
+ };
159
+ /**
160
+ * Computes one target's status against the operator's registry. Pure
161
+ * read-only I/O (existence checks, file hashing, reading the target's own
162
+ * repo manifest); no printing, no `process.exit`. Exported standalone so a
163
+ * later `adopt` command can reuse the same per-target computation `doctor`
164
+ * uses.
165
+ */
166
+ export declare function inspectTarget(target: OperatorTarget, operator: OperatorManifest, kitVersion: string): TargetReport;
167
+ /**
168
+ * Walks the operator manifest's target registry at `home` and reports each
169
+ * target's status. No printing, no `process.exit`: `cli.ts` turns this
170
+ * into human or `--json` output and applies `exitCode` itself.
171
+ *
172
+ * Exit-code contract: 2 when no operator manifest exists (nothing else is
173
+ * evaluated); else 1 if any *remaining* target (after an optional prune) is
174
+ * `drift`, `missing`, `no-manifest`, or `unverifiable`; else 0.
175
+ *
176
+ * `--prune`: targets whose status is `missing` or `no-manifest` are removed
177
+ * from the operator manifest's `targets` array and persisted (only when at
178
+ * least one target was actually removed, mirroring `setup`'s no-op-write
179
+ * avoidance) before the exit code and `targets` in the returned report are
180
+ * computed, so both reflect the post-prune registry. `unverifiable` targets
181
+ * are never removed (see {@link TargetStatus}'s doc comment). `pruned`
182
+ * always lists the removed paths, even when empty. The whole re-read,
183
+ * report computation, and write run inside {@link updateOperatorManifest}'s
184
+ * single locked critical section (review round 1, H1's own fix, reused
185
+ * here rather than doctor keeping a second, unlocked read-modify-write path
186
+ * of its own), so a concurrent `apply`/`setup`/another `doctor --prune`
187
+ * cannot land its own write in between this read and this write.
188
+ *
189
+ * `options.lockOptions` is exposed only so tests can shrink
190
+ * `updateOperatorManifest`'s lock-acquire timeout/staleness/poll windows
191
+ * below their production defaults (see `OperatorManifestLockOptions`);
192
+ * production callers should omit it entirely. `updateOperatorManifest`
193
+ * (via `withOperatorManifestLock`) can itself throw rather than return,
194
+ * most commonly `OperatorManifestLockTimeoutError` (another
195
+ * orchestrator-workflow command holds the lock past the timeout) or any
196
+ * other error raised while acquiring it (e.g. `EACCES` creating the lock
197
+ * directory under a read-only operator home). This function deliberately
198
+ * does not catch either: `cli.ts`'s `doctor` action is the layer that
199
+ * turns such a throw into a `--json`/human-readable exit-2 report,
200
+ * exactly the way it already turns a returned `DoctorReport` into one.
201
+ */
202
+ export declare function runDoctor(home: string, options?: {
203
+ prune?: boolean;
204
+ lockOptions?: OperatorManifestLockOptions;
205
+ }): DoctorReport;
206
+ /**
207
+ * The extra `--json` key `adopt` adds only for a target status that should
208
+ * be unreachable immediately after a successful directory/manifest read
209
+ * (`missing`/`no-manifest`/`unverifiable`, i.e. wherever
210
+ * {@link adoptExitCodeForStatus} returns `2`): `error:
211
+ * "unexpected-target-status"`, so a `--json` consumer can tell this genuine
212
+ * internal-error case apart from any other result sharing the same exit
213
+ * code (fix-round, review finding M3). Appended at the end of this module,
214
+ * after every other exported member, so adding it does not shift any
215
+ * existing `doctor.ts:` line citation in docs/okf. A pure function,
216
+ * exported and unit-tested directly (the branch itself stays unreachable
217
+ * through a live `adopt` run, the same "unreachable in practice" property
218
+ * `cli.ts`'s own comment on that branch already documents).
219
+ */
220
+ export declare function adoptJsonExtras(status: TargetStatus): {
221
+ error: "unexpected-target-status";
222
+ } | Record<string, never>;
package/dist/doctor.js ADDED
@@ -0,0 +1,411 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { PACKAGE_VERSION } from "./assets.js";
5
+ import { MANIFEST_PATH, readInstalledManifest } from "./init.js";
6
+ import { DEFAULT_MODELS, ROLES } from "./models.js";
7
+ import { OPERATOR_MANIFEST_FILENAME, operatorManifestState, updateOperatorManifest, } from "./operator-manifest.js";
8
+ export function targetReportToJson(report) {
9
+ return {
10
+ path: report.path,
11
+ status: report.status,
12
+ installedVersion: report.installedVersion,
13
+ pin: report.pin,
14
+ divergence: report.divergence,
15
+ driftFiles: report.driftFiles,
16
+ versionLag: report.versionLag,
17
+ reason: report.reason,
18
+ };
19
+ }
20
+ /**
21
+ * Maps a single target's status to `adopt`'s single-target exit-code
22
+ * contract: 0 for `clean`/`divergent`/`version-lag`, 1 for `drift`, 2 for
23
+ * `missing`/`no-manifest`/`unverifiable`. A pure function, exported and
24
+ * unit-testable directly against all seven {@link TargetStatus} values,
25
+ * rather than left as the inline ternary chain `cli.ts`'s `adopt` action
26
+ * used to carry (fix-round, review findings M3/L5): a live `adopt` run can
27
+ * only ever exercise `clean`/`divergent`/`version-lag`/`drift` in practice
28
+ * (the directory and manifest were just read successfully immediately
29
+ * before this status is computed), so the `missing`/`no-manifest`/
30
+ * `unverifiable` branches were previously pinned by nothing at all.
31
+ */
32
+ export function adoptExitCodeForStatus(status) {
33
+ switch (status) {
34
+ case "missing":
35
+ case "no-manifest":
36
+ case "unverifiable":
37
+ return 2;
38
+ case "drift":
39
+ return 1;
40
+ case "clean":
41
+ case "divergent":
42
+ case "version-lag":
43
+ return 0;
44
+ }
45
+ }
46
+ /**
47
+ * Whether `adopt`'s human-mode success line ("Adopted ...") must be
48
+ * suppressed for a target report whose status is `status`: true for exactly
49
+ * the three statuses {@link adoptExitCodeForStatus} maps to exit code 2
50
+ * (`missing`, `no-manifest`, `unverifiable`), statuses that should not
51
+ * occur for a target whose directory and manifest were just verified
52
+ * immediately before this status was computed, so printing the success line
53
+ * ahead of the stderr bug note would be misleading. A pure function,
54
+ * exported and unit-tested directly against all seven {@link TargetStatus}
55
+ * values, rather than left as `cli.ts`'s inline `exitCode === 2` check
56
+ * (fix-round-2), the same reasoning that already pulled
57
+ * `adoptExitCodeForStatus` itself out of an inline ternary chain.
58
+ */
59
+ export function suppressSuccessLine(status) {
60
+ return adoptExitCodeForStatus(status) === 2;
61
+ }
62
+ function sha256(content) {
63
+ return createHash("sha256").update(content, "utf8").digest("hex");
64
+ }
65
+ /** Resolves a role's model against a possibly-partial models map, falling
66
+ * back to the shipped default the same way `cli.ts`'s `defaultsAsManifest`
67
+ * and `readInstalledManifest`'s per-role degradation both already do. */
68
+ function resolvedModel(models, role) {
69
+ return models[role] ?? DEFAULT_MODELS[role];
70
+ }
71
+ /**
72
+ * Relative paths (from the repo manifest's `files` ledger) whose on-disk
73
+ * sha256 no longer matches the recorded hash, or that are missing/not a
74
+ * regular file on disk. Empty when the target is clean of drift.
75
+ */
76
+ function computeDriftFiles(targetPath, manifest) {
77
+ const drifted = [];
78
+ for (const [relativePath, recordedHash] of Object.entries(manifest.files)) {
79
+ const filePath = join(targetPath, relativePath);
80
+ let isFile = false;
81
+ try {
82
+ isFile = existsSync(filePath) && statSync(filePath).isFile();
83
+ }
84
+ catch {
85
+ isFile = false;
86
+ }
87
+ if (!isFile) {
88
+ drifted.push(relativePath);
89
+ continue;
90
+ }
91
+ // A file that exists and stat's as a regular file can still fail to
92
+ // read (permissions, a race with something else removing it, ...). A
93
+ // read failure means this path's drift status against the recorded
94
+ // hash cannot be verified, so it is counted as drift rather than
95
+ // aborting the whole target (and the rest of the registry).
96
+ let content;
97
+ try {
98
+ content = readFileSync(filePath, "utf8");
99
+ }
100
+ catch {
101
+ drifted.push(relativePath);
102
+ continue;
103
+ }
104
+ if (sha256(content) !== recordedHash) {
105
+ drifted.push(relativePath);
106
+ }
107
+ }
108
+ return drifted;
109
+ }
110
+ function baseReport(target, operator, status, reason) {
111
+ return {
112
+ path: target.path,
113
+ status,
114
+ installedVersion: null,
115
+ pin: null,
116
+ divergence: null,
117
+ driftFiles: null,
118
+ repoProfile: null,
119
+ operatorProfile: operator.defaults.profile,
120
+ repoTiers: null,
121
+ operatorTiers: operator.defaults.tiers,
122
+ divergentModelRoles: [],
123
+ versionLag: false,
124
+ reason,
125
+ };
126
+ }
127
+ /**
128
+ * Stats `path`, distinguishing "does not exist" (`ENOENT`) from every other
129
+ * stat failure (most commonly `EACCES` on an ancestor directory). Plain
130
+ * `existsSync` cannot make this distinction: it swallows every error alike
131
+ * and returns `false` either way (review round 2, M2), which is exactly
132
+ * what let an inaccessible-but-present target get misreported as
133
+ * `missing`/`no-manifest` and then pruned out of the registry on a guess.
134
+ */
135
+ export function statOrClassify(path) {
136
+ try {
137
+ return { kind: "ok", stat: statSync(path) };
138
+ }
139
+ catch (error) {
140
+ return error.code === "ENOENT"
141
+ ? { kind: "enoent" }
142
+ : { kind: "error" };
143
+ }
144
+ }
145
+ /**
146
+ * Computes one target's status against the operator's registry. Pure
147
+ * read-only I/O (existence checks, file hashing, reading the target's own
148
+ * repo manifest); no printing, no `process.exit`. Exported standalone so a
149
+ * later `adopt` command can reuse the same per-target computation `doctor`
150
+ * uses.
151
+ */
152
+ export function inspectTarget(target, operator, kitVersion) {
153
+ const dirStat = statOrClassify(target.path);
154
+ if (dirStat.kind === "enoent") {
155
+ return baseReport(target, operator, "missing", null);
156
+ }
157
+ if (dirStat.kind === "error") {
158
+ return baseReport(target, operator, "unverifiable", "directory not accessible");
159
+ }
160
+ if (!dirStat.stat.isDirectory()) {
161
+ return baseReport(target, operator, "missing", null);
162
+ }
163
+ const manifestStat = statOrClassify(join(target.path, MANIFEST_PATH));
164
+ if (manifestStat.kind === "enoent") {
165
+ return baseReport(target, operator, "no-manifest", null);
166
+ }
167
+ if (manifestStat.kind === "error") {
168
+ return baseReport(target, operator, "unverifiable", "directory not accessible");
169
+ }
170
+ const manifest = readInstalledManifest(target.path);
171
+ if (!manifest) {
172
+ return baseReport(target, operator, "unverifiable", "manifest unreadable");
173
+ }
174
+ const driftFiles = computeDriftFiles(target.path, manifest);
175
+ const divergentModelRoles = ROLES.filter((role) => resolvedModel(manifest.models, role) !==
176
+ resolvedModel(operator.defaults.models, role));
177
+ const divergence = {
178
+ profile: manifest.profile !== operator.defaults.profile,
179
+ tiers: manifest.tiers !== operator.defaults.tiers,
180
+ models: divergentModelRoles.length > 0,
181
+ };
182
+ // A recorded pin suppresses version-lag only when the pin equals the
183
+ // repo's own installed version: that is the expected, deliberate-stay
184
+ // state. When the pin and the installed version differ, the installed
185
+ // manifest no longer reflects what was pinned (someone changed the pin
186
+ // without reapplying, or the installed version drifted some other way),
187
+ // so the target is still version-lag, even though it carries a pin
188
+ // (drift, if also present, still takes precedence over the final status
189
+ // field below). With no pin at all, version-lag compares the installed
190
+ // version against the running kit version, as before.
191
+ const hasPin = typeof manifest.pin === "string" && manifest.pin.length > 0;
192
+ const versionLag = hasPin
193
+ ? manifest.pin !== manifest.version
194
+ : manifest.version !== kitVersion;
195
+ let status;
196
+ if (driftFiles.length > 0) {
197
+ status = "drift";
198
+ }
199
+ else if (divergence.profile || divergence.tiers || divergence.models) {
200
+ status = "divergent";
201
+ }
202
+ else if (versionLag) {
203
+ status = "version-lag";
204
+ }
205
+ else {
206
+ status = "clean";
207
+ }
208
+ return {
209
+ path: target.path,
210
+ status,
211
+ installedVersion: manifest.version.length > 0 ? manifest.version : null,
212
+ pin: hasPin ? manifest.pin : null,
213
+ divergence,
214
+ driftFiles: driftFiles.length > 0 ? driftFiles : null,
215
+ repoProfile: manifest.profile,
216
+ operatorProfile: operator.defaults.profile,
217
+ repoTiers: manifest.tiers,
218
+ operatorTiers: operator.defaults.tiers,
219
+ divergentModelRoles,
220
+ versionLag,
221
+ reason: null,
222
+ };
223
+ }
224
+ const REMOVE_ON_PRUNE = new Set([
225
+ "missing",
226
+ "no-manifest",
227
+ ]);
228
+ /**
229
+ * Re-reads `<home>/manifest.json`'s raw JSON (independent of
230
+ * `readOperatorManifest`'s own parsed, validated result) and counts the
231
+ * entries in its `targets` array, or `null` when the file cannot be read
232
+ * or parsed at all. `readOperatorManifest` silently drops any raw target
233
+ * entry that fails its own per-entry shape check, so its parsed
234
+ * `manifest.targets.length` alone cannot tell whether the file held extra,
235
+ * unvalidatable entries; this reads the same bytes a second time to answer
236
+ * exactly that (review round 2, M3).
237
+ */
238
+ function countRawTargets(home) {
239
+ try {
240
+ const raw = JSON.parse(readFileSync(join(home, OPERATOR_MANIFEST_FILENAME), "utf8"));
241
+ if (typeof raw !== "object" || raw === null)
242
+ return null;
243
+ const candidate = raw;
244
+ return Array.isArray(candidate.targets) ? candidate.targets.length : 0;
245
+ }
246
+ catch {
247
+ return null;
248
+ }
249
+ }
250
+ /**
251
+ * Walks the operator manifest's target registry at `home` and reports each
252
+ * target's status. No printing, no `process.exit`: `cli.ts` turns this
253
+ * into human or `--json` output and applies `exitCode` itself.
254
+ *
255
+ * Exit-code contract: 2 when no operator manifest exists (nothing else is
256
+ * evaluated); else 1 if any *remaining* target (after an optional prune) is
257
+ * `drift`, `missing`, `no-manifest`, or `unverifiable`; else 0.
258
+ *
259
+ * `--prune`: targets whose status is `missing` or `no-manifest` are removed
260
+ * from the operator manifest's `targets` array and persisted (only when at
261
+ * least one target was actually removed, mirroring `setup`'s no-op-write
262
+ * avoidance) before the exit code and `targets` in the returned report are
263
+ * computed, so both reflect the post-prune registry. `unverifiable` targets
264
+ * are never removed (see {@link TargetStatus}'s doc comment). `pruned`
265
+ * always lists the removed paths, even when empty. The whole re-read,
266
+ * report computation, and write run inside {@link updateOperatorManifest}'s
267
+ * single locked critical section (review round 1, H1's own fix, reused
268
+ * here rather than doctor keeping a second, unlocked read-modify-write path
269
+ * of its own), so a concurrent `apply`/`setup`/another `doctor --prune`
270
+ * cannot land its own write in between this read and this write.
271
+ *
272
+ * `options.lockOptions` is exposed only so tests can shrink
273
+ * `updateOperatorManifest`'s lock-acquire timeout/staleness/poll windows
274
+ * below their production defaults (see `OperatorManifestLockOptions`);
275
+ * production callers should omit it entirely. `updateOperatorManifest`
276
+ * (via `withOperatorManifestLock`) can itself throw rather than return,
277
+ * most commonly `OperatorManifestLockTimeoutError` (another
278
+ * orchestrator-workflow command holds the lock past the timeout) or any
279
+ * other error raised while acquiring it (e.g. `EACCES` creating the lock
280
+ * directory under a read-only operator home). This function deliberately
281
+ * does not catch either: `cli.ts`'s `doctor` action is the layer that
282
+ * turns such a throw into a `--json`/human-readable exit-2 report,
283
+ * exactly the way it already turns a returned `DoctorReport` into one.
284
+ */
285
+ export function runDoctor(home, options = {}) {
286
+ const state = operatorManifestState(home);
287
+ if (state.kind !== "ok") {
288
+ // `absent`: no file to read. `unreadable`: the file exists but is
289
+ // corrupt or does not validate. Distinguished here so the CLI can tell
290
+ // an operator who has simply never run `setup` apart from one whose
291
+ // manifest needs repair (the latter must not be papered over by
292
+ // re-running `setup`, which would silently rewrite `targets: []` over
293
+ // a possibly-fine targets array sitting next to the unreadable
294
+ // envelope).
295
+ const error = state.kind === "absent"
296
+ ? "no-operator-manifest"
297
+ : "operator-manifest-unreadable";
298
+ return {
299
+ operatorHome: home,
300
+ operatorVersion: PACKAGE_VERSION,
301
+ targets: [],
302
+ pruned: [],
303
+ exitCode: 2,
304
+ unvalidatedDropped: 0,
305
+ error,
306
+ };
307
+ }
308
+ const pruned = [];
309
+ let unvalidatedDropped = 0;
310
+ let targets;
311
+ if (options.prune) {
312
+ // `computedReports`/`pruned`/`unvalidatedDropped` are captured from
313
+ // inside `mutate` (mirroring `apply`'s own `resolvedTargetPath`/
314
+ // `alreadyRegistered` capture in cli.ts) since `mutate` can only return
315
+ // an `OperatorManifest | undefined`. `mutate` re-reads and re-computes
316
+ // from scratch rather than reusing `state` above (which may already be
317
+ // stale by the time the lock is granted), the same reasoning `apply`'s
318
+ // own re-read documents.
319
+ let computedReports;
320
+ const result = updateOperatorManifest(home, (current, innerState) => {
321
+ if (innerState.kind !== "ok" || !current) {
322
+ return undefined;
323
+ }
324
+ const reports = current.targets.map((target) => inspectTarget(target, current, PACKAGE_VERSION));
325
+ computedReports = reports;
326
+ const keepPaths = new Set();
327
+ for (const report of reports) {
328
+ if (REMOVE_ON_PRUNE.has(report.status)) {
329
+ pruned.push(report.path);
330
+ }
331
+ else {
332
+ keepPaths.add(report.path);
333
+ }
334
+ }
335
+ if (pruned.length === 0) {
336
+ return undefined;
337
+ }
338
+ const rawTargetCount = countRawTargets(home);
339
+ if (rawTargetCount !== null) {
340
+ unvalidatedDropped = Math.max(0, rawTargetCount - current.targets.length);
341
+ }
342
+ return {
343
+ ...current,
344
+ targets: current.targets.filter((target) => keepPaths.has(target.path)),
345
+ updatedAt: new Date().toISOString(),
346
+ };
347
+ }, options.lockOptions);
348
+ if (result.state.kind !== "ok") {
349
+ // The manifest that read `"ok"` in the outer, unlocked check above
350
+ // (`state`) was found gone or unreadable once the lock was actually
351
+ // granted: a concurrent writer's own read-modify-write landed in
352
+ // between that outer read and this one. `state` is now stale and
353
+ // must not be used as a fallback source of targets (that fallback
354
+ // is exactly what previously let a `--prune` run report, and
355
+ // silently keep, target rows against a registry that no longer
356
+ // exists on disk). Report the failure directly instead, the same
357
+ // shape the outer absent/unreadable check above already returns.
358
+ const error = result.state.kind === "absent"
359
+ ? "no-operator-manifest"
360
+ : "operator-manifest-unreadable";
361
+ return {
362
+ operatorHome: home,
363
+ operatorVersion: PACKAGE_VERSION,
364
+ targets: [],
365
+ pruned: [],
366
+ exitCode: 2,
367
+ unvalidatedDropped: 0,
368
+ error,
369
+ };
370
+ }
371
+ // `result.state.kind === "ok"` means `mutate` above ran with a truthy
372
+ // `current`, so `computedReports` was always assigned.
373
+ targets = computedReports.filter((report) => !pruned.includes(report.path));
374
+ }
375
+ else {
376
+ targets = state.manifest.targets.map((target) => inspectTarget(target, state.manifest, PACKAGE_VERSION));
377
+ }
378
+ const exitCode = targets.some((report) => report.status === "drift" ||
379
+ report.status === "missing" ||
380
+ report.status === "no-manifest" ||
381
+ report.status === "unverifiable")
382
+ ? 1
383
+ : 0;
384
+ return {
385
+ operatorHome: home,
386
+ operatorVersion: PACKAGE_VERSION,
387
+ targets,
388
+ pruned,
389
+ exitCode,
390
+ unvalidatedDropped,
391
+ };
392
+ }
393
+ /**
394
+ * The extra `--json` key `adopt` adds only for a target status that should
395
+ * be unreachable immediately after a successful directory/manifest read
396
+ * (`missing`/`no-manifest`/`unverifiable`, i.e. wherever
397
+ * {@link adoptExitCodeForStatus} returns `2`): `error:
398
+ * "unexpected-target-status"`, so a `--json` consumer can tell this genuine
399
+ * internal-error case apart from any other result sharing the same exit
400
+ * code (fix-round, review finding M3). Appended at the end of this module,
401
+ * after every other exported member, so adding it does not shift any
402
+ * existing `doctor.ts:` line citation in docs/okf. A pure function,
403
+ * exported and unit-tested directly (the branch itself stays unreachable
404
+ * through a live `adopt` run, the same "unreachable in practice" property
405
+ * `cli.ts`'s own comment on that branch already documents).
406
+ */
407
+ export function adoptJsonExtras(status) {
408
+ return adoptExitCodeForStatus(status) === 2
409
+ ? { error: "unexpected-target-status" }
410
+ : {};
411
+ }