@cohortapp/agent-sdk 2.11.13 → 2.11.15

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.
@@ -1086,3 +1086,99 @@ function formatNotice(band, status) {
1086
1086
  }
1087
1087
  return `Seat spend at ${status.pct}% ($${usd} / $${cap} today) — heads-up only, nothing is degraded yet.${envelope}`;
1088
1088
  }
1089
+
1090
+ // ---------------------------------------------------------------------------
1091
+ // Rolling usage window (subscription seats: TOKEN VOLUME, not dollars)
1092
+ // ---------------------------------------------------------------------------
1093
+ //
1094
+ // A Max seat has no dollar meter; what it can exhaust is a rolling token window
1095
+ // (≈5-hour session + weekly). The exact caps are UNPUBLISHED, so pacing is
1096
+ // DEFAULT-OFF: with GOV_WINDOW_5H_TOKENS / GOV_WEEK_TOKENS unset (0),
1097
+ // windowBand() is always "ok" and nothing paces — but the rolling volume is
1098
+ // still COMPUTED and surfaced so an operator can watch usage climb and calibrate
1099
+ // the caps. Reads the SAME cost-tracking ledger dailyStatus uses; "tokens" here
1100
+ // is input+output+cache summed over the window. The hard safety net remains
1101
+ // rate-guard's usage-limit recognition (recordUsageLimit holds the breaker until
1102
+ // reset); this is the proactive, opt-in layer above it.
1103
+
1104
+ const WINDOW_5H_MS = 5 * 60 * 60 * 1000;
1105
+ const WINDOW_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
1106
+ export const WINDOW_5H_TOKEN_CAP = Math.max(0, parseInt(process.env.GOV_WINDOW_5H_TOKENS || "0", 10) || 0);
1107
+ export const WINDOW_WEEK_TOKEN_CAP = Math.max(0, parseInt(process.env.GOV_WEEK_TOKENS || "0", 10) || 0);
1108
+ export const WINDOW_APPROACH_FRACTION = (() => {
1109
+ const f = parseFloat(process.env.GOV_WINDOW_APPROACH || "0.8");
1110
+ return f > 0 && f < 1 ? f : 0.8;
1111
+ })();
1112
+
1113
+ function rowTokens(r) {
1114
+ return (
1115
+ (Number(r.input_tokens) || 0) +
1116
+ (Number(r.output_tokens) || 0) +
1117
+ (Number(r.cache_read_input_tokens) || 0) +
1118
+ (Number(r.cache_creation_input_tokens) || 0)
1119
+ );
1120
+ }
1121
+
1122
+ /**
1123
+ * Rolling token volume + session count over the last 5 hours and 7 days, from
1124
+ * the cost-tracking ledger. Never throws (a missing/unreadable ledger → zeros).
1125
+ */
1126
+ export function rollingWindowUsage(deps) {
1127
+ const now = clock(deps)();
1128
+ const dir = ledgerDir(deps);
1129
+ let t5 = 0, s5 = 0, t7 = 0, s7 = 0;
1130
+ let files = [];
1131
+ try {
1132
+ files = readdirSync(dir).filter((f) => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f)).sort().slice(-8);
1133
+ } catch {
1134
+ return { window5hTokens: 0, window5hSessions: 0, week7dTokens: 0, week7dSessions: 0 };
1135
+ }
1136
+ for (const f of files) {
1137
+ let body;
1138
+ try { body = readFileSync(join(dir, f), "utf-8"); } catch { continue; }
1139
+ for (const line of body.split("\n")) {
1140
+ if (!line.trim()) continue;
1141
+ let r;
1142
+ try { r = JSON.parse(line); } catch { continue; }
1143
+ const at = r && r.ts ? Date.parse(r.ts) : NaN;
1144
+ if (!Number.isFinite(at)) continue;
1145
+ const dt = now - at;
1146
+ if (dt < 0 || dt > WINDOW_WEEK_MS) continue;
1147
+ const tok = rowTokens(r);
1148
+ t7 += tok; s7 += 1;
1149
+ if (dt <= WINDOW_5H_MS) { t5 += tok; s5 += 1; }
1150
+ }
1151
+ }
1152
+ return { window5hTokens: t5, window5hSessions: s5, week7dTokens: t7, week7dSessions: s7 };
1153
+ }
1154
+
1155
+ /**
1156
+ * The usage-window band: "ok" | "approaching" | "at". DEFAULT-OFF — an unset
1157
+ * (0) cap yields "ok" for that horizon, so the fleet paces on the window only
1158
+ * once an operator has calibrated GOV_WINDOW_5H_TOKENS / GOV_WEEK_TOKENS. The
1159
+ * band is the WORSE of the 5h and weekly horizons.
1160
+ */
1161
+ export function windowBand(deps) {
1162
+ const u = rollingWindowUsage(deps);
1163
+ // Caps come from deps first (tests / a per-seat override), else the env consts.
1164
+ const cap5 = deps && deps.cap5h != null ? deps.cap5h : WINDOW_5H_TOKEN_CAP;
1165
+ const cap7 = deps && deps.capWeek != null ? deps.capWeek : WINDOW_WEEK_TOKEN_CAP;
1166
+ const frac = deps && deps.approachFraction != null ? deps.approachFraction : WINDOW_APPROACH_FRACTION;
1167
+ const bandFor = (used, cap) => {
1168
+ if (!cap || cap <= 0) return "ok";
1169
+ if (used >= cap) return "at";
1170
+ if (used >= cap * frac) return "approaching";
1171
+ return "ok";
1172
+ };
1173
+ const rank = { ok: 0, approaching: 1, at: 2 };
1174
+ const b5 = bandFor(u.window5hTokens, cap5);
1175
+ const b7 = bandFor(u.week7dTokens, cap7);
1176
+ const band = rank[b5] >= rank[b7] ? b5 : b7;
1177
+ return {
1178
+ band,
1179
+ ...u,
1180
+ cap5h: cap5 || null,
1181
+ capWeek: cap7 || null,
1182
+ active: cap5 > 0 || cap7 > 0,
1183
+ };
1184
+ }
@@ -18,6 +18,8 @@ import {
18
18
  readNotified,
19
19
  BANDS,
20
20
  DEFAULT_DAILY_SPEND_CAP_USD,
21
+ rollingWindowUsage,
22
+ windowBand,
21
23
  } from "./budget-guard.mjs";
