@cohortapp/agent-sdk 2.18.13 → 2.18.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.
Files changed (46) hide show
  1. package/bin/maestro.mjs +38 -1
  2. package/docs/runbooks/fleet-rollout.md +58 -7
  3. package/docs/runbooks/recovery-and-failover.md +18 -0
  4. package/lib/assurance/batch.mjs +353 -0
  5. package/lib/assurance/first-reply.mjs +423 -0
  6. package/lib/assurance/notice-voice.mjs +357 -0
  7. package/lib/assurance/plan-note.mjs +43 -0
  8. package/lib/assurance/room-budget.mjs +55 -6
  9. package/lib/cadence-failure-class.mjs +245 -0
  10. package/lib/claude-bin.mjs +26 -7
  11. package/lib/cli/doctor-checks.mjs +149 -1
  12. package/lib/comms/send-gate.mjs +59 -0
  13. package/lib/diagnostics/alerts.mjs +33 -0
  14. package/lib/engine/agents/usage.mjs +45 -0
  15. package/lib/engine/budget.mjs +293 -29
  16. package/lib/engine/cli.mjs +54 -5
  17. package/lib/engine/loop.mjs +30 -0
  18. package/lib/engine/output/json.mjs +26 -0
  19. package/lib/engine/wire/errors.mjs +179 -0
  20. package/lib/engine/wire/search.mjs +44 -8
  21. package/lib/identity/persona.mjs +31 -2
  22. package/lib/org/quota.mjs +27 -0
  23. package/lib/session/config.mjs +4 -0
  24. package/lib/session/identity.mjs +71 -7
  25. package/lib/session/launch-failure.mjs +251 -0
  26. package/lib/session/resume-target.mjs +86 -0
  27. package/lib/telemetry/alerts.mjs +94 -0
  28. package/lib/telemetry/collect.mjs +155 -2
  29. package/lib/upgrade/pinned-drift.mjs +467 -0
  30. package/package.json +1 -1
  31. package/scaffold/config/alerts.yaml +7 -0
  32. package/scripts/ci/check-cadence-prompts-exist.mjs +96 -0
  33. package/scripts/ci/check.mjs +3 -0
  34. package/scripts/daemon/agent-daemon.mjs +75 -5
  35. package/scripts/daemon/assurance.mjs +709 -44
  36. package/scripts/daemon/cadence-consumer.mjs +281 -34
  37. package/scripts/daemon/deliver.mjs +109 -0
  38. package/scripts/daemon/dispatcher.mjs +21 -3
  39. package/scripts/daemon/inbox-deferral.mjs +102 -9
  40. package/scripts/daemon/session-lock.mjs +41 -1
  41. package/scripts/emergency-stop.sh +114 -13
  42. package/scripts/fleet/rollout.mjs +256 -10
  43. package/scripts/healthcheck.sh +131 -33
  44. package/scripts/local-triggers/autoupdate.sh +144 -11
  45. package/scripts/resume-operations.sh +101 -6
  46. package/scripts/session/supervisor.mjs +198 -5
