@cohortapp/agent-sdk 2.18.12 → 2.18.14

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 (43) hide show
  1. package/bin/maestro.mjs +38 -1
  2. package/docs/runbooks/fleet-rollout.md +14 -7
  3. package/docs/runbooks/recovery-and-failover.md +18 -0
  4. package/lib/cadence-failure-class.mjs +245 -0
  5. package/lib/claude-bin.mjs +26 -7
  6. package/lib/cli/doctor-checks.mjs +149 -1
  7. package/lib/comms/send-gate.mjs +6 -4
  8. package/lib/diagnostics/alerts.mjs +33 -0
  9. package/lib/engine/agents/usage.mjs +45 -0
  10. package/lib/engine/budget.mjs +293 -29
  11. package/lib/engine/cli.mjs +54 -5
  12. package/lib/engine/loop.mjs +30 -0
  13. package/lib/engine/output/json.mjs +26 -0
  14. package/lib/engine/wire/errors.mjs +179 -0
  15. package/lib/engine/wire/search.mjs +44 -8
  16. package/lib/identity/claude-md.mjs +107 -0
  17. package/lib/identity/disclosure-instructions.mjs +148 -0
  18. package/lib/identity/disclosure-scrub.mjs +207 -0
  19. package/lib/identity/persona.mjs +141 -6
  20. package/lib/org/inbound/conversation-frame.mjs +289 -0
  21. package/lib/org/inbound/directedness.mjs +27 -7
  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/collect.mjs +129 -0
  28. package/lib/upgrade/pinned-drift.mjs +467 -0
  29. package/package.json +1 -1
  30. package/plugins/maestro-skills/skills/persona-discipline.md +24 -2
  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/ci/run-tests.mjs +16 -2
  35. package/scripts/daemon/cadence-consumer.mjs +281 -34
  36. package/scripts/daemon/context-compiler.mjs +9 -1
  37. package/scripts/daemon/prompt-builder.mjs +219 -137
  38. package/scripts/daemon/responder.mjs +226 -26
  39. package/scripts/emergency-stop.sh +114 -13
  40. package/scripts/fleet/rollout.mjs +10 -3
  41. package/scripts/healthcheck.sh +131 -33
  42. package/scripts/resume-operations.sh +101 -6
  43. 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 {
@@ -0,0 +1,107 @@
1
+ /**
2
+ * claude-md.mjs — reading a seat's CLAUDE.md into a prompt preamble.
3
+ *
4
+ * WHY THIS MODULE EXISTS
5
+ * Both prompt planes scrape the seat's CLAUDE.md for house-style sections:
6
+ * the 60-second quick reply (scripts/daemon/responder.mjs#loadPreamble) and
7
+ * the full session (scripts/daemon/prompt-builder.mjs#extractPreamble). They
8
+ * had SEPARATE copies of the scrape, and only one of them was ever fixed — so
9
+ * the fix for "the preamble hands the model an unresolved identity template"
10
+ * landed on the quick reply while the session plane, which is the tier a
11
+ * substantive reply actually escalates to, kept every defect. One definition,
12
+ * two callers, is the only shape that cannot drift that way again.
13
+ *
14
+ * PURE. Every function here takes the file's text as a parameter and returns a
15
+ * string; nothing reads the filesystem, the clock or the environment, so what a
16
+ * given seat file yields is a unit test (claude-md.test.mjs).
17
+ */
18
+
19
+ /**
20
+ * A scaffold placeholder line — `*Configured by \`maestro setup\`*` and variants.
21
+ *
22
+ * The scaffold ships these under `## Company Context` and `### Autonomy Model`,
23
+ * and the scrape handed them to the model verbatim: a standing note, on every
24
+ * turn, that the seat's company context and autonomy bands are unconfigured.
25
+ * That is the same "you are not set up" signal that pushes a model into the
26
+ * generic-assistant register in which it introduces itself.
27
+ */
28
+ export const SCAFFOLD_SENTINEL = /^\s*\*?\s*(?:Configured by|To be configured|TBD)\b[^\n]*$/i;
29
+
30
+ /** Markdown heading depth, 0 for a non-heading. @param {string} l @returns {number} */
31
+ function headingDepth(l) {
32
+ const m = /^(#{2,6})\s+\S/.exec(l);
33
+ return m ? m[1].length : 0;
34
+ }
35
+
36
+ /**
37
+ * Drop scaffold placeholder lines, and any heading they leave empty. PURE.
38
+ *
39
+ * A heading with nothing under it says less than nothing, so it goes with its
40
+ * placeholder rather than standing as an unanswered promise.
41
+ *
42
+ * @param {string} text
43
+ * @returns {string}
44
+ */
45
+ export function stripScaffoldSentinels(text) {
46
+ const kept = String(text || "").split("\n").filter((l) => !SCAFFOLD_SENTINEL.test(l));
47
+ // Second pass: a heading whose whole body was a sentinel is now empty.
48
+ const out = [];
49
+ for (let i = 0; i < kept.length; i++) {
50
+ const line = kept[i];
51
+ const depth = headingDepth(line);
52
+ if (depth) {
53
+ let j = i + 1;
54
+ while (j < kept.length && kept[j].trim() === "") j++;
55
+ // Empty only when EOF follows, or a heading at the SAME OR SHALLOWER
56
+ // level. A parent heading followed by its own subheading has a body.
57
+ const next = j < kept.length ? headingDepth(kept[j]) : 0;
58
+ if (j >= kept.length || (next && next <= depth)) continue;
59
+ }
60
+ out.push(line);
61
+ }
62
+ return out.join("\n").replace(/\n{3,}/g, "\n\n").trim();
63
+ }
64
+
65
+ /**
66
+ * Scrape the named `##` sections out of a CLAUDE.md. PURE, so which sections a
67
+ * given seat file yields is a unit test.
68
+ *
69
+ * @param {string} raw CLAUDE.md contents
70
+ * @param {string[]} targets `## ` headings to keep
71
+ * @returns {string}
72
+ */
73
+ export function scrapeClaudeMdSections(raw, targets) {
74
+ const sections = [];
75
+ let capturing = false;
76
+ for (const line of String(raw || "").split("\n")) {
77
+ if (targets.some((h) => line.startsWith(h))) { capturing = true; sections.push(line); continue; }
78
+ if (capturing && /^## [A-Z]/.test(line) && !targets.some((h) => line.startsWith(h))) { capturing = false; continue; }
79
+ if (capturing) sections.push(line);
80
+ }
81
+ return sections.join("\n").trim();
82
+ }
83
+
84
+ /**
85
+ * The `##` headings a preamble scrape should keep.
86
+ *
87
+ * `## Identity` is kept ONLY when no persona block rendered. On an enrolled
88
+ * seat the persona is resolved from config/agent.json and the CLAUDE.md
89
+ * `## Identity` section is still the scaffold template — unresolved
90
+ * `{{agent.fullName}}` tokens followed by "If those tokens are still
91
+ * unresolved, your identity has not been configured yet — run … maestro setup".
92
+ * Rendering both gives the model a correct identity immediately followed by a
93
+ * notice that its identity is unconfigured; two identity blocks, one of them
94
+ * unresolved, is worse than either alone.
95
+ *
96
+ * On an UNENROLLED seat there is no persona, and the scaffold section — tokens
97
+ * and all — is the only identity prose there is. It is restored deliberately:
98
+ * a prompt that says "run maestro setup" is the honest rendering of a seat
99
+ * that has not been set up.
100
+ *
101
+ * @param {boolean} hasPersona did renderSeatPersona() produce a block?
102
+ * @param {string[]} houseStyle extra `## ` headings this plane wants
103
+ * @returns {string[]}
104
+ */
105
+ export function preambleTargets(hasPersona, houseStyle) {
106
+ return hasPersona ? [...houseStyle] : ["## Identity", ...houseStyle];
107
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * disclosure-instructions.mjs — a standing instruction to VOLUNTEER what the
3
+ * agent is must not reach a prompt, wherever it was written.
4
+ *
5
+ * WHAT BROKE
6
+ * Seats posted a self-introduction into the middle of live threads — a rendering
7
+ * of `channels.<c>.identity_line` from policies/ai-disclosure.yaml, the
8
+ * opener naming the agent's own nature and pairing it with its principal —
9
+ * on a release-status post and on a correction to the agent's
10
+ * OWN earlier figures, in the org's own #risk-compliance channel. Two causes
11
+ * were framework-side and are fixed in code (an unresolved identity template,
12
+ * and an unframed transcript). The THIRD is not framework-side at all:
13
+ *
14
+ * The preamble scrape copies `## Communication Rules` out of the SEAT'S OWN
15
+ * CLAUDE.md, verbatim. A seat file carrying a standing rule of the
16
+ * shape "on first message in any thread, introduce yourself as <the
17
+ * identity_line>" puts that line straight into every prompt the seat builds, and it survives every
18
+ * `maestro` upgrade — the repo cannot see it, and a repo test that renders the
19
+ * scaffold will never fail on it.
20
+ *
21
+ * So the check has to run at RUNTIME over the composed preamble, on the text
22
+ * that is actually about to be handed to the model, rather than in a test over
23
+ * a file the fleet does not use.
24
+ *
25
+ * WHAT THIS IS NOT
26
+ * It is NOT a disclosure policy and it must never weaken one. The truthfulness
27
+ * invariant — a sincere question about whether someone is an AI is answered
28
+ * plainly — lives in lib/identity/persona.mjs#voiceRules and
29
+ * policies/ai-disclosure.yaml, and is untouched here. What these patterns
30
+ * describe is the UNPROMPTED self-introduction: an instruction to open with,
31
+ * attach, or lead with a statement of what the agent is.
32
+ *
33
+ * Because of that distinction the patterns are deliberately narrow, and a line
34
+ * that FORBIDS the behaviour is kept rather than dropped ({@link PROHIBITION}):
35
+ * stripping "never introduce yourself as an AI" would remove a rule that says
36
+ * exactly what this module wants said. A pointer to the disclosure policy —
37
+ * "policies/ai-disclosure.yaml is the authority on where proactive disclosure
38
+ * is legally required" — is likewise a restraint, not an instruction, and is
39
+ * kept; every pattern below therefore requires an imperative verb rather than
40
+ * matching the bare words "proactive disclosure".
41
+ *
42
+ * PURE. No I/O, no clock, no env — the caller supplies the text and receives the
43
+ * text plus what was dropped, so the exact behaviour is a unit test.
44
+ */
45
+
46
+ /**
47
+ * Instructions to volunteer what the agent is.
48
+ *
49
+ * Deliberately narrower than "the word AI appears": the persona block's
50
+ * truthfulness bullet contains "AI" and MUST keep containing it.
51
+ */
52
+ export const DISCLOSURE_INSTRUCTION_PATTERNS = Object.freeze([
53
+ // "state that you are an AI assistant", "mention you're a bot", …
54
+ /\b(?:state|disclose|declare|mention|note|announce|say|confirm)\b[^.\n]{0,60}\b(?:you are|you're|that you are|yourself as)\b[^.\n]{0,40}\b(?:an? AI\b|AI (?:assistant|agent)|artificial intelligence|a bot\b|a language model)/i,
55
+ // "introduce yourself", "identify yourself as …"
56
+ /\bintroduce yourself\b/i,
57
+ /\bidentify yourself as\b/i,
58
+ // Prose that IS the self-introduction, quoted as a model to copy.
59
+ /\ban AI (?:assistant|agent) working (?:with|for|alongside|on behalf of)\b/i,
60
+ // "add the identity line", "lead with an AI-disclosure statement", …
61
+ /\b(?:add|include|append|prepend|attach|open with|lead with|start with|begin with|preface \w+ with|end with|sign off with)\b[^.\n]{0,60}\b(?:identity line|proactive[_ ]disclosure|AI[- ]disclosure|disclosure (?:line|statement|notice))/i,
62
+ // The observed opener itself, as a template to follow.
63
+ /\bbefore we (?:get into it|begin|start)\b/i,
64
+ ]);
65
+
66
+ /**
67
+ * A line that FORBIDS the behaviour rather than demanding it.
68
+ *
69
+ * Checked against the text BEFORE the match, so "Do not introduce yourself" is
70
+ * kept while "On first message, introduce yourself" is dropped. Deliberately
71
+ * generous — keeping a line the stripper was unsure about is the safe error,
72
+ * because the only cost of a kept prohibition is a duplicate of a rule the
73
+ * framework already states, while the cost of a dropped one is a rule lost.
74
+ */
75
+ const PROHIBITION = /\b(?:do not|don'?t|never|no need to|must not|should not|avoid|without|rather than|instead of|refrain from|stop)\b/i;
76
+
77
+ /**
78
+ * Does this ONE line read as an instruction to volunteer what the agent is?
79
+ *
80
+ * @param {string} line
81
+ * @returns {RegExp|null} the pattern that matched, or null
82
+ */
83
+ export function disclosureInstructionMatch(line) {
84
+ const text = String(line || "");
85
+ if (!text.trim()) return null;
86
+ for (const rx of DISCLOSURE_INSTRUCTION_PATTERNS) {
87
+ const m = rx.exec(text);
88
+ if (!m) continue;
89
+ // A prohibition anywhere before the match keeps the line.
90
+ if (PROHIBITION.test(text.slice(0, m.index))) continue;
91
+ return rx;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * Drop every line of `text` that instructs the agent to introduce or disclose
98
+ * itself, and report what went.
99
+ *
100
+ * LINE GRANULARITY IS THE POINT. This runs over seat-authored prose whose shape
101
+ * nothing here controls; a line is the largest unit that can be removed without
102
+ * guessing where a rule begins and ends. A bullet with indented sub-bullets
103
+ * therefore loses only its own line, which reads as a truncated rule rather than
104
+ * a silently rewritten one — and `dropped` is returned so the caller can say so
105
+ * out loud instead of editing the operator's file behind their back.
106
+ *
107
+ * @param {string} text
108
+ * @returns {{text: string, dropped: string[]}}
109
+ */
110
+ export function stripDisclosureInstructions(text) {
111
+ const src = String(text || "");
112
+ if (!src) return { text: "", dropped: [] };
113
+ const dropped = [];
114
+ const kept = src.split("\n").filter((line) => {
115
+ if (!disclosureInstructionMatch(line)) return true;
116
+ dropped.push(line.trim());
117
+ return false;
118
+ });
119
+ return { text: kept.join("\n").replace(/\n{3,}/g, "\n\n"), dropped };
120
+ }
121
+
122
+ /**
123
+ * Strip, and WARN ONCE PER PROCESS per distinct line, naming the file the line
124
+ * came from. The seat operator cannot see this from the repo, so silence is the
125
+ * failure mode that let it run for as long as it did.
126
+ *
127
+ * @param {string} text
128
+ * @param {string} source a human-readable provenance, e.g. "<seat>/CLAUDE.md"
129
+ * @param {(msg:string)=>void} [warn]
130
+ * @returns {string}
131
+ */
132
+ const _warned = new Set();
133
+ export function stripDisclosureInstructionsAndWarn(text, source, warn = console.error) {
134
+ const { text: out, dropped } = stripDisclosureInstructions(text);
135
+ for (const line of dropped) {
136
+ const key = `${source}::${line}`;
137
+ if (_warned.has(key)) continue;
138
+ _warned.add(key);
139
+ warn(
140
+ `[identity] dropped a self-introduction instruction from ${source}: ${JSON.stringify(line.slice(0, 200))}` +
141
+ ` — agents do not announce what they are unprompted; edit that file to remove the line.`,
142
+ );
143
+ }
144
+ return out;
145
+ }
146
+
147
+ /** For tests. */
148
+ export const _test = { PROHIBITION };