22
24
 
23
25
  const FIXED = Date.UTC(2026, 5, 9, 12, 0, 0); // 2026-06-09T12:00:00Z
@@ -363,3 +365,63 @@ test("dailyStatus: a subscription-auth seat STILL bands on session volume (a rea
363
365
  assert.equal(sub.mode, "degraded");
364
366
  } finally { await rm(dir); }
365
367
  });
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // rolling usage window (token volume) + windowBand (default-off pacing)
371
+ // ---------------------------------------------------------------------------
372
+
373
+ function seedWindowLedger(dir, rows) {
374
+ // rows: [{ minsAgo, tokens }] written to today's + yesterday's files by ts.
375
+ const now = FIXED;
376
+ const byFile = {};
377
+ for (const r of rows) {
378
+ const at = new Date(now - r.minsAgo * 60_000);
379
+ const day = at.toISOString().slice(0, 10);
380
+ (byFile[day] ||= []).push(JSON.stringify({
381
+ ts: at.toISOString(),
382
+ total_cost_usd: 0.1, model: "sonnet", cadence: "x",
383
+ input_tokens: r.tokens, output_tokens: 0,
384
+ }));
385
+ }
386
+ for (const [day, lines] of Object.entries(byFile)) {
387
+ writeFileSync(join(dir, `${day}.jsonl`), lines.join("\n") + "\n");
388
+ }
389
+ }
390
+
391
+ test("rollingWindowUsage sums tokens/sessions in the 5h and 7d windows", async () => {
392
+ const dir = await makeLedgerDir();
393
+ try {
394
+ seedWindowLedger(dir, [
395
+ { minsAgo: 10, tokens: 1000 }, // in 5h + 7d
396
+ { minsAgo: 60, tokens: 2000 }, // in 5h + 7d
397
+ { minsAgo: 400, tokens: 500 }, // >5h (400m), in 7d
398
+ { minsAgo: 60 * 24 * 8, tokens: 9999 }, // >7d — excluded
399
+ ]);
400
+ const u = rollingWindowUsage({ ledgerDir: dir, now: clk });
401
+ assert.equal(u.window5hTokens, 3000);
402
+ assert.equal(u.window5hSessions, 2);
403
+ assert.equal(u.week7dTokens, 3500);
404
+ assert.equal(u.week7dSessions, 3);
405
+ } finally { await rm(dir); }
406
+ });
407
+
408
+ test("windowBand is DEFAULT-OFF: no caps → always 'ok' however high usage climbs", async () => {
409
+ const dir = await makeLedgerDir();
410
+ try {
411
+ seedWindowLedger(dir, [{ minsAgo: 5, tokens: 10_000_000 }]);
412
+ const b = windowBand({ ledgerDir: dir, now: clk }); // no caps
413
+ assert.equal(b.band, "ok");
414
+ assert.equal(b.active, false);
415
+ assert.equal(b.window5hTokens, 10_000_000);
416
+ } finally { await rm(dir); }
417
+ });
418
+
419
+ test("windowBand bands 'approaching' then 'at' once a 5h cap is calibrated", async () => {
420
+ const dir = await makeLedgerDir();
421
+ try {
422
+ seedWindowLedger(dir, [{ minsAgo: 5, tokens: 8500 }]);
423
+ assert.equal(windowBand({ ledgerDir: dir, now: clk, cap5h: 10_000 }).band, "approaching"); // 85% of 10k
424
+ assert.equal(windowBand({ ledgerDir: dir, now: clk, cap5h: 8000 }).band, "at"); // over 8k
425
+ assert.equal(windowBand({ ledgerDir: dir, now: clk, cap5h: 100_000 }).band, "ok"); // well under
426
+ } finally { await rm(dir); }
427
+ });
@@ -130,6 +130,35 @@ function voiceRules(a) {
130
130
  ];
