@cohortapp/agent-sdk 2.5.0 → 2.6.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 (108) hide show
  1. package/bin/maestro.mjs +185 -88
  2. package/bin/maestro.test.mjs +175 -48
  3. package/docs/runbooks/backup-restore.md +65 -33
  4. package/framework-features.json +4 -4
  5. package/lib/backup/policy.mjs +710 -0
  6. package/lib/backup/policy.test.mjs +305 -0
  7. package/lib/budget-escalate.mjs +133 -0
  8. package/lib/budget-escalate.test.mjs +232 -0
  9. package/lib/budget-guard.envelope.test.mjs +476 -0
  10. package/lib/budget-guard.mjs +853 -75
  11. package/lib/budget-guard.test.mjs +91 -42
  12. package/lib/cadences.mjs +33 -0
  13. package/lib/channels/orgmail/adapter.mjs +88 -3
  14. package/lib/channels/orgmail/adapter.test.mjs +137 -0
  15. package/lib/channels/repeat-suppressor.mjs +198 -0
  16. package/lib/channels/repeat-suppressor.test.mjs +134 -0
  17. package/lib/comms/receipts.mjs +297 -0
  18. package/lib/cost/ledger-row.mjs +333 -0
  19. package/lib/cost/ledger-row.test.mjs +183 -0
  20. package/lib/execution/drive.mjs +28 -1
  21. package/lib/execution/effects.mjs +191 -12
  22. package/lib/execution/effects.test.mjs +50 -11
  23. package/lib/goals/admission.mjs +13 -1
  24. package/lib/goals/admission.test.mjs +26 -1
  25. package/lib/goals/loop.mjs +13 -0
  26. package/lib/kpi-sensors.test.mjs +3 -0
  27. package/lib/mandate/cache.mjs +13 -5
  28. package/lib/mandate/derive.mjs +146 -21
  29. package/lib/mandate/derive.test.mjs +50 -6
  30. package/lib/mandate/model.mjs +32 -4
  31. package/lib/mandate/refresh.test.mjs +16 -2
  32. package/lib/mcp/server.test.mjs +12 -3
  33. package/lib/model-router/economics.mjs +107 -76
  34. package/lib/model-router/economics.test.mjs +64 -46
  35. package/lib/model-router/integration-coverage.test.mjs +39 -37
  36. package/lib/model-router/ledger.mjs +75 -22
  37. package/lib/model-router/ledger.test.mjs +35 -2
  38. package/lib/org/client.mjs +14 -0
  39. package/lib/org/cost-sync.mjs +16 -2
  40. package/lib/org/doctor.mjs +62 -1
  41. package/lib/org/doctor.test.mjs +36 -3
  42. package/lib/org/email-remedy.mjs +49 -0
  43. package/lib/org/engagement-ledger.mjs +376 -0
  44. package/lib/org/engagement-ledger.test.mjs +112 -0
  45. package/lib/org/engagement.mjs +1056 -0
  46. package/lib/org/engagement.test.mjs +739 -0
  47. package/lib/org/inbound/hydrate.mjs +107 -15
  48. package/lib/org/inbound/hydrate.test.mjs +127 -0
  49. package/lib/org/messaging.mjs +230 -3
  50. package/lib/org/messaging.test.mjs +110 -1
  51. package/lib/org/param-contract.mjs +56 -2
  52. package/lib/org/param-contract.test.mjs +26 -0
  53. package/lib/org/protocol.checksum +1 -1
  54. package/lib/org/protocol.mjs +5 -0
  55. package/lib/org/protocol.test.mjs +7 -1
  56. package/lib/org/tool-surface.mjs +506 -10
  57. package/lib/org/tool-surface.test.mjs +191 -7
  58. package/lib/org/ui-parity.mjs +333 -6
  59. package/lib/org/ui-parity.test.mjs +96 -3
  60. package/lib/org/work-ledger.mjs +241 -0
  61. package/lib/org/work-ledger.test.mjs +237 -0
  62. package/lib/plan/adoption-e2e.test.mjs +366 -0
  63. package/lib/plan/budget-enforcement.test.mjs +400 -0
  64. package/lib/plan/budget-runtime.mjs +215 -0
  65. package/lib/plan/compile.mjs +201 -5
  66. package/lib/plan/compile.test.mjs +19 -5
  67. package/lib/plan/emit.mjs +8 -0
  68. package/lib/plan/emit.test.mjs +18 -0
  69. package/lib/resource-governor.mjs +58 -12
  70. package/lib/resource-governor.test.mjs +41 -1
  71. package/lib/security/audit-engine.mjs +45 -8
  72. package/lib/security/audit-engine.test.mjs +35 -0
  73. package/lib/setup/enroll-from-cohort.mjs +14 -1
  74. package/lib/setup/sections/mandate.mjs +48 -7
  75. package/lib/setup/sections/mandate.test.mjs +17 -2
  76. package/lib/setup/sections/orgmail.mjs +10 -2
  77. package/lib/setup/state.mjs +83 -2
  78. package/lib/telemetry/collect.mjs +360 -20
  79. package/lib/telemetry/collect.test.mjs +266 -0
  80. package/package.json +1 -1
  81. package/scripts/cost/track-claude-usage.mjs +207 -48
  82. package/scripts/cost/track-claude-usage.test.mjs +148 -0
  83. package/scripts/daemon/agent-daemon.mjs +315 -17
  84. package/scripts/daemon/assurance-e2e.test.mjs +421 -0
  85. package/scripts/daemon/assurance.mjs +944 -0
  86. package/scripts/daemon/assurance.test.mjs +668 -0
  87. package/scripts/daemon/cadence-consumer-governance.test.mjs +56 -0
  88. package/scripts/daemon/cadence-consumer.mjs +147 -9
  89. package/scripts/daemon/cadence-consumer.test.mjs +6 -0
  90. package/scripts/daemon/cadence-handlers.mjs +158 -0
  91. package/scripts/daemon/cadence-handlers.test.mjs +64 -0
  92. package/scripts/daemon/deliver.mjs +314 -0
  93. package/scripts/daemon/dispatcher-governance.test.mjs +10 -0
  94. package/scripts/daemon/dispatcher.mjs +64 -6
  95. package/scripts/daemon/responder-cost.test.mjs +68 -0
  96. package/scripts/daemon/responder.mjs +351 -298
  97. package/scripts/local-triggers/generate-plists.test.mjs +7 -4
  98. package/scripts/maintenance/backup-run.mjs +415 -0
  99. package/scripts/maintenance/backup-to-cloud.sh +16 -116
  100. package/scripts/org/send-orgmail.mjs +16 -0
  101. package/scripts/record-receipt.sh +63 -0
  102. package/scripts/restore-from-backup.sh +14 -3
  103. package/scripts/restore-from-backup.test.mjs +8 -5
  104. package/scripts/send-email-threaded.py +47 -0
  105. package/scripts/send-sms.sh +4 -0
  106. package/scripts/send-whatsapp.sh +4 -0
  107. package/scripts/setup/init-backup.mjs +93 -38
  108. package/scripts/slack-send.sh +12 -0
