@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
@@ -1,33 +1,54 @@
1
1
  /**
2
- * lib/budget-guard.mjs — daily spend cap + essential-only mode (WS4).
3
- *
4
- * Sums today's per-session cost rows from the existing ledger
5
- * (`state/cost-tracking/<date>.jsonl`, written by
6
- * scripts/cost/track-claude-usage.mjs) and reports where the day sits against
7
- * a configured cap. As spend crosses 50 / 75 / 90 %, a single escalating
8
- * notice is emitted PER BAND PER DAY (tracked in
9
- * `state/cost-tracking/<date>.budget.json` so we never spam). At 100 % the
10
- * governor flips to `essentialOnly`: backlog + cadence-escalation work is
11
- * DEFERRED, but inbox DMs are still answered (Invariant #1 — never brick; we
12
- * degrade, we don't go dark on the user).
2
+ * lib/budget-guard.mjs — the seat's ceiling, and what happens when it is hit.
3
+ *
4
+ * Sums today's per-session cost rows from the ledger
5
+ * (`state/cost-tracking/<date>.jsonl`) and reports where the period sits against
6
+ * the seat's FUNDED ENVELOPE, plus the enforcement posture that band implies.
7
+ *
8
+ * ── WHERE THE CEILING COMES FROM (this is the part that was missing) ────────
9
+ * It used to be `DAILY_SPEND_CAP_USD` / `config/recovery.yaml` — files that live
10
+ * inside the agent's own repo, which is to say: the seat set its own budget. The
11
+ * real number is `Employee.payBasis.meteredBudget` in hq, published on the
12
+ * mandate body as `seatBudgetCents` and cached at `state/mandate/cache.json`.
13
+ *
14
+ * hq Employee.payBasis.meteredBudget → mandate body seatBudgetCents
15
+ * → state/mandate/cache.json → readSeatEnvelope → dailyAllowance
16
+ * → every decision below.
17
+ *
18
+ * The envelope is monthly; today's allowance is the REMAINING envelope over the
19
+ * REMAINING days (a quiet week funds a busy one). The local cap survives as a
20
+ * SAFETY NET: when the two disagree the lower binds, and the disagreement is
21
+ * logged. When hq published nothing, the local cap binds and every read carries
22
+ * an "UNFUNDED SEAT" degradation — an agent that cannot know its funding must
23
+ * say so loudly rather than assume a number. (The last time something assumed
24
+ * one, the whole fleet ran on an invented 500c-per-obligation allowance.)
25
+ *
26
+ * ── THE LADDER (a ladder, not a cliff) — see `postureForBand` ───────────────
27
+ * 80 % escalate to the supervisor (never to the owner)
28
+ * 100 % degrade the rung: cheapest model class, fan-out 1, D3 stops
29
+ * 125 % suspend the OUTCOME obligations (not the seat)
30
+ * >150 % refuse at spawn level: DEFER anything that is not a human reply
31
+ *
32
+ * No band self-clears by agent action. Bands reset at the period boundary (the
33
+ * ledger + notice files are UTC-date-stamped) or when a supervisor/human RAISES
34
+ * the envelope — fingerprinted on the binding cap, which the seat cannot raise
35
+ * on its own (the binding cap is the LOWER of envelope and local config).
13
36
  *
14
37
  * Config knobs (env-overridable; tests inject via deps):
15
- * DAILY_SPEND_CAP_USD hard cap in USD (default 25)
38
+ * DAILY_SPEND_CAP_USD local safety-net cap in USD (default 25)
16
39
  * DAILY_ITERATION_CAP optional cap on session count (default 0 = off)
17
40
  *
18
- * The window resets at UTC midnight automatically — the ledger + budget files
19
- * are date-stamped, so "today" is simply the current UTC date.
20
- *
21
41
  * Constraints (CLAUDE.md): ESM, Node built-ins only, injectable clock +
22
42
  * ledger path so tests are hermetic. Never throws on I/O failure: a guard
23
- * that can't read the ledger reports band 0 (essentialOnly:false) — degrade
24
- * toward keeping work flowing, not toward bricking.
43
+ * that can't read the ledger reports band 0 — degrade toward keeping work
44
+ * flowing, not toward bricking. FAIL-OPEN IS FINE; SILENT IS NOT.
25
45
  */
26
46
 
