@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
@@ -6,12 +6,16 @@
6
6
  * routing happens at dispatch/spawn time only, never mid-session (cache prefixes
7
7
  * are model-scoped; a mid-session switch silently re-bills the entire prefix, G8).
8
8
  *
9
- * 1. budgetLadder(spentUSD, capUSD) -> { band:0|75|90|100, degrade }
10
- * A LADDER, not a cliff (§6.4):
11
- * <75% → policy as written
12
- * ≥75% → prefer cheaper tiers (downgrade one rung)
13
- * ≥90% → cheap-only + batch deferrable
14
- * 100% → essential-only (still answers inbox DMs)
9
+ * 1. budgetLadder(spentUSD, capUSD) -> { band:0|100|125|150, degrade }
10
+ * A LADDER, not a cliff (§6.4) — banded against the SEAT ENVELOPE hq
11
+ * published, not a local config cap, and rung-for-rung identical to
12
+ * `lib/budget-guard.postureForBand`:
13
+ * <100% → policy as written (80% is a supervisor notice, not a routing
14
+ * change — see budgetLadder)
15
+ * ≥100% → cheapest class only, batch deferrable, fan-out capped at 1,
16
+ * self-directed work stopped
17
+ * ≥125% → essential-only (still answers inbox DMs)
18
+ * ≥150% → the spawn gate refuses everything but a human reply
15
19
  *
16
20
  * 2. degradeChain(chain, band) — a resolveChain `opts` hook that TIGHTENS a
17
21
  * candidate chain per band (drops/reorders refs). Subordinate to the gates
@@ -49,59 +53,100 @@ import { writeJsonAtomic, appendJsonl } from "../fs-atomic.mjs";
49
53
  // ---------------------------------------------------------------------------
50
54
 
51
55
  /**
52
- * The economics bands. Note these are the ladder's ECONOMIC bands (0/75/90/100),
53
- * deliberately coarser than budget-guard's notification bands ([50,75,90,100]):
54
- * 50% is a "you're halfway" notice, not a routing change — the first DEGRADATION
55
- * happens at 75% (§4.2 budget_ladder). budgetLadder collapses 50% back to band 0
56
- * so a caller cannot accidentally degrade routing on a mere halfway notice.
56
+ * The economics bands. These are now THE SAME BANDS the governance ladder uses
57
+ * (`lib/budget-guard.BANDS` = [80,100,125,150]) rather than a second, coarser
58
+ * set — two ladders with different rungs meant "degraded" in the router and
59
+ * "degraded" in the governor were different states, and neither matched the
60
+ * decision. 80% is the supervisor NOTICE rung: it is deliberately NOT a routing
61
+ * change, so `budgetLadder` collapses it back to band 0 for routing purposes and
62
+ * the first routing degradation happens at 100% — the rung where the seat has
63
+ * spent what the org actually funded.
57
64
  */
58
- export const LADDER_BANDS = Object.freeze([0, 75, 90, 100]);
65
+ export const LADDER_BANDS = Object.freeze([0, 100, 125, 150]);
59
66
 
60
67
  /** Map a spend percentage onto an economics band. */
61
68
  function ladderBandForPct(pct) {
69
+ if (pct >= 150) return 150;
70
+ if (pct >= 125) return 125;
62
71
  if (pct >= 100) return 100;
63
- if (pct >= 90) return 90;
64
- if (pct >= 75) return 75;
65
72
  return 0;
66
73
  }
67
74
 
