orchestrator-workflow 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { runInit } from "./init.js";
2
2
  export type { InitOptions } from "./init.js";
3
3
  export { runUninstall } from "./uninstall.js";
4
4
  export type { UninstallReport } from "./uninstall.js";
5
- export { detectHarnesses, parseHarnessList, HARNESSES } from "./detect.js";
5
+ export { detectHarnesses, parseHarnessList, parseHarnessOption, HARNESSES, } from "./detect.js";
6
6
  export type { Harness } from "./detect.js";
7
7
  export { CLASS_MODELS, DEFAULT_MODELS, DEFAULT_PROFILE, DEFAULT_TIER, MODEL_ALIASES, MODEL_CLASSES, PROFILES, ROLES, ROLE_TIERS, TIER_DEFS, claudeModelValue, isProfile, opencodeModelValue, parseModelsSpec, parseProfile, rolesForProfile, } from "./models.js";
8
8
  export type { ModelAlias, ModelClass, Profile, Role, Tier } from "./models.js";
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { runInit } from "./init.js";
2
2
  export { runUninstall } from "./uninstall.js";
3
- export { detectHarnesses, parseHarnessList, HARNESSES } from "./detect.js";
3
+ export { detectHarnesses, parseHarnessList, parseHarnessOption, HARNESSES, } from "./detect.js";
4
4
  export { CLASS_MODELS, DEFAULT_MODELS, DEFAULT_PROFILE, DEFAULT_TIER, MODEL_ALIASES, MODEL_CLASSES, PROFILES, ROLES, ROLE_TIERS, TIER_DEFS, claudeModelValue, isProfile, opencodeModelValue, parseModelsSpec, parseProfile, rolesForProfile, } from "./models.js";
5
5
  export { PACKAGE_VERSION } from "./assets.js";
package/dist/init.d.ts CHANGED
@@ -36,7 +36,17 @@ export interface InitOptions {
36
36
  * by the role's own preselected model.
37
37
  */
38
38
  opencodeClassModels?: Record<ModelClass, string | undefined>;
39
+ /**
40
+ * Repo kit-version pin (distinct from the actually-installed `version`),
41
+ * so a later `apply` command can gate on it. A `string` sets a new
42
+ * recorded kit-version pin; `null` clears an existing pin; `undefined`
43
+ * (the default, omitted) carries the previous manifest's pin forward
44
+ * unchanged. An empty or whitespace-only string is normalized to a clear
45
+ * too, the same as `null`.
46
+ */
47
+ pin?: string | null;
39
48
  }