package/bin/maestro.mjs CHANGED
@@ -30,6 +30,7 @@ import { execFileSync, spawnSync } from "node:child_process";
30
30
  import { createHash } from "node:crypto";
31
31
  import { homedir } from "node:os";
32
32
  import { checkOwnershipSet } from "../lib/fs-ownership.mjs";
33
+ import { summariseRows as summariseCostRows } from "../lib/cost/ledger-row.mjs";
33
34
  import { resolveArchetype, migrateLegacyArchetype } from "../lib/archetype.mjs";
34
35
  import { runAudit, applyFixes, buildAttestation } from "../lib/security/audit-engine.mjs";
35
36
  import { selectProvider } from "../lib/secrets/providers.mjs";
@@ -1448,70 +1449,132 @@ Per-file behaviour:
1448
1449
  // in isolation from disk + the doctor's side-effecting ok/warn/fail output.
1449
1450
 
1450
1451
  /**
1451
- * Sum estimated_usd and count rows from already-parsed ledger rows.
1452
- * Tolerant of missing/NaN estimated_usd (treated as 0) and ignores non-objects.
1452
+ * Fold already-parsed ledger rows into the billable total plus the measurement
1453
+ * provenance the tripwire needs.
1454
+ *
1455
+ * Bills against lib/cost/ledger-row.mjs — the CLI's authoritative
1456
+ * `total_cost_usd` where present, the cache-aware token estimate as a logged
1457
+ * fallback. It used to sum `estimated_usd` blindly, which both under-counted
1458
+ * (no cache tier) and treated an unmeasured session as a free one.
1459
+ *
1453
1460
  * @param {Array<object>} rows
1454
- * @returns {{ rowCount: number, totalUsd: number }}
1461
+ * @returns {{ rowCount:number, totalUsd:number, measured:number, unmeasured:number, nonLlmRows:number, degradations:string[] }}
1455
1462
  */