68
75
  /**
69
76
  * Compute the budget degradation for the day's spend. PURE. NEVER throws.
70
77
  *
71
- * `degrade` is the structured instruction the resolver / dispatcher consume:
72
- * - band 0 → { preferCheaper:false, cheapOnly:false, batchDeferrable:false,
73
- * essentialOnly:false, downgradeTiers:0 }
74
- * - band 75 → preferCheaper (downgrade one tier)
75
- * - band 90 → cheapOnly + batchDeferrable (defer batch-eligible work)
76
- * - band 100 → essentialOnly (inbox DMs answered on the cheapest qualifying
77
- * row; everything else DEFER)
78
+ * `degrade` is the structured instruction the resolver / dispatcher consume, and
79
+ * it mirrors `lib/budget-guard.postureForBand` rung for rung:
80
+ * - band 0 → nothing. (80% is a supervisor notice, not a routing change.)
81
+ * - band 100 → cheapOnly + batchDeferrable + fan-out capped at 1 + no
82
+ * self-directed work. THE DEGRADE RUNG: inbox DMs are still
83
+ * answered, on the cheapest qualifying row.
84
+ * - band 125 → essentialOnly on top (OUTCOME obligations are suspended
85
+ * elsewhere; here it means non-inbox work stops being routed).
86
+ * - band 150 → the spawn gate refuses anything that is not a human reply;
87
+ * routing stays cheap-only for what does get through.
88
+ *
89
+ * `preferCheaper` (the old one-tier downgrade at 75%) is retained as the band-100
90
+ * behaviour's companion rather than a rung of its own: there is no longer a band
91
+ * between "fine" and "spent the envelope", because the old 75/90 rungs were
92
+ * measured against a local config file rather than the org's funding.
78
93
  *
79
94
  * @param {number} spentUSD today's spend (>= 0)
80
- * @param {number} capUSD the daily cap (> 0; <= 0 ⇒ no cap ⇒ band 0)
81
- * @returns {{ band:0|75|90|100, pct:number, spentUSD:number, capUSD:number,
95
+ * @param {number} capUSD the binding daily allowance (> 0; <= 0 ⇒ band 0)
96
+ * @returns {{ band:0|100|125|150, pct:number, spentUSD:number, capUSD:number,
82
97
  * degrade:{ preferCheaper:boolean, cheapOnly:boolean,
83
98
  * batchDeferrable:boolean, essentialOnly:boolean,
99
+ * maxFanout:number|null, selfDirected:boolean,
84
100
  * downgradeTiers:number } }}
85
101
  */
