@cohortapp/agent-sdk 2.5.1 → 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 (106) 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/messaging.mjs +230 -3
  48. package/lib/org/messaging.test.mjs +110 -1
  49. package/lib/org/param-contract.mjs +56 -2
  50. package/lib/org/param-contract.test.mjs +26 -0
  51. package/lib/org/protocol.checksum +1 -1
  52. package/lib/org/protocol.mjs +5 -0
  53. package/lib/org/protocol.test.mjs +7 -1
  54. package/lib/org/tool-surface.mjs +506 -10
  55. package/lib/org/tool-surface.test.mjs +191 -7
  56. package/lib/org/ui-parity.mjs +333 -6
  57. package/lib/org/ui-parity.test.mjs +96 -3
  58. package/lib/org/work-ledger.mjs +241 -0
  59. package/lib/org/work-ledger.test.mjs +237 -0
  60. package/lib/plan/adoption-e2e.test.mjs +366 -0
  61. package/lib/plan/budget-enforcement.test.mjs +400 -0
  62. package/lib/plan/budget-runtime.mjs +215 -0
  63. package/lib/plan/compile.mjs +201 -5
  64. package/lib/plan/compile.test.mjs +19 -5
  65. package/lib/plan/emit.mjs +8 -0
  66. package/lib/plan/emit.test.mjs +18 -0
  67. package/lib/resource-governor.mjs +58 -12
  68. package/lib/resource-governor.test.mjs +41 -1
  69. package/lib/security/audit-engine.mjs +45 -8
  70. package/lib/security/audit-engine.test.mjs +35 -0
  71. package/lib/setup/enroll-from-cohort.mjs +14 -1
  72. package/lib/setup/sections/mandate.mjs +48 -7
  73. package/lib/setup/sections/mandate.test.mjs +17 -2
  74. package/lib/setup/sections/orgmail.mjs +10 -2
  75. package/lib/setup/state.mjs +83 -2
  76. package/lib/telemetry/collect.mjs +360 -20
  77. package/lib/telemetry/collect.test.mjs +266 -0
  78. package/package.json +1 -1
  79. package/scripts/cost/track-claude-usage.mjs +207 -48
  80. package/scripts/cost/track-claude-usage.test.mjs +148 -0
  81. package/scripts/daemon/agent-daemon.mjs +315 -17
  82. package/scripts/daemon/assurance-e2e.test.mjs +421 -0
  83. package/scripts/daemon/assurance.mjs +944 -0
  84. package/scripts/daemon/assurance.test.mjs +668 -0
  85. package/scripts/daemon/cadence-consumer-governance.test.mjs +56 -0
  86. package/scripts/daemon/cadence-consumer.mjs +147 -9
  87. package/scripts/daemon/cadence-consumer.test.mjs +6 -0
  88. package/scripts/daemon/cadence-handlers.mjs +158 -0
  89. package/scripts/daemon/cadence-handlers.test.mjs +64 -0
  90. package/scripts/daemon/deliver.mjs +314 -0
  91. package/scripts/daemon/dispatcher-governance.test.mjs +10 -0
  92. package/scripts/daemon/dispatcher.mjs +64 -6
  93. package/scripts/daemon/responder-cost.test.mjs +68 -0
  94. package/scripts/daemon/responder.mjs +351 -298
  95. package/scripts/local-triggers/generate-plists.test.mjs +7 -4
  96. package/scripts/maintenance/backup-run.mjs +415 -0
  97. package/scripts/maintenance/backup-to-cloud.sh +16 -116
  98. package/scripts/org/send-orgmail.mjs +16 -0
  99. package/scripts/record-receipt.sh +63 -0
  100. package/scripts/restore-from-backup.sh +14 -3
  101. package/scripts/restore-from-backup.test.mjs +8 -5
  102. package/scripts/send-email-threaded.py +47 -0
  103. package/scripts/send-sms.sh +4 -0
  104. package/scripts/send-whatsapp.sh +4 -0
  105. package/scripts/setup/init-backup.mjs +93 -38
  106. package/scripts/slack-send.sh +12 -0
@@ -17,6 +17,9 @@
17
17
  * + ALTITUDE_CADENCES → SCHEDULE, origin "archetype"
18
18
  * 3. each ADOPTED objective w/ sensor → SCHEDULE `schedule.measure.<key>`
19
19
  * + OUTCOME `outcome.<key>`
20
+ * "adopted" means `state:"active"` AND
21
+ * adopted by somebody other than the
22
+ * owner — see rule 3 below
20
23
  * 4. each adopted objective with a
21
24
  * REVIEWER collaborator → SCHEDULE `schedule.review.<key>`
22
25
  * 5. mandate `reactsTo[]` + the four
@@ -41,9 +44,18 @@
41
44
  import { createHash } from "node:crypto";
42
45
  import { canonical } from "../mandate/cache.mjs";
43
46
  import { assertMandateContract } from "../mandate/contract.mjs";
47
+ import { adoptedObjectives, adoptionDefect, memberIdOf, validateMandateBody } from "../mandate/model.mjs";
48
+ import { isHumanLaneCadence } from "../cadences.mjs";
44
49
 