1456
1463
  function summariseLedgerRows(rows) {
1457
- let rowCount = 0;
1458
- let totalUsd = 0;
1459
- for (const row of rows || []) {
1460
- if (!row || typeof row !== "object") continue;
1461
- rowCount++;
1462
- const usd = Number(row.estimated_usd);
1463
- if (Number.isFinite(usd)) totalUsd += usd;
1464
- }
1465
- return { rowCount, totalUsd: +totalUsd.toFixed(6) };
1464
+ const s = summariseCostRows(rows || []);
1465
+ return {
1466
+ // rowCount stays "LLM session rows" — attribution rows for zero-model work
1467
+ // are not sessions and must not arm a blindness alarm on their own.
1468
+ rowCount: s.sessions,
1469
+ totalUsd: s.measuredUsd,
1470
+ measured: s.measured,
1471
+ unmeasured: s.unmeasured,
1472
+ // WHICH failure produced `unmeasured`: usage unread, or usage read and
1473
+ // unpriceable. Different bugs, different fixes, different sentences.
1474
+ noTokenRows: s.noTokenRows,
1475
+ unpricedRows: s.unpricedRows,
1476
+ nonLlmRows: s.nonLlmRows,
1477
+ degradations: s.degradations,
1478
+ };
1466
1479
  }
1467
1480
 
1468
1481
  /**
1469
- * Decide the cost-telemetry posture from today's ledger rows and a count of
1470
- * sessions known to have run today (rows themselves OR a daemon heartbeat that
1471
- * proves activity). The contract the spec pins down:
1482
+ * Decide the cost-telemetry posture from today's ledger rows and the number of
1483
+ * sessions the daemon logged today.
1484
+ *
1485
+ * The contract, restated so it detects the real defect instead of the clock:
1472
1486
  *
1473
- * RED — sessions ran today (rows exist OR heartbeat active) but summed
1474
- * estimated_usd === 0 → "spend telemetry is blind".
1475
- * GREEN — no sessions ran (nothing to measure) OR spend > 0.
1487
+ * RED — sessions ran and we cannot price them. Three distinct ways:
1488
+ * (a) unmeasured rows exist — a caller recorded measurement:"unknown"
1489
+ * (b) sessions ran with NO ledger row at all — spawned but never recorded
1490
+ * (c) measured rows exist but bill to $0 — pricing itself is broken
1491
+ * GREEN — no sessions ran (nothing to measure), or every session that ran is
1492
+ * measured and bills above $0.
1493
+ *
1494
+ * `sessionCount` MUST be evidence that a session actually ran (a logged session
1495
+ * start/completion), NOT a liveness signal. The previous version accepted a
1496
+ * fresh daemon heartbeat, so it went RED every UTC day between midnight and the
1497
+ * first session — an empty ledger plus a live daemon — and printed a fix that
1498
+ * had already shipped. A loud wrong alarm trains the operator to ignore the one
1499
+ * channel meant to be trustworthy, so it is as much a defect as a quiet bug.
1476
1500
  *
1477
1501
  * @param {{ rows?: Array<object>, sessionCount?: number }} input
1478
- * rows — parsed ledger rows for today (may be empty)
1479
- * sessionCount — sessions evidenced today from outside the ledger (e.g. a
1480
- * fresh daemon heartbeat). Activity = (rows.length>0) || (sessionCount>0).
1481
- * @returns {{ red: boolean, status: "red"|"green", reason: string, rowCount: number, totalUsd: number, sessionCount: number }}
1502
+ * @returns {{ red:boolean, status:"red"|"green", reason:string, fix:string|null, rowCount:number, totalUsd:number, sessionCount:number, unmeasured:number, unrecorded:number }}
1482
1503
  */