@@ -100,6 +100,28 @@ export function buildResult({ outcome, sessionId, durationMs, wire, session = nu
100
100
  modelTier: outcome.modelTier,
101
101
  requestIds: outcome.requestIds,
102
102
  costMicros: outcome.costMicros,
103
+ // W16 (hq a60b9595): what the gateway says ABOUT `costMicros` above.
104
+ // `enforcement` is the billing mode that produced it — only "on" means
105
+ // money moved, `null` means the endpoint did not say — so a consumer
106
+ // summing `costMicros` (or `total_cost_usd`) as spend must read this in
107
+ // the same breath. `shadowCostMicros` is what enforcement WOULD have
108
+ // charged, kept under its own name because folding a counterfactual into
109
+ // a charge is the misread the gateway split the fields to prevent; it is
110
+ // null in production today. `quotaSource` says why `quota` is empty when
111
+ // it is: the ledger answered and there is no allowance, the read failed,
112
+ // or nothing was read — three answers one `{}` used to collapse.
113
+ //
114
+ // SCOPE: these three have the SAME scope as `costMicros` above, on purpose.
115
+ // On a run with subagents cli.mjs replaces `costMicros` with the
116
+ // parent+children sum and replaces these three with the fold over the same
117
+ // set (agents/usage.mjs), so a parent served under "on" whose child was
118
+ // served under "shadow" reports "mixed" — never "on" beside a total that
119
+ // includes the child's shadow figure. Per-child modes are on
120
+ // `cohort.agents[]`. (`gatewayUsage` below does NOT follow that rule; its
121
+ // own comment says so.)
122
+ enforcement: outcome.enforcement ?? null,
123
+ shadowCostMicros: outcome.shadowCostMicros ?? null,
124
+ quotaSource: outcome.quotaSource ?? null,
103
125
  quota: outcome.quota,
104
126
  usage: outcome.usage,
105
127
  // CF-158: the gateway's own view of the same turns, off the terminal
@@ -151,6 +173,10 @@ export function buildSetupErrorResult({ sessionId, code, message, durationMs, wi
151
173
  modelTier: null,
152
174
  requestIds: [],
153
175
  costMicros: null,
176
+ // No request was made, so the endpoint stated nothing (W16).
177
+ enforcement: null,
178
+ shadowCostMicros: null,
179
+ quotaSource: null,
154
180
  quota: null,
155
181
  usage: emptyUsage("estimated"),
156
182
  // No request was made, so the gateway reported nothing (CF-158).
@@ -379,6 +379,14 @@ export function readCohortHeaders(headers) {
379
379
  // Headers never carry usage — only the terminal frame does (CF-158). The key
380
380
  // is present so the merged shape is uniform whether a frame arrived or not.
381
381
  usage: null,
382
+ // §4.6 defines no header for any of these three either: only the terminal
383
+ // frame — and the identical object on a non-streamed response — carries
384
+ // them. Present and null so a caller reads one shape whether a frame
385
+ // arrived or not, and so "the endpoint did not say" is never silently the
386
+ // same value as a mode it did say.
387
+ enforcement: null,
388
+ shadowCostMicros: null,
389
+ quotaSource: null,
382
390
  quota: {
383
391
  windows,
384
392
  creditsRemainingMicros: micros(credits, true),
@@ -400,6 +408,132 @@ function micros(v, signed) {
400
408
  return (signed ? /^-?\d+$/ : /^\d+$/).test(s) ? s : null;
401
409
  }
402
410
 
411
+ /**
412
+ * The billing modes the gateway names in the frame's `enforcement` field
413
+ * (design §4.8, shipped hq a60b9595). `on` is the only one under which a
414
+ * reported `cost_micros` is money that moved.
415
+ *
416
+ * A value outside this set reads as UNKNOWN (`null`), never as `"on"`: the
417
+ * field exists to say whether a figure is money, and promoting a word this
418
+ * client has never seen to `"on"` would put a counterfactual charge into a
419
+ * seat's spend — the very misread this contract was extended to prevent.
420
+ */
421
+ export const ENFORCEMENT_MODES = new Set(["off", "shadow", "on"]);
422
+
423
+ /**
424
+ * `enforcement` as one of the three modes, or null for absent, non-string or
425
+ * unrecognised. PURE, never throws.
426
+ * @param {unknown} v @returns {'off'|'shadow'|'on'|null}
427
+ */
428
+ export function readEnforcement(v) {
429
+ return typeof v === "string" && ENFORCEMENT_MODES.has(v) ? /** @type {any} */ (v) : null;
430
+ }
431
+
432
+ /**
433
+ * The four answers `quota_source` splits a previously-ambiguous empty `quota`
434
+ * object into (design §4.8):
435
+ *
436
+ * ledger the ledger answered — this seat genuinely has no included
437
+ * allowance (a money-only band reports no windows by design)
438
+ * unreadable the quota read FAILED. An empty quota here is not headroom.
439
+ * not_read admission is off, so nothing was read.
440
+ * not_admitted a defensive fourth answer no served request produces today:
441
+ * read it as "unknown", not as a state to branch on.
442
+ *
443
+ * An unrecognised value is also unknown, and is reported as such rather than
444
+ * being rounded to the nearest known one.
445
+ */
446
+ export const QUOTA_SOURCES = new Set(["ledger", "unreadable", "not_read", "not_admitted"]);
447
+
448
+ /** @param {unknown} v @returns {'ledger'|'unreadable'|'not_read'|'not_admitted'|null} */
449
+ export function readQuotaSource(v) {
450
+ return typeof v === "string" && QUOTA_SOURCES.has(v) ? /** @type {any} */ (v) : null;
451
+ }
452
+
453
+ /**
454
+ * `enforcement` as a CLOSED four-word vocabulary: one of the three modes, the
455
+ * client's own `"unknown"` when the endpoint stated something else, or null when
456
+ * it stated nothing. PURE, never throws.
457
+ *
458
+ * The fourth word exists so the seam does not erase the difference between "the
459
+ * endpoint said nothing" and "the endpoint said a word this client has never
460
+ * seen" — a gateway that ships a fourth mode would otherwise reach a seat as
461
+ * silence, and nobody would learn it had spoken. `"unknown"` can never be
462
+ * confused with `"on"`, which is the one guarantee that matters for money.
463
+ * @param {unknown} v @returns {'off'|'shadow'|'on'|'unknown'|null}
464
+ */
465
+ export function readEnforcementState(v) {
466
+ return v == null || v === "" ? null : readEnforcement(v) ?? "unknown";
467
+ }
468
+
469
+ /**
470
+ * `quota_source` as the same closed vocabulary: one of the four §4.8 answers,
471
+ * `"unknown"` for anything else stated, null for silence. PURE.
472
+ * @param {unknown} v @returns {'ledger'|'unreadable'|'not_read'|'not_admitted'|'unknown'|null}
473
+ */
474
+ export function readQuotaSourceState(v) {
475
+ return v == null || v === "" ? null : readQuotaSource(v) ?? "unknown";
476
+ }
477
+
478
+ /**
479
+ * Fold one request's stated `enforcement` into what a whole run has observed.
480
+ * PURE. Silence never changes what was already stated; a stated word this
481
+ * client does not know folds to `"unknown"` (distinct from "never stated");
482
+ * two different modes in one run fold to `"mixed"`.
483
+ * @param {'off'|'shadow'|'on'|'unknown'|'mixed'|null|undefined} prev
484
+ * @param {unknown} next
485
+ * @returns {'off'|'shadow'|'on'|'unknown'|'mixed'|null}
486
+ */
487
+ export function foldEnforcement(prev, next) {
488
+ return fold(prev, next, readEnforcement);
489
+ }
490
+
491
+ /**
492
+ * Fold one request's stated `quota_source` into what a whole run has observed.
493
+ * Same rules as {@link foldEnforcement}.
494
+ * @param {'ledger'|'unreadable'|'not_read'|'not_admitted'|'unknown'|'mixed'|null|undefined} prev
495
+ * @param {unknown} next
496
+ * @returns {'ledger'|'unreadable'|'not_read'|'not_admitted'|'unknown'|'mixed'|null}
497
+ */
498
+ export function foldQuotaSource(prev, next) {
499
+ return fold(prev, next, readQuotaSource);
500
+ }
501
+
502
+ /**
503
+ * Combine two ALREADY-FOLDED statements — each already one of the closed
504
+ * vocabulary's words, `"mixed"`, or null for "nothing stated". PURE.
505
+ *
506
+ * This is the fold one level up: a parent run folds its own requests with
507
+ * {@link foldEnforcement} (which reads RAW wire values), and then folds that
508
+ * result with each subagent's. Passing a folded `"mixed"` back through
509
+ * `foldEnforcement` would flatten it to `"unknown"`, because `"mixed"` is this
510
+ * client's word and not one the gateway ever sends.
511
+ *
512
+ * Silence never unsays a statement; two different statements are `"mixed"`.
513
+ * @template {string} T
514
+ * @param {T|'mixed'|null|undefined} a @param {T|'mixed'|null|undefined} b
515
+ * @returns {T|'mixed'|null}
516
+ */
517
+ export function combineStated(a, b) {
518
+ const x = a ?? null;
519
+ const y = b ?? null;
520
+ if (y === null) return /** @type {any} */ (x);
521
+ if (x === null) return /** @type {any} */ (y);
522
+ return /** @type {any} */ (x === y ? x : "mixed");
523
+ }
524
+
525
+ /**
526
+ * @param {any} prev @param {unknown} next @param {(v:unknown)=>string|null} read
527
+ * @returns {any}
528
+ */
529
+ function fold(prev, next, read) {
530
+ const before = prev ?? null;
531
+ if (next == null || next === "") return before;
532
+ const m = read(next) ?? "unknown";
533
+ if (before === null) return m;
534
+ return before === m ? before : "mixed";
535
+ }
536
+
403
537
  /** SSE event name of the gateway's terminal cost/quota frame. */
404
538
  export const COHORT_FRAME_EVENT = "cohort";
405
539
 
@@ -435,6 +569,31 @@ export const COHORT_FRAME_EVENT = "cohort";
435
569
  * nothing fails when it is absent. See docs/engine/eval.md §3; the fix for the
436
570
  * contract itself belongs to hq, which this repo does not edit.
437
571
  *
572
+ * ## W16 — `enforcement`, `shadow_cost_micros`, `quota_source` (hq a60b9595)
573
+ *
574
+ * The frame gained three fields, all normative in §4.8, all optional here:
575
+ *
576
+ * `enforcement` "off" | "shadow" | "on" — the billing mode that
577
+ * produced the two cost figures. Only under `on` is
578
+ * `cost_micros` money that moved. A word outside the
579
+ * three reaches the merged result as `"unknown"`, never
580
+ * as `"on"` and never as silence.
581
+ * `shadow_cost_micros` what enforcement WOULD have charged. It is a separate
582
+ * field on purpose and is carried under the separate key
583
+ * `shadowCostMicros`; it is never folded into
584
+ * `costMicros` and never counted as spend. Non-null only
585
+ * where the retail card is partial — null in production
586
+ * today, which has no published price book.
587
+ * `quota_source` "ledger" | "unreadable" | "not_read" | "not_admitted"
588
+ * — why `quota` is empty when it is. `{}` used to
589
+ * collapse "no allowance" and "the read failed", which
590
+ * call for opposite behaviour.
591
+ *
592
+ * Each is read through its own total parser ({@link readEnforcement},
593
+ * {@link readQuotaSource}, and `micros` for the figure): absent, wrong-typed
594
+ * and unrecognised all yield null, nothing throws, and an unrecognised
595
+ * `enforcement` is NEVER promoted to `"on"`.
596
+ *
438
597
  * @param {string} data
439
598
  * @returns {Partial<ReturnType<typeof readCohortHeaders>> & { quota?: any }}
440
599
  */
@@ -469,6 +628,18 @@ export function readCohortFrame(data) {
469
628
  // CF-158: undocumented upstream, so absence is normal and never an error.
470
629
  const usage = readFrameUsage(o.usage);
471
630
  if (usage) patch.usage = usage;
631
+ // W16 (hq a60b9595): enforcement, shadow_cost_micros and quota_source. Each
632
+ // is read defensively and only SET when it is usable, so an omitted or
633
+ // unrecognised value leaves the merged value at null rather than overwriting
634
+ // a mode an earlier frame stated. `shadow_cost_micros` lands under a name
635
+ // that cannot be confused with `costMicros`, because the gateway's whole
636
+ // reason for splitting the fields is that one is money and the other is not.
637
+ const mode = readEnforcementState(o.enforcement);
638
+ if (mode) patch.enforcement = mode;
639
+ const shadow = micros(o.shadow_cost_micros, false);
640
+ if (shadow !== null) patch.shadowCostMicros = shadow;
641
+ const qs = readQuotaSourceState(o.quota_source);
642
+ if (qs) patch.quotaSource = qs;
472
643
  return patch;
473
644
  }
474
645
 
@@ -486,6 +657,14 @@ export function mergeCohort(base, patch) {
486
657
  modelTier: patch.modelTier ?? base.modelTier,
487
658
  costMicros: patch.costMicros ?? base.costMicros,
488
659
  usage: patch.usage ?? base.usage ?? null,
660
+ // W16. `enforcement` qualifies `costMicros`: only under "on" is that figure
661
+ // money that moved. `shadowCostMicros` is what enforcement WOULD have
662
+ // charged and is never spend. `quotaSource` says why `quota` is empty when
663
+ // it is. null means the endpoint did not say — read as unknown, and NOT as
664
+ // a licence to assume "on"; the budget meter decides what to do with that.
665
+ enforcement: patch.enforcement ?? base.enforcement ?? null,
666
+ shadowCostMicros: patch.shadowCostMicros ?? base.shadowCostMicros ?? null,
667
+ quotaSource: patch.quotaSource ?? base.quotaSource ?? null,
489
668
  quota: {
490
669
  windows: { ...base.quota.windows, ...(pq.windows || {}) },
491
670
  creditsRemainingMicros: pq.creditsRemainingMicros ?? base.quota.creditsRemainingMicros,
@@ -6,9 +6,19 @@
6
6
  * POST <base>/cohort/v1/search
7
7
  * Authorization: Bearer <seat token>
8
8
  * body {"query":"…","maxResults":8,"allowedDomains":["…"],"blockedDomains":["…"]}
9
- * 200 {"results":[{"title":"…","url":"…","snippet":"…"}],"costMicros":"1200"}
9
+ * 200 {"results":[{"title":"…","url":"…","snippet":"…"}],"costMicros":"1200",
10
+ * "shadowCostMicros":"…|null","enforcement":"off|shadow|on"}
10
11
  * (costMicros: decimal integer string of micro-USD, or a safe integer;
11
12
  * `x-cohort-request-id` and `x-cohort-cost-micros` headers as on every route)
13
+ *
14
+ * **`costMicros` is money only under `enforcement: "on"` (W16, design §4.8).**
15
+ * The search body spells the three billing fields in camelCase because it is a
16
+ * plain JSON response rather than the `cohort` object, but they carry exactly
17
+ * the frame's meanings: under `shadow` a priced search settles for real and
18
+ * moves no money, and `shadowCostMicros` is what enforcement WOULD have charged.
19
+ * All three ride the returned `cohort` object, so the budget meter applies the
20
+ * same rule to a search as to a model call — a search's cost would otherwise be
21
+ * the one figure still counted as spend under shadow billing.
12
22
  * 404 the gateway has no search → the tool reports search as unavailable
13
23
  * 4xx/5xx refusals use the §4.6/§4.8 error bodies
14
24
  *
@@ -19,7 +29,7 @@
19
29
  * @module lib/engine/wire/search
20
30
  */
21
31
 
22
- import { classifyHttpError, classifyTransportError, tokenFailureError, header } from "./errors.mjs";
32
+ import { classifyHttpError, classifyTransportError, tokenFailureError, header, readEnforcementState } from "./errors.mjs";
23
33
 
24
34
  export const SEARCH_ROUTE = "/cohort/v1/search";
25
35
  export const DEFAULT_SEARCH_TIMEOUT_MS = 30_000;
@@ -41,7 +51,8 @@ function micros(v) {
41
51
  /**
42
52
  * Validate the 200 body (pure).
43
53
  * @param {unknown} body
44
- * @returns {{ok:true, results:Array<{title:string, url:string, snippet:string}>, costMicros:string|null}|{ok:false, message:string}}
54
+ * @returns {{ok:true, results:Array<{title:string, url:string, snippet:string}>, costMicros:string|null,
55
+ * shadowCostMicros:string|null, enforcement:'off'|'shadow'|'on'|'unknown'|null}|{ok:false, message:string}}
45
56
  */
46
57
  export function parseSearchBody(body) {
47
58
  if (!body || typeof body !== "object" || !Array.isArray(/** @type any */ (body).results)) return { ok: false, message: "the search response has no results array" };
@@ -49,7 +60,15 @@ export function parseSearchBody(body) {
49
60
  const results = b.results
50
61
  .filter((r) => r && typeof r === "object" && typeof r.url === "string" && r.url !== "")
51
62
  .map((r) => ({ title: typeof r.title === "string" ? r.title : r.url, url: r.url, snippet: typeof r.snippet === "string" ? r.snippet : "" }));
52
- return { ok: true, results, costMicros: micros(b.costMicros) };
63
+ return {
64
+ ok: true,
65
+ results,
66
+ costMicros: micros(b.costMicros),
67
+ // W16. Read defensively and kept apart from the charge, exactly as on the
68
+ // frame: an unrecognised enforcement is "unknown", never "on".
69
+ shadowCostMicros: micros(b.shadowCostMicros),
70
+ enforcement: readEnforcementState(b.enforcement),
71
+ };
53
72
  }
54
73
 
55
74
  /**
@@ -65,8 +84,8 @@ export function parseSearchBody(body) {
65
84
  * @param {AbortSignal} [p.signal]
66
85
  * @param {number} [p.timeoutMs]
67
86
  * @param {() => number} [p.now]
68
- * @returns {Promise<{ok:true, results:Array<{title:string,url:string,snippet:string}>, cohort:{requestId:string|null, costMicros:string|null}}
69
- * | {ok:false, error:{kind:string, code:string, status:number|null, message:string}, cohort:{requestId:string|null, costMicros:string|null}|null, accepted:boolean}>}
87
+ * @returns {Promise<{ok:true, results:Array<{title:string,url:string,snippet:string}>, cohort:{requestId:string|null, costMicros:string|null, shadowCostMicros:string|null, enforcement:string|null}}
88
+ * | {ok:false, error:{kind:string, code:string, status:number|null, message:string}, cohort:{requestId:string|null, costMicros:string|null, shadowCostMicros:string|null, enforcement:string|null}|null, accepted:boolean}>}
70
89
  */
71
90
  export async function searchWeb(p) {
72
91
  const fetchImpl = p.fetchImpl ?? globalThis.fetch;
@@ -106,7 +125,15 @@ export async function searchWeb(p) {
106
125
  const err = classifyTransportError(e, controller.signal);
107
126
  return { ok: false, error: { kind: err.kind, code: err.code, status: null, message: err.message }, cohort: null, accepted: false };
108
127
  }
109
- const cohort = { requestId: header(res.headers, "x-cohort-request-id"), costMicros: micros(header(res.headers, "x-cohort-cost-micros")) };
128
+ // §4.6 defines no header for the billing fields; only the body carries
129
+ // them. Null here so the shape is uniform on a refusal, which has no body
130
+ // to read them from.
131
+ const cohort = {
132
+ requestId: header(res.headers, "x-cohort-request-id"),
133
+ costMicros: micros(header(res.headers, "x-cohort-cost-micros")),
134
+ shadowCostMicros: null,
135
+ enforcement: null,
136
+ };
110
137
  if (res.status === 401 && typeof p.token === "function" && !refresh) {
111
138
  await res.body?.cancel().catch(() => {});
112
139
  refresh = true;
@@ -130,7 +157,16 @@ export async function searchWeb(p) {
130
157
  }
131
158
  const parsed = parseSearchBody(json);
132
159
  if (!parsed.ok) return { ok: false, error: { kind: "protocol", code: "search_protocol_error", status: res.status, message: parsed.message }, cohort, accepted: true };
133
- return { ok: true, results: parsed.results.slice(0, body.maxResults), cohort: { requestId: cohort.requestId, costMicros: parsed.costMicros ?? cohort.costMicros } };
160
+ return {
161
+ ok: true,
162
+ results: parsed.results.slice(0, body.maxResults),
163
+ cohort: {
164
+ requestId: cohort.requestId,
165
+ costMicros: parsed.costMicros ?? cohort.costMicros,
166
+ shadowCostMicros: parsed.shadowCostMicros,
167
+ enforcement: parsed.enforcement,
168
+ },
169
+ };
134
170
  }
135
171
  return { ok: false, error: { kind: "auth", code: "http_401", status: 401, message: "the gateway refused the refreshed token" }, cohort: null, accepted: false };
136
172
  } finally {
@@ -42,6 +42,10 @@
42
42
 
43
43
  import { readFileSync } from "fs";
44
44
  import { join } from "path";
45
+ // A seat's config/agent.json is seat-local prose too — `maestro upgrade` never
46
+ // touches it — and its free-text fields render straight into the persona block
47
+ // at the TOP of every prompt both planes build. See scrubAgentConfig.
48
+ import { scrubSeatText } from "./disclosure-scrub.mjs";
45
49
 
46
50
  /**
47
51
  * Human-readable gloss for each archetype altitude band (archetypes/altitudes/).
@@ -349,13 +353,33 @@ export function configuredStr(v) {
349
353
  return t;
350
354
  }
351
355
 
352
- /** Strip scaffold sentinels out of a parsed config/agent.json. @param {object} a @returns {object} */
356
+ /**
357
+ * The free-text fields of config/agent.json — the ones that render as PROSE
358
+ * rather than as a name or a title, and so can carry a standing instruction.
359
+ *
360
+ * `persona`, `background` and `bio` reach `### Background` and the persona
361
+ * prose at the very top of every prompt, ahead of the framework's own rules.
362
+ * That is the path that made this list necessary: the CLAUDE.md scrape and the
363
+ * sender profiles were filtered first, a poisoned seat was rendered end to end,
364
+ * and the opener came out of `config/agent.json#persona` — a file nobody had
365
+ * thought of as prompt text because it looks like configuration.
366
+ */
367
+ const AGENT_PROSE_FIELDS = ["companyDescription", "persona", "background", "bio"];
368
+
369
+ /**
370
+ * Strip scaffold sentinels out of a parsed config/agent.json — and scrub the
371
+ * free-text fields, which are seat-local prose in an instruction position.
372
+ * @param {object} a @returns {object}
373
+ */
353
374
  export function scrubAgentConfig(a) {
354
375
  const src = a && typeof a === "object" ? a : {};
355
376
  const out = { ...src };
356
377
  for (const k of ["firstName", "lastName", "fullName", "title", "company", "companyDescription", "persona", "background", "bio"]) {
357
378
  if (k in out) out[k] = configuredStr(out[k]);
358
379
  }
380
+ for (const k of AGENT_PROSE_FIELDS) {
381
+ if (typeof out[k] === "string" && out[k]) out[k] = scrubSeatText(out[k]).trim();
382
+ }
359
383
  // A surname with no first name and no full name is not an identity — better
360
384
  // to render no name at all than "You are AGENT." (the scaffold ships
361
385
  // firstName "UNCONFIGURED" / lastName "AGENT").
@@ -375,6 +399,11 @@ export function scrubCompanyConfig(c) {
375
399
  for (const k of ["name", "legalName", "description", "tagline", "industry", "stage"]) {
376
400
  if (k in out) out[k] = configuredStr(out[k]);
377
401
  }
402
+ // `description` and `tagline` render as prose in the persona block, the same
403
+ // as the agent's own free-text fields.
404
+ for (const k of ["description", "tagline"]) {
405
+ if (typeof out[k] === "string" && out[k]) out[k] = scrubSeatText(out[k]).trim();
406
+ }
378
407
  return out;
379
408
  }
380
409
 
@@ -408,4 +437,4 @@ export function renderSeatPersona(root, opts = {}) {
408
437
  }
409
438
  }
410
439
 
411
- export const _test = { ALTITUDE_STANDING, personaProse, reportingLine, voiceRules, list, SELF_PRESENTATION_RULES };
440
+ export const _test = { ALTITUDE_STANDING, personaProse, reportingLine, voiceRules, list, SELF_PRESENTATION_RULES, AGENT_PROSE_FIELDS };
package/lib/org/quota.mjs CHANGED
@@ -22,6 +22,20 @@
22
22
  * Money stays a decimal string of integer micro-USD end to end; nothing here
23
23
  * turns it into a float except the display helpers budget-guard calls.
24
24
  *
25
+ * ## The one import from `lib/engine`, and why it is deliberate (W16)
26
+ *
27
+ * `readEnforcementState` / `readQuotaSourceState` below come from
28
+ * `lib/engine/wire/errors.mjs` — the only non-test import from `lib/org` into
29
+ * `lib/engine` in this repo. It is a considered choice, not an accident:
30
+ * `parseCohortSseFrame` here and `readCohortFrame` there are two parsers of the
31
+ * SAME `event: cohort` frame, and the §4.8 vocabularies they read
32
+ * (`off|shadow|on`, `ledger|unreadable|not_read|not_admitted`) are exactly the
33
+ * kind of pinned value set that drifts when it is written down twice — a
34
+ * gateway that adds a fourth mode would then reach one parser as a new word and
35
+ * the other as silence. The alternative was a duplicated set, i.e. the drift.
36
+ * If this layering is ever unwanted, move those two readers to a leaf module
37
+ * both layers may import; do NOT re-declare the value sets here.
38
+ *
25
39
  * @module lib/org/quota
26
40
  */
27
41
 
@@ -31,6 +45,11 @@ import { existsSync, readFileSync, mkdirSync } from "node:fs";
31
45
  import { join, dirname, resolve } from "node:path";
32
46
 
33
47
  import { writeJsonAtomic } from "../fs-atomic.mjs";
48
+ // W16: the §4.8 vocabularies for `enforcement` and `quota_source` are parsed in
49
+ // exactly one place, the engine's wire reader. Both helpers are pure and total.
50
+ // Restating the value sets here is how the two frame parsers in this repo would
51
+ // start disagreeing about what the gateway said.
52
+ import { readEnforcementState, readQuotaSourceState } from "../engine/wire/errors.mjs";
34
53
 
35
54
  /** The cache may never be older than this (the pre-spawn gate reads it). */
36
55
  export const QUOTA_MAX_TTL_MS = 30_000;
@@ -192,6 +211,14 @@ export function parseCohortSseFrame(input) {
192
211
  requestId: str(obj.request_id) || str(obj.requestId),
193
212
  modelTier: str(obj.model_tier) || str(obj.modelTier),
194
213
  costMicros: micros(obj.cost_micros ?? obj.costMicros),
214
+ // W16 (hq a60b9595, design §4.8). `enforcement` qualifies `costMicros`:
215
+ // only under "on" did money move. `shadowCostMicros` is what enforcement
216
+ // WOULD have charged and is never a charge. `quotaSource` says why `quota`
217
+ // is empty when it is. Unrecognised or absent values read as null — an
218
+ // unknown `enforcement` is never promoted to "on".
219
+ enforcement: readEnforcementState(obj.enforcement),
220
+ shadowCostMicros: micros(obj.shadow_cost_micros ?? obj.shadowCostMicros),
221
+ quotaSource: readQuotaSourceState(obj.quota_source ?? obj.quotaSource),
195
222
  funding: str(obj.funding),
196
223
  windows,
197
224
  usage: obj.usage && typeof obj.usage === "object" ? { ...obj.usage } : null,
@@ -127,6 +127,10 @@ export function sessionPaths(agentRoot) {
127
127
  upgradeNoticeFile: join(stateDir, "upgrade-notice.json"),
128
128
  lastExitFile: join(stateDir, "last-exit"),
129
129
  attentionFile: join(stateDir, "attention.json"),
130
+ // Consecutive identical launch failures, carried across supervisor
131
+ // lifetimes (lib/session/launch-failure.mjs). A counter held in memory is
132
+ // zero on every launch, which is why the bound never bound.
133
+ launchFailuresFile: join(stateDir, "launch-failures.json"),
130
134
  daemonHealthFile: join(agentRoot, "state", "dashboards", "daemon-health.yaml"),
131
135
  daemonPidFile: join(agentRoot, "state", "daemon.pid"),
132
136
  configFile: join(agentRoot, CONFIG_REL),
@@ -65,6 +65,7 @@ export function parseMainSession(text) {
65
65
  };
66
66
  if (typeof raw.lastLaunchAt === "string") rec.lastLaunchAt = raw.lastLaunchAt;
67
67
  if (typeof raw.rotatedFrom === "string") rec.rotatedFrom = raw.rotatedFrom;
68
+ if (typeof raw.rotatedReason === "string") rec.rotatedReason = raw.rotatedReason;
68
69
  return rec;
69
70
  }
70
71
 
@@ -178,38 +179,101 @@ export function recordLaunch(record, opts = {}) {
178
179
  *
179
180
  * relaunch — the session ended (cleanly, or after living past the window):
180
181
  * exit 75 and let launchd bring it back with the same id.
181
- * rotate — a resume died inside the failure window: mint a fresh id first.
182
+ * rotate — the resume target is unusable: mint a fresh id first.
182
183
  * backoff — the rotation budget for this hour is spent: sleep, then exit 75.
183
184
  *
184
185
  * An UNKNOWN exit code (the exit file was not written) inside the window is
185
186
  * treated as a failure: the cost of a wrong rotation is a fresh transcript,
186
187
  * the cost of a wrong relaunch is a seat stuck in a 30-second crash loop.
187
188
  *
188
- * @param {{record:object, mode:"session-id"|"resume", exitCode:number|null, startedAt:number, endedAt:number, now?:number|Function, failureWindowMs?:number, maxRotationsPerHour?:number}} a
189
- * @returns {{action:"relaunch"}|{action:"rotate"}|{action:"backoff", sleepMs:number}}
189
+ * ── PROVEN OUTRANKS INFERRED, AND IS NOT RATIONED ───────────────────────────
190
+ * `resumeTargetMissing` is the runtime's own verdict, read off the screen by
191
+ * `launch-failure#classifyLaunchFailure` ("No conversation found with session
192
+ * ID: …") or off the disk by `resume-target#resumeTargetState`. When it is
193
+ * true, rotating is not a guess that might help — it is the ONLY repair, and
194
+ * it provably works. So it bypasses {@link MAX_ROTATIONS_PER_HOUR}, which
195
+ * exists to bound blind rotation. Leaving it inside the budget is precisely
196
+ * how James Kirkland's seat spent days relaunching a dead id on a ten-minute
197
+ * backoff: the budget throttled the one action that would have fixed it.
198
+ *
199
+ * ── `beatSeen` NARROWS THE GUESS, IT DOES NOT WIDEN IT ──────────────────────
200
+ * The twenty-second window is a proxy for "it never really started". A launch
201
+ * that DID write a heartbeat and then died inside that window did start, so it
202
+ * no longer rotates: its transcript is good and the fault is elsewhere. The
203
+ * converse is deliberately NOT symmetric — "ran an hour, never beat, unknown
204
+ * exit" is not evidence enough to destroy a transcript on, because a broken
205
+ * heartbeat writer looks exactly like it.
206
+ *
207
+ * The one place a never-beaten launch rotates outside the window is when it
208
+ * has ALREADY failed identically to the escalation limit (`streakAtLimit`):
209
+ * every other repair has been tried, the seat is escalating anyway, and a
210
+ * fresh id is the last thing left that could work. It is suppressed when WE
211
+ * ended the session (`selfStopped`: the watchdog restarting a wedged session),
212
+ * since that is exactly the case where the transcript is still good.
213
+ *
214
+ * @param {object} a
215
+ * @param {object} a.record
216
+ * @param {"session-id"|"resume"} a.mode
217
+ * @param {number|null} a.exitCode
218
+ * @param {number} a.startedAt
219
+ * @param {number} a.endedAt
220
+ * @param {boolean} [a.resumeTargetMissing] PROVEN: the conversation is not on this machine
221
+ * @param {boolean} [a.beatSeen] did this launch write a heartbeat? (undefined = could not tell)
222
+ * @param {boolean} [a.streakAtLimit] has this launch failed identically to the escalation limit?
223
+ * @param {boolean} [a.selfStopped] did the supervisor itself end the session?
224
+ * @param {number|Function} [a.now]
225
+ * @param {number} [a.failureWindowMs]
226
+ * @param {number} [a.maxRotationsPerHour]
227
+ * @returns {{action:"relaunch"}|{action:"rotate", proven:boolean, why:string}|{action:"backoff", sleepMs:number}}
190
228
  */
191
229
  export function rotationDecision(a) {
192
230
  const windowMs = a.failureWindowMs ?? FAILURE_WINDOW_MS;
193
231
  const max = a.maxRotationsPerHour ?? MAX_ROTATIONS_PER_HOUR;
194
232
  const durationMs = Number(a.endedAt) - Number(a.startedAt);
195
233
  const failed = a.exitCode === null || a.exitCode === undefined || a.exitCode !== 0;
196
- if (a.mode !== "resume" || !failed || !(durationMs < windowMs)) return { action: "relaunch" };
234
+ if (a.mode !== "resume") return { action: "relaunch" };
235
+ if (a.resumeTargetMissing === true) {
236
+ return { action: "rotate", proven: true, why: "the conversation this launch resumed does not exist on this machine" };
237
+ }
238
+ if (!failed || a.selfStopped === true) return { action: "relaunch" };
239
+ const insideWindow = durationMs < windowMs && a.beatSeen !== true;
240
+ const lastResort = a.beatSeen === false && a.streakAtLimit === true;
241
+ if (!insideWindow && !lastResort) return { action: "relaunch" };
197
242
  const t = nowMs(a.now);
198
243
  const recent = (a.record && a.record.rotations || []).filter((r) => t - Date.parse(r) < HOUR_MS);
199
244
  if (recent.length >= max) return { action: "backoff", sleepMs: BACKOFF_MS };
200
- return { action: "rotate" };
245
+ return {
246
+ action: "rotate",
247
+ proven: false,
248
+ why: insideWindow
249
+ ? "the resume died inside the failure window"
250
+ : "every recent launch has failed identically and never beaten — a fresh id is the last untried repair",
251
+ };
201
252
  }
202
253
 
203
254
  /**
204
255
  * Pure: the record after a rotation — fresh id, zero resumes, this rotation
205
256
  * stamped, and the rotation history pruned to the last hour.
206
- * @param {object} record @param {{now?:number|Function, uuid?:Function}} [opts]
257
+ *
258
+ * THIS IS BOTH HALVES OF THE REPAIR, AND THAT IS THE POINT. A dead resume id
259
+ * needs the stale record INVALIDATED (so nothing ever resumes it again) and a
260
+ * FRESH id to fall back to (so the next launch has somewhere to go). Doing
261
+ * only the first leaves the supervisor with no id and `newMainSession` would
262
+ * be minted anyway; doing only the second leaves the dead id in the file for
263
+ * the next reader. One write does both: the dead id survives only as
264
+ * `rotatedFrom`, which is provenance, not a target — nothing resumes it.
265
+ *
266
+ * `reason` is recorded so the next reader of the file (a person, `maestro
267
+ * session status`) can tell a proven repair from a heuristic one.
268
+ *
269
+ * @param {object} record @param {{now?:number|Function, uuid?:Function, reason?:string}} [opts]
207
270
  */
208
271
  export function rotateMainSession(record, opts = {}) {
209
272
  const t = nowMs(opts.now);
210
273
  const fresh = newMainSession({ now: t, uuid: opts.uuid });
211
274
  const kept = (record.rotations || []).filter((r) => t - Date.parse(r) < HOUR_MS);
212
- return { ...fresh, rotations: [...kept, iso(t)], rotatedFrom: record.sessionId };
275
+ const reason = typeof opts.reason === "string" && opts.reason.trim() ? opts.reason.trim().slice(0, 300) : "";
276
+ return { ...fresh, rotations: [...kept, iso(t)], rotatedFrom: record.sessionId, ...(reason ? { rotatedReason: reason } : {}) };
213
277
  }
214
278
 
215
279
  export default {