45
- /** Bump when emission rules change — it is part of `inputsHash`. */
46
- export const COMPILER_VERSION = 1;
50
+ /**
51
+ * Bump when emission rules change — it is part of `inputsHash`.
52
+ *
53
+ * 2: adopted objectives are now filtered through `mandate/model.adoptedObjectives`
54
+ * (the client-side half of the anti-Goodhart law) instead of a bare
55
+ * `state === "active"` check, so a self-adopted or structurally-invalid node
56
+ * compiles nothing and surfaces as drift.
57
+ */
58
+ export const COMPILER_VERSION = 2;
47
59
 
48
60
  /** Read primitives every session-spawning obligation may use. */
49
61
  export const BASELINE_TOOLS = Object.freeze(["Read", "Grep", "Glob", "LS"]);
@@ -320,6 +332,10 @@ export function compilePlan(input = {}) {
320
332
  uses,
321
333
  budget_cents_per_period: defaultBudget,
322
334
  offline_safe: c.mode === "inline",
335
+ // The human-reply lane — see isHumanLaneObligation. Stamped here (not
336
+ // inferred at enforcement time) so the plan on disk SAYS which obligations
337
+ // a budget breach may never stop, and an operator can read it.
338
+ human_lane: isHumanLaneCadence(c.id),
323
339
  status: "active",
324
340
  });
325
341
  }
@@ -351,8 +367,43 @@ export function compilePlan(input = {}) {
351
367
  }
352
368
 
353
369
  // ── 3/4. adopted objectives → measure + outcome (+ review) ──────────────
354
- const adopted = (Array.isArray(mandate.objectives) ? mandate.objectives : [])
355
- .filter((o) => o && o.state === "active" && o.kind !== "PILLAR");
370
+ // THE ANTI-GOODHART LAW, CLIENT SIDE. This used to be `state === "active"` and
371
+ // nothing else — which meant the compiler trusted the snapshot's state column
372
+ // and NOTHING about who put it there. hq now refuses self-adoption and
373
+ // peer-adoption at the handler, but the compiler is the offline half of the
374
+ // same law: an agent that is partitioned, replaying a cached body, or handed a
375
+ // hand-edited state file must not compile a graded OUTCOME obligation from a
376
+ // number it adopted for itself. `adoptedObjectives` drops self-adopted and
377
+ // structurally-invalid nodes; `validateMandateBody` says why, out loud.
378
+ const owner = memberIdOf(mandate) || input.memberId || null;
379
+ const validation = validateMandateBody(mandate, { ownerMemberId: owner });
380
+ for (const err of validation.errors) note("error", `[compilePlan] mandate: ${err}`);
381
+ for (const w of validation.warnings) note("warn", `[compilePlan] mandate: ${w}`);
382
+ const adopted = adoptedObjectives(mandate, {
383
+ ownerMemberId: owner,
384
+ badKeys: validation.badKeys,
385
+ }).filter((o) => o.kind !== "PILLAR");
386
+ // Named separately so a REFUSAL is never indistinguishable from an absence:
387
+ // "you have 4 active objectives and I compiled 0" is a bug report, and it has
388
+ // to be legible from the plan alone.
389
+ for (const o of (Array.isArray(mandate.objectives) ? mandate.objectives : [])) {
390
+ if (!o || o.state !== "active" || o.kind === "PILLAR") continue;
391
+ if (adopted.includes(o)) continue;
392
+ const defect = adoptionDefect(o, owner);
393
+ const reason =
394
+ defect === "self_adopted"
395
+ ? `adoptedById equals the owning seat (${owner}) \u2014 a seat cannot define, measure and be graded on the same number`
396
+ : defect === "missing_adoption_provenance"
397
+ ? "the objective is active but carries NO adoptedById \u2014 nobody is on record as having handed this seat the number, so it cannot drive graded work (a deleted field is the cheapest tamper there is, and it used to fail open)"
398
+ : "the objective did not validate; see plan warnings";
399
+ drift.push({
400
+ kind: defect || "invalid_objective",
401
+ key: o.key ? `outcome.${o.key}` : null,
402
+ detail: { objectiveKey: o.key || null, reason },
403
+ });
404
+ note("error",
405
+ `[compilePlan] objective "${o.key || "?"}" is active but compiles NOTHING: ${reason}`);
406
+ }
356
407
  const proposedCount = (Array.isArray(mandate.objectives) ? mandate.objectives : []).filter((o) => o && o.state === "proposed").length;
357
408
 
358
409
  // Budget envelope: highest-weight objectives get the envelope first; the rest
@@ -544,6 +595,150 @@ export function compilePlan(input = {}) {
544
595
  };
545
596
  }
546
597
 