1483
- function evaluateCostTripwire({ rows = [], sessionCount = 0 } = {}) {
1484
- const { rowCount, totalUsd } = summariseLedgerRows(rows);
1504
+ function evaluateCostTripwire({ rows = [], sessionCount = 0, sessionSource = "dispatcher" } = {}) {
1505
+ const { rowCount, totalUsd, measured, unmeasured, noTokenRows, unpricedRows, nonLlmRows } = summariseLedgerRows(rows);
1485
1506
  const sessions = Math.max(0, Number(sessionCount) || 0);
1486
- const hadActivity = rowCount > 0 || sessions > 0;
1487
1507
 
1488
- if (!hadActivity) {
1508
+ // LIKE FOR LIKE. `sessionCount` comes from logs/daemon/<date>-sessions.jsonl,
1509
+ // which ONLY the dispatcher writes (`responder` and the cadence consumer have
1510
+ // no logSession). Comparing it against rows from EVERY writer meant the
1511
+ // responder rows this changeset added would mask a dispatcher that had stopped
1512
+ // recording entirely: 35 dispatcher sessions unpriced + 40 responder rows read
1513
+ // as `unrecorded: 0`, status green. Count only the rows from the writer the
1514
+ // session log belongs to.
1515
+ const sourceRows = rows.filter((r) => r && (r.source ?? null) === sessionSource);
1516
+ const sourceSessions = summariseLedgerRows(sourceRows).rowCount;
1517
+ const unrecorded = Math.max(0, sessions - sourceSessions);
1518
+ const base = { rowCount, totalUsd, sessionCount: sessions, unmeasured, unrecorded, sourceRowCount: sourceSessions };
1519
+
1520
+ if (rowCount === 0 && sessions === 0) {
1489
1521
  return {
1522
+ ...base,
1490
1523
  red: false,
1491
1524
  status: "green",
1492
- reason: "no sessions ran today — nothing to measure",
1493
- rowCount,
1494
- totalUsd,
1495
- sessionCount: sessions,
1525
+ fix: null,
1526
+ reason: nonLlmRows > 0
1527
+ ? `no LLM sessions ran today — nothing to measure (${nonLlmRows} zero-model attribution row(s))`
1528
+ : "no sessions ran today — nothing to measure",
1496
1529
  };
1497
1530
  }
1498
- if (totalUsd > 0) {
1531
+
1532
+ if (noTokenRows > 0) {
1499
1533
  return {
1500
- red: false,
1501
- status: "green",
1502
- reason: `spend telemetry live ($${totalUsd} across ${rowCount} ledger row(s))`,
1503
- rowCount,
1504
- totalUsd,
1505
- sessionCount: sessions,
1534
+ ...base,
1535
+ red: true,
1536
+ status: "red",
1537
+ reason: `spend telemetry is partially blind — ${noTokenRows}/${rowCount} session row(s) recorded NO token counts (measurement:"unknown"). Their cost is unknown, not zero.`,
1538
+ fix: "check logs/daemon for cost_usage_parse_failed — a caller ran a session but could not read usage from the `claude --output-format json` envelope. Each row's unmeasured_reason names the parse failure.",
1539
+ };
1540
+ }
1541
+
1542
+ if (unpricedRows > 0) {
1543
+ return {
1544
+ ...base,
1545
+ red: true,
1546
+ status: "red",
1547
+ reason: `spend telemetry is blind — ${unpricedRows}/${rowCount} session row(s) bill to $0 despite carrying usage. Pricing is broken, not the day.`,
1548
+ fix: "callers must pass real --input-tokens/--output-tokens (and --cache-read-tokens/--cache-creation-tokens/--total-cost-usd) to scripts/cost/track-claude-usage.mjs, lifted from the `claude --output-format json` envelope. A model missing from the router catalog also lands here.",
1549
+ };
1550
+ }
1551
+
1552
+ if (unrecorded > 0) {
1553
+ return {
1554
+ ...base,
1555
+ red: true,
1556
+ status: "red",
1557
+ reason: `spend telemetry is incomplete — the ${sessionSource} logged ${sessions} session(s) today but only ${sourceSessions} ${sessionSource} row(s) reached the cost ledger; ${unrecorded} ran unpriced.`,
1558
+ fix: "a spawn path is not calling scripts/cost/track-claude-usage.mjs at all. Every path that spawns `claude` must record a row — measured, or explicitly --tokens-unknown.",
1506
1559
  };
1507
1560
  }
1561
+
1562
+ if (measured > 0 && totalUsd <= 0) {
1563
+ return {
1564
+ ...base,
1565
+ red: true,
1566
+ status: "red",
1567
+ reason: `spend telemetry is blind — ${measured} measured session(s) bill to $0. Pricing is broken, not the day.`,
1568
+ fix: "callers must pass real --input-tokens/--output-tokens (and --cache-read-tokens/--cache-creation-tokens/--total-cost-usd) to scripts/cost/track-claude-usage.mjs, lifted from the `claude --output-format json` envelope.",
1569
+ };
1570
+ }
1571
+
1508
1572
  return {
1509
- red: true,
1510
- status: "red",
1511
- reason: `spend telemetry is blind — ${hadActivity ? "sessions ran today" : ""} but ledger sums $0 (${rowCount} row(s)${sessions ? `, heartbeat-active` : ""}). Callers are recording 0 tokens; budget governor can never trip on spend.`,
1512
- rowCount,
1513
- totalUsd,
1514
- sessionCount: sessions,
1573
+ ...base,
1574
+ red: false,
1575
+ status: "green",
1576
+ fix: null,
1577
+ reason: `spend telemetry live ($${totalUsd} across ${measured} measured session(s) today${unmeasured ? `, ${unmeasured} unmeasured` : ""})`,
1515
1578
  };
1516
1579
  }
1517
1580
 
@@ -1942,13 +2005,34 @@ async function doctor() {
1942
2005
  }
1943
2006
  } catch { /* breaker read best-effort */ }
1944
2007
 
1945
- // Today's budget band.
2008
+ // Today's budget band + the enforcement posture it implies.
2009
+ //
2010
+ // The band is measured against the seat's hq-FUNDED envelope
2011
+ // (Employee.payBasis.meteredBudget → mandate body seatBudgetCents), with the
2012
+ // local cap as a safety net. An unfunded seat is reported as exactly that —
2013
+ // "no ceiling is being enforced" is a finding, not a clean bill of health.
1946
2014
  try {
1947
2015
  const { dailyStatus } = await import(join(MAESTRO_ROOT, "lib", "budget-guard.mjs"));
1948
2016
  const b = dailyStatus({ agentRoot: cwd });
1949
- if (b.essentialOnly) { warn(`Daily budget cap reached ($${b.spentUSD}/$${b.capUSD}) — essential-only (inbox replies continue; backlog+cadence deferred)`); issues++; }
1950
- else if (b.band >= 75) { warn(`Daily budget at ${b.band}% ($${b.spentUSD}/$${b.capUSD})`); }
1951
- else ok(`Daily budget band ${b.band}% ($${b.spentUSD}/$${b.capUSD})`);
2017
+ const where = `$${b.spentUSD.toFixed(2)}/$${b.capUSD.toFixed(2)} today (${b.pct}%, ${b.capSource})`;
2018
+ const monthly = b.envelope && b.envelope.monthUSD != null
2019
+ ? `; envelope $${b.envelope.monthUSD.toFixed(2)}/mo, MTD $${b.month.spentUSD.toFixed(2)} (${b.monthPct}%)`
2020
+ : "";
2021
+ if (b.mode === "refused") { fail(`Budget REFUSED — ${where}${monthly}: spawns other than direct human replies are deferred`); issues++; }
2022
+ else if (b.mode === "suspended") { warn(`Budget SUSPENDED — ${where}${monthly}: OUTCOME obligations suspended; inbox + offline-safe cadences continue`); issues++; }
2023
+ else if (b.mode === "degraded") { warn(`Budget DEGRADED — ${where}${monthly}: cheapest model class, fan-out 1, self-directed work stopped`); issues++; }
2024
+ else if (b.band >= 80) { warn(`Budget at ${b.band}% — ${where}${monthly} (supervisor notified; nothing degraded yet)`); }
2025
+ else ok(`Budget band ${b.band}% — ${where}${monthly}`);
2026
+ if (!b.funded) {
2027
+ warn(
2028
+ "Seat is UNFUNDED: hq published no seatBudgetCents, so the org's ceiling is not being enforced — " +
2029
+ "the number above is this seat's own config/recovery.yaml. Set Employee.payBasis.meteredBudget for this member in Cohort."
2030
+ );
2031
+ issues++;
2032
+ }
2033
+ if (!b.supervisorMemberId) {
2034
+ warn("No supervisor edge on the mandate — budget escalations have nowhere to go, and are NOT sent to the owner by design");
2035
+ }
1952
2036
  } catch { /* ledger read best-effort */ }
1953
2037
 
1954
2038
  // Cost-telemetry tripwire (observability F2). Read today's cost ledger and
@@ -1967,25 +2051,36 @@ async function doctor() {
1967
2051
  try { rows.push(JSON.parse(line)); } catch { /* skip malformed row */ }
1968
2052
  }
1969
2053
  }
1970
- // Heartbeat as out-of-ledger session evidence: a fresh consumer heartbeat
1971
- // (<5m) means the daemon is live and spawning, so $0 spend is suspicious
1972
- // even before the first ledger row lands.
1973
- let heartbeatActive = 0;
2054
+ // Out-of-ledger session evidence: sessions the daemon LOGGED as completed
2055
+ // today. This deliberately replaced a liveness heartbeat. A fresh
2056
+ // heartbeat only proves the daemon is up, so "heartbeat + empty ledger"
2057
+ // fired RED every UTC day between midnight and the first session and
2058
+ // printed a fix that had already shipped — an alarm that cries wolf daily
2059
+ // is worse than no alarm, because the operator learns to skip it.
2060
+ // A logged completion is proof a session actually ran and therefore that
2061
+ // a ledger row is genuinely owed.
2062
+ let loggedSessions = 0;
1974
2063
  try {
1975
- const health = JSON.parse(readFileSync(join(cwd, "state/cadence-bus/health.json"), "utf-8"));
1976
- const ageMs = Date.now() - new Date(health.ts).getTime();
1977
- if (Number.isFinite(ageMs) && ageMs >= 0 && ageMs < 5 * 60_000) heartbeatActive = 1;
1978
- } catch { /* no heartbeat — rely on ledger rows alone */ }
2064
+ const sessionLog = join(cwd, "logs/daemon", `${ledgerDate}-sessions.jsonl`);
2065
+ if (existsSync(sessionLog)) {
2066
+ for (const line of readFileSync(sessionLog, "utf-8").split("\n")) {
2067
+ if (!line.trim()) continue;
2068
+ try {
2069
+ if (JSON.parse(line).event === "completed") loggedSessions++;
2070
+ } catch { /* skip malformed line */ }
2071
+ }
2072
+ }
2073
+ } catch { /* no session log — rely on ledger rows alone */ }
1979
2074
 