27
47
  import {
28
48
  existsSync,
29
49
  mkdirSync,
30
50
  readFileSync,
51
+ readdirSync,
31
52
  writeFileSync,
32
53
  renameSync,
33
54
  unlinkSync,
@@ -35,6 +56,8 @@ import {
35
56
  import { join, resolve, dirname } from "node:path";
36
57
  import { randomBytes } from "node:crypto";
37
58
 
59
+ import { summariseRows, imputeUnmeasured } from "./cost/ledger-row.mjs";
60
+
38
61
  // ---------------------------------------------------------------------------
39
62
  // Config
40
63
  // ---------------------------------------------------------------------------
@@ -42,6 +65,29 @@ import { randomBytes } from "node:crypto";
42
65
  export const DEFAULT_DAILY_SPEND_CAP_USD = 25;
43
66
  export const DEFAULT_DAILY_ITERATION_CAP = 0; // 0 = no session-count cap
44
67
 
68
+ /**
69
+ * Envelope freshness, mirroring `lib/mandate/cache.mjs` TIERS (duplicated as two
70
+ * numbers rather than imported: this module's contract is Node built-ins only
71
+ * because it sits on the spawn hot path and must never throw on an import).
72
+ * AGING report it, keep enforcing it.
73
+ * EXPIRY stop treating the cached number as hq's decision.
74
+ */
75
+ export const ENVELOPE_AGING_SEC = 24 * 3600;
76
+ export const ENVELOPE_EXPIRY_SEC = 7 * 24 * 3600;
77
+
78
+ /**
79
+ * The band a seat sits at when sessions ran and NONE of them could be measured
80
+ * or priced from evidence at any horizon.
81
+ *
82
+ * DEGRADE, not normal, and not refuse. "We cannot measure, therefore we degrade"
83
+ * is a decision that needs no price table: it costs the seat its frontier models
84
+ * and its self-directed work while leaving every inbox reply intact, and it
85
+ * cannot be reached by a seat that is simply cheap today (an empty ledger is
86
+ * quiet, not blind — `summariseRows().blind` requires sessions to have run).
87
+ * Without it, `blind` was computed, surfaced, and enforced by nobody.
88
+ */
89
+ export const BLIND_MIN_BAND = 100;
90
+
45
91
  /**
46
92
  * Read the daily spend cap from config/recovery.yaml, if present. We extract the
47
93
  * single scalar we need with a targeted regex rather than pulling in a YAML
@@ -109,14 +155,18 @@ function todayUtc(deps) {
109
155
  return new Date(clock(deps)()).toISOString().slice(0, 10);
110
156
  }
111
157
 
158
+ function agentRootOf(deps) {
159
+ return resolve(
160
+ (deps && deps.agentRoot) ||
161
+ process.env.AGENT_ROOT ||
162
+ process.env.AGENT_DIR ||
163
+ process.cwd()
164
+ );
165
+ }
166
+
112
167
  function ledgerDir(deps) {
113
168
  if (deps && deps.ledgerDir) return resolve(deps.ledgerDir);
114
- const root =
115
- (deps && deps.agentRoot) ||
116
- process.env.AGENT_ROOT ||
117
- process.env.AGENT_DIR ||
118
- process.cwd();
119
- return join(resolve(root), "state", "cost-tracking");
169
+ return join(agentRootOf(deps), "state", "cost-tracking");
120
170
  }
121
171
 
122
172
  function ledgerFile(deps) {
@@ -128,20 +178,92 @@ function budgetStateFile(deps) {
128
178
  }
129
179
 
130
180
  // ---------------------------------------------------------------------------
131
- // Banding
181
+ // Banding + enforcement posture
132
182
  // ---------------------------------------------------------------------------
133
183
 
134
- /** Bands are the integer thresholds we notify at. 0 = below 50 %. */
135
- export const BANDS = [50, 75, 90, 100];
184
+ /**
185
+ * The governance bands, as INCLUSIVE LOWER BOUNDS on `pct`. 0 = under 80 %.
186
+ *
187
+ * These replaced [50, 75, 90, 100]. The old set banded a LOCAL cap using an
188
+ * under-read numerator: three of its four rungs fired before anything was at
189
+ * risk, and only the top rung had teeth (and it was binary — essential-only or
190
+ * nothing). The new set bands the SEAT ENVELOPE hq published, using the
191
+ * authoritative cost, so 100 % means "this seat has spent what the org funded",
192
+ * not "a config file in the agent's own repo said 25".
193
+ *
194
+ * 150 is inclusive on purpose (the decision writes ">150 %"): a seat sitting at
195
+ * exactly 1.5x its funded envelope is already over, and a strict `>` would make
196
+ * the refuse rung unreachable at the round number most likely to be hit.
197
+ */
198
+ export const BANDS = [80, 100, 125, 150];
199
+
200
+ /** Enforcement modes, ascending. The resource governor consumes these by name. */
201
+ export const MODES = Object.freeze({
202
+ NORMAL: "normal",
203
+ DEGRADED: "degraded",
204
+ SUSPENDED: "suspended",
205
+ REFUSED: "refused",
206
+ });
207
+
208
+ /** `lib/execution/route.mjs` rung 5 = `team` (sub-agent fan-out). */
209
+ const TEAM_RUNG = 5;
136
210
 
137
211
  function bandForPct(pct) {
212
+ if (!Number.isFinite(pct)) return 0;
213
+ if (pct >= 150) return 150;
214
+ if (pct >= 125) return 125;
138
215
  if (pct >= 100) return 100;
139
- if (pct >= 90) return 90;
140
- if (pct >= 75) return 75;
141
- if (pct >= 50) return 50;
216
+ if (pct >= 80) return 80;
142
217
  return 0;
143
218
  }
144
219
 
220
+ /**
221
+ * The structured enforcement instruction for a band. PURE, total, never throws.
222
+ *
223
+ * Every consumer reads THIS instead of re-deriving thresholds from `band`, so
224
+ * the ladder is written down exactly once:
225
+ * lib/model-router/economics.budgetLadder → routing (cheapest class)
226
+ * lib/resource-governor.admit → spawn admission
227
+ * lib/plan/compile.applyBudgetPosture → obligation suspension
228
+ * scripts/daemon/cadence-handlers → D3 self-directed work
229
+ *
230
+ * The rungs, and why each is where it is:
231
+ * 80 ESCALATE — one notice to the SUPERVISOR, once per band per period.
232
+ * Never to the owner: an agent told it is running out of money has every
233
+ * incentive to optimise the meter instead of the work.
234
+ * 100 DEGRADE THE RUNG — cheapest model class, fan-out capped at 1 (the
235
+ * `team` rung becomes unavailable), self-directed D3 work stops. Inbox
236
+ * DMs are still answered, at the degraded rung. This is the rung that was
237
+ * missing: enforcement used to be binary.
238
+ * 125 SUSPEND THE OBLIGATIONS, NOT THE SEAT — OUTCOME obligations flip to
239
+ * `suspended` with a `budget_breach` drift row. Inline/offline_safe
240
+ * cadences continue.
241
+ * 150 REFUSE, AT SPAWN LEVEL ONLY — DEFER (never hard-refuse) anything that is
242
+ * not a direct human reply. A seat-level refuse would brick the seat, and
243
+ * Invariant #1 forbids that.
244
+ *
245
+ * @param {number} band 0|80|100|125|150
246
+ */
247
+ export function postureForBand(band) {
248
+ const b = Number(band) || 0;
249
+ const mode =
250
+ b >= 150 ? MODES.REFUSED
251
+ : b >= 125 ? MODES.SUSPENDED
252
+ : b >= 100 ? MODES.DEGRADED
253
+ : MODES.NORMAL;
254
+ return Object.freeze({
255
+ band: b,
256
+ mode,
257
+ escalateToSupervisor: b >= 80,
258
+ cheapModelOnly: b >= 100,
259
+ maxFanout: b >= 100 ? 1 : null,
260
+ maxRung: b >= 100 ? TEAM_RUNG - 1 : null,
261
+ selfDirected: b < 100,
262
+ suspendOutcomeObligations: b >= 125,
263
+ refuseNonHumanSpawn: b >= 150,
264
+ });
265
+ }
266
+
145
267
  // ---------------------------------------------------------------------------
146
268
  // Ledger sum
147
269
  // ---------------------------------------------------------------------------
@@ -149,23 +271,378 @@ function bandForPct(pct) {
149
271
  /**
150
272
  * Sum today's spend + session count from the JSONL ledger. Never throws;
151
273
  * malformed lines are skipped, a missing file yields zeroes.
274
+ *
275
+ * WHAT CHANGED AND WHY (2026-08-12)
276
+ * ---------------------------------
277
+ * This used to sum `row.estimated_usd` and count every line as one session.
278
+ * Both halves were wrong, and both made the cap unable to bind:
279
+ *
280
+ * - `estimated_usd` on the v1 tracker rows was priced from a table with NO
281
+ * CACHE TIER, while ~99% of this agent's prompt tokens are cache reads. The
282
+ * governor saw $90.79 on a day that actually cost $235.62 against a $25 cap.
283
+ * We now bill against the CLI's authoritative `total_cost_usd` and fall back
284
+ * to the (now cache-aware) estimate only when it is absent — logged, never
285
+ * silent. See lib/cost/ledger-row.mjs.
286
+ * - Rows with no measured tokens summed as $0, so a systematic usage-parse
287
+ * regression would quietly drive the day's spend toward zero exactly when
288
+ * telemetry broke. Unmeasured sessions are now imputed at the day's mean
289
+ * measured cost and reported in `degradations`.
290
+ * - Zero-LLM attribution rows (message sends) were counted as sessions,
291
+ * inflating the iteration-cap denominator. They are excluded from `sessions`
292
+ * and reported as `nonLlmRows`.
152
293
  */
153
294
  function sumToday(deps) {
154
295
  const file = ledgerFile(deps);
155
- let spentUSD = 0;
156
- let sessions = 0;
157
- if (!existsSync(file)) return { spentUSD: 0, sessions: 0 };
296
+ const emptyResult = {
297
+ spentUSD: 0, sessions: 0, measuredUSD: 0, imputedUSD: 0,
298
+ unmeasuredSessions: 0, nonLlmRows: 0, blind: false, degradations: [],
299
+ };
300
+ if (!existsSync(file)) return emptyResult;
158
301
  let body;
159
- try { body = readFileSync(file, "utf-8"); } catch { return { spentUSD: 0, sessions: 0 }; }
302
+ try { body = readFileSync(file, "utf-8"); } catch { return emptyResult; }
303
+
304
+ const rows = [];
305
+ let malformed = 0;
160
306
  for (const line of body.split("\n")) {
161
307
  if (!line.trim()) continue;
162
- let row;
163
- try { row = JSON.parse(line); } catch { continue; }
164
- const usd = Number(row.estimated_usd);
165
- if (Number.isFinite(usd)) spentUSD += usd;
166
- sessions += 1;
308
+ try { rows.push(JSON.parse(line)); } catch { malformed += 1; }
309
+ }
310
+
311
+ const summary = summariseRows(rows);
312
+ // When TODAY measured nothing, fall back to the month's mean measured session
313
+ // cost rather than to an invented per-session floor. A day that is blind end
314
+ // to end is exactly when a made-up number does the most damage: the previous
315
+ // $0.25/session constant was ~8x below this fleet's observed $1.95 mean, so a
316
+ // total outage on a $68 day imputed $8.75 and reported band 0.
317
+ const imputed = imputeUnmeasured(summary, {
318
+ fallbackPerSessionUsd: deps && deps.__monthMeanUsd !== undefined
319
+ ? deps.__monthMeanUsd
320
+ : monthMeanSessionUsd(deps),
321
+ });
322
+ const degradations = [...summary.degradations];
323
+ if (malformed > 0) degradations.push(`${malformed} unparseable ledger line(s) skipped`);
324
+ if (imputed.imputedUsd > 0) {
325
+ degradations.push(
326
+ `imputed $${imputed.imputedUsd} for ${summary.unmeasured} unmeasured session(s) at $${imputed.perSessionUsd}/session (${imputed.basis}) — counted toward the cap so a telemetry outage cannot silence the governor`
327
+ );
328
+ }
329
+ if (imputed.unpriceable) {
330
+ degradations.push(
331
+ `${summary.unmeasured} unmeasured session(s) and NO measured session at any horizon (today or month-to-date) — ` +
332
+ "their cost cannot be imputed from evidence and is NOT being invented. The band is floored at DEGRADE instead " +
333
+ "(see BLIND_MIN_BAND): we cannot measure, therefore we degrade."
334
+ );
335
+ }
336
+
337
+ return {
338
+ spentUSD: +(summary.measuredUsd + imputed.imputedUsd).toFixed(6),
339
+ measuredUSD: summary.measuredUsd,
340
+ imputedUSD: imputed.imputedUsd,
341
+ sessions: summary.sessions,
342
+ unmeasuredSessions: summary.unmeasured,
343
+ nonLlmRows: summary.nonLlmRows,
344
+ blind: summary.blind,
345
+ unpriceable: imputed.unpriceable === true,
346
+ degradations,
347
+ };
348
+ }
349
+
350
+ /**
351
+ * The month's mean measured session cost — the second rung of the imputation
352
+ * ladder, used when TODAY has measured nothing at all. Evidence, not a constant.
353
+ * Returns null when the whole month is unmeasured (then nothing is invented).
354
+ */
355
+ function monthMeanSessionUsd(deps) {
356
+ const dir = ledgerDir(deps);
357
+ const month = todayUtc(deps).slice(0, 7);
358
+ let files = [];
359
+ try {
360
+ files = readdirSync(dir).filter((f) => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f) && f.startsWith(month));
361
+ } catch {
362
+ return null;
363
+ }
364
+ let usd = 0;
365
+ let n = 0;
366
+ for (const f of files) {
367
+ let body;
368
+ try { body = readFileSync(join(dir, f), "utf-8"); } catch { continue; }
369
+ const rows = [];
370
+ for (const line of body.split("\n")) {
371
+ if (!line.trim()) continue;
372
+ try { rows.push(JSON.parse(line)); } catch { /* skip */ }
373
+ }
374
+ const s = summariseRows(rows);
375
+ usd += s.measuredUsd;
376
+ n += s.measured;
377
+ }
378
+ return n > 0 && usd > 0 ? usd / n : null;
379
+ }
380
+
381
+ // ---------------------------------------------------------------------------
382
+ // The denominator — what the ORG funded, not what the seat configured
383
+ // ---------------------------------------------------------------------------
384
+
385
+ /**
386
+ * Sum every date-stamped ledger file in the current UTC month.
387
+ *
388
+ * This is what lets a quiet week fund a busy one: the daily allowance is the
389
+ * REMAINING envelope over the REMAINING days, not a flat 1/N slice that expires
390
+ * unused each midnight.
391
+ */
392
+ export function monthToDate(deps) {
393
+ const dir = ledgerDir(deps);
394
+ const month = todayUtc(deps).slice(0, 7); // YYYY-MM
395
+ let files = [];
396
+ try {
397
+ files = readdirSync(dir).filter((f) => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f) && f.startsWith(month));
398
+ } catch {
399
+ return { spentUSD: 0, days: 0 };
400
+ }
401
+ // One month-wide mean, computed once, so a day that measured NOTHING still
402
+ // imputes from evidence instead of contributing $0 to the month's total.
403
+ const fallbackPerSessionUsd = monthMeanSessionUsd(deps);
404
+ let spentUSD = 0;
405
+ for (const f of files.sort()) {
406
+ let body;
407
+ try { body = readFileSync(join(dir, f), "utf-8"); } catch { continue; }
408
+ const rows = [];
409
+ for (const line of body.split("\n")) {
410
+ if (!line.trim()) continue;
411
+ try { rows.push(JSON.parse(line)); } catch { /* skip */ }
412
+ }
413
+ const summary = summariseRows(rows);
414
+ spentUSD += summary.measuredUsd + imputeUnmeasured(summary, { fallbackPerSessionUsd }).imputedUsd;
415
+ }
416
+ return { spentUSD: +spentUSD.toFixed(6), days: files.length };
417
+ }
418
+
419
+ /**
420
+ * Read the seat's FUNDED envelope off the cached mandate body.
421
+ *
422
+ * The chain, end to end, with no invented link:
423
+ * hq `Employee.payBasis.meteredBudget` (dollars/month, `ai_seat` basis)
424
+ * → `loadMandateEnvelope` (× 100)
425
+ * → mandate body `seatBudgetCents` + `budgetPeriod:"monthly"`
426
+ * → `mandate.get` → `state/mandate/cache.json`
427
+ * → HERE → `dailyAllowance` → every enforcement decision below.
428
+ *
429
+ * A null at any link travels as a null and NAMES ITSELF; nothing downstream
430
+ * substitutes a number for it. (The last time something did, every obligation in
431
+ * the fleet ran on an invented 500c allowance for months.)
432
+ *
433
+ * @returns {{ seatBudgetCents:number|null, budgetPeriod:string|null,
434
+ * supervisorMemberId:string|null, source:string, reason:string|null }}
435
+ */
436
+ export function readSeatEnvelope(deps) {
437
+ if (deps && deps.seatEnvelope !== undefined) {
438
+ const e = deps.seatEnvelope || {};
439
+ return {
440
+ seatBudgetCents: Number.isFinite(e.seatBudgetCents) ? e.seatBudgetCents : null,
441
+ budgetPeriod: e.budgetPeriod || (Number.isFinite(e.seatBudgetCents) ? "monthly" : null),
442
+ supervisorMemberId: e.supervisorMemberId || null,
443
+ source: e.source || "injected",
444
+ reason: e.reason || (Number.isFinite(e.seatBudgetCents) ? null : "the injected envelope carries no seatBudgetCents"),
445
+ };
446
+ }
447
+ const path =
448
+ (deps && deps.mandateCachePath) ||
449
+ join(agentRootOf(deps), "state", "mandate", "cache.json");
450
+ const miss = (reason, source = "mandate-cache") => ({
451
+ seatBudgetCents: null, budgetPeriod: null, supervisorMemberId: null, source, reason,
452
+ });
453
+ if (!existsSync(path)) return miss("no state/mandate/cache.json — this seat has never fetched a mandate");
454
+ let record;
455
+ try {
456
+ record = JSON.parse(readFileSync(path, "utf-8"));
457
+ } catch (err) {
458
+ return miss(`state/mandate/cache.json is unreadable (${err && err.message ? err.message : err})`);
459
+ }
460
+ const body = (record && record.body) || {};
461
+ const supervisor =
462
+ (Array.isArray(body.collaborators) ? body.collaborators : []).find(
463
+ (c) => c && String(c.role || "").toUpperCase() === "REVIEWER" && c.memberId
464
+ ) || null;
465
+
466
+ if (!Number.isFinite(body.seatBudgetCents)) {
467
+ // Carry hq's OWN words when it published them — hq knows which link is null,
468
+ // and paraphrasing it here would lose the one actionable sentence.
469
+ const named = (Array.isArray(body.degradations) ? body.degradations : []).find(
470
+ (d) => d && d.field === "seatBudgetCents"
471
+ );
472
+ return {
473
+ seatBudgetCents: null,
474
+ budgetPeriod: null,
475
+ supervisorMemberId: supervisor ? supervisor.memberId : null,
476
+ source: record && record.source === "server" ? "hq" : `mandate-cache:${(record && record.source) || "unknown"}`,
477
+ reason: named
478
+ ? `hq published no envelope: ${named.reason}`
479
+ : record && record.source !== "server"
480
+ ? "the mandate cache is LOCAL (never adopted from hq), so it carries no funded envelope"
481
+ : "hq published seatBudgetCents:null — the seat has no funded envelope",
482
+ };
483
+ }
484
+ // ── PROVENANCE + STALENESS, ON THE SUCCESS PATH ───────────────────────────
485
+ // `record.source` was checked only in the MISS branch, and `fetchedAt` was not
486
+ // read at all. Both matter here, and neither is cosmetic:
487
+ //
488
+ // provenance — a `source:"local"` cache is one the seat wrote for itself
489
+ // (`lib/setup/sections/mandate.mjs` derives a local body offline). A
490
+ // seatBudgetCents on such a record is the seat setting its own ceiling,
491
+ // which is the ONE thing this module exists to prevent. Refuse it; the
492
+ // local safety-net cap binds instead, loudly.
493
+ // staleness — hq can revoke funding (`Employee.payBasis.meteredBudget` →
494
+ // null) while the agent is partitioned. Without an expiry the cached
495
+ // number keeps producing `funded:true` and a live daily allowance forever,
496
+ // and the "employee.payBasis.meteredBudget" label is then a provenance
497
+ // claim asserted rather than verified. Past the mandate cache's own
498
+ // EXPIRED tier (7d, lib/mandate/cache.mjs TIERS.stale) the envelope stops
499
+ // being authoritative. AGING is kept but reported.
500
+ const recordSource = (record && record.source) || "unknown";
501
+ if (recordSource !== "server") {
502
+ return {
503
+ seatBudgetCents: null,
504
+ budgetPeriod: null,
505
+ supervisorMemberId: supervisor ? supervisor.memberId : null,
506
+ source: `mandate-cache:${recordSource}`,
507
+ reason:
508
+ `the mandate cache carries a seatBudgetCents but its source is "${recordSource}", not "server" — ` +
509
+ "a locally-derived body is the SEAT's own number and may not be used as the org-funded ceiling",
510
+ };
511
+ }
512
+ const ageSec = envelopeAgeSeconds(record, clock(deps)());
513
+ if (ageSec >= ENVELOPE_EXPIRY_SEC) {
514
+ return {
515
+ seatBudgetCents: null,
516
+ budgetPeriod: null,
517
+ supervisorMemberId: supervisor ? supervisor.memberId : null,
518
+ source: "mandate-cache:expired",
519
+ reason:
520
+ `the cached envelope was fetched ${Math.floor(ageSec / 3600)}h ago (> ${ENVELOPE_EXPIRY_SEC / 3600}h) — ` +
521
+ "hq may have changed or revoked this seat's funding since, so the number is no longer authoritative. " +
522
+ "Run `maestro mandate sync` (or let the mandate cadence refresh it) to re-establish the ceiling.",
523
+ };
167
524
  }
168
- return { spentUSD: +spentUSD.toFixed(6), sessions };
525
+
526
+ return {
527
+ seatBudgetCents: body.seatBudgetCents,
528
+ budgetPeriod: body.budgetPeriod || "monthly",
529
+ supervisorMemberId: supervisor ? supervisor.memberId : null,
530
+ source: body.budgetSource || "employee.payBasis.meteredBudget",
531
+ reason: null,
532
+ ageSeconds: ageSec,
533
+ // Kept as a DEGRADATION rather than a refusal: an envelope nobody has
534
+ // refreshed in a day or three is still hq's number, and refusing it would
535
+ // brick a seat whose only sin is a quiet mandate cadence.
536
+ staleReason: ageSec >= ENVELOPE_AGING_SEC
537
+ ? `the funded envelope was last fetched from hq ${Math.floor(ageSec / 3600)}h ago — it is still being enforced, but it expires at ${ENVELOPE_EXPIRY_SEC / 3600}h`
538
+ : null,
539
+ };
540
+ }
541
+
542
+ /** Seconds since the mandate cache was fetched; Infinity when never/unparseable. */
543
+ function envelopeAgeSeconds(record, nowMs) {
544
+ const at = record && record.fetchedAt;
545
+ if (!at) return Infinity;
546
+ const then = Date.parse(String(at));
547
+ if (!Number.isFinite(then) || !Number.isFinite(nowMs)) return Infinity;
548
+ return Math.max(0, (nowMs - then) / 1000);
549
+ }
550
+
551
+ function daysInUtcMonth(ms) {
552
+ const d = new Date(ms);
553
+ return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate();
554
+ }
555
+
556
+ /**
557
+ * The binding daily allowance, and how it was arrived at.
558
+ *
559
+ * The envelope is monthly dollars. Today's allowance is the REMAINING envelope
560
+ * spread over the REMAINING days of the month, reconciled against month-to-date
561
+ * actuals — an under-spent week raises today's ceiling instead of expiring.
562
+ *
563
+ * When the envelope and the local `DAILY_SPEND_CAP_USD` disagree, THE LOWER ONE
564
+ * BINDS and the disagreement is logged: a seat may not spend past what the org
565
+ * funded, and a local config file may not be used to authorise MORE than the org
566
+ * funded either. With no envelope at all the local cap binds and that fact is a
567
+ * degradation on every single read — an agent that cannot know its funding says
568
+ * so, loudly, rather than assuming a number.
569
+ */
570
+ export function dailyAllowance(deps) {
571
+ const degradations = [];
572
+ const configuredCapUSD = cfgSpendCap(deps);
573
+ const env = readSeatEnvelope(deps);
574
+ const now = clock(deps)();
575
+ const daysInMonth = daysInUtcMonth(now);
576
+ const dayOfMonth = new Date(now).getUTCDate();
577
+ const mtd = monthToDate(deps);
578
+ const today = sumToday(deps);
579
+
580
+ if (env.seatBudgetCents == null) {
581
+ degradations.push(
582
+ `UNFUNDED SEAT: ${env.reason || "no funded envelope"} — falling back to the LOCAL cap ` +
583
+ `($${configuredCapUSD}/day, which this seat can edit). The org-funded ceiling is NOT being enforced.`
584
+ );
585
+ return {
586
+ allowanceUSD: configuredCapUSD,
587
+ capSource: "local-config",
588
+ configuredCapUSD,
589
+ funded: false,
590
+ envelope: { seatBudgetCents: null, monthUSD: null, source: env.source, supervisorMemberId: env.supervisorMemberId },
591
+ month: { spentUSD: mtd.spentUSD, pct: null, daysInMonth, dayOfMonth },
592
+ degradations,
593
+ disagreementUSD: null,
594
+ };
595
+ }
596
+
597
+ const monthUSD = env.seatBudgetCents / 100;
598
+ const remainingDays = Math.max(1, daysInMonth - dayOfMonth + 1);
599
+ // Month-to-date INCLUDING today would charge today's spend against today's own
600
+ // allowance twice, so the allowance is set from the days already closed.
601
+ const spentBeforeToday = Math.max(0, mtd.spentUSD - today.spentUSD);
602
+ const remainingUSD = monthUSD - spentBeforeToday;
603
+ const envelopeAllowanceUSD = remainingUSD > 0 ? remainingUSD / remainingDays : 0;
604
+
605
+ let allowanceUSD = envelopeAllowanceUSD;
606
+ let capSource = "seat-envelope";
607
+ let disagreementUSD = null;
608
+ if (Number.isFinite(configuredCapUSD) && configuredCapUSD > 0) {
609
+ disagreementUSD = +(envelopeAllowanceUSD - configuredCapUSD).toFixed(6);
610
+ if (configuredCapUSD < envelopeAllowanceUSD) {
611
+ allowanceUSD = configuredCapUSD;
612
+ capSource = "local-config";
613
+ }
614
+ if (Math.abs(disagreementUSD) > 0.01) {
615
+ degradations.push(
616
+ `the seat envelope allows $${envelopeAllowanceUSD.toFixed(2)} today ` +
617
+ `(remaining $${remainingUSD.toFixed(2)} over ${remainingDays} day(s)) but the local cap says ` +
618
+ `$${configuredCapUSD.toFixed(2)} — the LOWER binds ($${allowanceUSD.toFixed(2)}, ${capSource})`
619
+ );
620
+ }
621
+ }
622
+
623
+ return {
624
+ allowanceUSD: +allowanceUSD.toFixed(6),
625
+ capSource,
626
+ configuredCapUSD,
627
+ funded: true,
628
+ envelope: {
629
+ seatBudgetCents: env.seatBudgetCents,
630
+ monthUSD,
631
+ source: env.source,
632
+ supervisorMemberId: env.supervisorMemberId,
633
+ remainingUSD: +remainingUSD.toFixed(6),
634
+ remainingDays,
635
+ allowanceUSD: +envelopeAllowanceUSD.toFixed(6),
636
+ },
637
+ month: {
638
+ spentUSD: mtd.spentUSD,
639
+ pct: monthUSD > 0 ? +((mtd.spentUSD / monthUSD) * 100).toFixed(2) : null,
640
+ daysInMonth,
641
+ dayOfMonth,
642
+ },
643
+ degradations,
644
+ disagreementUSD,
645
+ };
169
646
  }
170
647
 
171
648
  // ---------------------------------------------------------------------------
@@ -173,53 +650,233 @@ function sumToday(deps) {
173
650
  // ---------------------------------------------------------------------------
174
651
 
175
652
  /**
176
- * Compute the day's budget status.
653
+ * Compute the period's budget status and the enforcement posture it implies.
654
+ *
655
+ * The band is the WORSE of two readings, because either one alone is gameable by
656
+ * the calendar:
657
+ * dayPct today's spend against today's binding allowance
658
+ * monthPct month-to-date spend against the whole funded envelope
659
+ * A seat that blew the month on the 3rd is in the suspend band on the 4th even
660
+ * though "today" looks quiet; a seat with a healthy month that spikes 3x in one
661
+ * day still degrades today.
177
662
  *
178
- * @param {object} [deps] { now?, ledgerDir?, agentRoot?, capUSD?, iterationCap? }
179
- * @returns {{
180
- * spentUSD:number, capUSD:number, pct:number, band:0|50|75|90|100,
181
- * essentialOnly:boolean, sessions:number, iterationCap:number, date:string
182
- * }}
663
+ * @param {object} [deps] { now?, ledgerDir?, agentRoot?, capUSD?, iterationCap?,
664
+ * seatEnvelope?, mandateCachePath?, log? }
183
665
  */
184
666
  export function dailyStatus(deps) {
185
- const capUSD = cfgSpendCap(deps);
186
667
  const iterationCap = cfgIterationCap(deps);
187
- const { spentUSD, sessions } = sumToday(deps);
668
+ const today = sumToday(deps);
669
+ const { spentUSD, sessions } = today;
670
+ const allowance = dailyAllowance(deps);
671
+ const extraDegradations = [];
188
672
 
189
- const spendPct = capUSD > 0 ? (spentUSD / capUSD) * 100 : 0;
673
+ // ── THE LATCH: a seat may not demote its own band by editing its own repo ──
674
+ // The module invariant is "no band self-clears by agent action", and the
675
+ // fingerprint half of it was true only when the ENVELOPE was already the
676
+ // binding cap. In the ordinary posture — a $25 local `config/recovery.yaml`
677
+ // cap under a larger envelope share — the local file IS the binding cap, so
678
+ // raising it raised the ceiling, dropped the percentage, and walked the seat
679
+ // out of `degraded` (measured: $28 spent, band 100 → one edit → band 80).
680
+ // So for BANDING we use the lowest binding cap observed this period. It resets
681
+ // when hq moves the envelope (an act the seat cannot perform) and at the
682
+ // period boundary. Lowering the local cap still tightens immediately.
683
+ const latched = readCapLatch(deps, allowance);
684
+ const capUSD = allowance.allowanceUSD;
685
+ const bandingCapUSD = latched.bandingCapUSD;
686
+ if (bandingCapUSD < capUSD - 0.005) {
687
+ extraDegradations.push(
688
+ `the binding daily cap read $${capUSD.toFixed(2)} but this period already ran under $${bandingCapUSD.toFixed(2)} ` +
689
+ `(${latched.source}); banding uses the LOWER — a seat cannot raise its own ceiling out of a band it is already in. ` +
690
+ "Only hq (Employee.payBasis.meteredBudget) can raise it."
691
+ );
692
+ }
693
+
694
+ // `null`, not 0, when there is no allowance left to divide by. Forcing this to
695
+ // 0 reported "0 % of today's allowance" for a seat whose envelope was fully
696
+ // spent — a reading that is not merely useless but backwards.
697
+ const spendPct = bandingCapUSD > 0
698
+ ? (spentUSD / bandingCapUSD) * 100
699
+ : spentUSD > 0 ? null : 0;
700
+ if (spendPct === null) {
701
+ extraDegradations.push(
702
+ `today's allowance is $0.00 (the monthly envelope is spent out) and $${spentUSD.toFixed(2)} was spent anyway — ` +
703
+ "the day percentage is undefined, not 0; the MONTH percentage is the binding reading"
704
+ );
705
+ }
706
+ const monthPct = Number.isFinite(allowance.month.pct) ? allowance.month.pct : 0;
190
707
  const iterPct = iterationCap > 0 ? (sessions / iterationCap) * 100 : 0;
191
- // The binding constraint is whichever is closer to the cap.
192
- const pct = Math.max(spendPct, iterPct);
193
- const band = bandForPct(pct);
708
+ // The binding constraint is whichever is closest to (or furthest past) the cap.
709
+ // A null day-reading contributes nothing; the exhausted-envelope case is
710
+ // already ≥100 % on the month, so nothing is lost by skipping it.
711
+ const pct = Math.max(spendPct ?? 0, monthPct, iterPct);
712
+ let band = bandForPct(pct);
713
+
714
+ // ── BLIND ⇒ DEGRADE ────────────────────────────────────────────────────────
715
+ // `blind` (sessions ran, none measured) was computed and reported and enforced
716
+ // by nothing. It is now load-bearing: we do not invent a price, we lower the
717
+ // rung. Only when the day is TRULY unpriceable — if a month mean was available
718
+ // the spend was imputed from it and the ordinary band applies.
719
+ if (today.blind && today.unpriceable && band < BLIND_MIN_BAND) {
720
+ extraDegradations.push(
721
+ `band raised ${band}% → ${BLIND_MIN_BAND}%: ${today.unmeasuredSessions} session(s) ran today and NONE could be ` +
722
+ "measured or priced. An unmeasurable seat is degraded, not trusted."
723
+ );
724
+ band = BLIND_MIN_BAND;
725
+ }
726
+ const posture = postureForBand(band);
727
+
728
+ const degradations = [...today.degradations, ...allowance.degradations, ...extraDegradations];
729
+ // Degradations ride on the returned status AND go to the log: the daemon is
730
+ // usually the only thing awake when telemetry breaks, and a governor that
731
+ // quietly under-counts is the failure mode this module now exists to prevent.
732
+ if (degradations.length > 0) {
733
+ for (const d of degradations) {
734
+ try { console.warn(`[budget-guard] degraded: ${d}`); } catch { /* never throw */ }
735
+ }
736
+ }
194
737
 
195
738
  return {
196
739
  spentUSD,
197
740
  capUSD,
741
+ // The cap the BAND was computed against (the latch above). Equal to capUSD
742
+ // except when the seat raised its own local cap mid-period.
743
+ bandingCapUSD,
198
744
  pct: +pct.toFixed(2),
745
+ dayPct: spendPct === null ? null : +spendPct.toFixed(2),
746
+ monthPct: +monthPct.toFixed(2),
199
747
  band,
200
- essentialOnly: band >= 100,
748
+ mode: posture.mode,
749
+ posture,
750
+ // Legacy field, kept so no caller silently loses its gate during the
751
+ // rollout. It now means the SUSPEND band (125 %) — the rung whose behaviour
752
+ // matches what `essentialOnly` always did: defer non-inbox work.
753
+ essentialOnly: band >= 125,
201
754
  sessions,
202
755
  iterationCap,
203
756
  date: todayUtc(deps),
757
+ // Where the ceiling came from. `funded:false` means hq published no envelope
758
+ // and the number below is the seat's own config, not the org's decision.
759
+ funded: allowance.funded,
760
+ capSource: allowance.capSource,
761
+ configuredCapUSD: allowance.configuredCapUSD,
762
+ envelope: allowance.envelope,
763
+ month: allowance.month,
764
+ supervisorMemberId: allowance.envelope.supervisorMemberId || null,
765
+ // Measurement provenance — a caller can tell a cheap day from a blind one.
766
+ measuredUSD: today.measuredUSD,
767
+ imputedUSD: today.imputedUSD,
768
+ unmeasuredSessions: today.unmeasuredSessions,
769
+ nonLlmRows: today.nonLlmRows,
770
+ blind: today.blind,
771
+ degradations,
204
772
  };
205
773
  }
206
774
 
207
775
  /**
208
- * Read the per-day notification ledger ({ notified_bands: number[] }). Never
209
- * throws.
776
+ * The lowest binding cap this period has run under, and the envelope it belongs
777
+ * to. See the LATCH comment in {@link dailyStatus} for why banding may not use a
778
+ * cap the seat just raised for itself.
779
+ *
780
+ * Stored beside the notice ledger (same UTC-date-stamped file, so it resets at
781
+ * the period boundary). The latch is dropped when the ENVELOPE fingerprint
782
+ * changes — hq raising or lowering the funded envelope is a real governance act
783
+ * and must take effect immediately, in both directions.
784
+ *
785
+ * Never throws; on any read/write failure the current cap is used unlatched
786
+ * (fail-open) and the caller sees `source:"unlatched"`.
210
787
  */
211
- export function readNotified(deps) {
788
+ function readCapLatch(deps, allowance) {
789
+ const current = Number(allowance && allowance.allowanceUSD);
790
+ const envelopeFp = `${(allowance && allowance.envelope && allowance.envelope.seatBudgetCents) ?? "null"}:${(allowance && allowance.envelope && allowance.envelope.source) || "none"}`;
791
+ if (!Number.isFinite(current) || current <= 0) {
792
+ return { bandingCapUSD: Number.isFinite(current) ? current : 0, source: "unlatched" };
793
+ }
212
794
  const p = budgetStateFile(deps);
213
- if (!existsSync(p)) return { notified_bands: [] };
795
+ let prior = null;
796
+ try {
797
+ if (existsSync(p)) prior = JSON.parse(readFileSync(p, "utf-8"));
798
+ } catch {
799
+ return { bandingCapUSD: current, source: "unlatched" };
800
+ }
801
+ const priorMin = prior && Number(prior.min_binding_cap_usd);
802
+ const priorFp = prior && prior.envelope_id;
803
+ const keep =
804
+ Number.isFinite(priorMin) && priorMin > 0 && priorFp === envelopeFp
805
+ ? Math.min(priorMin, current)
806
+ : current;
807
+
808
+ if (!prior || prior.min_binding_cap_usd !== keep || prior.envelope_id !== envelopeFp) {
809
+ writeNotified(
810
+ {
811
+ ...(prior && typeof prior === "object" ? prior : {}),
812
+ min_binding_cap_usd: +keep.toFixed(6),
813
+ envelope_id: envelopeFp,
814
+ },
815
+ deps
816
+ );
817
+ }
818
+ return {
819
+ bandingCapUSD: +keep.toFixed(6),
820
+ source: keep < current ? `the lowest cap seen this period under the same envelope` : "current",
821
+ };
822
+ }
823
+
824
+ /**
825
+ * Read the per-period notification ledger. Never throws.
826
+ *
827
+ * `envelope_fingerprint` is what makes "NO BAND SELF-CLEARS BY AGENT ACTION"
828
+ * enforceable. Notices clear only when the BINDING cap moves UP — and the seat
829
+ * cannot do that to itself, because the binding cap is the LOWER of the org
830
+ * envelope and the local config: editing the local file upward changes nothing,
831
+ * and editing it downward only tightens. Raising the ceiling is a supervisor /
832
+ * human act in hq, exactly like adoption.
833
+ *
834
+ * @param {object} [deps]
835
+ * @param {string} [currentFingerprint] `${capUSD}:${capSource}` for this read
836
+ */
837
+ export function readNotified(deps, currentFingerprint) {
838
+ const p = budgetStateFile(deps);
839
+ if (!existsSync(p)) return { notified_bands: [], envelope_fingerprint: currentFingerprint ?? null };
214
840
  try {
215
841
  const raw = JSON.parse(readFileSync(p, "utf-8"));
216
842
  const arr = Array.isArray(raw.notified_bands) ? raw.notified_bands.filter((n) => BANDS.includes(n)) : [];
217
- return { notified_bands: arr };
843
+ const stored = raw.envelope_fingerprint ?? null;
844
+ const delivery = raw.delivery && typeof raw.delivery === "object" ? raw.delivery : {};
845
+ // The period file ALSO carries the cap latch (`min_binding_cap_usd` /
846
+ // `envelope_id`). Carry those through untouched: `maybeNotify` merge-writes
847
+ // over whatever this returns, so dropping them here would silently reset the
848
+ // latch on the first notice — and the latch is what stops a seat demoting
849
+ // its own band.
850
+ const latch = {
851
+ ...(raw.min_binding_cap_usd !== undefined ? { min_binding_cap_usd: raw.min_binding_cap_usd } : {}),
852
+ ...(raw.envelope_id !== undefined ? { envelope_id: raw.envelope_id } : {}),
853
+ };
854
+ if (currentFingerprint != null && stored != null && stored !== currentFingerprint) {
855
+ const now = Number(String(currentFingerprint).split(":")[0]);
856
+ const before = Number(String(stored).split(":")[0]);
857
+ if (Number.isFinite(now) && Number.isFinite(before) && now > before) {
858
+ return { ...latch, notified_bands: [], envelope_fingerprint: currentFingerprint, cleared: "envelope-raised", delivery: {} };
859
+ }
860
+ }
861
+ return { ...latch, notified_bands: arr, envelope_fingerprint: stored, delivery };
218
862
  } catch {
219
- return { notified_bands: [] };
863
+ return { notified_bands: [], envelope_fingerprint: currentFingerprint ?? null, delivery: {} };
220
864
  }
221
865
  }
222
866
 
867
+ /**
868
+ * Delivery retry policy for a band notice. See {@link maybeNotify}.
869
+ *
870
+ * A band is marked notified only once the caller CONFIRMS delivery, or once we
871
+ * have tried `MAX_DELIVERY_ATTEMPTS` times and given up (which is logged as an
872
+ * error, because a rung whose whole purpose is "tell the supervisor" silently
873
+ * not telling anybody is the failure this subsystem exists to end).
874
+ */
875
+ export const MAX_DELIVERY_ATTEMPTS = 6;
876
+ /** Matches the cadence consumer's 5-minute escalation timer. */
877
+ export const DELIVERY_RETRY_MS = 5 * 60_000;
878
+
879
+ /** Merge-write the period state file (it carries the notice ledger AND the cap latch). */
223
880
  function writeNotified(state, deps) {
224
881
  const p = budgetStateFile(deps);
225
882
  try {
@@ -234,22 +891,52 @@ function writeNotified(state, deps) {
234
891
  }
235
892
 
236
893
  /**
237
- * Emit AT MOST ONE notice per band per day. If the current band is above any
238
- * not-yet-notified band threshold(s), returns the highest such band as the
239
- * notice to surface and records every crossed band as notified (so a jump
240
- * from 40 %→95 % notifies once at 90, not three times). When `notify` is
241
- * provided it is invoked with a structured payload; otherwise the caller can
242
- * use the return value.
243
- *
244
- * @param {object} [deps] { now?, ledgerDir?, capUSD?, notify? }
245
- * @returns {{ notified:boolean, band:0|50|75|90|100, status:object, message?:string }}
894
+ * The fingerprint notices are keyed to. THE ENVELOPE ONLY — never the binding
895
+ * cap.
896
+ *
897
+ * It used to be `${capUSD}:${capSource}`, which meant a seat that raised its own
898
+ * `config/recovery.yaml` cap moved the fingerprint upward and `readNotified`
899
+ * read that as "a supervisor raised the ceiling", wiping the notice ledger. The
900
+ * seat could then re-clear its own alarms indefinitely. `seatBudgetCents` is set
901
+ * in hq on the Employee record and is the one number the seat cannot write.
902
+ */
903
+ function fingerprintOf(status) {
904
+ const cents = (status.envelope && status.envelope.seatBudgetCents) ?? null;
905
+ return `${cents == null ? "unfunded" : cents}:${(status.envelope && status.envelope.source) || "none"}`;
906
+ }
907
+
908
+ /**
909
+ * Emit AT MOST ONE notice per band per period, ADDRESSED TO THE SUPERVISOR.
910
+ *
911
+ * If the current band is above any not-yet-notified threshold(s), the highest
912
+ * such band is surfaced and every crossed band is recorded (so a jump from
913
+ * 40 %→130 % notifies once at 125, not three times).
914
+ *
915
+ * THE RECIPIENT IS NOT THE OWNER. It is the seat's REVIEWER collaborator —
916
+ * `WorkforceMember.supervisorId` as hq published it on the mandate body. An
917
+ * agent told "you are running out of money" has every incentive to optimise the
918
+ * meter rather than the work, and the meter is the one number it must not be
919
+ * able to move. When no supervisor edge exists the notice is LOGGED LOUDLY and
920
+ * sent NOWHERE: an unsupervised seat is a provisioning gap, not a licence to
921
+ * hand the agent its own budget alarm.
922
+ *
923
+ * @param {object} [deps] { now?, ledgerDir?, capUSD?, notify?, log? }
924
+ * @returns {{ notified:boolean, band:0|80|100|125|150, status:object,
925
+ * message?:string, recipient?:{memberId:string, role:"REVIEWER"}|null }}
246
926
  */
247
927
  export function maybeNotify(deps) {
928
+ const log = (deps && typeof deps.log === "function")
929
+ ? deps.log
930
+ : (level, msg) => { (level === "error" ? console.error : console.warn)(`[budget-guard] ${msg}`); };
248
931
  const status = dailyStatus(deps);
249
932
  if (status.band === 0) return { notified: false, band: 0, status };
250
933
 
251
- const { notified_bands } = readNotified(deps);
252
- const already = new Set(notified_bands);
934
+ const fp = fingerprintOf(status);
935
+ const prior = readNotified(deps, fp);
936
+ const already = new Set(prior.notified_bands);
937
+ if (prior.cleared === "envelope-raised") {
938
+ log("warn", `budget notices cleared: the binding ceiling moved up to $${status.capUSD.toFixed(2)} (${status.capSource})`);
939
+ }
253
940
 
254
941
  // Every band threshold at or below the current band that hasn't fired yet.
255
942
  const crossed = BANDS.filter((b) => status.band >= b && !already.has(b));
@@ -257,23 +944,114 @@ export function maybeNotify(deps) {
257
944
  return { notified: false, band: status.band, status };
258
945
  }
259
946
 
260
- // Mark them all notified; surface ONE notice at the highest crossed band.
947
+ // Surface ONE notice at the highest crossed band.
261
948
  const topBand = crossed[crossed.length - 1];
262
- writeNotified({ notified_bands: [...notified_bands, ...crossed].sort((a, b) => a - b) }, deps);
949
+
950
+ // ── ATTEMPT, NOT COMPLETION ────────────────────────────────────────────────
951
+ // The band used to be written into `notified_bands` HERE, before anything was
952
+ // put on the wire — so one ECONNREFUSED (or a daemon that started before the
953
+ // org credential resolved) burned the rung for the whole period and the
954
+ // 5-minute timer then polled forever without ever retrying. The band is now
955
+ // recorded as an ATTEMPT with a backoff, and only `commit()` — called by the
956
+ // delivery leg after a successful send — marks it notified. After
957
+ // MAX_DELIVERY_ATTEMPTS we stop retrying and record it as UNDELIVERED, loudly:
958
+ // giving up quietly is the same bug wearing a hat.
959
+ const now = clock(deps)();
960
+ const track = prior.delivery && prior.delivery[String(topBand)];
961
+ const attempts = Number(track && track.attempts) || 0;
962
+ const lastAt = Number(track && track.lastAttemptMs) || 0;
963
+ const retryMs = Number.isFinite(deps && deps.retryMs) ? deps.retryMs : DELIVERY_RETRY_MS;
964
+
965
+ if (attempts > 0 && attempts < MAX_DELIVERY_ATTEMPTS && now - lastAt < retryMs) {
966
+ return {
967
+ notified: false,
968
+ band: status.band,
969
+ status,
970
+ deferred: true,
971
+ reason: `retry backoff: attempt ${attempts}/${MAX_DELIVERY_ATTEMPTS} was ${Math.round((now - lastAt) / 1000)}s ago`,
972
+ };
973
+ }
974
+
975
+ const persist = (patch) =>
976
+ writeNotified({ ...prior, envelope_fingerprint: fp, ...patch }, deps);
977
+
978
+ const markNotified = () =>
979
+ persist({
980
+ notified_bands: [...prior.notified_bands, ...crossed].sort((a, b) => a - b),
981
+ delivery: { ...prior.delivery, [String(topBand)]: { attempts: attempts + 1, lastAttemptMs: now, delivered: true } },
982
+ });
983
+
984
+ // Record the attempt immediately so a crashed process cannot spin, but do NOT
985
+ // consume the band.
986
+ const gaveUp = attempts + 1 >= MAX_DELIVERY_ATTEMPTS;
987
+ persist({
988
+ notified_bands: gaveUp
989
+ ? [...prior.notified_bands, ...crossed].sort((a, b) => a - b)
990
+ : prior.notified_bands,
991
+ delivery: {
992
+ ...prior.delivery,
993
+ [String(topBand)]: { attempts: attempts + 1, lastAttemptMs: now, delivered: false, gaveUp },
994
+ },
995
+ });
996
+ if (gaveUp) {
997
+ log(
998
+ "error",
999
+ `band ${topBand}% notice ABANDONED after ${MAX_DELIVERY_ATTEMPTS} delivery attempts — the supervisor was never told. ` +
1000
+ "This is a delivery outage, not a quiet period: check the org credential and channel.resolveOrCreateDm."
1001
+ );
1002
+ }
263
1003
 
264
1004
  const message = formatNotice(topBand, status);
1005
+ const recipient = status.supervisorMemberId
1006
+ ? { memberId: status.supervisorMemberId, role: "REVIEWER" }
1007
+ : null;
1008
+ if (!recipient) {
1009
+ log(
1010
+ "error",
1011
+ `band ${topBand}% reached and this seat has NO supervisor edge (the mandate body carries no REVIEWER ` +
1012
+ `collaborator) — the notice has nowhere to go and is deliberately NOT being sent to the owner. ${message}`
1013
+ );
1014
+ // Nothing can be delivered, so retrying every 5 minutes forever would only
1015
+ // spam the log. Consume the band; the missing edge is a provisioning fact
1016
+ // doctor reports separately.
1017
+ markNotified();
1018
+ return { notified: true, band: topBand, status, message, recipient: null, commit: () => {}, delivered: false };
1019
+ }
1020
+ log("warn", `band ${topBand}% → notifying supervisor ${recipient.memberId}: ${message}`);
1021
+
265
1022
  const notifyFn = deps && typeof deps.notify === "function" ? deps.notify : null;
266
1023
  if (notifyFn) {
267
- try { notifyFn({ band: topBand, status, message }); } catch { /* notice is best-effort */ }
1024
+ // An injected `notify` IS the delivery leg (tests, and any caller that wires
1025
+ // one). A throw means it did not arrive, so the band stays uncommitted.
1026
+ try {
1027
+ notifyFn({ band: topBand, status, message, recipient });
1028
+ markNotified();
1029
+ return { notified: true, band: topBand, status, message, recipient, commit: () => {}, delivered: true };
1030
+ } catch (err) {
1031
+ log("error", `band ${topBand}% notice threw on delivery (${err && err.message ? err.message : err}) — NOT marking the band notified; it will be retried`);
1032
+ return { notified: true, band: topBand, status, message, recipient, commit: markNotified, delivered: false };
1033
+ }
268
1034
  }
269
- return { notified: true, band: topBand, status, message };
1035
+
1036
+ // No injected delivery: the caller (lib/budget-escalate.mjs) owns the wire and
1037
+ // MUST call `commit()` once the notice is actually delivered.
1038
+ return { notified: true, band: topBand, status, message, recipient, commit: markNotified, delivered: false, attempts: attempts + 1 };
270
1039
  }
271
1040
 
272
1041
  function formatNotice(band, status) {
273
1042
  const usd = status.spentUSD.toFixed(2);
274
1043
  const cap = status.capUSD.toFixed(2);
1044
+ const envelope = status.envelope && status.envelope.monthUSD != null
1045
+ ? ` Seat envelope $${status.envelope.monthUSD.toFixed(2)}/month (${status.capSource}); month-to-date $${status.month.spentUSD.toFixed(2)} (${status.monthPct}%).`
1046
+ : " NO org-funded envelope is published for this seat — only the seat's own local cap binds.";
1047
+ if (band >= 150) {
1048
+ return `Seat spend at ${status.pct}% of its ceiling ($${usd} / $${cap} today). REFUSING new sessions that are not a direct human reply; inbox replies continue.${envelope}`;
1049
+ }
1050
+ if (band >= 125) {
1051
+ return `Seat spend at ${status.pct}% ($${usd} / $${cap} today). OUTCOME obligations SUSPENDED (budget_breach drift recorded); inline + offline-safe cadences continue.${envelope}`;
1052
+ }
275
1053
  if (band >= 100) {
276
- return `Daily spend cap reached: $${usd} / $${cap} (${status.pct}%). Entering essential-only mode — inbox replies continue; backlog + scheduled work deferred until UTC midnight.`;
1054
+ return `Seat spend at ${status.pct}% ($${usd} / $${cap} today). Degrading: cheapest model class, sub-agent fan-out capped at 1, self-directed work stopped. Inbox replies continue.${envelope}`;
277
1055
  }
278
- return `Daily spend at ${band}%: $${usd} / $${cap} (${status.pct}%).`;
1056
+ return `Seat spend at ${status.pct}% ($${usd} / $${cap} today) — heads-up only, nothing is degraded yet.${envelope}`;
279
1057
  }