598
+ // ---------------------------------------------------------------------------
599
+ // The RUNTIME budget transition
600
+ // ---------------------------------------------------------------------------
601
+
602
+ /**
603
+ * Apply a live budget posture to an already-compiled plan.
604
+ *
605
+ * `compilePlan` already suspends an objective whose apportioned share does not
606
+ * fit the seat envelope — but that is a PLAN-TIME decision made once, against
607
+ * the mandate's arithmetic. It cannot see what the seat actually spent since.
608
+ * This is the same transition made at RUNTIME, against the ledger, so the 125 %
609
+ * rung of the ladder ("suspend the obligations, not the seat") is real rather
610
+ * than a sentence in a design doc.
611
+ *
612
+ * WHAT IT SUSPENDS: obligations that exist because of an adopted objective —
613
+ * `OUTCOME` rows and the `measure`/`review` SCHEDULEs that serve them (they carry
614
+ * `objective_id`). Those are the seat's discretionary, funded work.
615
+ *
616
+ * WHAT IT NEVER SUSPENDS, at any band:
617
+ * - `mode:"inline"` obligations. They are deterministic local computation with
618
+ * no model call — they cost nothing, so suspending them buys nothing and
619
+ * stops the seat's heartbeat.
620
+ * - standard cadences and REACTs with no objective behind them — the seat's
621
+ * inbox and its ability to answer a human.
622
+ * Invariant #1: we degrade, we never brick.
623
+ *
624
+ * `offline_safe` IS NOT AN EXEMPTION, and the trap is worth naming: the compiler
625
+ * marks every OUTCOME and every `schedule.measure.*` as `offline_safe:true`
626
+ * (they can run without the network), so treating that flag as "free" exempts
627
+ * precisely the obligations this rung exists to suspend — the ceiling compiles,
628
+ * fires, and suspends nothing. Offline-safe means "does not need hq"; it says
629
+ * nothing about whether the work spends money.
630
+ *
631
+ * PURE and idempotent: applying the same posture twice yields the same plan and
632
+ * the same single drift row per obligation. Never throws — a governance bug must
633
+ * not be able to wedge a plan.
634
+ *
635
+ * @param {object} plan a `compilePlan` result (or `{obligations}`)
636
+ * @param {object} posture `lib/budget-guard.postureForBand()` output
637
+ * @param {object} [detail] extra fields folded into each drift record (spend, cap…)
638
+ * @returns {{obligations:object[], drift:object[], suspended:string[], counts:object}}
639
+ */
640
+ export function applyBudgetPosture(plan, posture, detail = {}) {
641
+ const obligations = Array.isArray(plan && plan.obligations) ? plan.obligations : Array.isArray(plan) ? plan : [];
642
+ const drift = [];
643
+ const suspended = [];
644
+ const suspend = !!(posture && posture.suspendOutcomeObligations);
645
+
646
+ const out = obligations.map((ob) => {
647
+ if (!ob || typeof ob !== "object") return ob;
648
+ if (!suspend) return ob;
649
+ if (ob.status !== "active") return ob; // already suspended at plan time
650
+ if (ob.mode === "inline") return ob; // deterministic, zero-cost, load-bearing
651
+ // "Because of an adopted objective" is the test — not the obligation kind.
652
+ // A `schedule.measure.*` costs the seat money on the same warrant its
653
+ // OUTCOME does, and leaving it running would keep spending while the thing
654
+ // it measures is suspended.
655
+ const fromObjective = ob.kind === "OUTCOME" || (ob.objective_id != null && ob.scope === "mandate");
656
+ if (!fromObjective) return ob;
657
+
658
+ suspended.push(ob.key);
659
+ drift.push({
660
+ kind: "budget_breach",
661
+ key: ob.key,
662
+ detail: {
663
+ reason: "runtime spend breached the seat envelope",
664
+ band: posture.band,
665
+ mode: posture.mode,
666
+ objective_id: ob.objective_id || null,
667
+ budget_cents_per_period: Number.isFinite(ob.budget_cents_per_period) ? ob.budget_cents_per_period : null,
668
+ ...detail,
669
+ },
670
+ });
671
+ return { ...ob, status: "suspended", suspended_by: "budget", suspended_band: posture.band };
672
+ });
673
+
674
+ return {
675
+ obligations: out,
676
+ drift,
677
+ suspended,
678
+ counts: {
679
+ total: out.length,
680
+ suspended: out.filter((o) => o && o.status === "suspended").length,
681
+ active: out.filter((o) => o && o.status === "active").length,
682
+ },
683
+ };
684
+ }
685
+
686
+ /**
687
+ * May THIS obligation run right now, under this posture?
688
+ *
689
+ * The single-obligation form of {@link applyBudgetPosture}, for the hot path:
690
+ * the cadence consumer holds a registry entry, not a plan. Same rule, same
691
+ * exemptions, so the daemon and the plan can never disagree about what is
692
+ * suspended.
693
+ *
694
+ * @param {{kind?:string, scope?:string, objective_id?:string|null,
695
+ * mode?:string, status?:string}} ob
696
+ * @param {object} posture
697
+ * @returns {{allowed:boolean, reason:string}}
698
+ */
699
+ export function obligationAllowedUnderPosture(ob = {}, posture = {}) {
700
+ if (ob.status && ob.status !== "active") {
701
+ return { allowed: false, reason: `obligation status is ${ob.status}` };
702
+ }
703
+ // Only INLINE work is unconditionally free. `offline_safe` is not an
704
+ // exemption — see applyBudgetPosture; every OUTCOME carries it.
705
+ if (ob.mode === "inline") {
706
+ return { allowed: true, reason: "inline work is deterministic and costs nothing — it continues at every band" };
707
+ }
708
+ if (posture.refuseNonHumanSpawn && !isHumanLaneObligation(ob)) {
709
+ return {
710
+ allowed: false,
711
+ reason: `budget band ${posture.band}% — refusing spawns that are not a direct human reply`,
712
+ };
713
+ }
714
+ const fromObjective = ob.kind === "OUTCOME" || (ob.objective_id != null && ob.scope === "mandate");
715
+ if (posture.suspendOutcomeObligations && fromObjective) {
716
+ return { allowed: false, reason: `budget band ${posture.band}% — OUTCOME obligations are suspended` };
717
+ }
718
+ return { allowed: true, reason: "within the seat envelope" };
719
+ }
720
+
721
+ /**
722
+ * Is a human waiting on the other end of this obligation?
723
+ *
724
+ * The 150 % rung refuses "anything that is not a direct human reply", and that
725
+ * predicate used to be `kind === "REACT"`. Every cadence the consumer evaluates
726
+ * is a SCHEDULE, so at band 150 the gate blocked `inbox-processor` and
727
+ * `messaging-inbound` — the cadence that pulls org DMs and @mentions into the
728
+ * inbox pipeline at all. A budget rung that stops the seat reading its inbox is
729
+ * precisely the bricking Invariant #1 forbids, so the lane is now explicit:
730
+ *
731
+ * REACT — fires on an org event somebody else caused. Human-facing
732
+ * by construction (an assignment, an approval request, a DM).
733
+ * human_lane:true — stamped by the compiler from lib/cadences.HUMAN_LANE_CADENCES.
734
+ *
735
+ * Kept as a positive allow-list rather than a deny-list: a new cadence added
736
+ * next quarter defaults to "deferrable under a breach", which is the safe side.
737
+ */
738
+ export function isHumanLaneObligation(ob = {}) {
739
+ return ob.human_lane === true || ob.kind === "REACT";
740
+ }
741
+
547
742
  /** The identity-bearing projection an obligationsHash is taken over. */