1980
- const tripwire = evaluateCostTripwire({ rows, sessionCount: heartbeatActive });
2075
+ // `sessionSource` names the writer the session log belongs to, so the two
2076
+ // populations being compared are the same population.
2077
+ const tripwire = evaluateCostTripwire({ rows, sessionCount: loggedSessions, sessionSource: "dispatcher" });
1981
2078
  if (tripwire.red) {
1982
2079
  fail(`Cost telemetry BLIND — ${tripwire.reason}`);
1983
- fail(` Fix: callers must pass real --input-tokens/--output-tokens to scripts/cost/track-claude-usage.mjs (lift usage from the claude --output-format json envelope).`);
2080
+ if (tripwire.fix) fail(` Fix: ${tripwire.fix}`);
1984
2081
  issues++;
1985
- } else if (tripwire.rowCount === 0 && heartbeatActive === 0) {
1986
- ok("Cost telemetry: no sessions recorded today (nothing to measure)");
1987
2082
  } else {
1988
- ok(`Cost telemetry live ($${tripwire.totalUsd} across ${tripwire.rowCount} ledger row(s) today)`);
2083
+ ok(`Cost telemetry: ${tripwire.reason}`);
1989
2084
  }
1990
2085
  } catch { /* cost ledger read best-effort */ }
