@ouro.bot/cli 0.1.0-alpha.515 → 0.1.0-alpha.516

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/changelog.json CHANGED
@@ -1,6 +1,16 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.516",
6
+ "changes": [
7
+ "Layer 1 of the harness-hardening sequence (1→4→2→3 from `docs/planning/2026-04-28-1900-planning-harness-hardening-and-repairguide.md`). Replaces the daemon-wide rollup at `daemon-entry.ts` (the binary `degraded.length > 0 ? \"degraded\" : \"ok\"` literal) with a five-state vocabulary: `healthy / partial / degraded / safe-mode / down`. A single sick agent no longer tips the whole daemon to `degraded`.",
8
+ "Type structure: `RollupStatus` (4-state, returned by the new pure `computeDaemonRollup` decision function in `daemon-rollup.ts`) and `DaemonStatus = RollupStatus | \"down\"` (full daemon-status; `down` is caller-owned because it represents pre-inventory failure, before the rollup is reachable). Both unions project from a single source-of-truth literal tuple so future widening touches one site.",
9
+ "`renderRollupStatusLine` in `cli-render.ts` uses a compiler-forced `never`-typed exhaustive switch — adding a future state compile-errors at every consumer using the pattern. The `degraded` literal carries three copy variants picked by inspecting cached agent statuses: empty map (fresh install, prompts `ouro hatch`), non-empty + any running agent (legacy stale cache from pre-Layer-1 daemons, prompts `ouro up` refresh), non-empty + zero running (all-failed live-check, prompts `ouro doctor`).",
10
+ "`runtime-readers.ts:readDaemonHealthDeep` parse tightened to use `isDaemonStatus`. `OutlookDaemonHealthDeep.status` widened to `DaemonStatus | \"unknown\"` so legacy serialized strings (`\"running\"`, `\"ok\"`) coerce defensively rather than failing the parse during rollout.",
11
+ "9759 tests pass (508 test files); coverage gate clean. The per-agent live-check loop in `cli-exec.ts` is intentionally untouched — it was already try/catch-isolated; the bug was in how its output rolled up. Subsequent PRs (layers 4, 2, 3) build on this PR's vocabulary."
12
+ ]
13
+ },
4
14
  {
5
15
  "version": "0.1.0-alpha.515",
6
16
  "changes": [
@@ -45,6 +45,7 @@ exports.formatTable = formatTable;
45
45
  exports.formatDaemonStatusOutput = formatDaemonStatusOutput;
46
46
  exports.formatVersionOutput = formatVersionOutput;
47
47
  exports.buildStoppedStatusPayload = buildStoppedStatusPayload;
48
+ exports.renderRollupStatusLine = renderRollupStatusLine;
48
49
  exports.daemonUnavailableStatusOutput = daemonUnavailableStatusOutput;
49
50
  exports.isDaemonUnavailableError = isDaemonUnavailableError;
50
51
  exports.formatMcpResponse = formatMcpResponse;
@@ -472,6 +473,69 @@ function buildStoppedStatusPayload(socketPath, syncRows = [], agentRows = []) {
472
473
  providers: [],
473
474
  };
474
475
  }
476
+ /**
477
+ * Render the cached daemon-rollup status as a one-line string for the
478
+ * "daemon not running" view. Each `DaemonStatus` literal maps to a
479
+ * label + a brief explanatory copy fragment. The default branch is
480
+ * `never`-typed so future widening of `DaemonStatus` compile-errors
481
+ * here — Layer 1's compiler-forced exhaustiveness contract.
482
+ *
483
+ * The `degraded` literal splits into two copy variants based on the
484
+ * cached health file's `agents` map:
485
+ * - empty map → "no agents configured" (fresh-install copy).
486
+ * - non-empty map → "none ready" (all-agents-failed-live-check copy).
487
+ *
488
+ * The split lives at the render layer (not in the rollup status itself)
489
+ * so the same status can carry distinct UX copy without inflating the
490
+ * type union.
491
+ */
492
+ function renderRollupStatusLine(health) {
493
+ const status = health.status;
494
+ const tail = `(pid ${health.pid}, uptime ${health.uptimeSeconds}s)`;
495
+ /* v8 ignore next -- v8 instruments the switch statement itself as a branch; the never-typed default below is unreachable by construction so v8 cannot observe its branch firing @preserve */
496
+ switch (status) {
497
+ case "healthy":
498
+ return `Last known status: healthy ${tail}`;
499
+ case "partial":
500
+ return `Last known status: partial — some agents unhealthy ${tail}`;
501
+ case "degraded": {
502
+ // Three-way copy split based on the cached agents map:
503
+ // - empty map → fresh install / no agents configured.
504
+ // - non-empty + any agent reports "running" → legacy stale cache:
505
+ // pre-Layer-1, status="degraded" meant "any sick component," so a
506
+ // running agent could coexist with a degraded daemon. Post-Layer-1,
507
+ // degraded means "zero serving" — mutually exclusive with a running
508
+ // agent. A live disagreement therefore implies the cache pre-dates
509
+ // the rollup-semantics fix; prompt for `ouro up` to refresh rather
510
+ // than falsely claim "none ready."
511
+ // - non-empty + zero agents reporting "running" → all-failed copy.
512
+ const agentEntries = Object.values(health.agents);
513
+ if (agentEntries.length === 0) {
514
+ return `Last known status: degraded — no agents configured (run \`ouro hatch\` to add one) ${tail}`;
515
+ }
516
+ const anyRunning = agentEntries.some((agent) => agent.status === "running");
517
+ if (anyRunning) {
518
+ return `Last known status: degraded — stale cache, run \`ouro up\` to refresh ${tail}`;
519
+ }
520
+ return `Last known status: degraded — agents configured but none ready (run \`ouro doctor\`) ${tail}`;
521
+ }
522
+ case "safe-mode":
523
+ return `Last known status: safe-mode — crash loop tripped ${tail}`;
524
+ case "down":
525
+ return `Last known status: down ${tail}`;
526
+ /* v8 ignore start -- compiler-forced exhaustiveness: the never-typed default branch is unreachable by construction; if DaemonStatus widens, tsc errors at the assignment before the throw can run @preserve */
527
+ default: {
528
+ // Compiler-forced exhaustiveness. If DaemonStatus grows a new
529
+ // literal, this `never` cast errors at tsc, forcing every
530
+ // consumer to handle it explicitly. NEVER replace this with a
531
+ // permissive `default:` returning a fallback string — that's
532
+ // exactly how the old "ok | degraded" semantics leaked through.
533
+ const _exhaustive = status;
534
+ throw new Error(`unhandled daemon status: ${_exhaustive}`);
535
+ }
536
+ /* v8 ignore stop */
537
+ }
538
+ }
475
539
  function daemonUnavailableStatusOutput(socketPath, healthFilePath) {
476
540
  // Read per-agent sync config and bundle list from disk so the user still
477
541
  // sees them when the daemon is down. Best-effort: any fs error returns []
@@ -515,7 +579,7 @@ function daemonUnavailableStatusOutput(socketPath, healthFilePath) {
515
579
  const resolvedHealthPath = healthFilePath ?? (0, daemon_health_1.getDefaultHealthPath)();
516
580
  const health = (0, daemon_health_1.readHealth)(resolvedHealthPath);
517
581
  if (health) {
518
- lines.push(`Last known status: ${health.status} (pid ${health.pid}, uptime ${health.uptimeSeconds}s)`);
582
+ lines.push(renderRollupStatusLine(health));
519
583
  if (health.safeMode?.active) {
520
584
  lines.push(`SAFE MODE: ${health.safeMode.reason}`);
521
585
  }
@@ -43,6 +43,7 @@ const index_1 = require("../../nerves/index");
43
43
  const message_router_1 = require("./message-router");
44
44
  const health_monitor_1 = require("./health-monitor");
45
45
  const daemon_health_1 = require("./daemon-health");
46
+ const daemon_rollup_1 = require("./daemon-rollup");
46
47
  const task_scheduler_1 = require("./task-scheduler");
47
48
  const runtime_logging_1 = require("./runtime-logging");
48
49
  const sense_manager_1 = require("./sense-manager");
@@ -173,12 +174,40 @@ function buildDaemonHealthState() {
173
174
  since: snapshot.lastCrashAt ?? daemonStartedAt,
174
175
  };
175
176
  });
177
+ // Preserved for backwards-compatible inspection: callers (status
178
+ // command, outlook surface, etc.) may still read this combined list
179
+ // for per-component reasons. The rollup status field above is what
180
+ // changed meaning — the array is still the union of bootstrap +
181
+ // agent-derived degradation entries.
176
182
  const degraded = [
177
183
  ...degradedComponents.map((entry) => ({ ...entry })),
178
184
  ...agentDegradedComponents,
179
185
  ];
186
+ // Layer 1 rollup: project per-agent snapshots into the minimal
187
+ // AgentRollupInput shape and let computeDaemonRollup decide. The
188
+ // input is "every enabled agent" — managedAgents was filtered via
189
+ // listEnabledBundleAgents at module init, and snapshots only covers
190
+ // agents the process manager was told to manage, so by construction
191
+ // these entries are all enabled. The rollup function is a pure
192
+ // declarative function on the data we hand it.
193
+ //
194
+ // Note: safe-mode is wired as `false` here. Existing crash-loop
195
+ // detection (safe-mode.ts) already runs at the daemon-up boot path
196
+ // (cli-exec.ts), not from inside the daemon process itself. Once
197
+ // the daemon is up and reaching this rollup, safe mode no longer
198
+ // applies — the daemon is by definition past the crash-loop gate.
199
+ // If a future PR moves safe-mode signal into the running daemon,
200
+ // wire it through this third argument.
201
+ const rollupStatus = (0, daemon_rollup_1.computeDaemonRollup)({
202
+ enabledAgents: snapshots.map((snapshot) => ({
203
+ name: snapshot.name,
204
+ status: snapshot.status,
205
+ })),
206
+ bootstrapDegraded: degradedComponents,
207
+ safeMode: false,
208
+ });
180
209
  return {
181
- status: degraded.length > 0 ? "degraded" : "ok",
210
+ status: rollupStatus,
182
211
  mode,
183
212
  pid: process.pid,
184
213
  startedAt: daemonStartedAt,
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.HEALTH_TRACKED_EVENTS = exports.DaemonHealthWriter = void 0;
37
+ exports.isRollupStatus = isRollupStatus;
38
+ exports.isDaemonStatus = isDaemonStatus;
37
39
  exports.getDefaultHealthPath = getDefaultHealthPath;
38
40
  exports.createHealthNervesSink = createHealthNervesSink;
39
41
  exports.readHealth = readHealth;
@@ -41,6 +43,38 @@ const fs = __importStar(require("fs"));
41
43
  const os = __importStar(require("os"));
42
44
  const path = __importStar(require("path"));
43
45
  const runtime_1 = require("../../nerves/runtime");
46
+ /**
47
+ * Daemon-wide rollup vocabulary — locked layer-1 contract.
48
+ *
49
+ * - `RollupStatus` is what `computeDaemonRollup` returns (post-inventory,
50
+ * four-state). The function never returns `"down"` because by the time
51
+ * the rollup is reachable the daemon has already started, opened its
52
+ * socket, and read its agent inventory — pre-inventory failure is the
53
+ * caller's domain.
54
+ * - `DaemonStatus` is what `DaemonHealthState.status` accepts. The caller
55
+ * widens the rollup result with `"down"` along the daemon-entry failure
56
+ * path (e.g. when the daemon process can't read inventory at all).
57
+ *
58
+ * `isRollupStatus` and `isDaemonStatus` are runtime guards used both by
59
+ * `readHealth` (validating cached health files on disk) and by render-side
60
+ * consumers that want to narrow `unknown` JSON into the typed union before
61
+ * branching on it.
62
+ */
63
+ // Single source of truth — the literal lists below are the runtime
64
+ // projection of the type unions. A future literal added to RollupStatus
65
+ // MUST also be added to ROLLUP_STATUS_LITERALS or `satisfies` blows up
66
+ // at tsc. That tightens the Layer 1 contract: producer + consumer +
67
+ // guard all stay in lockstep.
68
+ const ROLLUP_STATUS_LITERALS = ["healthy", "partial", "degraded", "safe-mode"];
69
+ const DAEMON_STATUS_LITERALS = [...ROLLUP_STATUS_LITERALS, "down"];
70
+ const ROLLUP_STATUS_VALUES = new Set(ROLLUP_STATUS_LITERALS);
71
+ const DAEMON_STATUS_VALUES = new Set(DAEMON_STATUS_LITERALS);
72
+ function isRollupStatus(value) {
73
+ return typeof value === "string" && ROLLUP_STATUS_VALUES.has(value);
74
+ }
75
+ function isDaemonStatus(value) {
76
+ return typeof value === "string" && DAEMON_STATUS_VALUES.has(value);
77
+ }
44
78
  class DaemonHealthWriter {
45
79
  healthPath;
46
80
  constructor(healthPath) {
@@ -111,7 +145,7 @@ function readHealth(healthPath) {
111
145
  try {
112
146
  const raw = fs.readFileSync(healthPath, "utf-8");
113
147
  const parsed = JSON.parse(raw);
114
- if (typeof parsed.status !== "string" ||
148
+ if (!isDaemonStatus(parsed.status) ||
115
149
  typeof parsed.mode !== "string" ||
116
150
  typeof parsed.pid !== "number" ||
117
151
  typeof parsed.startedAt !== "string" ||
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.computeDaemonRollup = computeDaemonRollup;
4
+ /**
5
+ * Pure rollup decision function — given the post-inventory daemon
6
+ * surface, returns the daemon-wide rollup state per the locked Layer 1
7
+ * vocabulary table:
8
+ *
9
+ * | rollup | when |
10
+ * | ---------- | --------------------------------------------------------- |
11
+ * | healthy | every enabled agent serving + no bootstrap-degraded + no safe-mode |
12
+ * | partial | (≥1 serving + ≥1 not serving) OR (all serving + ≥1 bootstrap-degraded) |
13
+ * | degraded | zero enabled agents serving (fresh install OR all unhealthy) |
14
+ * | safe-mode | `safeMode === true` overrides everything else |
15
+ *
16
+ * The function NEVER returns `"down"`. By the time `computeDaemonRollup`
17
+ * is reachable, the daemon process has started, opened its socket, and
18
+ * read its agent inventory — pre-inventory failure is the caller's
19
+ * domain. `daemon-entry.ts`'s startup-failure path assigns `"down"` to
20
+ * `DaemonHealthState.status` directly without consulting this function.
21
+ */
22
+ function computeDaemonRollup(input) {
23
+ // Safe mode wins, period. Crash-loop detection trumps everything —
24
+ // we want the human to see SAFE MODE, not a noisy partial/degraded.
25
+ if (input.safeMode) {
26
+ return "safe-mode";
27
+ }
28
+ // Count serving agents. "Serving" = "running" worker status.
29
+ // Anything else (crashed/stopped/starting/etc) is not serving.
30
+ let serving = 0;
31
+ let notServing = 0;
32
+ for (const agent of input.enabledAgents) {
33
+ if (agent.status === "running") {
34
+ serving++;
35
+ }
36
+ else {
37
+ notServing++;
38
+ }
39
+ }
40
+ // Zero-serving wins over bootstrap-degraded — we have no working
41
+ // agents to surface a "partially working" story about. This covers
42
+ // both fresh-install (`enabledAgents.length === 0`) and
43
+ // all-failed-live-check (`serving === 0` with `notServing > 0`).
44
+ // Render layer (cli-render.ts) splits the UX copy by inspecting the
45
+ // agents map; the rollup itself doesn't carry the distinction.
46
+ if (serving === 0) {
47
+ return "degraded";
48
+ }
49
+ // From here we have ≥1 serving agent. The remaining choice is
50
+ // healthy vs partial.
51
+ const hasUnhealthyAgent = notServing > 0;
52
+ const hasBootstrapDegraded = input.bootstrapDegraded.length > 0;
53
+ if (hasUnhealthyAgent || hasBootstrapDegraded) {
54
+ return "partial";
55
+ }
56
+ return "healthy";
57
+ }
@@ -49,6 +49,7 @@ const runtime_1 = require("../../../nerves/runtime");
49
49
  const habit_parser_1 = require("../../habits/habit-parser");
50
50
  const habit_runtime_state_1 = require("../../habits/habit-runtime-state");
51
51
  const identity_1 = require("../../identity");
52
+ const daemon_health_1 = require("../../daemon/daemon-health");
52
53
  const shared_1 = require("./shared");
53
54
  const agent_machine_1 = require("./agent-machine");
54
55
  const sessions_1 = require("./sessions");
@@ -270,7 +271,12 @@ function readDaemonHealthDeep(healthPath) {
270
271
  const raw = fs.readFileSync(resolvedPath, "utf-8");
271
272
  const health = JSON.parse(raw);
272
273
  return {
273
- status: typeof health.status === "string" ? health.status : "unknown",
274
+ // Layer 1: tighten the parse so only post-Layer-1 vocabulary
275
+ // carries through. Stale cached files that still hold legacy
276
+ // string values like "ok" or "running" — written by an older
277
+ // daemon binary — fall back to "unknown" so downstream Outlook
278
+ // consumers can detect the unparseable case explicitly.
279
+ status: (0, daemon_health_1.isDaemonStatus)(health.status) ? health.status : "unknown",
274
280
  mode: typeof health.mode === "string" ? health.mode : "unknown",
275
281
  pid: typeof health.pid === "number" ? health.pid : 0,
276
282
  startedAt: typeof health.startedAt === "string" ? health.startedAt : "",
@@ -87,6 +87,12 @@ const DISPATCH_EXEMPT_PATTERNS = [
87
87
  // HTTP health probe: pure HTTP utility factory. The HealthMonitor caller
88
88
  // owns observability via daemon.health_result events.
89
89
  "daemon/http-health-probe",
90
+ // Rollup decision function: pure decision tree mapping per-agent
91
+ // snapshots + bootstrap-degraded entries + safe-mode flag to a
92
+ // RollupStatus. No side effects. The caller (daemon-entry.ts
93
+ // buildDaemonHealthState → DaemonHealthWriter) owns observability via
94
+ // daemon.health_written when the rolled-up state is persisted.
95
+ "daemon/daemon-rollup",
90
96
  // Attachment helper modules: generic file-path/extension utilities and the
91
97
  // source registry are pure support seams. The orchestrator/adapters that
92
98
  // call them own the observability.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.515",
3
+ "version": "0.1.0-alpha.516",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",