131
131
  }
132
132
 
133
+ /**
134
+ * Message-craft doctrine — the "how you shape an outbound message" half of the
135
+ * voice, injected into BOTH prompt-assembly paths so the two planes write the
136
+ * same way: the full-session prompt (scripts/daemon/prompt-builder.mjs
137
+ * #buildPrompt) and the quick-reply system prompt (scripts/daemon/responder.mjs
138
+ * #realGenerateResponse).
139
+ *
140
+ * Deliberately a separately-exported CONSTANT rather than part of voiceRules():
141
+ * the responder does not render the persona block — it calls neither renderPersona
142
+ * nor voiceRules — so folding it in would reach only full sessions and leave every
143
+ * reactive quick reply unshaped. As framework code it also survives per-seat
144
+ * CLAUDE.md drift, where the same doctrine lives as human-readable scaffold data.
145
+ *
146
+ * Phrased "distil by default; structure only when the message must be large" so it
147
+ * does NOT contradict the scaffold's `No unsolicited structure` rule
148
+ * (scaffold/CLAUDE.md `## Communication Rules`): headings/bullets are for the
149
+ * genuinely large message, never decoration on a small one. The attach-don't-dump
150
+ * bullet leans on mechanisms already sanctioned elsewhere in the prompt (the PDF
151
+ * builder, Slack upload, email --attachment) and on the standing "never reference a
152
+ * local file path" rule.
153
+ */
154
+ export const MESSAGE_CRAFT = [
155
+ "How you shape a message:",
156
+ "- Distil to what matters, by default. Lead with the answer, the decision, or the ask; cut throat-clearing, restatement of the question, and background the reader already has. Most replies are a few sentences — send those as a few sentences, and keep them scannable.",
157
+ "- Offer depth, don't front-load it. Give the sharp version and add \"I can go deeper on X if useful\" rather than dumping every detail pre-emptively.",
158
+ "- When a message genuinely must be long, FORMAT it for legibility: a short intro line, then short paragraphs, a heading, or a few bullets for the parts that actually are a list. Structure serves a large message; it never decorates a small one, and it is never a wall of text.",
159
+ "- When the content is genuinely verbose — a full memo or report, a long analysis, a large table or dataset — do NOT dump it into the chat. Attach it as a document and put a two-to-three-line summary plus the attachment in the message: generate a branded PDF (scripts/pdf-generation/build-document.mjs, or `npm run pdf:memo -- --input <file.md>`) or upload the file (Slack: scripts/slack-upload-v2.py; email: send with --attachment). Never reference a local file path in an outbound message.",
160
+ ].join("\n");
161
+
133
162
  /**
134
163
  * Render the persona block from resolved config. Pure — no I/O, never throws.
135
164
  *
@@ -4,7 +4,7 @@ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
 
7
- import { renderPersona, loadPersonaBlock } from "./persona.mjs";
7
+ import { renderPersona, loadPersonaBlock, MESSAGE_CRAFT } from "./persona.mjs";
8
8
 
9
9
  const AGENT = {
10
10
  firstName: "Jamie",
@@ -100,6 +100,31 @@ test("junk values in list fields are dropped, not rendered", () => {
100
100
  assert.doesNotMatch(out, /\[object Object\]/);
101
101
  });
102
102
 
103
+ // The message-craft doctrine is a separately-exported constant (NOT part of the
104
+ // persona block) precisely so the responder — which never renders the persona —
105
+ // can inject the identical string. These pin the substance both planes rely on.
106
+ test("MESSAGE_CRAFT carries the distil-by-default / format-when-large / attach-when-verbose doctrine", () => {
107
+ // Distil by default; most replies are short.
108
+ assert.match(MESSAGE_CRAFT, /Distil to what matters, by default/);
109
+ assert.match(MESSAGE_CRAFT, /Lead with the answer, the decision, or the ask/);
110
+ // Offer depth on request rather than front-loading.
111
+ assert.match(MESSAGE_CRAFT, /Offer depth, don't front-load it/);
112
+ // Format only when the message must be large — never a wall of text.
113
+ assert.match(MESSAGE_CRAFT, /when a message genuinely must be long, FORMAT it/i);
114
+ assert.match(MESSAGE_CRAFT, /never a wall of text/);
115
+ // Attach verbose content with a short summary rather than dumping it.
116
+ assert.match(MESSAGE_CRAFT, /do NOT dump it into the chat/);
117
+ assert.match(MESSAGE_CRAFT, /two-to-three-line summary/);
118
+ });
119
+
120
+ test("MESSAGE_CRAFT names the REAL attach mechanisms and forbids local paths", () => {
121
+ assert.match(MESSAGE_CRAFT, /scripts\/pdf-generation\/build-document\.mjs/);
122
+ assert.match(MESSAGE_CRAFT, /npm run pdf:memo/);
123
+ assert.match(MESSAGE_CRAFT, /scripts\/slack-upload-v2\.py/);
124
+ assert.match(MESSAGE_CRAFT, /--attachment/);
125
+ assert.match(MESSAGE_CRAFT, /Never reference a local file path/);
126
+ });
127
+
103
128
  test("loadPersonaBlock reads an agent repo, and fails open on a broken config", () => {
104
129
  const dir = mkdtempSync(join(tmpdir(), "persona-"));
105
130
  try {
@@ -504,6 +504,16 @@ export function admit(req = {}, deps) {
504
504
  if (mode === "suspended" && (source === "backlog" || source === "cadence")) {
505
505
  return decide(DEFER, "budget suspended (>=125% of the seat envelope): deferring non-inbox work");
506
506
  }
507
+ // USAGE-WINDOW PACING (default-off; only active once GOV_WINDOW_* caps are
508
+ // calibrated — see budget-guard.windowBand). At the rolling window's limit,
509
+ // pause self-directed work so a burst spreads across windows instead of
510
+ // exhausting one; a direct human reply is never paced. "approaching" is folded
511
+ // into the concurrency ceiling below (throttle, don't stop). `windowBand`
512
+ // defaults to "ok" so an uncalibrated seat behaves exactly as before.
513
+ const windowBand = req.windowBand || "ok";
514
+ if (windowBand === "at" && !humanReply) {
515
+ return decide(DEFER, `usage window at limit: deferring ${source} work so it spreads across windows; direct human replies continue`);
516
+ }
507
517
  // NOTE: `degraded` (>=100% / unfunded) no longer hard-stops backlog. Owner
508
518
  // instruction (2026-08-25): THROTTLE, don't stop — the seat keeps a thin
509
519
  // trickle of self-directed work so autonomous progress never fully halts,
@@ -524,10 +534,14 @@ export function admit(req = {}, deps) {
524
534
  // ABOVE the REACTIVE_RESERVE math: reserve keeps steady-state headroom; this
525
535
  // clamps to a trickle for the seconds a human is actually waiting.
526
536
  const reactiveBusy = numField(d.reactiveInFlight, 0) > 0;
537
+ // "approaching" the usage window → throttle self-directed work toward a trickle
538
+ // (like a reactive turn) so the seat glides into the window rather than slamming
539
+ // it. Default-off: windowBand is "ok" unless GOV_WINDOW_* caps are set.
540
+ const windowApproaching = windowBand === "approaching";
527
541
  let sourceCeiling;
528
542
  if (humanReply) {
529
543
  sourceCeiling = effectiveMax;
530
- } else if (reactiveBusy) {
544
+ } else if (reactiveBusy || windowApproaching) {
531
545
  sourceCeiling = Math.min(effectiveMax, REACTIVE_INFLIGHT_BACKLOG_MAX);
532
546
  if (mode === "degraded" && source === "backlog") {
533
547
  sourceCeiling = Math.min(sourceCeiling, DEGRADED_BACKLOG_MAX);
@@ -540,6 +554,8 @@ export function admit(req = {}, deps) {
540
554
  if (liveCount >= sourceCeiling) {
541
555
  const why = reactiveBusy
542
556
  ? `reactive turn in flight: self-directed ${source} yields the subscription, capped to ${sourceCeiling} (${liveCount} live)`
557
+ : windowApproaching
558
+ ? `usage window approaching: self-directed ${source} throttled to ${sourceCeiling} to spread across windows (${liveCount} live)`
543
559
  : mode === "degraded" && source === "backlog"
544
560
  ? `budget degraded: self-directed backlog throttled to ${sourceCeiling} (${liveCount} live; inbox unaffected)`
545
561
  : throttleCeiling < dMax
@@ -180,6 +180,33 @@ test("admit: one backlog session is still allowed alongside a reply (cap 1, not
180
180
  assert.equal(r.decision, DECISIONS.ADMIT);
181
181
  });
182
182
 
183
+ // ---------------------------------------------------------------------------
184
+ // Usage-window pacing (default-off; driven by req.windowBand from budget-guard)
185
+ // ---------------------------------------------------------------------------
186
+
187
+ test("admit: windowBand 'at' DEFERS self-directed work; a human reply is never paced", () => {
188
+ assert.equal(admit({ source: "backlog", windowBand: "at" }, deps()).decision, DECISIONS.DEFER);
189
+ assert.equal(admit({ source: "cadence", windowBand: "at" }, deps()).decision, DECISIONS.DEFER);
190
+ // inbox / humanReply continue at the window limit
191
+ assert.equal(admit({ source: "inbox", windowBand: "at" }, deps()).decision, DECISIONS.ADMIT);
192
+ assert.equal(admit({ source: "backlog", humanReply: true, windowBand: "at" }, deps()).decision, DECISIONS.ADMIT);
193
+ });
194
+
195
+ test("admit: windowBand 'approaching' throttles backlog to a trickle (QUEUE), inbox unaffected", () => {
196
+ // 1 already live → backlog capped to REACTIVE_INFLIGHT_BACKLOG_MAX(1) → QUEUE
197
+ const r = admit({ source: "backlog", windowBand: "approaching" }, deps({ liveClaude: { count: 1, rssMB: 300 } }));
198
+ assert.equal(r.decision, DECISIONS.QUEUE);
199
+ assert.match(r.reason, /usage window approaching/);
200
+ // inbox still admits at the same live count
201
+ assert.equal(admit({ source: "inbox", windowBand: "approaching" }, deps({ liveClaude: { count: 1, rssMB: 300 } })).decision, DECISIONS.ADMIT);
202
+ });
203
+
204
+ test("admit: windowBand default (undefined/'ok') changes nothing — pacing is off by default", () => {
205
+ // 3 live, no window signal → backlog still ADMITs (reserve leaves 5)
206
+ assert.equal(admit({ source: "backlog" }, deps({ liveClaude: { count: 3, rssMB: 900 } })).decision, DECISIONS.ADMIT);
207
+ assert.equal(admit({ source: "backlog", windowBand: "ok" }, deps({ liveClaude: { count: 3, rssMB: 900 } })).decision, DECISIONS.ADMIT);
208
+ });
209
+
183
210
  test("admit: throttle ceiling clamps effectiveMax below dynamicMax → QUEUE earlier", () => {
184
211
  // dynamicMax 8 but soft-throttle pinned the ceiling to 3; 3 live → QUEUE.
185
212
  const r = admit({ source: "backlog" }, deps({ throttleCeiling: 3, liveClaude: { count: 3, rssMB: 1000 } }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cohortapp/agent-sdk",
3
- "version": "2.11.13",
3
+ "version": "2.11.15",
4
4
  "description": "Cohort Agent SDK — autonomous AI colleague runtime. Deploy senior AI colleagues on dedicated Mac minis, wired to the Cohort operating surface.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -103,6 +103,16 @@ message (Slack, email, WhatsApp, SMS, voice, drafted artefacts):
103
103
  - **Disagree when warranted.** Don't soften real concerns into politeness.
104
104
  - **Brevity is a feature.** If the answer is one sentence, send one
105
105
  sentence. Do not pad to look thorough.
106
+ - **Distil by default; format only when large.** Lead with the answer,
107
+ the decision, or the ask; offer more detail on request rather than
108
+ front-loading it. When a message genuinely must be long, format it for
109
+ legibility (a short intro line, then short paragraphs or a few bullets)
110
+ — never a wall of text. When the content is genuinely verbose (a full
111
+ report, a long analysis, a large table), do NOT dump it in the chat:
112
+ attach it as a document and give a two-to-three-line summary. Generate a
113
+ branded PDF (`npm run pdf:memo -- --input <file.md>`) or upload the file
114
+ (Slack: `scripts/slack-upload-v2.py`; email: send with `--attachment`).
115
+ See Document Sharing below.
106
116
 
107
117
  System-style output (status indicators, checklists, formal headers) is
108
118
  opt-in — only when the user explicitly requests it or the medium clearly
@@ -601,8 +601,10 @@ export function startConsumer(opts = {}) {
601
601
  // as a human reply, and every cadence tick arrives as `source:"cadence"`.
602
602
  // Two gates, one predicate, or the rung silences the inbox on the second
603
603
  // hop instead of the first.
604
+ let windowBand;
605
+ try { windowBand = budgetGuardModule.windowBand?.({ agentRoot })?.band; } catch { /* default-off */ }
604
606
  const adm = governor.admit(
605
- { source: "cadence", mode, humanReply: cadence ? isHumanLaneCadence(cadence) : false },
607
+ { source: "cadence", mode, humanReply: cadence ? isHumanLaneCadence(cadence) : false, windowBand },
606
608
  governor.defaultDeps({ agentRoot })
607
609
  );