548
743
  function identityOf(ob) {
549
744
  return {
@@ -583,5 +778,6 @@ function usesForArchetypeCadence(c, reachable) {
583
778
 
584
779
  export default {
585
780
  COMPILER_VERSION, BASELINE_TOOLS, STANDARD_CADENCE_USES, STANDARD_REACTS,
586
- compilePlan, solveSlot, busyWindows, allowedToolsFor, actionClassesFor, toolNameFor, rankOf,
781
+ compilePlan, applyBudgetPosture, obligationAllowedUnderPosture, isHumanLaneObligation,
782
+ solveSlot, busyWindows, allowedToolsFor, actionClassesFor, toolNameFor, rankOf,
587
783
  };
@@ -42,7 +42,10 @@ function objective(over = {}) {
42
42
  key: "pipeline-coverage", kind: "OBJECTIVE", text: "Pipeline coverage", state: "active",
43
43
  metric: "pipeline_coverage_x", unit: "x", direction: "up", target: 3, tolerance: 0.2,
44
44
  weight: 1, cadence: "weekly", sensor: { capability: "crm_list_deals", params: { stage: "open" }, source: "method" },
45
- collaborators: [], id: "obj_7Kx", charterSectionId: "cs_12", ...over,
45
+ // `adoptedById` is part of the fixture because it is part of the LAW: an
46
+ // active objective with no adopter on record compiles nothing (see
47
+ // lib/mandate/model.adoptionDefect). It must differ from the body's memberId.
48
+ collaborators: [], id: "obj_7Kx", charterSectionId: "cs_12", adoptedById: "M-SUPERVISOR", ...over,
46
49
  };
47
50
  }
48
51
 
@@ -314,14 +317,23 @@ test("deriveMandate: charter pillars become PILLARs, KPI categories become OBJEC
314
317
  { kind: "PILLAR", order: 0, body: "1) Own the agent-platform substrate — runtime, memory, tools." },
315
318
  { kind: "PILLAR", order: 1, body: "2) Make the fleet governable: every capability observable." },
316
319
  ],
317
- kpiCategories: [{ id: "execution", name: "Execution & Follow-Through", examples: ["Commitment-closure rate"] }],
320
+ kpiCategories: [
321
+ { id: "execution", name: "Fleet execution & follow-through", examples: ["Commitment-closure rate"] },
322
+ // Overlaps neither pillar's words. It is CARRIED but left unplaced —
323
+ // the old deriver silently parented it onto pillar #1.
324
+ { id: "warehouse-latency", name: "Warehouse latency", examples: ["p95 ms"] },
325
+ ],
318
326
  manifest: manifest(),
319
327
  });
320
328
  assert.equal(d.counts.pillars, 2);
321
- assert.equal(d.counts.objectives, 1);
329
+ assert.equal(d.counts.objectives, 2);
322
330
  const exec = d.objectives.find((o) => o.key === "execution");
323
331
  assert.equal(exec.kind, "OBJECTIVE");
324
- assert.ok(exec.parentKey, "every objective hangs off a pillar (provenance)");
332
+ assert.ok(exec.parentKey, "every PLACEABLE objective hangs off a pillar (provenance)");
333
+ assert.ok(exec.charterSectionId !== undefined);
334
+ const orphan = d.objectives.find((o) => o.key === "warehouse-latency");
335
+ assert.equal(orphan.parentKey, null, "no fabricated pillar edge");
336
+ assert.ok(d.degradations.some((x) => x.kind === "no_pillar_trace" && x.key === "warehouse-latency"));
325
337
  assert.equal(exec.state, "proposed", "NOTHING is adopted by the agent");
326
338
  assert.equal(exec.sensor.source, "method");
327
339
  assert.equal(exec.sensor.capability, "board_ready", "bound to a REACHABLE capability");
@@ -359,7 +371,9 @@ test("end to end: derive → compile produces a plan whose obligations trace bac
359
371
  manifest: manifest(),
360
372
  });
361
373
  // Simulate the human adoption step in hq.
