omp-conductor 0.18.2 → 0.19.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.
Files changed (60) hide show
  1. package/README.md +105 -40
  2. package/REFERENCE.md +865 -30
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +26 -0
  5. package/src/admission.ts +212 -26
  6. package/src/ask.ts +288 -1
  7. package/src/briefs/orchestrator.md +6 -5
  8. package/src/cli.ts +5 -1
  9. package/src/command-help.ts +9 -1
  10. package/src/command-manifest.ts +36 -3
  11. package/src/commands/arm.ts +5 -1
  12. package/src/commands/context.ts +2 -0
  13. package/src/commands/message.ts +26 -2
  14. package/src/commands/reconcile-units.ts +104 -0
  15. package/src/commands/release-composition.ts +232 -0
  16. package/src/commands/resume.ts +2 -27
  17. package/src/commands/setup.ts +101 -16
  18. package/src/commands/stats.ts +11 -30
  19. package/src/commands/tail.ts +31 -1
  20. package/src/commands/upgrade.ts +20 -3
  21. package/src/commands/verb.ts +2 -1
  22. package/src/config-schema.ts +19 -0
  23. package/src/config.ts +80 -0
  24. package/src/credential-class.ts +366 -0
  25. package/src/daemon.ts +1218 -288
  26. package/src/dashboard/app.js +504 -2
  27. package/src/dashboard/controls.ts +336 -0
  28. package/src/dashboard/index.html +30 -0
  29. package/src/dashboard/server.ts +271 -30
  30. package/src/dashboard/style.css +116 -0
  31. package/src/dashboard/transcript.ts +173 -0
  32. package/src/doctor.ts +377 -20
  33. package/src/failure-class.ts +59 -0
  34. package/src/fleet.ts +497 -15
  35. package/src/host.ts +6 -130
  36. package/src/omp.ts +29 -0
  37. package/src/orchestrator-tick.ts +343 -88
  38. package/src/pause.ts +233 -0
  39. package/src/settlement.ts +159 -2
  40. package/src/setup-answers.ts +97 -0
  41. package/src/setup-host.ts +321 -1155
  42. package/src/setup-install.ts +204 -27
  43. package/src/setup-wizard.ts +111 -50
  44. package/src/setup.ts +33 -0
  45. package/src/spend-telemetry.ts +117 -0
  46. package/src/stats.ts +35 -0
  47. package/src/status-render.ts +348 -19
  48. package/src/store.ts +1229 -55
  49. package/src/telegram-freshness.ts +269 -0
  50. package/src/to-spec.ts +27 -0
  51. package/src/types.ts +697 -4
  52. package/src/unblock.ts +22 -0
  53. package/src/unit-reconcile.ts +303 -0
  54. package/src/upgrade-verify.ts +8 -1
  55. package/src/upgrade.ts +299 -12
  56. package/src/verbs/actions.ts +124 -10
  57. package/src/verbs/protocol.ts +70 -2
  58. package/src/verbs/server.ts +447 -8
  59. package/src/wake.ts +48 -0
  60. package/src/worker.ts +403 -3
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Is the installed `omp-telegram` current, and is its daemon running it? (#961)
3
+ *
4
+ * Conductor already proves its *own* three install surfaces carry one identity
5
+ * (#904/#915) and shows divergence without an on-demand run (#919). This is the
6
+ * same check for the one plugin a documented conductor contract depends on.
7
+ *
8
+ * The incident that motivated it: the package floor mandates answering an
9
+ * inbound message with a targetless `telegram_send`, so the reply stays in the
10
+ * topic it arrived in. On 2026-08-21 that call refused three times with
11
+ * `no active telegram chat — pass chat_id`, and each refusal cost an improvised
12
+ * recovery — once by passing route ids by hand, which is precisely the
13
+ * anti-pattern #882 names as its silent fake. The cause was that the installed
14
+ * 0.12.1 predates the target ladder: its send path had two rungs, and the
15
+ * published 0.12.2 has four plus per-rung diagnostics. The fix had been on npm
16
+ * for days. No conductor surface said so, so three sessions did transcript
17
+ * archaeology to reach a conclusion `npm view` answers in one call.
18
+ *
19
+ * Three properties are deliberate:
20
+ *
21
+ * - **Read-only, and never a remedy.** Installing stays operator-owned under the
22
+ * running-conductor boundary. The deliverable is that the operator is told.
23
+ * - **Offline-tolerant.** A registry that cannot be reached is `unknown`, never
24
+ * a finding. A fleet with no outbound npm access must not grow a permanent
25
+ * warning it can do nothing about.
26
+ * - **No baked-in baseline.** A minimum version compiled into conductor goes
27
+ * stale in exactly the way this module exists to catch, and re-creates #904's
28
+ * divergence with one more copy of the number. Every version compared here is
29
+ * read from a surface that exists at runtime.
30
+ */
31
+
32
+ import { readFileSync } from "node:fs";
33
+ import { homedir } from "node:os";
34
+ import { join } from "node:path";
35
+
36
+ /** The plugin this checks. Named once. */
37
+ export const TELEGRAM_PACKAGE = "omp-telegram";
38
+
39
+ /** One surface's answer: a version, or why there isn't one. */
40
+ export type VersionRead =
41
+ | { kind: "version"; version: string }
42
+ | { kind: "absent" }
43
+ | { kind: "unreadable"; problem: string };
44
+
45
+ /** What the three surfaces said. */
46
+ export interface TelegramSurfaces {
47
+ /** The copy on disk, as `omp plugin list --json` reports it. */
48
+ installed: VersionRead;
49
+ /** What the running daemon says it is, from its own record. */
50
+ daemon: VersionRead;
51
+ /** The newest published version, or `unreadable` when the registry is not
52
+ * reachable — which is a non-finding, not a problem. */
53
+ published: VersionRead;
54
+ }
55
+
56
+ /**
57
+ * The verdict, in the vocabulary a surface renders.
58
+ *
59
+ * `unknown` is load-bearing: it is what an unreachable registry or an absent
60
+ * daemon produces, and it must never read as "current". A check that cannot run
61
+ * says so rather than certifying.
62
+ */
63
+ export type TelegramFreshnessState =
64
+ | "current"
65
+ | "installed-behind"
66
+ | "daemon-stale"
67
+ | "not-installed"
68
+ | "unknown";
69
+
70
+ export interface TelegramFreshness {
71
+ state: TelegramFreshnessState;
72
+ surfaces: TelegramSurfaces;
73
+ /** One operator-facing sentence, or undefined when there is nothing to say.
74
+ * Never contains a token, chat id or topic id — only versions and paths. */
75
+ detail?: string;
76
+ }
77
+
78
+ /** Numeric-dotted comparison, prerelease-agnostic: -1, 0 or 1.
79
+ *
80
+ * Deliberately not a semver dependency. The only question asked here is
81
+ * "is the installed one older than the published one", and a prerelease
82
+ * suffix is compared as a tiebreak string rather than by semver's precedence
83
+ * rules — an installed prerelease of the newest version is not behind. */
84
+ export function compareVersions(a: string, b: string): number {
85
+ const parse = (v: string): { nums: number[]; rest: string } => {
86
+ const m = /^v?(\d+(?:\.\d+)*)(.*)$/.exec(v.trim());
87
+ if (m === null) return { nums: [], rest: v.trim() };
88
+ return { nums: m[1]!.split(".").map((n) => Number(n)), rest: m[2] ?? "" };
89
+ };
90
+ const pa = parse(a);
91
+ const pb = parse(b);
92
+ const width = Math.max(pa.nums.length, pb.nums.length);
93
+ for (let i = 0; i < width; i++) {
94
+ const x = pa.nums[i] ?? 0;
95
+ const y = pb.nums[i] ?? 0;
96
+ if (x !== y) return x < y ? -1 : 1;
97
+ }
98
+ if (pa.rest === pb.rest) return 0;
99
+ // A release sorts above its own prereleases: "" beats "-rc.1".
100
+ if (pa.rest === "") return 1;
101
+ if (pb.rest === "") return -1;
102
+ return pa.rest < pb.rest ? -1 : 1;
103
+ }
104
+
105
+ /** The plugin's version from an `omp plugin list --json` payload.
106
+ *
107
+ * Tolerant by design, unlike conductor's own surface read: this plugin is a
108
+ * peer the fleet depends on, not the package being upgraded, so an inventory
109
+ * that cannot be parsed is `unreadable` rather than a thrown error that would
110
+ * take a whole `doctor` run down with it. */
111
+ export function installedFromInventory(raw: string): VersionRead {
112
+ let parsed: unknown;
113
+ try {
114
+ parsed = JSON.parse(raw);
115
+ } catch {
116
+ return { kind: "unreadable", problem: "omp plugin list returned invalid JSON" };
117
+ }
118
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
119
+ return { kind: "unreadable", problem: "omp plugin list returned an invalid inventory" };
120
+ }
121
+ const npm = Reflect.get(parsed, "npm");
122
+ if (!Array.isArray(npm)) {
123
+ return { kind: "unreadable", problem: "omp plugin list returned no npm inventory" };
124
+ }
125
+ const entry = npm.find(
126
+ (e) => e !== null && typeof e === "object" && Reflect.get(e, "name") === TELEGRAM_PACKAGE,
127
+ );
128
+ if (entry === undefined) return { kind: "absent" };
129
+ const version = Reflect.get(entry, "version");
130
+ return typeof version === "string" && version.length > 0
131
+ ? { kind: "version", version }
132
+ : { kind: "unreadable", problem: `${TELEGRAM_PACKAGE} is installed with no version` };
133
+ }
134
+
135
+ /** Where the daemon writes its own identity. Resolved exactly the way every
136
+ * other reader of that directory resolves it, so a test's override works here
137
+ * without a second convention. */
138
+ export function telegramStateDir(stateDir?: string): string {
139
+ const override = stateDir?.trim() ?? process.env.OMP_TELEGRAM_STATE_DIR?.trim();
140
+ return override ? override : join(homedir(), ".omp", "agent", "telegram");
141
+ }
142
+
143
+ /** What the running daemon says it is. `absent` when it is not running — which
144
+ * is not a fault here: a fleet may have no daemon up, and that is `unknown`
145
+ * rather than stale. */
146
+ export function daemonVersion(stateDir?: string): VersionRead {
147
+ const path = join(telegramStateDir(stateDir), "daemon.json");
148
+ let raw: unknown;
149
+ try {
150
+ raw = JSON.parse(readFileSync(path, "utf8"));
151
+ } catch (err) {
152
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return { kind: "absent" };
153
+ return {
154
+ kind: "unreadable",
155
+ problem: `cannot read ${path}: ${err instanceof Error ? err.message : String(err)}`,
156
+ };
157
+ }
158
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
159
+ return { kind: "unreadable", problem: `${path} is not a daemon record` };
160
+ }
161
+ const version = Reflect.get(raw, "version");
162
+ return typeof version === "string" && version.length > 0
163
+ ? { kind: "version", version }
164
+ : { kind: "unreadable", problem: `${path} records no version` };
165
+ }
166
+
167
+ /**
168
+ * Decide, from three reads.
169
+ *
170
+ * Order matters, and it is worth stating: `installed-behind` is checked before
171
+ * `daemon-stale` because the remedy differs — a behind install needs an install
172
+ * *and* a restart, and reporting only the restart would leave the operator one
173
+ * step short. A daemon matching a behind install is therefore still reported as
174
+ * behind, not as stale.
175
+ */
176
+ export function classifyTelegramFreshness(surfaces: TelegramSurfaces): TelegramFreshness {
177
+ const { installed, daemon, published } = surfaces;
178
+
179
+ if (installed.kind === "absent") {
180
+ return {
181
+ state: "not-installed",
182
+ surfaces,
183
+ detail: `${TELEGRAM_PACKAGE} is not installed; the reply-in-topic contract the brief mandates has no transport`,
184
+ };
185
+ }
186
+ if (installed.kind === "unreadable") {
187
+ return { state: "unknown", surfaces, detail: installed.problem };
188
+ }
189
+
190
+ if (published.kind === "version" && compareVersions(installed.version, published.version) < 0) {
191
+ const running =
192
+ daemon.kind === "version" && daemon.version !== installed.version
193
+ ? `; the daemon is on ${daemon.version}`
194
+ : "";
195
+ return {
196
+ state: "installed-behind",
197
+ surfaces,
198
+ detail:
199
+ `${TELEGRAM_PACKAGE} ${installed.version} is installed, ${published.version} is published${running}` +
200
+ ` — install it and restart the daemon (a publish does not bump an installed copy)`,
201
+ };
202
+ }
203
+
204
+ if (daemon.kind === "version" && daemon.version !== installed.version) {
205
+ return {
206
+ state: "daemon-stale",
207
+ surfaces,
208
+ detail:
209
+ `the running ${TELEGRAM_PACKAGE} daemon is ${daemon.version} but ${installed.version} is installed` +
210
+ " — restart it, or it keeps serving the old code",
211
+ };
212
+ }
213
+
214
+ // Nothing to compare against: the registry was unreachable, so "current" is
215
+ // an answer this check has not earned.
216
+ if (published.kind !== "version") {
217
+ return {
218
+ state: "unknown",
219
+ surfaces,
220
+ detail:
221
+ published.kind === "unreadable"
222
+ ? `${TELEGRAM_PACKAGE} ${installed.version} is installed; the newest published version could not be read (${published.problem})`
223
+ : `${TELEGRAM_PACKAGE} ${installed.version} is installed; nothing published to compare against`,
224
+ };
225
+ }
226
+
227
+ return { state: "current", surfaces };
228
+ }
229
+
230
+ /** What reading the surfaces needs. Injected so the whole check is testable
231
+ * without npm, a daemon, or a plugin install. */
232
+ export interface TelegramFreshnessDeps {
233
+ /** `omp plugin list --json`, and `npm view omp-telegram version`. */
234
+ run: (cmd: string, args: readonly string[]) => Promise<{ code: number; stdout: string }>;
235
+ /** Override for the daemon record's directory, in tests. */
236
+ stateDir?: string;
237
+ }
238
+
239
+ /** Read all three surfaces and classify. Never throws: every failure is a
240
+ * `VersionRead` that the classifier turns into `unknown`. */
241
+ export async function checkTelegramFreshness(
242
+ deps: TelegramFreshnessDeps,
243
+ ): Promise<TelegramFreshness> {
244
+ const inventory = await deps
245
+ .run("omp", ["plugin", "list", "--json"])
246
+ .then((r): VersionRead =>
247
+ r.code === 0
248
+ ? installedFromInventory(r.stdout)
249
+ : { kind: "unreadable", problem: "omp plugin list failed" },
250
+ )
251
+ .catch((err: unknown): VersionRead => ({
252
+ kind: "unreadable",
253
+ problem: `omp plugin list failed: ${err instanceof Error ? err.message : String(err)}`,
254
+ }));
255
+ const published = await deps
256
+ .run("npm", ["view", TELEGRAM_PACKAGE, "version"])
257
+ .then((r): VersionRead => {
258
+ const version = r.stdout.trim();
259
+ return r.code === 0 && version.length > 0
260
+ ? { kind: "version", version }
261
+ : { kind: "unreadable", problem: "npm registry unreachable" };
262
+ })
263
+ .catch((): VersionRead => ({ kind: "unreadable", problem: "npm registry unreachable" }));
264
+ return classifyTelegramFreshness({
265
+ installed: inventory,
266
+ daemon: daemonVersion(deps.stateDir),
267
+ published,
268
+ });
269
+ }
package/src/to-spec.ts CHANGED
@@ -406,3 +406,30 @@ export function parseToSpecEvidence(evidence: string): ToSpecResult | undefined
406
406
  const parsed = ToSpecResultSchema.safeParse(raw.result);