608
610
  if (adm.decision !== "ADMIT") return { admit: false, reason: adm.reason || adm.decision };
@@ -191,11 +191,45 @@ test("reconcile blocks a marker that is older than the freshness window", async
191
191
  } finally { await cleanup(dir); }
192
192
  });
193
193
 
194
+ test("resume reconcile DEFERS (never re-storms) when the backlog budget is 0 (reactive-only seat)", async () => {
195
+ // DAEMON_MAX_CONCURRENT=3 → backlogCap = 3 - RESERVED_INBOX_SLOTS(3) = 0, so a
196
+ // reactive-only seat must resume NOTHING on boot — the fresh-seat storm root.
197
+ process.env.DAEMON_MAX_CONCURRENT = "3";
198
+ const { mod, dir } = await freshDispatcher();
199
+ try {
200
+ plantMarker(dir, "s-a", { itemId: "BL-A", sourceFile: "q.yaml", startedAt: Date.now() });
201
+ plantMarker(dir, "s-b", { itemId: "BL-B", sourceFile: "q.yaml", startedAt: Date.now() });
202
+ let spawned = 0;
203
+ const stats = mod.reconcileResumePending({ spawnResume: () => { spawned++; } });
204
+ assert.equal(spawned, 0, "no resume spawns on a budget-0 seat");
205
+ assert.equal(stats.resumed, 0);
206
+ assert.equal(stats.deferred, 2, "both live markers deferred, not blocked/expired");
207
+ // markers are LEFT on disk (deferred, not retired) for a later reconcile
208
+ const left = readdirSync(join(dir, "state/sessions/resume-pending")).filter((f) => f.endsWith(".json"));
209
+ assert.equal(left.length, 2, "deferred markers persist");
210
+ } finally { delete process.env.DAEMON_MAX_CONCURRENT; await cleanup(dir); }
211
+ });
212
+
213
+ test("resume reconcile is GATED (resumes nothing) while the shared breaker is open", async () => {
214
+ const { mod, dir } = await freshDispatcher();
215
+ try {
216
+ mod.setGovernanceForTests({ rateGuard: { checkRateLimit: () => ({ allowed: false, retryAt: Date.now() + 60_000 }) } });
217
+ plantMarker(dir, "s-x", { itemId: "BL-X", sourceFile: "q.yaml", startedAt: Date.now() });
218
+ let spawned = 0;
219
+ const stats = mod.reconcileResumePending({ spawnResume: () => { spawned++; } });
220
+ assert.equal(spawned, 0, "an open breaker (e.g. a usage-limit hold) blocks all resume");
221
+ assert.equal(stats.resumed, 0);
222
+ assert.equal(stats.scanned, 0, "returns before scanning when the breaker is open");
223
+ // the marker is untouched, ready for the next reconcile after the breaker clears
224
+ assert.equal(readdirSync(join(dir, "state/sessions/resume-pending")).filter((f) => f.endsWith(".json")).length, 1);
225
+ } finally { await cleanup(dir); }
226
+ });
227
+
194
228
  test("reconcile on an empty resume-pending dir is a no-op", async () => {
195
229
  const { mod, dir } = await freshDispatcher();
196
230
  try {
197
231
  const stats = mod.reconcileResumePending({ spawnResume: () => { throw new Error("should not spawn"); } });
198
- assert.deepEqual(stats, { scanned: 0, resumed: 0, blocked: 0, expired: 0 });
232
+ assert.deepEqual(stats, { scanned: 0, resumed: 0, blocked: 0, expired: 0, deferred: 0 });
199
233
  } finally { await cleanup(dir); }
200
234
  });