362
- const adopted = d.objectives.map((o) => (o.kind === "OBJECTIVE" ? { ...o, state: "active", target: 0.95, id: `obj_${o.key}` } : o));
374
+ // Simulating hq's adopt handler means simulating ALL of it — including the
375
+ // `adoptedById` column it stamps. Without one the compiler refuses the row.
376
+ const adopted = d.objectives.map((o) => (o.kind === "OBJECTIVE" ? { ...o, state: "active", target: 0.95, id: `obj_${o.key}`, adoptedById: "M-SUPERVISOR" } : o));
363
377
  const p = compilePlan({ mandate: toMandateBody({ objectives: adopted }), manifest: manifest(), standardCadences: STANDARD });
364
378
  const outcome = p.obligations.find((o) => o.kind === "OUTCOME");
365
379
  assert.ok(outcome, "adoption turns the proposal into a real obligation");
package/lib/plan/emit.mjs CHANGED
@@ -95,6 +95,14 @@ export function toRegistry(obligations) {
95
95
  guardModule: ob.guard_module || null,
96
96
  budgetCents: Number.isFinite(ob.budget_cents_per_period) ? ob.budget_cents_per_period : null,
97
97
  obligationKey: ob.key,
98
+ // The three fields the RUNTIME budget gate needs (see
99
+ // `lib/plan/compile.obligationAllowedUnderPosture`). The consumer holds a
100
+ // registry entry, not a plan, and without these it could not tell a
101
+ // funded objective's cadence from the seat's heartbeat — so a budget
102
+ // breach either suspended everything or nothing.
103
+ scope: ob.scope || "mandate",
104
+ objectiveId: ob.objective_id || null,
105
+ offlineSafe: ob.offline_safe === true,
98
106
  };
99
107
  }
100
108
  return reg;