1991
2086
 
@@ -2325,35 +2420,37 @@ async function doctor() {
2325
2420
  }
2326
2421
  }
2327
2422
 
2328
- // ── Backup configured + fresh (DR-config, gaps-enterprise-ops G16) ──────
2329
- // RED when backup is unconfigured: at fleet scale a machine with no DR target
2330
- // is a silent data-loss risk. Previously doctor exited 0 on a missing backup
2331
- // target; now it FAILs so the gap surfaces. GREEN only when a target is
2332
- // configured AND a recent (<25h) successful run is recorded.
2333
- const backupCfg = join(cwd, ".maestro/backup-config.yaml");
2334
- if (existsSync(backupCfg)) {
2335
- const cfg = readFileSync(backupCfg, "utf-8");
2336
- if (/enabled:\s*true/.test(cfg)) {
2337
- const lastBackup = join(cwd, ".maestro/last-backup.json");
2338
- if (existsSync(lastBackup)) {
2339
- try {
2340
- const lb = JSON.parse(readFileSync(lastBackup, "utf-8"));
2341
- const age = Date.now() - new Date(lb.completed_at).getTime();
2342
- if (age < 25 * 60 * 60 * 1000) ok(`Backup configured + fresh: last run ${Math.round(age / 3600000)}h ago (${lb.provider}://${lb.bucket})`);
2343
- else { warn(`Last backup ${Math.round(age / 3600000)}h ago — investigate scripts/maintenance/backup-to-cloud.sh`); issues++; }
2344
- } catch { warn("last-backup.json is malformed"); }
2345
- } else {
2346
- warn("Backup enabled but no successful run yet — schedule scripts/maintenance/backup-to-cloud.sh");
2347
- issues++;
2348
- }
2349
- } else {
2350
- fail("Backup target present but disabled (.maestro/backup-config.yaml: enabled is not true).");
2351
- console.log(" fix: set enabled: true and schedule scripts/maintenance/backup-to-cloud.sh");
2352
- issues++;
2423
+ // ── Backup posture (DR, gaps-enterprise-ops G16) ────────────────────────
2424
+ // The verdict is NOT computed here — lib/backup/policy.mjs owns it, shared
2425
+ // with the nightly-backup cadence and the runner, because three call sites
2426
+ // each deriving their own idea of "configured" is how they drifted apart.
2427
+ //
2428
+ // The ladder it applies (see backupVerdict):
2429
+ // fail — no config / unparseable / disabled / nothing would be archived
2430
+ // fail — enabled but no run has EVER succeeded (there is no restore point)
2431
+ // warn — restore points are stale, OR they are local-only (a dead machine
2432
+ // still loses everything — named in words, not left implied)
2433
+ // ok — fresh AND off-machine
2434
+ //
2435
+ // "local-only" is a WARN and not a FAIL on purpose: a check that is red on
2436
+ // every machine in the fleet is a check nobody reads, and "no restore point
2437
+ // at all" is a genuinely worse state than "restore points that would not
2438
+ // survive a house fire". Doctor now tells you which one you have.
2439
+ try {
2440
+ const { resolveBackupPlan, backupVerdict } = await import("../lib/backup/policy.mjs");
2441
+ const { backupFreshness } = await import("../lib/diagnostics/backup-freshness.mjs");
2442
+ const plan = resolveBackupPlan({ agentRoot: cwd });
2443
+ const verdict = backupVerdict(plan, backupFreshness({ agentRoot: cwd }));
2444
+ if (verdict.level === "ok") ok(verdict.msg);
2445
+ else if (verdict.level === "warn") { warn(verdict.msg); issues++; }
2446
+ else { fail(verdict.msg); issues++; }
2447
+ if (verdict.fix) console.log(` fix: ${verdict.fix}`);
2448
+ for (const v of plan.violations || []) {
2449
+ warn(` backup include path "${v.path}" is DROPPED by the credential deny-list (${v.pattern}) — it will never be archived.`);
2353
2450
  }
2354
- } else {
2355
- fail("No backup target configured — fleet machines need a DR backup (.maestro/backup-config.yaml missing).");
2356
- console.log(" fix: create .maestro/backup-config.yaml (enabled: true, provider, bucket) and schedule scripts/maintenance/backup-to-cloud.sh");
2451
+ } catch (err) {
2452
+ // Fail-open, never silent: a doctor probe must not take doctor down.
2453
+ warn(`Backup posture could not be evaluated (${err && err.message}) — treat as unbacked until this resolves.`);
2357
2454
  issues++;
2358
2455
  }
2359
2456