49
+ export declare const MANIFEST_PATH: string;
40
50
  export interface Manifest {
41
51
  kit: string;
42
52
  version: string;
@@ -52,6 +62,31 @@ export interface Manifest {
52
62
  */
53
63
  files: Record<string, string>;
54
64
  installedAt: string;
65
+ /**
66
+ * Optional kit-version pin recorded for this repo (distinct from
67
+ * `version`, the actually-installed kit version). Absent when no pin was
68
+ * ever recorded or an existing one was cleared.
69
+ */
70
+ pin?: string;
71
+ /**
72
+ * True only when the raw manifest JSON's `harnesses` field was itself an
73
+ * array AND that raw array had zero elements -- i.e. the operator
74
+ * recorded an explicit empty harness set (a real `--harness none`
75
+ * install). An array with entries that all fail the known-harness filter
76
+ * (e.g. `["cursor"]`, or `["Claude"]` with the wrong case) also filters
77
+ * down to `harnesses: []` but must NOT set this flag: the raw field was
78
+ * never actually recorded as empty, it just failed to name anything this
79
+ * kit recognizes, and treating that the same as a deliberate `none` would
80
+ * silently degrade a live install to templates-only on a plain re-run
81
+ * (see CHANGELOG). A missing/malformed `harnesses` field (not an array at
82
+ * all) is the same "not a recorded empty set" case and also leaves this
83
+ * `false`. Only `readInstalledManifest` ever sets this from an actual
84
+ * on-disk manifest. A synthetic previous
85
+ * (e.g. `apply`'s `buildApplyPrevious` in cli.ts) leaves it `undefined`,
86
+ * which the harnesses-stickiness gate in `cli-inputs.ts` treats as "not
87
+ * recorded" and therefore never sticky.
88
+ */
89
+ harnessesRecordedEmpty?: boolean;
55
90
  }
56
91
  /**
57
92
  * A manifest can be hand-written or tampered with, and uninstall deletes by
package/dist/init.js CHANGED
@@ -6,7 +6,7 @@ import { HARNESSES } from "./detect.js";
6
6
  import { CLASS_MODELS, DEFAULT_PROFILE, DEFAULT_TIER, READ_ONLY_ROLES, ROLES, ROLE_TIERS, TIER_DEFS, assertValidModelId, claudeModelValue, isProfile, opencodeModelValue, rolesForProfile, } from "./models.js";
7
7
  import { emptyReport, ensureClaudeImport, installFile, upsertMarkerSection, } from "./writers.js";
8
8
  const SKILL_NAME = "orchestrator-workflow";
9
- const MANIFEST_PATH = join(".ai", "workflow", "manifest.json");
9
+ export const MANIFEST_PATH = join(".ai", "workflow", "manifest.json"); // shared with doctor.ts/cli.ts (L9); see readInstalledManifest below
10
10
  function sha256(content) {
11
11
  return createHash("sha256").update(content, "utf8").digest("hex");
12
12
  }
@@ -44,7 +44,17 @@ export function readInstalledManifest(targetDir) {
44
44
  const candidate = raw;
45
45
  if (candidate.kit !== SKILL_NAME)
46
46
  return undefined;
47
- const harnesses = (Array.isArray(candidate.harnesses) ? candidate.harnesses : []).filter((value) => HARNESSES.includes(value));
47
+ // Captured before filtering, and deliberately on the RAW array's own
48
+ // length, not the filtered one: an invalid array element (a string, an
49
+ // unknown harness name) also filters down to `harnesses: []` below, but
50
+ // must not be mistaken for a deliberate recorded `harnesses: []` -- see
51
+ // the `Manifest.harnessesRecordedEmpty` doc comment above for why.
52
+ const rawHarnessesIsArray = Array.isArray(candidate.harnesses);
53
+ const rawHarnesses = rawHarnessesIsArray
54
+ ? candidate.harnesses
55
+ : [];
56
+ const harnessesRecordedEmpty = rawHarnessesIsArray && rawHarnesses.length === 0;
57
+ const harnesses = rawHarnesses.filter((value) => HARNESSES.includes(value));
48
58
  const models = {};
49
59
  if (typeof candidate.models === "object" && candidate.models !== null) {
50
60
  for (const role of ROLES) {
@@ -81,15 +91,28 @@ export function readInstalledManifest(targetDir) {
81
91
  // (the same per-field-degradation style as `profile` above) rather than
82
92
  // throwing on a legacy manifest.
83
93
  const tiers = typeof candidate.tiers === "boolean" ? candidate.tiers : false;
94
+ // A hand-written or damaged manifest may carry a non-string `pin`; that
95
+ // degrades to "no recorded pin" here (the same per-field-degradation
96
+ // style as `profile`/`tiers` above) rather than throwing. An empty or
97
+ // whitespace-only stored `pin` degrades the same way: it can never
98
+ // usefully name a kit version to gate on, so it is dropped rather than
99
+ // carried forward as a value nothing can act on.
84
100
  return {
85
101
  kit: SKILL_NAME,
86
102
  version: typeof candidate.version === "string" ? candidate.version : "",
87
103
  harnesses,
104
+ harnessesRecordedEmpty,
88
105
  models: models,
89
106
  profile,
90
107
  tiers,
91
108
  files,
92
109
  installedAt: typeof candidate.installedAt === "string" ? candidate.installedAt : "",
110
+ // The kit-version pin is deliberately free-form here: unlike the
111
+ // fields above it never reaches generated frontmatter, a shell, or a
112
+ // path, and the command that gates on it validates the value itself.
113
+ ...(typeof candidate.pin === "string" && candidate.pin.trim() !== ""
114
+ ? { pin: candidate.pin.trim() }
115
+ : {}),
93
116
  };
94
117
  }
95
118
  function yamlQuote(value) {
@@ -265,6 +288,18 @@ export function runInit(options) {
265
288
  const tiers = options.tiers ?? false;
266
289
  const report = emptyReport();
267
290
  const previous = readInstalledManifest(targetDir);
291
+ // `null` clears an existing pin, a string sets a new one, and omitted
292
+ // (`undefined`) carries the previous manifest's pin forward unchanged. An
293
+ // empty or whitespace-only string is normalized to a clear as well: it can
294
+ // never usefully name a kit version to gate on, so treating it as a
295
+ // sticky value would let a stray empty input linger unnoticed instead of
296
+ // clearing the pin the caller most likely meant.
297
+ const normalizedPin = typeof options.pin === "string"
298
+ ? options.pin.trim() === ""
299
+ ? null
300
+ : options.pin.trim()
301
+ : options.pin;
302
+ const pin = normalizedPin === null ? undefined : (normalizedPin ?? previous?.pin);
268
303
  const installedFiles = {};
269
304
  // Both leftover-note loops below are ledger-driven, not enumeration-
270
305
  // driven: they only ever push a note for a relative path that the
@@ -331,6 +366,64 @@ export function runInit(options) {
331
366
  }
332
367
  }
333
368
  }
369
+ // Dropping a harness this run (a previously installed harness no longer
370
+ // in options.harnesses -- including the whole set collapsing to
371
+ // `--harness none`) leaves that harness's files on disk but out of the
372
+ // manifest's file ledger, the same untracked-leftover shape as the two
373
+ // note loops above; surface it the same way instead of a silent leftover
374
+ // for `uninstall` to trip over later. Unlike the two loops above (which
375
+ // only ever touch claude/opencode agent files), this one also covers
376
+ // codex's SKILL.md under `.agents/`, since codex has no per-role agent
377
+ // files but its skill file is still a ledger-tracked kit-owned file.
378
+ //
379
+ // Deliberately keyed off `HARNESSES` (every known harness) and
380
+ // `previous.files` (the raw file ledger) rather than `previous.harnesses`
381
+ // (the sanitized, filtered harness list): a damaged/hand-edited manifest
382
+ // whose raw `harnesses` field lists a valid harness under an unrecognized
383
+ // name (e.g. `["cursor"]` where it once said `["claude"]`) filters that
384
+ // harness's name out of `previous.harnesses` entirely, but its files are
385
+ // still sitting in `previous.files` under `.claude/`; deriving the
386
+ // dropped-harness set from `previous.harnesses` would silently miss those
387
+ // notes (see CHANGELOG). Checking each
388
+ // known harness's own file-ledger prefix directly is immune to that: a
389
+ // harness with no files in the ledger under its prefix produces no notes
390
+ // either way, whether or not `previous.harnesses` ever named it.
391
+ if (previous) {
392
+ const harnessDirs = {
393
+ claude: ".claude",
394
+ codex: ".agents",
395
+ opencode: ".opencode",
396
+ };
397
+ for (const harness of HARNESSES) {
398
+ if (options.harnesses.includes(harness))
399
+ continue;
400
+ const prefix = harnessDirs[harness] + sep;
401
+ for (const relativePath of Object.keys(previous.files)) {
402
+ if (relativePath.startsWith(prefix)) {
403
+ report.notes.push(`${relativePath}: now untracked after --harness dropped ${harness}; run \`orchestrator-workflow uninstall\` first next time, or remove it by hand.`);
404
+ }
405
+ }
406
+ }
407
+ // AGENTS.md/CLAUDE.md are never ledger-tracked in the first place
408
+ // (upsertMarkerSection/ensureClaudeImport below write them directly,
409
+ // not through installKitFile, so they never enter previous.files);
410
+ // when the harness set collapses to none this run, this install skips
411
+ // writing them (see the `options.harnesses.length > 0` guard below),
412
+ // so note them by on-disk existence instead of a ledger lookup -- the
413
+ // same untracked signal for a file the ledger never recorded. Gated on
414
+ // ledger evidence (any known harness's file prefix present in
415
+ // `previous.files`), not on the sanitized `previous.harnesses`, for the
416
+ // same reason as the loop above: a damaged manifest can filter a valid
417
+ // harness out of `previous.harnesses` while its files remain recorded.
418
+ const hadTrackedHarnessFiles = Object.keys(previous.files).some((relativePath) => HARNESSES.some((harness) => relativePath.startsWith(harnessDirs[harness] + sep)));
419
+ if (hadTrackedHarnessFiles && options.harnesses.length === 0) {
420
+ for (const name of ["AGENTS.md", "CLAUDE.md"]) {
421
+ if (existsSync(join(targetDir, name))) {
422
+ report.notes.push(`${name}: now untracked after --harness dropped to none; run \`orchestrator-workflow uninstall\` first next time, or remove it by hand.`);
423
+ }
424
+ }
425
+ }
426
+ }
334
427
  /**
335
428
  * Installs a kit-owned file. An unedited file (it still matches the hash
336
429
  * recorded at install time) is updated in place when the kit content
@@ -360,10 +453,15 @@ export function runInit(options) {
360
453
  }
361
454
  installKitFile(join(".ai", "runs", ".gitkeep"), "");
362
455
  // Codex and opencode read AGENTS.md natively; Claude Code gets it via the
363
- // CLAUDE.md import. The policy section is therefore installed regardless of
364
- // the harness selection. AGENTS.md and CLAUDE.md are user-owned: only the
365
- // fenced section and the import line are ever touched.
366
- upsertMarkerSection(report, join(targetDir, "AGENTS.md"), readAsset("agents-md-section.md"));
456
+ // CLAUDE.md import. The policy section is therefore installed whenever any
457
+ // harness is selected, regardless of which one. AGENTS.md and CLAUDE.md
458
+ // are user-owned: only the fenced section and the import line are ever
459
+ // touched. `options.harnesses.length === 0` is templates-only mode
460
+ // (`--harness none`): only `.ai/workflow/**` and `.ai/runs/.gitkeep` are
461
+ // written, so AGENTS.md is left untouched (and never created) too.
462
+ if (options.harnesses.length > 0) {
463
+ upsertMarkerSection(report, join(targetDir, "AGENTS.md"), readAsset("agents-md-section.md"));
464
+ }
367
465
  const skill = readAsset(join("skill", "SKILL.md"));
368
466
  if (options.harnesses.includes("claude")) {
369
467
  installKitFile(join(".claude", "skills", SKILL_NAME, "SKILL.md"), skill);
@@ -427,6 +525,7 @@ export function runInit(options) {
427
525
  profile,
428
526
  tiers,
429
527
  files: installedFiles,
528
+ ...(pin !== undefined ? { pin } : {}),
430
529
  };
431
530
  const manifestPath = join(targetDir, MANIFEST_PATH);
432
531
  if (previous &&
@@ -438,6 +537,7 @@ export function runInit(options) {
438
537
  profile: previous.profile,
439
538
  tiers: previous.tiers,
440
539
  files: previous.files,
540
+ ...(previous.pin !== undefined ? { pin: previous.pin } : {}),
441
541
  }) === JSON.stringify(desired)) {
442
542
  report.skipped.push(manifestPath);
443
543
  }
@@ -0,0 +1,277 @@
1
+ import type { Harness } from "./detect.js";
2
+ import type { Profile, Role } from "./models.js";
3
+ export declare const OPERATOR_HOME_DIRNAME = ".orchestrator-workflow";
4
+ export declare const OPERATOR_HOME_ENV = "ORCHESTRATOR_WORKFLOW_HOME";
5
+ export declare const OPERATOR_MANIFEST_FILENAME = "manifest.json";
6
+ /**
7
+ * Operator-level defaults applied when a target is (re-)applied without its
8
+ * own explicit flags. `models` is a `Partial<Record<Role, string>>` rather
9
+ * than a full `Record`, mirroring `readInstalledManifest`'s per-role
10
+ * degradation: a hand-written or legacy operator manifest may carry only
11
+ * some roles, and the rest should fall back to `DEFAULT_MODELS` at the call
12
+ * site rather than forcing every role to be present here.
13
+ */
14
+ export interface OperatorManifestDefaults {
15
+ harnesses: Harness[];
16
+ profile: Profile;
17
+ tiers: boolean;
18
+ models: Partial<Record<Role, string>>;
19
+ }
20
+ /** One target directory this operator has applied the kit to. */
21
+ export interface OperatorTarget {
22
+ path: string;
23
+ lastAppliedVersion: string;
24
+ lastAppliedAt: string;
25
+ }
26
+ export interface OperatorManifest {
27
+ kit: "orchestrator-workflow";
28
+ schemaVersion: 1;
29
+ defaults: OperatorManifestDefaults;
30
+ targets: OperatorTarget[];
31
+ createdAt: string;
32
+ updatedAt: string;
33
+ }
34
+ /**
35
+ * Resolves the operator-level home directory. Precedence: an explicit
36
+ * argument, then `ORCHESTRATOR_WORKFLOW_HOME`, then `~/.orchestrator-workflow/`.
37
+ * Both the explicit argument and the env var are made absolute via
38
+ * `node:path`'s `resolve` (relative to `process.cwd()`), matching the
39
+ * Precedence: an explicit argument, then the environment override, then the
40
+ * default directory under the user's home.
41
+ * access, no directory creation, no env-var reads beyond the lookup itself.
42
+ */
43
+ export declare function resolveOperatorHome(explicit?: string): string;
44
+ /** Creates a fresh operator manifest with no targets yet applied. */
45
+ export declare function createOperatorManifest(defaults: OperatorManifestDefaults, now?: string): OperatorManifest;
46
+ /**
47
+ * Reads the operator-level manifest at `<home>/manifest.json`, if any. The
48
+ * file can be hand-written or damaged, so every field is sanitized the same
49
+ * way `readInstalledManifest` sanitizes a per-repo manifest: anything
50
+ * invalid degrades to a safe default instead of throwing. Only the
51
+ * envelope fields (`kit`, `schemaVersion`) are hard requirements; a
52
+ * mismatch there means "not a manifest we recognize" and the whole read
53
+ * returns `undefined` rather than guessing.
54
+ */
55
+ export declare function readOperatorManifest(home: string): OperatorManifest | undefined;
56
+ /** Default lock-acquire timeout, in milliseconds. Deliberately kept above
57
+ * {@link DEFAULT_LOCK_STALE_MS}: a caller that starts anywhere from 0ms up
58
+ * to `DEFAULT_LOCK_STALE_MS` after a killed holder left its lock behind
59
+ * must still live long enough, polling, to see that lock cross the
60
+ * staleness threshold and reclaim it, rather than timing out first. */
61
+ export declare const DEFAULT_LOCK_TIMEOUT_MS = 40000;
62
+ /** Default age, in milliseconds, past which a held lock directory is
63
+ * treated as abandoned and reclaimed. See {@link DEFAULT_LOCK_TIMEOUT_MS}
64
+ * for why this must stay smaller than the timeout. */
65
+ export declare const DEFAULT_LOCK_STALE_MS = 30000;
66
+ /** Default delay, in milliseconds, between acquire retries. */
67
+ export declare const DEFAULT_LOCK_POLL_MS = 20;
68
+ /** Options accepted by {@link withOperatorManifestLock}, exposed only so
69
+ * tests can shrink the timeout/staleness/poll windows below their
70
+ * production defaults; callers outside test code should omit this
71
+ * entirely. */
72
+ export interface OperatorManifestLockOptions {
73
+ timeoutMs?: number;
74
+ staleMs?: number;
75
+ pollMs?: number;
76
+ }
77
+ /** Thrown by {@link withOperatorManifestLock} when the lock could not be
78
+ * acquired within `timeoutMs`. Callers distinguish this from any error
79
+ * `fn` itself might throw so they can print lock-specific operator
80
+ * guidance instead of a generic failure. */
81
+ export declare class OperatorManifestLockTimeoutError extends Error {
82
+ constructor(lockPath: string);
83
+ }
84
+ /**
85
+ * Decides, from a lock directory's age measured *after* it was renamed
86
+ * aside during a reclaim attempt, whether that renamed copy should be
87
+ * destroyed (a genuinely abandoned lock) or handed back to its real owner
88
+ * (a lock that turned out fresh once re-checked; see
89
+ * {@link withOperatorManifestLock}'s reclaim comment for why this second
90
+ * check exists at all). `postAgeMs` is `undefined` when the renamed copy
91
+ * could no longer be stat'd by the time this runs (already gone, e.g. a
92
+ * third acquisition raced in and took it over): that is treated as
93
+ * hand-back-safe, not stale-and-destroy, since a lock this call cannot
94
+ * age must not be destroyed on its behalf — the same "when in doubt,
95
+ * don't tear down what might still be someone else's critical section"
96
+ * stance {@link withOperatorManifestLock}'s doc comment describes for its
97
+ * own `finally` release guard. A pure function so both branches, and the
98
+ * `undefined` case, are unit-testable without spawning a lock directory.
99
+ */
100
+ export declare function shouldDestroyReclaimedLock(postAgeMs: number | undefined, staleMs: number): boolean;
101
+ /**
102
+ * Runs `fn` while holding an advisory, same-machine lock on the operator
103
+ * manifest at `<home>/manifest.json`, so a read-modify-write sequence
104
+ * (read the manifest, compute an updated copy, write it back) that this
105
+ * function wraps end to end cannot interleave with another process's own
106
+ * read-modify-write against the same `home`. {@link updateOperatorManifest}
107
+ * is the only call site that should ever use this directly; it is what
108
+ * makes the locked read-modify-write the sole write path to the manifest.
109
+ *
110
+ * Mechanics: `mkdirSync(<home>/.manifest.lock)` is used as the mutex,
111
+ * since directory creation is atomic on every filesystem Node targets
112
+ * (POSIX `mkdir(2)`, Windows `CreateDirectory`): a second, concurrent
113
+ * `mkdirSync` call for the same path fails with `EEXIST` rather than
114
+ * silently succeeding, exactly the primitive a mutual-exclusion lock
115
+ * needs. Right after `mkdirSync` succeeds, a fresh random token is written
116
+ * to `<lockPath>/owner`: this is the lock's owner identity, and it is what
117
+ * makes both the stale-lock reclaim below and the release in `finally`
118
+ * safe under contention (see each for why).
119
+ *
120
+ * A caller that finds the lock held retries with a short synchronous sleep
121
+ * (`sleepSync`, `Atomics.wait` on a throwaway `SharedArrayBuffer`, not a
122
+ * CPU-spinning busy loop) until either it acquires the lock or `timeoutMs`
123
+ * (default {@link DEFAULT_LOCK_TIMEOUT_MS}) elapses, in which case it
124
+ * throws {@link OperatorManifestLockTimeoutError} without ever calling
125
+ * `fn`. On every failed attempt (not just the first), a lock directory
126
+ * older than `staleMs` (default {@link DEFAULT_LOCK_STALE_MS}, checked via
127
+ * its mtime) is treated as abandoned, most likely left behind by a process
128
+ * that crashed or was killed between acquiring and releasing it, and
129
+ * reclaim is attempted: `renameSync(lockPath, <lockPath>.<token>.stale)`
130
+ * moves it out of the way, then the renamed copy is removed. `renameSync`
131
+ * on a POSIX filesystem is atomic, so of any two waiters racing to reclaim
132
+ * the same stale-looking directory, at most one rename can ever succeed;
133
+ * the loser's `renameSync` throws (the source is already gone) and it
134
+ * falls through to the normal retry/timeout handling instead of also
135
+ * entering the critical section. Re-checking staleness on every attempt
136
+ * (rather than once per call) is safe precisely because of that atomicity:
137
+ * repeating the check cannot itself cause two callers to both believe they
138
+ * reclaimed the same lock, it only means a caller that starts partway
139
+ * through another's abandoned-lock window still gets a chance to reclaim
140
+ * it once that window is crossed, instead of being stuck waiting out the
141
+ * full timeout.
142
+ *
143
+ * The staleness check and the rename are still two separate syscalls, not
144
+ * one atomic operation, so a second process could complete an entire fresh
145
+ * acquisition of its own in the gap between them; the winning `renameSync`
146
+ * would then have relocated that fresh, actively-held lock rather than the
147
+ * abandoned one the check inspected. This is closed by re-checking age a
148
+ * second time on the renamed copy, which only the caller that just renamed
149
+ * it can observe (so this second read is itself race-free): a lock that
150
+ * was genuinely fresh still reads as fresh there, and is handed back
151
+ * (renamed to `lockPath` again) rather than destroyed, so its real owner
152
+ * is undisturbed (short of the exceedingly narrow case where a third
153
+ * acquisition lands in that same brief hand-back gap, at which point there
154
+ * is nothing left to hand it back to; that owner's own eventual release
155
+ * still no-ops safely, see `finally` below).
156
+ *
157
+ * This lock is advisory (nothing stops a caller from touching the
158
+ * manifest file without going through it, exactly like a POSIX file
159
+ * lock) and same-machine only (a directory on a network filesystem
160
+ * shared across hosts is not a safe mutex primitive here); it protects
161
+ * cooperating `orchestrator-workflow` processes on one machine against
162
+ * each other, not against an uncooperative writer or a multi-host setup.
163
+ * `home` is created first (`mkdirSync(home, { recursive: true })`) since
164
+ * the lock directory lives inside it and a first-ever `apply`/`setup`
165
+ * against a brand-new operator home would otherwise have nowhere to put
166
+ * it. The lock is released in `finally`, including when `fn` throws, but
167
+ * only if the owner file inside it still holds this call's own token: if
168
+ * another process's stale-lock reclaim has since taken the directory over
169
+ * (the true holder ran long enough past `staleMs` for a waiter to evict
170
+ * it), this call's own token no longer matches what is in the owner file,
171
+ * and removing the directory here would tear down a lock that is no
172
+ * longer this call's to release, leaving the new owner's critical section
173
+ * unprotected mid-flight. Skipping the removal in that case is the
174
+ * correct, if imperfect, response: the directory is left for its actual
175
+ * current owner to release normally.
176
+ */
177
+ export declare function withOperatorManifestLock<T>(home: string, fn: () => T, options?: OperatorManifestLockOptions): T;
178
+ /** The three states an operator-manifest read can land in: no file at
179
+ * `<home>/manifest.json` at all (`absent`, the fresh-operator case); a file
180
+ * that exists but `readOperatorManifest` could not turn into a manifest
181
+ * (`unreadable`, e.g. corrupt JSON, an unrecognized envelope, or a read
182
+ * failure such as a permissions error); or a file that parsed and
183
+ * validated (`ok`, with `manifest` set). Kept distinct from plain
184
+ * `readOperatorManifest`'s `OperatorManifest | undefined` so a caller (this
185
+ * module's own callers today, `apply`'s CLI action; `doctor`, once it
186
+ * exists, tomorrow) can tell "nothing set up yet" apart from "something is
187
+ * there and broken", since the two call for different operator advice: the
188
+ * former says run `setup`, the latter says back up and repair (or remove)
189
+ * the file first, since blindly running `setup` again would silently wipe
190
+ * whatever registry data survives in the damaged file.
191
+ */
192
+ export type OperatorManifestState = {
193
+ kind: "absent";
194
+ } | {
195
+ kind: "unreadable";
196
+ } | {
197
+ kind: "ok";
198
+ manifest: OperatorManifest;
199
+ };
200
+ export declare function operatorManifestState(home: string): OperatorManifestState;
201
+ /**
202
+ * The single locked read-modify-write entry point for the operator
203
+ * manifest: every write to `<home>/manifest.json` (`setup`, `apply`,
204
+ * `doctor --prune`, and `adopt`'s own registration step in cli.ts, all four
205
+ * today) goes through this function rather than ever calling the lock or
206
+ * the raw writer directly, so no command can bypass the lock and race
207
+ * another's read-modify-write.
208
+ *
209
+ * The whole re-read, `mutate`, and write run inside one
210
+ * `withOperatorManifestLock` critical section: `mutate` is handed the
211
+ * manifest re-read *inside the lock* (`current`, `undefined` when
212
+ * `state.kind` is not `"ok"`) rather than whatever the caller may have read
213
+ * before calling this, since that earlier read can already be stale by the
214
+ * time the lock is granted (another locked writer's own read-modify-write
215
+ * could have landed in between). `state` is the full {@link
216
+ * OperatorManifestState} the re-read produced, handed to `mutate` alongside
217
+ * `current` so it can distinguish "no manifest yet" from "manifest present
218
+ * but unreadable" when that distinction changes what it should do.
219
+ *
220
+ * `mutate` returning `undefined` means "do not write anything": the
221
+ * manifest is left exactly as re-read, and the returned `written` is
222
+ * `false`. Returning an `OperatorManifest` writes it (via the internal,
223
+ * unlocked writer, safe here since the write happens inside the lock) and
224
+ * `written` is `true`; the written manifest is also returned as
225
+ * `manifest` for a caller that wants it without a further read. A write
226
+ * that refreshes an already-existing manifest (`current` truthy) is also
227
+ * stamped with a fresh `updatedAt` here, unless `mutate`'s own returned
228
+ * value already carries a distinct one of its own, in which case that
229
+ * value is written verbatim (see the write itself, below, for the exact
230
+ * condition).
231
+ */
232
+ export declare function updateOperatorManifest(home: string, mutate: (current: OperatorManifest | undefined, state: OperatorManifestState) => OperatorManifest | undefined, options?: OperatorManifestLockOptions): {
233
+ state: OperatorManifestState;
234
+ written: boolean;
235
+ manifest?: OperatorManifest;
236
+ };
237
+ /**
238
+ * The operator-facing message for `apply`'s locked registration step
239
+ * finding the operator manifest not `"ok"` (unreadable or gone) once the
240
+ * lock was granted. The two cases get distinct wording: an unreadable
241
+ * manifest says the kit install itself already succeeded and only the
242
+ * registry write failed (unlike the *pre-install* unreadable check, which
243
+ * runs before any install work and so must not claim one happened), while
244
+ * a manifest gone missing mid-lock keeps its own separately-worded advice.
245
+ * A pure, exported function (rather than inlined at its one call site in
246
+ * cli.ts) so the wording can be unit-tested without spawning the CLI.
247
+ */
248
+ export declare function applyRegistrationFailureMessage(manifestKind: "unreadable" | "absent", manifestPath: string, targetDir: string): string;
249
+ /**
250
+ * Realpath that never throws: a recorded target whose directory has since
251
+ * been removed or moved (the `missing` case a later doctor reports) must not
252
+ * make an unrelated upsert fail, so the stored path is compared as written
253
+ * when it can no longer be resolved. Exported so cli.ts resolves a target
254
+ * path the same guarded way this module does internally, rather than
255
+ * keeping its own duplicate copy of the same guard.
256
+ */
257
+ export declare function safeRealpath(candidate: string): string;
258
+ /**
259
+ * Returns a new manifest with `targetPath` recorded as applied, plus
260
+ * whether `targetPath` was already registered (an update) rather than
261
+ * newly added, so a caller does not need its own separate, and
262
+ * potentially inconsistent, check against the same targets array. Pure:
263
+ * does not mutate `manifest` or its nested `targets` array/entries.
264
+ * Targets are deduplicated by realpath (`safeRealpath`, guarded against a
265
+ * target directory that no longer exists) rather than raw string
266
+ * equality, so the same directory reached via a symlink or a differently
267
+ * cased/relative path still updates the existing entry in place instead of
268
+ * appending a duplicate; the update branch also rewrites the stored
269
+ * `path` to the resolved realpath, so an entry once written as a raw,
270
+ * non-realpath string (a hand-edited manifest, or one written before this
271
+ * normalization existed) is normalized going forward instead of needing
272
+ * `safeRealpath` on every future comparison against it.
273
+ */
274
+ export declare function upsertOperatorTarget(manifest: OperatorManifest, targetPath: string, appliedVersion: string, appliedAt: string): {
275
+ manifest: OperatorManifest;
276
+ alreadyRegistered: boolean;
277
+ };