@@ -42,6 +42,7 @@ const ARCHETYPE = [
42
42
  function adoptedObjective() {
43
43
  return {
44
44
  key: "pipeline-coverage", kind: "OBJECTIVE", text: "Pipeline coverage", state: "active", id: "obj_7Kx",
45
+ adoptedById: "M-SUPERVISOR",
45
46
  metric: "pipeline_coverage_x", direction: "up", target: 3, tolerance: 0.2, weight: 1, cadence: "weekly",
46
47
  sensor: { capability: "crm_list_deals", params: { stage: "open" }, source: "method" }, collaborators: [],
47
48
  };
@@ -184,6 +185,23 @@ test("SUSPENDED obligations are in plan.yaml but never in the schedule artefacts
184
185
  assert.deepEqual(toTsvLines(obs), ["b|60|"]);
185
186
  });
186
187
 
188
+ test("the registry carries the provenance the RUNTIME budget gate needs", () => {
189
+ const obs = [
190
+ { key: "schedule.measure.gm", kind: "SCHEDULE", cadence_id: "measure-gm", scope: "mandate", objective_id: "obj_7Kx", mode: "guarded", prompt: "p.md", schedule: { interval: 60 }, status: "active", uses: [], allowed_tools: ["Read"], action_classes: [], offline_safe: true, source: { origin: "mandate", ref: "gm" }, budget_cents_per_period: 100 },
191
+ { key: "schedule.cadence-bus-heartbeat", kind: "SCHEDULE", cadence_id: "cadence-bus-heartbeat", scope: "standard", mode: "inline", prompt: "p.md", schedule: { interval: 300 }, status: "active", uses: [], allowed_tools: [], action_classes: [], offline_safe: false, source: { origin: "standard", ref: "cadence-bus-heartbeat" }, budget_cents_per_period: null },
192
+ ];
193
+ const reg = toRegistry(obs);
194
+ // Without these three the consumer cannot tell a funded objective's cadence
195
+ // from the seat's own heartbeat, so a budget breach would suspend either
196
+ // everything or nothing.
197
+ assert.equal(reg["measure-gm"].scope, "mandate");
198
+ assert.equal(reg["measure-gm"].objectiveId, "obj_7Kx");
199
+ assert.equal(reg["measure-gm"].offlineSafe, true);
200
+ assert.equal(reg["cadence-bus-heartbeat"].scope, "standard");
201
+ assert.equal(reg["cadence-bus-heartbeat"].objectiveId, null);
202
+ assert.equal(reg["cadence-bus-heartbeat"].offlineSafe, false);
203
+ });
204
+
187
205
  // ---------------------------------------------------------------------------
188
206
  // The no-op guard (SPEC §6.4: "if obligationsHash is unchanged, nothing on disk
189
207
  // moves"). config/plan.yaml is TRACKED, so a recompile that changes nothing must
@@ -29,9 +29,13 @@
29
29
  * liveRSS + 750MB > 0.60*totalmem → QUEUE
30
30
  * else → ADMIT
31
31
  *
32
- * Plus a budget `mode`: when essential-only (budget at 100%),
33
- * source==="backlog"/"cadence" → DEFER; inbox → still goes through the normal
34
- * ADMIT/QUEUE path (never go dark on the user).
32
+ * Plus the budget ladder (`mode`, from `lib/budget-guard.dailyStatus().mode`),
33
+ * banded against the seat's hq-funded envelope rather than a local config cap:
34
+ * degraded (≥100%) source==="backlog" → DEFER (D3 stops)
35
+ * suspended (≥125%) source==="backlog"|"cadence" → DEFER
36
+ * refused (>150%) anything that is not a direct human reply → DEFER
37
+ * Inbox always goes through the normal ADMIT/QUEUE path — never go dark on the
38
+ * user — and no band ever hard-refuses: DEFER leaves the work on disk.
35
39
  *
36
40
  * Purity: `admit` is a pure function of its injected `deps` snapshot. The
37
41
  * default `deps` reads real os.* + a 2s-cached `liveClaudeStats()` (via ps) +
@@ -256,13 +260,33 @@ const ADMIT = "ADMIT";
256
260
  const QUEUE = "QUEUE";
257
261
  const DEFER = "DEFER";
258
262
 
263
+ /**
264
+ * Normalise a budget mode onto the governance ladder.
265
+ *
266
+ * `lib/budget-guard.dailyStatus().mode` is the authority; the legacy
267
+ * `"essential-only"` string (and the `essentialOnly:true` flag) map onto
268
+ * `"suspended"`, which is exactly what that mode always did — defer non-inbox
269
+ * work, keep answering the human. Anything unrecognised is `"normal"`: an
270
+ * unknown mode must not silently become a stricter one.
271
+ */
272
+ function budgetModeOf(req) {
273
+ const raw = String(req.mode || (req.essentialOnly ? "essential-only" : "") || "").toLowerCase();
274
+ if (raw === "refused") return "refused";
275
+ if (raw === "suspended" || raw === "essential-only") return "suspended";
276
+ if (raw === "degraded") return "degraded";
277
+ return "normal";
278
+ }
279
+
259
280
  /**
260
281
  * The admission decision.
261
282
  *
262
- * @param {object} req { source, priority, mode }
263
- * source: "inbox" | "backlog" | "cadence" (default "backlog")
264
- * priority: "critical"|"high"|"normal"|"low" (default "normal")
265
- * mode: "essential-only" flips budget gating (else normal)
283
+ * @param {object} req { source, priority, mode, humanReply }
284
+ * source: "inbox" | "backlog" | "cadence" (default "backlog")
285
+ * priority: "critical"|"high"|"normal"|"low" (default "normal")
286
+ * mode: budget posture — "normal" | "degraded" | "suspended" | "refused"
287
+ * (legacy "essential-only" ≡ "suspended")
288
+ * humanReply: this spawn is a direct reply to a human. Only `source:"inbox"`
289
+ * work qualifies by default; pass it explicitly to be certain.
266
290
  * @param {object} [deps] injected snapshot; defaults to defaultDeps()
267
291
  * { freemem, totalmem, loadavg, cpus, liveClaude:{count,rssMB}, throttleCeiling }
268
292
  * @returns {{ decision:"ADMIT"|"QUEUE"|"DEFER", reason:string, snapshot:object }}
@@ -270,7 +294,11 @@ const DEFER = "DEFER";
270
294
  export function admit(req = {}, deps) {
271
295
  const d = deps || defaultDeps();
272
296
  const source = req.source || "backlog";
273
- const mode = req.mode || (req.essentialOnly ? "essential-only" : null);
297
+ const budgetMode = budgetModeOf(req);
298
+ const mode = budgetMode === "normal" ? null : budgetMode;
299
+ // A direct human reply is the ONE thing no budget band may stop. Invariant #1:
300
+ // we degrade, we never go dark on the user.
301
+ const humanReply = req.humanReply === true || source === "inbox";
274
302
 
275
303
  const totalmem = numField(d.totalmem, os.totalmem());
276
304
  const freemem = numField(d.freemem, os.freemem());
@@ -300,10 +328,28 @@ export function admit(req = {}, deps) {
300
328
 
301
329
  const decide = (decision, reason) => ({ decision, reason, snapshot });
302
330
 
303
- // (0) Budget essential-only: non-inbox work is deferred entirely. Inbox
304
- // falls through to the normal pressure gating below (we keep replying).
305
- if (mode === "essential-only" && (source === "backlog" || source === "cadence")) {
306
- return decide(DEFER, "essential-only: daily budget cap reached; deferring non-inbox work");
331
+ // (0) THE BUDGET LADDER. Three rungs, and none of them is a seat-level refuse:
332
+ //
333
+ // refused (>150% of the funded envelope) — DEFER everything that is not a
334
+ // direct human reply. This is the "REFUSE, at spawn level only"
335
+ // rung: the work stays on disk and the sweep retries it, the seat
336
+ // keeps talking to its human. A hard refuse here would brick the
337
+ // seat, which Invariant #1 forbids.
338
+ // suspended (≥125%) — DEFER backlog + cadence work; inbox falls through.
339
+ // (This is what the legacy "essential-only" mode always did.)
340
+ // degraded (≥100%) — self-directed BACKLOG work stops, but obligations
341
+ // (cadences) keep running on the cheapest model class. The rung
342
+ // that used to be missing entirely: enforcement was binary.
343
+ //
344
+ // Inbox work always falls through to the normal pressure gating below.
345
+ if (mode === "refused" && !humanReply) {
346
+ return decide(DEFER, `budget refused (>150% of the seat envelope): deferring ${source} work; direct human replies continue`);
347
+ }
348
+ if (mode === "suspended" && (source === "backlog" || source === "cadence")) {
349
+ return decide(DEFER, "budget suspended (>=125% of the seat envelope): deferring non-inbox work");
350
+ }
351
+ if (mode === "degraded" && source === "backlog") {
352
+ return decide(DEFER, "budget degraded (>=100% of the seat envelope): self-directed backlog work stopped; obligations + inbox continue");
307
353
  }
308
354
 
309
355
  // (1) Concurrency ceiling → QUEUE (work re-derivable from the queue).
@@ -172,9 +172,11 @@ test("admit: load exactly at the threshold is still ADMIT (strict >)", () => {
172
172
  // ---------------------------------------------------------------------------
173
173
 
174
174
  test("essential-only: backlog → DEFER without burning resources", () => {
175
+ // The legacy mode name still maps onto the SUSPENDED rung — same behaviour it
176
+ // always had, so no caller loses its gate during the rollout.
175
177
  const r = admit({ source: "backlog", mode: "essential-only" }, deps());
176
178
  assert.equal(r.decision, DECISIONS.DEFER);
177
- assert.match(r.reason, /essential-only/);
179
+ assert.match(r.reason, /budget suspended/);
178
180
  });
179
181
 
180
182
  test("essential-only: cadence → DEFER", () => {
@@ -280,3 +282,41 @@ test("admit reads a real throttle.json from agentRoot and clamps", async () => {
280
282
  assert.equal(r.decision, DECISIONS.QUEUE);
281
283
  } finally { await fsp.rm(dir, { recursive: true, force: true }); }
282
284
  });
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // the budget ladder (banded against the seat's hq-funded envelope)
288
+ // ---------------------------------------------------------------------------
289
+
290
+ test("degraded (>=100%): self-directed backlog stops, obligations + inbox continue", () => {
291
+ assert.equal(admit({ source: "backlog", mode: "degraded" }, deps()).decision, "DEFER");
292
+ assert.match(admit({ source: "backlog", mode: "degraded" }, deps()).reason, /self-directed backlog work stopped/);
293
+ assert.equal(admit({ source: "cadence", mode: "degraded" }, deps()).decision, "ADMIT",
294
+ "an obligation is not discretionary — it degrades, it does not stop");
295
+ assert.equal(admit({ source: "inbox", mode: "degraded" }, deps()).decision, "ADMIT");
296
+ });
297
+
298
+ test("suspended (>=125%): backlog + cadence DEFER, inbox continues", () => {
299
+ assert.equal(admit({ source: "backlog", mode: "suspended" }, deps()).decision, "DEFER");
300
+ assert.equal(admit({ source: "cadence", mode: "suspended" }, deps()).decision, "DEFER");
301
+ assert.equal(admit({ source: "inbox", mode: "suspended" }, deps()).decision, "ADMIT");
302
+ });
303
+
304
+ test("refused (>150%): everything that is not a human reply DEFERs — and it DEFERs, never refuses", () => {
305
+ for (const source of ["backlog", "cadence", "sweep"]) {
306
+ const r = admit({ source, mode: "refused" }, deps());
307
+ assert.equal(r.decision, "DEFER", `${source} must defer`);
308
+ assert.match(r.reason, /direct human replies continue/);
309
+ }
310
+ // The one thing no band may stop.
311
+ assert.equal(admit({ source: "inbox", mode: "refused" }, deps()).decision, "ADMIT");
312
+ assert.equal(admit({ source: "cadence", mode: "refused", humanReply: true }, deps()).decision, "ADMIT",
313
+ "an explicit human reply is admitted whatever its source");
314
+ // DEFER leaves the work on disk for the sweep; nothing is dropped, and the
315
+ // seat is never bricked (Invariant #1).
316
+ assert.notEqual(admit({ source: "backlog", mode: "refused" }, deps()).decision, "REFUSE");
317
+ });
318
+
319
+ test("an unrecognised budget mode is treated as normal, never as something stricter", () => {
320
+ assert.equal(admit({ source: "backlog", mode: "catastrophic" }, deps()).decision, "ADMIT");
321
+ assert.equal(admit({ source: "backlog", mode: "normal" }, deps()).decision, "ADMIT");
322
+ });
@@ -36,6 +36,7 @@
36
36
  import * as nodeFs from "node:fs";
37
37
  import { join } from "node:path";
38
38
  import { createHash } from "node:crypto";
39
+ import { resolveBackupPlan } from "../backup/policy.mjs";
39
40
 
40
41
  // Severity ordering — used to decide which unsuppressed failures fail the run
41
42
  // and to sort the summary. `info` never fails a run on its own.
@@ -316,27 +317,63 @@ export const CHECKS = [
316
317
  {
317
318
  id: "backup-configured",
318
319
  severity: "medium",
319
- title: "Off-machine backup is configured and enabled",
320
+ title: "Disaster-recovery backup is configured and enabled",
321
+ // ASKS THE DR CODE, rather than re-deriving "configured" from regexes.
322
+ //
323
+ // The regex form false-greened on two configs the DR code itself calls
324
+ // broken, because both patterns were anchored with `^\s*` under /m:
325
+ // - `bucket: ""` — the value THIS PROJECT SHIPS in its own default config
326
+ // — satisfied /^\s*bucket:\s*\S/m, because `\S` matches the quote
327
+ // character. The audit reported "off-machine tier set" while
328
+ // resolveBackupPlan reported offsiteConfigured:false and the runner
329
+ // logged "offsite is NOT configured".
330
+ // - top-level `enabled: false` with a nested `local.enabled: true` passed
331
+ // /^\s*enabled:\s*true/m on the INDENTED key, so the audit was green on
332
+ // a machine where backup-run.mjs refuses to run and doctor FAILs.
333
+ // Three subsystems, three answers, one file. lib/backup/policy.mjs is the
334
+ // single source of truth by design; this check now uses it. It costs one
335
+ // js-yaml import, which the module already ships as a dependency.
320
336
  run(agentRoot, deps) {
321
337
  const { fs } = resolveDeps(deps);
322
- const cfg = safeRead(fs, join(agentRoot, ".maestro/backup-config.yaml"));
323
- if (!cfg) {
338
+ const plan = resolveBackupPlan({ agentRoot, fs });
339
+ if (!plan.present) {
324
340
  return {
325
341
  ok: false,
326
342
  detail: "no .maestro/backup-config.yaml — a dead Mac mini loses all audit/cost history (G6/G16)",
327
- fix: "configure off-machine backup (scripts/maintenance/backup-to-cloud.sh) and set enabled: true",
343
+ fix: "maestro init backup-replication --apply (writes the default: nightly local restore points, no credentials needed)",
328
344
  fixable: false,
329
345
  };
330
346
  }
331
- if (!/enabled:\s*true/.test(cfg)) {
347
+ if (!plan.readable) {
332
348
  return {
333
349
  ok: false,
334
- detail: "backup config present but enabled: false",
335
- fix: "set enabled: true in .maestro/backup-config.yaml and schedule the backup job",
350
+ detail: ".maestro/backup-config.yaml is present but unparseable — the nightly backup cannot run",
351
+ fix: "fix the YAML, or delete it and re-run: maestro init backup-replication --apply",
336
352
  fixable: false,
337
353
  };
338
354
  }
339
- return { ok: true, detail: "off-machine backup configured (enabled: true)" };
355
+ if (!plan.enabled) {
356
+ return {
357
+ ok: false,
358
+ detail: "backup config present but enabled is not true — backup-run.mjs REFUSES to run",
359
+ fix: "set enabled: true in .maestro/backup-config.yaml",
360
+ fixable: false,
361
+ };
362
+ }
363
+ if (!plan.include.length) {
364
+ return {
365
+ ok: false,
366
+ detail: `backup enabled but every include path was rejected (${plan.violations.map((v) => `${v.path} → ${v.pattern}`).join(", ") || "empty include list"}) — the archive would be empty`,
367
+ fix: "restore the include list (state, knowledge, memory, outputs, config, .maestro)",
368
+ fixable: false,
369
+ };
370
+ }
371
+ return {
372
+ ok: true,
373
+ detail: plan.tier === "offsite"
374
+ ? `DR backup configured, off-machine tier set (${plan.offsite.provider}://${plan.offsite.bucket})`
375
+ : "DR backup configured — LOCAL restore points only; no off-machine copy (set offsite.provider + offsite.bucket)",
376
+ };
340
377
  },
341
378
  },
342
379
 
@@ -211,6 +211,41 @@ test("runAudit: backup not configured is a failure", async () => {
211
211
  } finally { cleanup(root); }
212
212
  });
213
213
 
214
+ test("runAudit: backup — an EMPTY bucket is not an off-machine tier", async () => {
215
+ // The regex form matched /^\s*bucket:\s*\S/m against `bucket: ""` — `\S` hits
216
+ // the quote character — and that is the value THIS PROJECT SHIPS in its own
217
+ // default config. The audit said "off-machine tier set" while
218
+ // resolveBackupPlan said offsiteConfigured:false and the runner logged
219
+ // "offsite is NOT configured": three subsystems, three answers, one file.
220
+ const root = makeGoodAgent();
221
+ try {
222
+ writeFileSync(
223
+ join(root, ".maestro/backup-config.yaml"),
224
+ "enabled: true\nprefix: acme\nlocal:\n enabled: true\noffsite:\n provider: gcs\n bucket: \"\"\n"
225
+ );
226
+ const r = await runAudit(root, { env: { MAESTRO_SCOPED_PERMISSIONS: "1" } });
227
+ const b = r.results.find((x) => x.id === "backup-configured");
228
+ assert.equal(b.ok, true, "local restore points are a real posture");
229
+ assert.match(b.detail, /LOCAL restore points only/, "but it must not claim off-machine");
230
+ } finally { cleanup(root); }
231
+ });
232
+
233
+ test("runAudit: backup — top-level `enabled: false` is a FAILURE even with a nested local.enabled: true", async () => {
234
+ // `^\s*` under /m matched the INDENTED key, so the audit passed on a machine
235
+ // where backup-run.mjs refuses to run and doctor FAILs.
236
+ const root = makeGoodAgent();
237
+ try {
238
+ writeFileSync(
239
+ join(root, ".maestro/backup-config.yaml"),
240
+ "enabled: false\nprefix: acme\nlocal:\n enabled: true\n"
241
+ );
242
+ const r = await runAudit(root, { env: { MAESTRO_SCOPED_PERMISSIONS: "1" } });
243
+ const b = r.results.find((x) => x.id === "backup-configured");
244
+ assert.equal(b.ok, false);
245
+ assert.match(b.detail, /REFUSES to run/);
246
+ } finally { cleanup(root); }
247
+ });
248
+
214
249
  test("runAudit: a check that throws becomes an ok:false posture row, not a crash", async () => {
215
250
  const root = makeGoodAgent();
216
251
  try {