86
- export function budgetLadder(spentUSD, capUSD) {
102
+ export function budgetLadder(spentUSD, capUSD, opts = {}) {
87
103
  const spent = numOr0(spentUSD);
88
104
  const cap = Number(capUSD);
89
105
  const hasCap = Number.isFinite(cap) && cap > 0;
90
- const pct = hasCap ? round2((spent / cap) * 100) : 0;
91
- const band = hasCap ? ladderBandForPct(pct) : 0;
106
+
107
+ // ── THE GOVERNOR'S BAND WINS, WHEN IT IS SUPPLIED ─────────────────────────
108
+ // Callers used to re-derive the band here from (spent, cap), which silently
109
+ // INVERTED the ladder once `dailyAllowance` started returning 0 for a seat
110
+ // whose monthly envelope was spent out: cap<=0 collapsed to band 0, so the
111
+ // further past its envelope a seat was, the LESS the router degraded. A seat
112
+ // at 200 % of its monthly envelope still routed the full frontier chain.
113
+ // `lib/budget-guard.dailyStatus` has already reconciled day vs month vs
114
+ // blindness; when it hands us its band we use it verbatim.
115
+ const supplied = Number(opts && opts.band);
116
+ if (Number.isFinite(supplied)) {
117
+ const band = ladderBandForPct(supplied);
118
+ return buildLadder(band, hasCap ? round2((spent / cap) * 100) : (spent > 0 ? 100 : 0), spent, hasCap ? cap : 0);
119
+ }
120
+
121
+ // No cap AND money already spent is not "no ceiling" — it is a ceiling of
122
+ // zero, i.e. fully consumed. Band 0 there is the same "a zero that means
123
+ // unmeasured" defect in percentage form.
124
+ const pct = hasCap ? round2((spent / cap) * 100) : (spent > 0 ? Infinity : 0);
125
+ const band = hasCap ? ladderBandForPct(pct) : (spent > 0 ? 150 : 0);
126
+ return buildLadder(band, Number.isFinite(pct) ? pct : 100, spent, hasCap ? cap : 0);
127
+ }
128
+
129
+ /** Shared tail of `budgetLadder` — the structured instruction for a band. */
130
+ function buildLadder(band, pct, spent, cap) {
92
131
 
93
132
  const degrade = {
94
- preferCheaper: band >= 75,
95
- cheapOnly: band >= 90,
96
- batchDeferrable: band >= 90,
97
- essentialOnly: band >= 100,
98
- // band 75 downgrades one tier; band 90+ collapses to cheap/fast directly
99
- // (degradeChain drops the frontier/default head), so the tier count is the
100
- // explain-string figure rather than a second knob.
101
- downgradeTiers: band >= 90 ? 99 : band >= 75 ? 1 : 0,
133
+ preferCheaper: band >= 100,
134
+ cheapOnly: band >= 100,
135
+ batchDeferrable: band >= 100,
136
+ essentialOnly: band >= 125,
137
+ // 100%: sub-agent fan-out capped at 1 (the `team` rung becomes unavailable)
138
+ // and self-directed D3 work stops. Both are read by the daemon, not by the
139
+ // chain resolver, but they belong to the same instruction so a caller cannot
140
+ // apply half the rung.
141
+ maxFanout: band >= 100 ? 1 : null,
142
+ selfDirected: band < 100,
143
+ // band 100+ collapses to cheap/fast directly (degradeChain drops the
144
+ // frontier/default head), so the tier count is the explain-string figure
145
+ // rather than a second knob.
146
+ downgradeTiers: band >= 100 ? 99 : 0,
102
147
  };
103
148
 
104
- return { band, pct, spentUSD: round6(spent), capUSD: hasCap ? cap : 0, degrade };
149
+ return { band, pct, spentUSD: round6(spent), capUSD: cap, degrade };
105
150
  }
106
151
 
107
152
  // ---------------------------------------------------------------------------
@@ -132,72 +177,58 @@ function inferTier(member) {
132
177
  * Tighten a resolved candidate chain for a budget band. PURE. NEVER throws.
133
178
  *
134
179
  * Contract (the `band` argument is the economics band from budgetLadder):
135
- * - band < 75 → the chain is returned unchanged.
136
- * - band ≥ 75 → each tier-tagged member is downgraded ONE rung toward cheap
137
- * (frontier→default→fast→cheap); the rest pass through. The
138
- * resulting chain is de-duplicated, preserving order.
139
- * - band ≥ 90 → frontier+default head members are DROPPED entirely (cheap/fast
180
+ * - band < 100 → the chain is returned unchanged (80 % is a notice rung).
181
+ * - band ≥ 100 → frontier+default head members are DROPPED entirely (cheap/fast
140
182
  * only); a chain that would empty out keeps its cheapest member
141
183
  * so the caller always has at least one candidate.
142
- * - `exempt` (pin or critical) → unchanged at any band below 100.
184
+ * - `exempt` (pin or critical) → unchanged at any band below 125.
143
185
  *
144
186
  * Members are `{ ref, alias?, ... }` (resolveChain's chain shape) or bare ref
145
187
  * strings. The returned members keep their original shape, only `ref`/`alias`
146
188
  * shift on a downgrade. A `rules`/sentinel member is always preserved as a tail.
147
189
  *
148
190
  * @param {Array<object|string>} chain resolved chain (alias-tagged members)
149
- * @param {0|75|90|100} band
191
+ * @param {0|100|125|150} band
150
192
  * @param {object} [opts]
151
193
  * @param {boolean} [opts.exempt] pin / critical — bypass degradation
152
- * @param {Record<string,string>} [opts.aliases] alias → ref, for retargeting a
153
- * downgraded member to the cheaper alias's ref
194
+ * @param {Record<string,string>} [opts.aliases] accepted and ignored — kept so
195
+ * callers built for the old one-rung downgrade
196
+ * do not have to change shape
154
197
  * @returns {Array<object|string>} the tightened chain
155
198
  */
156
199
  export function degradeChain(chain, band, opts = {}) {
157
200
  const members = Array.isArray(chain) ? chain : [];
158
201
  if (!members.length) return members;
159
202
  const b = Number(band) || 0;
160
- if (b < 75 || opts.exempt) return members.slice();
161
-
162
- const aliases = (opts && opts.aliases && typeof opts.aliases === "object") ? opts.aliases : {};
203
+ if (b < 100 || opts.exempt) return members.slice();
163
204
 
164
205
  const isSentinel = (m) =>
165
206
  (typeof m === "object" && m && (m.isRules || m.ref === "rules")) ||
166
207
  m === "rules";
167
208
 
168
- if (b >= 90) {
169
- // cheap/fast only — drop the frontier/default head.
170
- const kept = members.filter((m) => {
171
- if (isSentinel(m)) return true;
172
- const tier = inferTier(m);
173
- return tier == null || TIER_RANK[tier] >= TIER_RANK.fast;
174
- });
175
- if (kept.length) return dedupeByRef(kept);
176
- // Everything was frontier/default: keep the single cheapest as a safety net.
177
- const cheapest = members
178
- .filter((m) => !isSentinel(m))
179
- .sort((a, z) => (TIER_RANK[inferTier(z)] ?? -1) - (TIER_RANK[inferTier(a)] ?? -1))[0];
180
- return cheapest ? [cheapest] : members.slice();
181
- }
182
-
183
- // band >= 75: downgrade each tier-tagged member one rung.
184
- const next = members.map((m) => {
185
- if (isSentinel(m)) return m;
209
+ // Cheap/fast only — drop the frontier/default head.
210
+ //
211
+ // There is no longer a one-rung "prefer cheaper" step below this. It existed
212
+ // for the old 75% rung, which was measured against a local config cap; on the
213
+ // funded-envelope ladder the band below this one is 80%, which is a supervisor
214
+ // NOTICE and deliberately changes no routing. Keeping a branch no band can
215
+ // reach would be worse than not having the rung: it reads as enforcement that
216
+ // is in fact dead code.
217
+ const kept = members.filter((m) => {
218
+ if (isSentinel(m)) return true;
186
219
  const tier = inferTier(m);
187
- if (tier == null) return m;
188
- const i = TIER_RANK[tier];
189
- const downAlias = TIER_ORDER[Math.min(TIER_ORDER.length - 1, i + 1)];
190
- if (downAlias === tier) return m; // already cheapest
191
- // Retarget to the cheaper alias's ref when the caller supplied the alias map.
192
- if (typeof m === "object" && m) {
193
- const ref = aliases[downAlias] || m.ref;
194
- return { ...m, ref, alias: downAlias };
195
- }
196
- return aliases[downAlias] || m;
220
+ return tier == null || TIER_RANK[tier] >= TIER_RANK.fast;
197
221
  });
198
- return dedupeByRef(next);
222
+ if (kept.length) return dedupeByRef(kept);
223
+ // Everything was frontier/default: keep the single cheapest as a safety net,
224
+ // because a caller with an empty chain has no candidate at all.
225
+ const cheapest = members
226
+ .filter((m) => !isSentinel(m))
227
+ .sort((a, z) => (TIER_RANK[inferTier(z)] ?? -1) - (TIER_RANK[inferTier(a)] ?? -1))[0];
228
+ return cheapest ? [cheapest] : members.slice();
199
229
  }
200
230
 
231
+ /** Collapse chain members that resolved onto the same ref, preserving order. */
201
232
  function dedupeByRef(members) {
202
233
  const seen = new Set();
203
234
  const out = [];
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Tests for lib/model-router/economics.mjs — the router economics levers:
3
- * - budgetLadder transitions at 75 / 90 / 100 (a ladder, not a cliff)
3
+ * - budgetLadder transitions at 100 / 125 / 150 (a ladder, not a cliff) —
4
+ * the SAME rungs lib/budget-guard bands the seat envelope on
4
5
  * - degradeChain tightens a candidate chain per band
5
6
  * - batchLaneFor classifies deferrable vs realtime work
6
7
  * - cacheAffinity pin / lookupPin / resolvePin override (+ pin_overridden audit)
@@ -24,11 +25,11 @@ import {
24
25
  } from "./economics.mjs";
25
26
 
26
27
  // ---------------------------------------------------------------------------
27
- // budgetLadder — transitions at 75/90/100
28
+ // budgetLadder — transitions at 100/125/150 (the governance ladder's rungs)
28
29
  // ---------------------------------------------------------------------------
29
30
 
30
- test("budgetLadder: below 75% is band 0, no degradation", () => {
31
- for (const spent of [0, 10, 37, 74.9]) {
31
+ test("budgetLadder: below 100% is band 0, no degradation", () => {
32
+ for (const spent of [0, 10, 37, 80, 99.9]) {
32
33
  const r = budgetLadder(spent, 100);
33
34
  assert.equal(r.band, 0, `spent ${spent} → band 0`);
34
35
  assert.equal(r.degrade.preferCheaper, false);
@@ -37,45 +38,65 @@ test("budgetLadder: below 75% is band 0, no degradation", () => {
37
38
  }
38
39
  });
39
40
 
40
- test("budgetLadder: 50% notice does NOT degrade routing (band 0)", () => {
41
- // budget-guard notifies at 50%, but the economics ladder's first rung is 75%.
42
- const r = budgetLadder(50, 100);
41
+ test("budgetLadder: the 80% supervisor notice does NOT degrade routing (band 0)", () => {
42
+ // budget-guard notifies the SUPERVISOR at 80%, but a notice is not a routing
43
+ // change — the first degradation is at 100%, where the seat has spent what the
44
+ // org actually funded.
45
+ const r = budgetLadder(80, 100);
43
46
  assert.equal(r.band, 0);
44
47
  assert.equal(r.degrade.downgradeTiers, 0);
48
+ assert.equal(r.degrade.selfDirected, true);
45
49
  });
46
50
 
47
- test("budgetLadder: 75% → prefer cheaper (downgrade one tier)", () => {
48
- const r = budgetLadder(75, 100);
49
- assert.equal(r.band, 75);
50
- assert.equal(r.degrade.preferCheaper, true);
51
- assert.equal(r.degrade.cheapOnly, false);
52
- assert.equal(r.degrade.batchDeferrable, false);
53
- assert.equal(r.degrade.essentialOnly, false);
54
- assert.equal(r.degrade.downgradeTiers, 1);
55
- });
56
-
57
- test("budgetLadder: 90% → cheap-only + batch deferrable", () => {
58
- const r = budgetLadder(90, 100);
59
- assert.equal(r.band, 90);
51
+ test("budgetLadder: 100% → cheapest class, fan-out 1, no self-directed work", () => {
52
+ const r = budgetLadder(100, 100);
53
+ assert.equal(r.band, 100);
60
54
  assert.equal(r.degrade.preferCheaper, true);
61
55
  assert.equal(r.degrade.cheapOnly, true);
62
56
  assert.equal(r.degrade.batchDeferrable, true);
63
- assert.equal(r.degrade.essentialOnly, false);
57
+ assert.equal(r.degrade.maxFanout, 1);
58
+ assert.equal(r.degrade.selfDirected, false, "D3 stops here");
59
+ assert.equal(r.degrade.essentialOnly, false, "inbox + obligations still run — this rung is not a cliff");
64
60
  });
65
61
 
66
- test("budgetLadder: 100% → essential-only", () => {
67
- const r = budgetLadder(100, 100);
68
- assert.equal(r.band, 100);
62
+ test("budgetLadder: 125% → essential-only on top of the degrade rung", () => {
63
+ const r = budgetLadder(125, 100);
64
+ assert.equal(r.band, 125);
69
65
  assert.equal(r.degrade.essentialOnly, true);
70
66
  assert.equal(r.degrade.cheapOnly, true);
71
- // over-cap also stays band 100
72
- assert.equal(budgetLadder(250, 100).band, 100);
73
67
  });
74
68
 
75
- test("budgetLadder: no cap (cap<=0) collapses to band 0", () => {
76
- assert.equal(budgetLadder(500, 0).band, 0);
77
- assert.equal(budgetLadder(500, -1).band, 0);
78
- assert.equal(budgetLadder(500, NaN).band, 0);
69
+ test("budgetLadder: past 150% stays band 150", () => {
70
+ assert.equal(budgetLadder(150, 100).band, 150);
71
+ assert.equal(budgetLadder(250, 100).band, 150);
72
+ assert.equal(budgetLadder(250, 100).degrade.essentialOnly, true);
73
+ });
74
+
75
+ test("budgetLadder: a cap of ZERO with money spent is EXHAUSTED, not uncapped", () => {
76
+ // This test used to assert band 0 for every cap<=0, and it was correct when
77
+ // capUSD could only be a local config number > 0. `dailyAllowance` now returns
78
+ // 0 for a seat whose MONTHLY envelope is spent out — so cap===0 became a
79
+ // reachable production value meaning "there is no room at all", and collapsing
80
+ // it to band 0 inverted the ladder: the further past its envelope a seat was,
81
+ // the less the router degraded.
82
+ assert.equal(budgetLadder(500, 0).band, 150, "$500 spent against a $0 allowance is fully over");
83
+ assert.equal(budgetLadder(500, -1).band, 150);
84
+ assert.equal(budgetLadder(500, NaN).band, 150);
85
+ // Nothing spent and no cap is genuinely unknown — do not degrade on it.
86
+ assert.equal(budgetLadder(0, 0).band, 0);
87
+ assert.equal(budgetLadder(0, NaN).band, 0);
88
+ });
89
+
90
+ test("budgetLadder: the governor's band wins over any re-derivation", () => {
91
+ // The dispatcher and the cadence consumer pass `dailyStatus().band`, which has
92
+ // already reconciled day vs month vs blindness. Re-deriving from (spent, cap)
93
+ // is what let a seat at 109% of its MONTH route the full frontier chain
94
+ // because today happened to look quiet.
95
+ const r = budgetLadder(3, 30, { band: 125 });
96
+ assert.equal(r.band, 125);
97
+ assert.equal(r.degrade.cheapOnly, true);
98
+ assert.equal(r.degrade.essentialOnly, true);
99
+ assert.equal(budgetLadder(3, 30).band, 0, "and without it, the local reading still applies");
79
100
  });
80
101
 
81
102
  test("budgetLadder: pct is rounded and exposed", () => {
@@ -99,35 +120,33 @@ function aliasChain(...names) {
99
120
  return names.map((n) => ({ ref: ALIASES[n] || n, alias: ALIASES[n] ? n : null }));
100
121
  }
101
122
 
102
- test("degradeChain: band < 75 returns the chain unchanged", () => {
123
+ test("degradeChain: band < 100 returns the chain unchanged", () => {
103
124
  const chain = aliasChain("frontier", "default");
104
125
  const out = degradeChain(chain, 0, { aliases: ALIASES });
105
126
  assert.deepEqual(out, chain);
106
127
  assert.notEqual(out, chain, "returns a copy, not the same reference");
107
128
  });
108
129
 
109
- test("degradeChain: band 75 downgrades each tier one rung", () => {
130
+ test("degradeChain: the 80% notice rung changes no routing at all", () => {
131
+ // There is deliberately no one-rung "prefer cheaper" step any more: the band
132
+ // below the drop rung is a supervisor NOTICE, and a notice must not silently
133
+ // re-route the seat onto a worse model.
110
134
  const chain = aliasChain("frontier", "default");
111
- const out = degradeChain(chain, 75, { aliases: ALIASES });
112
- // frontier→default, default→fast
113
- assert.equal(out[0].alias, "default");
114
- assert.equal(out[0].ref, ALIASES.default);
115
- assert.equal(out[1].alias, "fast");
116
- assert.equal(out[1].ref, ALIASES.fast);
135
+ assert.deepEqual(degradeChain(chain, 80, { aliases: ALIASES }), chain);
117
136
  });
118
137
 
119
- test("degradeChain: band 90 drops frontier/default head, keeps cheap/fast", () => {
138
+ test("degradeChain: band 100 drops frontier/default head, keeps cheap/fast", () => {
120
139
  const chain = aliasChain("frontier", "default", "fast", "cheap");
121
- const out = degradeChain(chain, 90, { aliases: ALIASES });
140
+ const out = degradeChain(chain, 100, { aliases: ALIASES });
122
141
  const aliasesOut = out.map((m) => m.alias);
123
142
  assert.ok(!aliasesOut.includes("frontier"));
124
143
  assert.ok(!aliasesOut.includes("default"));
125
144
  assert.deepEqual(aliasesOut, ["fast", "cheap"]);
126
145
  });
127
146
 
128
- test("degradeChain: band 90 with an all-frontier chain keeps the single cheapest as a safety net", () => {
147
+ test("degradeChain: band 100 with an all-frontier chain keeps the single cheapest as a safety net", () => {
129
148
  const chain = aliasChain("frontier", "default");
130
- const out = degradeChain(chain, 90, { aliases: ALIASES });
149
+ const out = degradeChain(chain, 100, { aliases: ALIASES });
131
150
  assert.equal(out.length, 1);
132
151
  // 'default' is cheaper than 'frontier', so it's the safety-net survivor.
133
152
  assert.equal(out[0].alias, "default");
@@ -141,22 +160,21 @@ test("degradeChain: exempt (pin/critical) bypasses degradation", () => {
141
160
 
142
161
  test("degradeChain: preserves a rules sentinel as a tail at any band", () => {
143
162
  const chain = [...aliasChain("frontier"), { ref: "rules", isRules: true }];
144
- const out90 = degradeChain(chain, 90, { aliases: ALIASES });
163
+ const out90 = degradeChain(chain, 100, { aliases: ALIASES });
145
164
  assert.ok(out90.some((m) => m.ref === "rules"));
146
165
  });
147
166
 
148
167
  test("degradeChain: infers tier from literal refs without alias tags", () => {
149
168
  const chain = [{ ref: "anthropic/claude-opus-4-8" }, { ref: "anthropic/claude-haiku-4-5" }];
150
- const out = degradeChain(chain, 90, { aliases: ALIASES });
169
+ const out = degradeChain(chain, 100, { aliases: ALIASES });
151
170
  // opus (frontier) dropped; haiku (fast) kept.
152
171
  assert.equal(out.length, 1);
153
172
  assert.match(out[0].ref, /haiku/);
154
173
  });
155
174
 
156
175
  test("degradeChain: de-dupes refs that collapse onto the same model", () => {
157
- // default + fast at band 75 → fast + cheap; but if both map to fast they dedupe.
158
176
  const chain = aliasChain("fast", "fast");
159
- const out = degradeChain(chain, 75, { aliases: ALIASES });
177
+ const out = degradeChain(chain, 100, { aliases: ALIASES });
160
178
  assert.equal(out.length, 1);
161
179
  });
162
180
 
@@ -440,38 +440,42 @@ test("2b. context_overflow NEVER fails over (terminal, opens no breaker) inside
440
440
  });
441
441
 
442
442
  // ===========================================================================
443
- // 3. budgetLadder: band 75 degrades to cheaper tiers; band 100 essential-only;
444
- // degradeChain tightens the resolved chain.
443
+ // 3. budgetLadder: band 100 forces the cheapest class; band 125 essential-only;
444
+ // degradeChain tightens the resolved chain. The bands are the governance
445
+ // ladder's own rungs (lib/budget-guard.BANDS), measured against the seat's
446
+ // hq-funded envelope rather than a local config cap.
445
447
  // ===========================================================================
446
448
 
447
449
  test("3. budgetLadder bands + degradeChain tighten the resolved chain", () => {
448
450
  // ── budgetLadder maps spend% onto the economics bands. ───────────────────
449
- assert.deepEqual(LADDER_BANDS, [0, 75, 90, 100]);
451
+ assert.deepEqual(LADDER_BANDS, [0, 100, 125, 150]);
450
452
 
451
453
  const low = budgetLadder(40, 100); // 40%
452
- assert.equal(low.band, 0, "below 75% is band 0 (policy as written)");
454
+ assert.equal(low.band, 0, "below 100% is band 0 (policy as written)");
453
455
  assert.equal(low.degrade.preferCheaper, false);
454
456
 
455
- // 50% is a budget-guard NOTICE, not a routing change — collapses to band 0.
456
- assert.equal(budgetLadder(50, 100).band, 0, "50% halfway notice does not degrade routing");
457
+ // 80% is a budget-guard SUPERVISOR NOTICE, not a routing change.
458
+ assert.equal(budgetLadder(80, 100).band, 0, "the 80% notice does not degrade routing");
457
459
 
458
- const b75 = budgetLadder(80, 100); // 80%
459
- assert.equal(b75.band, 75, "≥75% is band 75");
460
- assert.equal(b75.degrade.preferCheaper, true, "band 75 prefers cheaper tiers");
461
- assert.equal(b75.degrade.downgradeTiers, 1, "band 75 downgrades one tier");
462
- assert.equal(b75.degrade.essentialOnly, false);
460
+ const b100 = budgetLadder(100, 100);
461
+ assert.equal(b100.band, 100, "≥100% — the seat has spent what the org funded");
462
+ assert.equal(b100.degrade.cheapOnly, true, "band 100 is cheapest-class only");
463
+ assert.equal(b100.degrade.batchDeferrable, true);
464
+ assert.equal(b100.degrade.maxFanout, 1, "sub-agent fan-out capped at 1");
465
+ assert.equal(b100.degrade.selfDirected, false, "self-directed D3 work stops");
466
+ assert.equal(b100.degrade.essentialOnly, false, "but inbox + obligations still run");
463
467
 
464
- const b90 = budgetLadder(95, 100);
465
- assert.equal(b90.band, 90, "≥90% is band 90");
466
- assert.equal(b90.degrade.cheapOnly, true, "band 90 is cheap/fast only");
467
- assert.equal(b90.degrade.batchDeferrable, true, "band 90 defers batch-eligible work");
468
+ const b125 = budgetLadder(130, 100);
469
+ assert.equal(b125.band, 125, "≥125% suspends the obligations");
470
+ assert.equal(b125.degrade.essentialOnly, true, "essential-only (still answers inbox DMs)");
468
471
 
469
- const b100 = budgetLadder(110, 100); // over cap
470
- assert.equal(b100.band, 100, "at/over cap is band 100");
471
- assert.equal(b100.degrade.essentialOnly, true, "band 100 is essential-only (still answers inbox DMs)");
472
+ const b150 = budgetLadder(200, 100);
473
+ assert.equal(b150.band, 150, "past 150% the spawn gate refuses non-human-reply work");
472
474
 
473
- // No cap (≤0) → band 0 (never degrade on an unknown cap).
474
- assert.equal(budgetLadder(500, 0).band, 0, "no cap ⇒ band 0");
475
+ // A cap of 0 with money already spent is an EXHAUSTED envelope, not an unknown
476
+ // one — see economics.test.mjs for why this assertion flipped.
477
+ assert.equal(budgetLadder(500, 0).band, 150, "spend against a $0 allowance is fully over");
478
+ assert.equal(budgetLadder(0, 0).band, 0, "but nothing spent under no cap stays band 0");
475
479
 
476
480
  // ── degradeChain tightens the alias-tagged chain that resolveChain built. ──
477
481
  const aliases = {
@@ -486,38 +490,36 @@ test("3. budgetLadder bands + degradeChain tighten the resolved chain", () => {
486
490
  { ref: aliases.default, alias: "default" },
487
491
  ];
488
492
 
489
- // band < 75 → unchanged.
493
+ // Below the drop rung → unchanged. Includes the 80% notice band.
490
494
  assert.deepEqual(degradeChain(chain, 0, { aliases }).map((m) => m.ref), [aliases.frontier, aliases.default]);
495
+ assert.deepEqual(degradeChain(chain, 80, { aliases }).map((m) => m.ref), [aliases.frontier, aliases.default]);
491
496
 
492
- // band 75 → each tier-tagged member downgrades ONE rung (frontier→default,
493
- // default→fast), de-duped preserving order.
494
- const tightened75 = degradeChain(chain, 75, { aliases });
495
- assert.deepEqual(
496
- tightened75.map((m) => m.alias),
497
- ["default", "fast"],
498
- "band 75 downgrades frontier→default and default→fast"
499
- );
500
- assert.deepEqual(tightened75.map((m) => m.ref), [aliases.default, aliases.fast]);
501
-
502
- // band 90 → cheap/fast only: the frontier/default head is DROPPED.
503
- const tightened90 = degradeChain(
497
+ // band 100 → cheap/fast only: the frontier/default head is DROPPED. An
498
+ // all-frontier chain keeps its single cheapest member so the caller is never
499
+ // left with no candidate at all.
500
+ const tightened100 = degradeChain(
504
501
  [
505
502
  { ref: aliases.frontier, alias: "frontier" },
506
503
  { ref: aliases.default, alias: "default" },
507
504
  { ref: aliases.fast, alias: "fast" },
508
505
  { ref: aliases.cheap, alias: "cheap" },
509
506
  ],
510
- 90,
507
+ 100,
511
508
  { aliases }
512
509
  );
513
510
  assert.deepEqual(
514
- tightened90.map((m) => m.alias),
511
+ tightened100.map((m) => m.alias),
515
512
  ["fast", "cheap"],
516
- "band 90 drops the frontier/default head, keeps cheap/fast"
513
+ "band 100 drops the frontier/default head, keeps cheap/fast"
514
+ );
515
+ assert.deepEqual(
516
+ degradeChain(chain, 100, { aliases }).map((m) => m.alias),
517
+ ["default"],
518
+ "a frontier+default chain keeps its CHEAPEST member as a safety net"
517
519
  );
518
520
 
519
521
  // exempt (pin / critical) → unchanged even at a degrading band.
520
- const exempt = degradeChain(chain, 90, { aliases, exempt: true });
522
+ const exempt = degradeChain(chain, 100, { aliases, exempt: true });
521
523
  assert.deepEqual(exempt.map((m) => m.ref), [aliases.frontier, aliases.default], "pin/critical is exempt from degradation");
522
524
 
523
525
  // batchLaneFor + spawnKnobsFor (the other §8 economics levers) are wired too.