201
235
 
@@ -75,7 +75,11 @@ function admitFor(source, priority) {
75
75
  console.warn(`[dispatcher] budget ${st.mode}: ${st.pct}% of $${st.capUSD}/day (${st.capSource}) — gating ${source} work`);
76
76
  }
77
77
  } catch { /* budget read best-effort */ }
78
- return governor.admit({ source, priority, mode }, governor.defaultDeps({ agentRoot: AGENT_REPO_DIR }));
78
+ // Usage-window band (default-off unless GOV_WINDOW_* caps set) → the governor
79
+ // paces self-directed work as the rolling window fills. Best-effort.
80
+ let windowBand;
81
+ try { windowBand = budgetGuard.windowBand?.({ agentRoot: AGENT_REPO_DIR })?.band; } catch { /* */ }
82
+ return governor.admit({ source, priority, mode, windowBand }, governor.defaultDeps({ agentRoot: AGENT_REPO_DIR }));
79
83
  } catch {
80
84
  return { decision: "ADMIT", reason: "governor-error-fail-open", snapshot: {} };
81
85
  }
@@ -499,13 +503,32 @@ export function resetActiveSessions(opts = {}) {
499
503
  */
500
504
  export function reconcileResumePending(opts = {}) {
501
505
  const now = (typeof opts.now === "function" ? opts.now : Date.now)();
502
- const stats = { scanned: 0, resumed: 0, blocked: 0, expired: 0 };
506
+ const stats = { scanned: 0, resumed: 0, blocked: 0, expired: 0, deferred: 0 };
503
507
  let files = [];
504
508
  try {
505
509
  if (!existsSync(RESUME_PENDING_DIR)) return stats;
506
510
  files = readdirSync(RESUME_PENDING_DIR).filter((f) => f.endsWith(".json") && !f.endsWith(".tmp"));
507
511
  } catch { return stats; }
508
512
 
513
+ // ADMISSION GATE — resume must honour the same throttle as any backlog spawn,
514
+ // or a stormed seat's leftover markers bypass it and re-storm on every boot
515
+ // (the fresh-seat backlog-storm root). Two guards:
516
+ // (1) if the shared breaker is open (a usage/rate limit is holding spawns),
517
+ // resume NOTHING — resuming would hammer a drained pool the moment it
518
+ // re-opens. Markers stay for the next reconcile after the breaker clears.
519
+ // (2) cap resumes this pass to the BACKLOG concurrency (in-memory, race-free
520
+ // — unlike the governor's ps count). DAEMON_MAX_CONCURRENT=3 →
521
+ // RESERVED_INBOX_SLOTS(3) → budget 0 → a reactive-only seat resumes
522
+ // nothing on boot (the item stays re-deliverable / the marker persists).
523
+ try {
524
+ const rb = rateGuard.checkRateLimit(RATE_PROVIDER, { agentRoot: AGENT_REPO_DIR });
525
+ if (rb && rb.allowed === false) {
526
+ logSession({ event: "resume_reconcile_gated", reason: "breaker_open", retry_at: rb.retryAt || null, markers: files.length });
527
+ return stats;
528
+ }
529
+ } catch { /* fail-open — a breaker read error must not strand recovery */ }
530
+ const resumeBudget = Math.max(0, MAX_CONCURRENT - RESERVED_INBOX_SLOTS - activeSessions.size);
531
+
509
532
  for (const file of files) {
510
533
  const path = join(RESUME_PENDING_DIR, file);
511
534
  stats.scanned++;
@@ -554,6 +577,15 @@ export function reconcileResumePending(opts = {}) {
554
577
  continue;
555
578
  }
556
579
 
580
+ // Over the backlog budget for this pass → DEFER (leave the marker, do NOT
581
+ // bump attempts — a deferral is not a try). The next reconcile picks it up
582
+ // once slots free / the throttle lifts. This is what stops the re-storm.
583
+ if (stats.resumed >= resumeBudget) {
584
+ logSession({ event: "resume_deferred_throttle", sessionId: marker.sessionId, item_id: marker.itemId, budget: resumeBudget });
585
+ stats.deferred++;
586
+ continue;
587
+ }
588
+
557
589
  // Bump the attempt count on the marker BEFORE re-dispatch so a crash mid-
558
590
  // resume still advances toward the 3-strike cap (no infinite loop).
559
591
  marker.recoveryAttempts = attempts + 1;
@@ -573,7 +605,7 @@ export function reconcileResumePending(opts = {}) {
573
605
  }
574
606
  }
575
607
  if (stats.scanned > 0) {
576
- console.log(`[dispatcher] resume reconcile: ${stats.resumed} resumed, ${stats.blocked} blocked, ${stats.expired} expired (of ${stats.scanned})`);
608
+ console.log(`[dispatcher] resume reconcile: ${stats.resumed} resumed, ${stats.deferred} deferred, ${stats.blocked} blocked, ${stats.expired} expired (of ${stats.scanned})`);
577
609
  }
578
610
  return stats;
579
611
  }
@@ -7,7 +7,7 @@ import { readFileSync, readdirSync } from "fs";
7
7
  import { join } from "path";
8
8
  import { createRequire } from "node:module";
9
9
  import { compileContext } from "./context-compiler.mjs";
10
- import { renderPersona } from "../../lib/identity/persona.mjs";
10
+ import { renderPersona, MESSAGE_CRAFT } from "../../lib/identity/persona.mjs";
11
11
  import { wrapExternalContent } from "../../lib/security/external-content.mjs";
12
12
  import { isEnabled as orgEnabled } from "../../lib/org/client.mjs";
13
13
  import { recall as orgRecall } from "../../lib/org/knowledge.mjs";
@@ -876,6 +876,14 @@ export async function buildPrompt(item, classResult, options = {}) {
876
876
  parts.push(SKILLS_GUIDANCE);
877
877
  parts.push("");
878
878
 
879
+ // 1c. Message-craft doctrine — the shared "how you shape an outbound message"
880
+ // rule (distil by default; format only when the message must be large; attach
881
+ // rather than dump genuinely verbose content). Exported from persona.mjs and
882
+ // injected into the responder's quick-reply prompt too, so reactive replies and
883
+ // full sessions write outbound prose the same way.
884
+ parts.push(MESSAGE_CRAFT);
885
+ parts.push("");
886
+
879
887
  // 1a. Holding message warning — TOP OF PROMPT so Claude sees it before action instructions.
880
888
  // This is the most critical instruction in the prompt: prevents double-replies.
881
889
  // We repeat it at section 7a as well, immediately before the action block.
@@ -212,6 +212,28 @@ test("buildPrompt caps the number of injected org facts (bounded)", async () =>
212
212
  });
213
213
  });