407
407
  return parsed.success ? parsed.data : undefined;
408
408
  }
409
+
410
+ /**
411
+ * The mirror of {@link parseToSpecEvidence} for the refusal payload
412
+ * {@link failureEvidence} writes: the recorded failure when a row is a
413
+ * refused pass, `undefined` for anything else (a valid verdict, a mechanical
414
+ * hold, an in-flight marker, a hand-edited row).
415
+ *
416
+ * A refusal is durable evidence too — it says a full delegated batch was
417
+ * already spent on this candidate and produced nothing usable — so the
418
+ * selection side needs to read it back rather than seeing only "not groomed"
419
+ * and re-spending immediately (#887).
420
+ */
421
+ export function parseToSpecFailureEvidence(evidence: string): ToSpecFailure | undefined {
422
+ let raw: unknown;
423
+ try {
424
+ raw = JSON.parse(evidence);
425
+ } catch {
426
+ return undefined;
427
+ }
428
+ if (!isObject(raw) || raw.kind !== "to-spec-failure") return undefined;
429
+ const failure = raw.failure;
430
+ if (!isObject(failure) || typeof failure.detail !== "string") return undefined;
431
+ if (failure.kind !== "malformed" && failure.kind !== "missing-source" && failure.kind !== "stale-source") {
432
+ return undefined;
433
+ }
434
+ return { kind: failure.kind, detail: failure.detail };
435
+ }