214
214
 
215
+ // ---------------------------------------------------------------------------
216
+ // Message-craft doctrine — the shared "how you shape an outbound message" rule
217
+ // (distil by default; format only when the message must be large; attach
218
+ // genuinely verbose content). The SAME constant leads the responder's quick-reply
219
+ // system prompt, so reactive replies and full sessions write outbound prose the
220
+ // same way.
221
+ // ---------------------------------------------------------------------------
222
+
223
+ test("buildPrompt injects the message-craft doctrine (distil / format / attach)", async () => {
224
+ writeOrgConfig(null);
225
+ const prompt = await buildPrompt(ITEM, CLASS, { type: "inbox" });
226
+ assert.match(prompt, /How you shape a message:/);
227
+ assert.match(prompt, /Distil to what matters, by default/);
228
+ assert.match(prompt, /when a message genuinely must be long, FORMAT it/i);
229
+ assert.match(prompt, /do NOT dump it into the chat/);
230
+ // The real attach mechanism, not a vague "share it somewhere".
231
+ assert.match(prompt, /scripts\/pdf-generation\/build-document\.mjs/);
232
+ assert.match(prompt, /scripts\/slack-upload-v2\.py/);
233
+ // The rest of the prompt is unchanged.
234
+ assert.ok(prompt.includes("--- INCOMING MESSAGE ---"));
235
+ });
236
+
215
237
  test("buildPrompt states the acknowledgement was sent when it actually was", async () => {
216
238
  const prompt = await buildPrompt(ITEM, CLASS, {
217
239
  type: "inbox",
@@ -60,6 +60,13 @@ import { startTyping, stopTyping } from "./typing-registry.mjs";
60
60
  // (in short: a 60s `claude --print` spawn to write "let me look into it" lost
61
61
  // its own race 2 times in 3, and lost it silently).
62
62
  import { composeAck } from "./assurance.mjs";
63
+ // Shared message-craft doctrine (distil by default; format only when the message
64
+ // must be large; attach genuinely verbose content rather than dumping it). The
65
+ // SAME constant leads the full-session prompt in prompt-builder.mjs, so a reactive
66
+ // quick reply and a full inbox/backlog session shape outbound prose identically.
67
+ // This path never renders the persona block, which is why the doctrine is a
68
+ // separately-exported constant rather than part of voiceRules().
69
+ import { MESSAGE_CRAFT } from "../../lib/identity/persona.mjs";
63
70
 
64
71
  const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.url).pathname, "../..");
65
72
  const SONNET_MODEL = "claude-sonnet-4-6";
@@ -708,7 +715,9 @@ If it's informational, acknowledge appropriately.
708
715
 
709
716
  Keep responses focused — 1-4 sentences for simple items, up to a short paragraph for more nuanced ones.
710
717
  Match the sender's tone and urgency level.
711
- ${profile ? `\nSender profile:\n${profile}` : ""}`;
718
+ ${profile ? `\nSender profile:\n${profile}` : ""}
719
+
720
+ ${MESSAGE_CRAFT}`;
712
721
 
713
722
  const conversationHistory = await loadConversationHistory(item);
714
723