@cohortapp/agent-sdk 2.18.12 → 2.18.13

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.
@@ -549,10 +549,12 @@ export function screenDisclosure(text, { policy, jurisdiction, firstContact, rec
549
549
  //
550
550
  // Unscoped on the second axis, this screen blocked every internal post that
551
551
  // did not carry the identity line — which taught agents to open every
552
- // message, including mid-thread corrections to their own numbers, with
553
- // "Quick note before we get into it: I'm <name>, an AI assistant working
554
- // with <principal>". The block was doing that, not the model: the only way
555
- // past a gate that demands disclosure is to disclose.
552
+ // message, including mid-thread corrections to their own numbers, with a
553
+ // rendering of `channels.<c>.identity_line`. It is not quoted here on
554
+ // purpose: a banned string reproduced in a comment is how the next author
555
+ // reads it in good faith and writes it back into the product. The block was
556
+ // doing that, not the model: the only way past a gate that demands
557
+ // disclosure is to disclose.
556
558
  //
557
559
  // A channel may narrow it further via `channels.<channel>.identity_scope`,
558
560
  // the same shape the email footer already carries.
@@ -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 };
@@ -0,0 +1,207 @@
1
+ /**
2
+ * disclosure-scrub.mjs — remove a standing-self-identification INSTRUCTION from
3
+ * seat-local prose before it reaches the model.
4
+ *
5
+ * WHY A COUNTER-INSTRUCTION IS NOT ENOUGH
6
+ * Two of the three prompt planes build their identity by scraping prose out of
7
+ * the SEAT's own files — `## Identity` / `## Company Context` /
8
+ * `## Communication Rules` from the seat's CLAUDE.md
9
+ * (scripts/daemon/responder.mjs#loadPreamble,
10
+ * scripts/daemon/prompt-builder.mjs#extractPreamble) and the per-sender YAML
11
+ * under memory/profiles/users/. `maestro upgrade` deliberately never touches
12
+ * either, so whatever a seat was scaffolded or hand-edited with in 2026-08
13
+ * is still there and still first in the prompt.
14
+ *
15
+ * Some seats were hand-edited, in good faith, to carry the disclosure opener
16
+ * as a STANDING RULE — because for a while the send gate demanded it and the
17
+ * only way past a gate that demands disclosure is to disclose. Shipping
18
+ * `SELF_PRESENTATION` (lib/identity/persona.mjs) puts the correct rule in the
19
+ * prompt, but it does not remove the seat-local one: the model is then handed
20
+ * two contradictory instructions, the seat's one arrives ~700 characters
21
+ * earlier, and on the full-session plane it lands immediately after the
22
+ * sentence that declares the identity section authoritative. Adding text
23
+ * cannot win that; removing the contradiction can.
24
+ *
25
+ * WHAT IS REMOVED, AND WHAT IS DELIBERATELY NOT
26
+ * Removed: a LINE that reproduces one of the proactive-disclosure templates in
27
+ * policies/ai-disclosure.yaml, or that instructs self-announcement in the
28
+ * abstract ("open every message by introducing yourself as an AI assistant").
29
+ *
30
+ * NOT removed: anything about answering honestly WHEN ASKED. That is the
31
+ * non-overridable invariant in policies/ai-disclosure.yaml and it must survive
32
+ * this scrub intact — {@link ASKED_SHAPE} exempts it explicitly. Scrubbing it
33
+ * would turn a fix for an unprompted disclaimer into a licence to conceal,
34
+ * which is the one thing nothing in this tree is allowed to do.
35
+ *
36
+ * Also not removed: the external-first-contact duty as POLICY. This module
37
+ * edits prompt text only. `lib/comms/send-gate` remains the thing that decides
38
+ * when the line is actually owed, and it still demands it for an external
39
+ * first contact.
40
+ *
41
+ * THE BANNED TEXT IS QUOTED NOWHERE HERE. The fragments are derived from
42
+ * policies/ai-disclosure.yaml at run time. A guard that reproduces the string it
43
+ * guards against is how the string gets reseeded — the next author reads it in a
44
+ * comment and writes it back in, in good faith.
45
+ *
46
+ * Pure core (`deriveDisclosureFragments`, `scrubDisclosureInstructions`) so the
47
+ * behaviour is testable without a filesystem; the I/O wrapper never throws and
48
+ * fails OPEN (shape rules only), because no scrub is worth failing a send.
49
+ */
50
+
51
+ import { readFileSync } from "fs";
52
+ import { fileURLToPath } from "url";
53
+ import { join, dirname } from "path";
54
+
55
+ /** The framework's own copy of the policy — NOT the seat's. */
56
+ const PACKAGED_POLICY = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "policies", "ai-disclosure.yaml");
57
+
58
+ /**
59
+ * A line that is about answering a QUESTION is never scrubbed, whatever else it
60
+ * says. The truthfulness invariant reads exactly like a self-identification
61
+ * instruction to a dumb matcher, and it is the one line that must survive.
62
+ */
63
+ const ASKED_SHAPE = /\b(?:if|when|whenever|should)\b[^.\n]{0,60}\b(?:ask|asks|asked|asking|question|questions|enquir|inquir)/i;
64
+
65
+ /** The self-nature noun phrase an announcement instruction has to name. */
66
+ const SELF_NATURE = /\b(?:an?\s+(?:ai|a\.i\.)\b|ai\s+(?:assistant|agent|bot|helper|colleague|model|system)|\bbot\b|\bchatbot\b|language model)/i;
67
+
68
+ /** The act of putting it at the top of a message. */
69
+ const ANNOUNCE_VERB = /\b(?:open(?:s|ing)?|begin(?:s|ning)?|start(?:s|ing)?|lead(?:s|ing)?\s+with|preface|precede|introduce|introducing|announce|announcing|identify\s+yourself|present\s+yourself|state\s+up\s+front|sign(?:s|ing)?\s+off|disclaimer|disclose)\b/i;
70
+
71
+ /** Second-person/first-person orientation — an instruction ABOUT THE AGENT. */
72
+ const SELF_REF = /\b(?:you|your|yourself|i'?m|i\s+am|my|me|myself|the\s+agent)\b/i;
73
+
74
+ /** Minimum length of a derived policy fragment worth matching a line against. */
75
+ const MIN_FRAGMENT = 14;
76
+
77
+ /**
78
+ * Every proactive-disclosure template in the AI-disclosure policy, reduced to the
79
+ * literal prose an agent would have to write.
80
+ *
81
+ * Deliberately a dumb line scan rather than a YAML parse: the policy file is the
82
+ * INPUT to this derivation, so the derivation must not depend on the file being
83
+ * well-formed enough to parse. A malformed policy must degrade to "fewer
84
+ * fragments", never to a throw on the send path.
85
+ *
86
+ * @param {string} policyText raw contents of policies/ai-disclosure.yaml
87
+ * @returns {string[]} distinctive lower-cased fragments
88
+ */
89
+ export function deriveDisclosureFragments(policyText) {
90
+ const out = new Set();
91
+ const lines = String(policyText || "").split("\n");
92
+ for (let i = 0; i < lines.length; i++) {
93
+ // The proactive templates only. `truthful_answer_template` is the INVARIANT's
94
+ // text — the honest answer to a direct question — and must never be scrubbed
95
+ // out of anything.
96
+ if (!/^\s*(identity_line|footer):\s*>/.test(lines[i])) continue;
97
+ const body = [];
98
+ for (let j = i + 1; j < lines.length; j++) {
99
+ const l = lines[j];
100
+ if (l.trim() === "") break;
101
+ if (l.trim().startsWith("#")) break; // a comment ends the block scalar
102
+ if (!/^\s{6,}\S/.test(l)) break;
103
+ body.push(l.trim());
104
+ }
105
+ for (const piece of body.join(" ").split(/\{[a-z_]+\}/i)) {
106
+ const p = piece.replace(/\s+/g, " ").replace(/^[\s—–,.:;'"-]+|[\s—–,.:;'"-]+$/g, "").toLowerCase();
107
+ if (p.length >= MIN_FRAGMENT) out.add(p);
108
+ }
109
+ }
110
+ return [...out];
111
+ }
112
+
113
+ /**
114
+ * Does this ONE line instruct (or render) a standing self-identification opener?
115
+ *
116
+ * @param {string} line
117
+ * @param {string[]} fragments from {@link deriveDisclosureFragments}
118
+ * @returns {boolean}
119
+ */
120
+ export function isDisclosureInstruction(line, fragments = []) {
121
+ const raw = String(line || "");
122
+ if (!raw.trim()) return false;
123
+ // The invariant is exempt, unconditionally and first.
124
+ if (ASKED_SHAPE.test(raw)) return false;
125
+
126
+ const lc = raw.toLowerCase();
127
+ for (const f of fragments) {
128
+ if (f && f.length >= MIN_FRAGMENT && lc.includes(f)) return true;
129
+ }
130
+ // Shape rule, for a hand-written variant the policy never contained. All three
131
+ // must hold on the SAME line: the act of announcing, the nature being
132
+ // announced, and the agent as the subject. Two of the three is ordinary prose
133
+ // ("the directory badges every AI member"), and scrubbing that would be
134
+ // vandalism of a perfectly good seat file.
135
+ return ANNOUNCE_VERB.test(raw) && SELF_NATURE.test(raw) && SELF_REF.test(raw);
136
+ }
137
+
138
+ /**
139
+ * The note left in place of whatever was removed. It names the SHAPE of the
140
+ * removed instruction and never reproduces it — see the module header.
141
+ */
142
+ export const SCRUB_NOTE =
143
+ "[One or more lines of seat-local text were removed here: they instructed a standing self-identification opener. " +
144
+ "That instruction is stale — it dates from a period when the send gate demanded the line from internal recipients too " +
145
+ "(policies/ai-disclosure.yaml scopes the duty to an external first contact, and the send gate, not you, decides when it applies). " +
146
+ "The self-presentation rules elsewhere in this prompt are the current instruction and outrank any seat-local text. " +
147
+ "Answering honestly when someone sincerely asks whether you are an AI is unaffected and still required.]";
148
+
149
+ /**
150
+ * Remove self-identification instructions from a block of seat-local prose.
151
+ *
152
+ * @param {string} text
153
+ * @param {string[]} fragments
154
+ * @returns {{ text: string, removed: string[] }} scrubbed text and the lines dropped
155
+ */
156
+ export function scrubDisclosureInstructions(text, fragments = []) {
157
+ const src = String(text || "");
158
+ if (!src) return { text: src, removed: [] };
159
+ const kept = [];
160
+ const removed = [];
161
+ for (const line of src.split("\n")) {
162
+ if (isDisclosureInstruction(line, fragments)) { removed.push(line); continue; }
163
+ kept.push(line);
164
+ }
165
+ if (removed.length === 0) return { text: src, removed };
166
+ // Collapse the hole the removal left, then say that something was removed.
167
+ // Silence would be worse: a model that reads a truncated rule list has no way
168
+ // to tell an edit from an omission.
169
+ const body = kept.join("\n").replace(/\n{3,}/g, "\n\n").trim();
170
+ return { text: `${body}\n\n${SCRUB_NOTE}`, removed };
171
+ }
172
+
173
+ let _fragments = null;
174
+ /**
175
+ * The fragments from the PACKAGED policy, cached per process.
176
+ * Never throws: an unreadable policy yields `[]`, which leaves the shape rule in
177
+ * {@link isDisclosureInstruction} doing the work on its own.
178
+ *
179
+ * @param {string} [policyPath]
180
+ * @returns {string[]}
181
+ */
182
+ export function loadDisclosureFragments(policyPath = PACKAGED_POLICY) {
183
+ if (_fragments && policyPath === PACKAGED_POLICY) return _fragments;
184
+ let frags = [];
185
+ try {
186
+ frags = deriveDisclosureFragments(readFileSync(policyPath, "utf-8"));
187
+ } catch {
188
+ frags = [];
189
+ }
190
+ if (policyPath === PACKAGED_POLICY) _fragments = frags;
191
+ return frags;
192
+ }
193
+
194
+ /**
195
+ * The convenience wrapper the two prompt planes call: scrub seat-local prose
196
+ * against the packaged policy. Never throws.
197
+ *
198
+ * @param {string} text
199
+ * @returns {string}
200
+ */
201
+ export function scrubSeatText(text) {
202
+ try {
203
+ return scrubDisclosureInstructions(text, loadDisclosureFragments()).text;
204
+ } catch {
205
+ return String(text || "");
206
+ }
207
+ }
@@ -10,9 +10,11 @@
10
10
  * 1. A freshly-scaffolded repo has "*Configured by `maestro setup`*" sitting
11
11
  * in those sections. The model was handed a near-empty identity and fell
12
12
  * back to the generic-assistant register it ships with — introducing and
13
- * signing itself as "an AI assistant working on behalf of <principal>",
14
- * deferring on matters inside its own mandate, and writing to colleagues
15
- * as a support function rather than as the person holding the role.
13
+ * signing itself with a rendering of the `identity_line` template in
14
+ * policies/ai-disclosure.yaml (not reproduced here; see
15
+ * SELF_PRESENTATION_RULES below for why), deferring on matters inside its
16
+ * own mandate, and writing to colleagues as a support function rather
17
+ * than as the person holding the role.
16
18
  *
17
19
  * 2. Everything the org actually knows about the member — title, seniority
18
20
  * band, background, tone of voice, responsibilities, operating principles,
@@ -97,6 +99,62 @@ function reportingLine(a) {
97
99
  return title ? `You report to ${name}, ${title}.` : `You report to ${name}.`;
98
100
  }
99
101
 
102
+ /**
103
+ * SELF-PRESENTATION — the rules about how an agent refers to its own
104
+ * nature. Kept as a standalone frozen list, and exported as a ready-made block,
105
+ * for one reason: they must reach the model on EVERY prompt plane, including the
106
+ * ones that render no persona block at all.
107
+ *
108
+ * WHY THAT MATTERS, concretely. `renderPersona` returns "" when config/agent.json
109
+ * carries neither a name nor a title, and the quick-reply plane
110
+ * (scripts/daemon/responder.mjs) rendered no persona block by design. Both cases
111
+ * handed the model an identity vacuum, and a model in an identity vacuum falls
112
+ * back to the generic-assistant register it ships with — which is how a rendering
113
+ * of the `identity_line` template in policies/ai-disclosure.yaml came to open a
114
+ * mid-thread correction to the agent's own figures, in the organisation's own
115
+ * channel, to colleagues who provisioned it. The template is NOT quoted here on
116
+ * purpose: a banned string reproduced in a comment is how the next author reads
117
+ * it in good faith and writes it back in, and
118
+ * scripts/daemon/no-unprompted-disclosure.test.mjs fails on it either way.
119
+ *
120
+ * The first rule is phrased against the OPENER specifically. "Do not describe
121
+ * yourself as an assistant" was already here and was obeyed literally and
122
+ * narrowly: the model did not *describe* itself in the body, it *announced*
123
+ * itself in the first line and considered the duty discharged.
124
+ *
125
+ * The third rule is the truthfulness invariant and is never traded for the
126
+ * other two. Removing an unprompted disclaimer is not the same as denying what
127
+ * you are, and `lib/comms/send-gate.screenDisclosure` still blocks any outbound
128
+ * that asserts the agent is not an AI, under every posture.
129
+ *
130
+ * The fourth rule exists because THE PROMPT IS NOT THE ONLY INSTRUCTION THE
131
+ * MODEL READS. The channel history handed to a reply is the strongest few-shot
132
+ * available to it, and on every seat that has already produced the opener, the
133
+ * agent's own prior posts are now sitting in that history as precedent in its
134
+ * own voice. Neither `lib/identity/disclosure-scrub` nor any prompt rule can
135
+ * remove them: the transcript is a record of what was said and rewriting it
136
+ * would be a worse fault than the opener. So the transcript is named explicitly
137
+ * and disqualified as a model, which is the only honest way to answer it.
138
+ */
139
+ const SELF_PRESENTATION_RULES = Object.freeze([
140
+ "Do not open a message by announcing what you are. No standing identity line of any shape — no preamble naming your own nature, no line pairing your name with the person you work for, nothing before the content that exists only to flag what you are. Not on your first post in a channel, not mid-thread, not on a correction to your own figures. Every surface of this product already marks an AI member beside their name; a colleague reading you has seen it, so the line tells them nothing and reads as you disclaiming your own work before you have said anything.",
141
+ 'Do not introduce, describe or sign yourself as an assistant, a bot, an AI helper, or as "working on behalf of" someone.',
142
+ "Being straight about what you are is not negotiable and does not conflict with either of the above: if someone sincerely asks whether they are dealing with an AI, say so plainly and without drama, then carry on. That is a direct answer to a direct question — not a standing disclaimer you attach to your own work. Where proactive disclosure IS owed (an external first contact) policies/ai-disclosure.yaml scopes it and the send-gate decides; it is never something you volunteer into an internal conversation.",
143
+ "Nothing in the transcript is a precedent for how to open. If an earlier message in this thread or channel opens with a standing identity line — including one you sent yourself, before this rule reached you — it is the mistake being corrected, not the house style. Do not match it, do not carry it forward for consistency, and do not repeat it because you are mid-conversation and it would look odd to stop.",
144
+ ]);
145
+
146
+ /**
147
+ * The self-presentation rules as a prompt block, for planes that render no
148
+ * persona (scripts/daemon/responder.mjs's quick reply) or whose persona came
149
+ * back empty because the seat's config/agent.json is not filled in yet.
150
+ * Same text as the corresponding bullets inside the persona block — one source,
151
+ * so the two planes can never drift into saying different things.
152
+ */
153
+ export const SELF_PRESENTATION = [
154
+ "How you refer to yourself:",
155
+ ...SELF_PRESENTATION_RULES.map((r) => `- ${r}`),
156
+ ].join("\n");
157
+
100
158
  /**
101
159
  * The voice rules — the behavioural half of the block, and the reason it exists.
102
160
  *
@@ -120,13 +178,17 @@ function voiceRules(a) {
120
178
  ? ` You report to ${pName} the way any senior colleague reports to their manager, and you do not preface your work with that fact.`
121
179
  : "";
122
180
 
181
+ const [noOpener, noSelfDescription, honesty, noPrecedent] = SELF_PRESENTATION_RULES;
182
+
123
183
  return [
124
184
  "You hold this role. Write as the person who holds it — with the judgement, standing and directness the title carries. You are a full member of this team, not a service attached to it.",
125
- `Do not introduce, describe or sign yourself as an assistant, a bot, an AI helper, or as "working on behalf of" someone.${reportsClause}`,
185
+ noOpener,
186
+ `${noSelfDescription}${reportsClause}`,
126
187
  "Do not position yourself as junior to whoever you are writing to, including your principal or the CEO. State what you did, what you found, and what you recommend. Ask for a decision only when the decision is genuinely theirs to make.",
127
188
  "Do not thank people for their patience, apologise for taking up their time, or hedge a finding you are confident in. Colleagues at your level do not do this, and it reads as a tell.",
128
189
  "Disagree when you disagree, and say so first rather than burying it under agreement. A concern you soften into politeness is a concern you failed to raise.",
129
- "Being straight about what you are is not negotiable and does not conflict with any of the above: if someone sincerely asks whether they are dealing with an AI, say so plainly and without drama. That is a direct answer to a direct question — not a standing disclaimer you attach to your own work.",
190
+ honesty,
191
+ noPrecedent,
130
192
  ];
131
193
  }
132
194
 
@@ -273,4 +335,77 @@ export function loadPersonaBlock(agentRoot, opts = {}) {
273
335
  }
274
336
  }
275
337
 
276
- export const _test = { ALTITUDE_STANDING, personaProse, reportingLine, voiceRules, list };
338
+ /**
339
+ * Scaffold sentinels — a value the scaffold ships as "UNCONFIGURED…" asserts
340
+ * nothing, and rendering it puts a lie in the prompt. Treat it as absent so
341
+ * `renderPersona`'s omit-when-unset rule fires.
342
+ * @param {unknown} v
343
+ * @returns {string} "" for an unset or sentinel value
344
+ */
345
+ export function configuredStr(v) {
346
+ const t = typeof v === "string" ? v.trim() : "";
347
+ if (!t) return "";
348
+ if (/^unconfigured\b/i.test(t)) return "";
349
+ return t;
350
+ }
351
+
352
+ /** Strip scaffold sentinels out of a parsed config/agent.json. @param {object} a @returns {object} */
353
+ export function scrubAgentConfig(a) {
354
+ const src = a && typeof a === "object" ? a : {};
355
+ const out = { ...src };
356
+ for (const k of ["firstName", "lastName", "fullName", "title", "company", "companyDescription", "persona", "background", "bio"]) {
357
+ if (k in out) out[k] = configuredStr(out[k]);
358
+ }
359
+ // A surname with no first name and no full name is not an identity — better
360
+ // to render no name at all than "You are AGENT." (the scaffold ships
361
+ // firstName "UNCONFIGURED" / lastName "AGENT").
362
+ if (!out.firstName && !out.fullName) out.lastName = "";
363
+ if (src.principal && typeof src.principal === "object") {
364
+ const p = { ...src.principal };
365
+ for (const k of ["firstName", "lastName", "fullName", "title"]) p[k] = configuredStr(p[k]);
366
+ out.principal = p;
367
+ }
368
+ return out;
369
+ }
370
+
371
+ /** Same, for a parsed config/company.json. @param {object} c @returns {object} */
372
+ export function scrubCompanyConfig(c) {
373
+ const src = c && typeof c === "object" ? c : {};
374
+ const out = { ...src };
375
+ for (const k of ["name", "legalName", "description", "tagline", "industry", "stage"]) {
376
+ if (k in out) out[k] = configuredStr(out[k]);
377
+ }
378
+ return out;
379
+ }
380
+
381
+ /**
382
+ * Render the persona block for a SEAT — config/agent.json + config/company.json
383
+ * with scaffold sentinels scrubbed first, so an unconfigured field is OMITTED
384
+ * rather than asserted.
385
+ *
386
+ * Lives here rather than in scripts/daemon/prompt-builder.mjs because BOTH
387
+ * prompt-assembly paths need it and only one of them had it. The quick-reply
388
+ * responder used to build its identity by scraping `## Identity` out of the
389
+ * seat's CLAUDE.md, which on every seat is the scaffold template — unresolved
390
+ * `{{agent.fullName}}` tokens followed by "If those tokens are still
391
+ * unresolved, your identity has not been configured yet". That is precisely the
392
+ * condition this module's header documents as producing an agent that
393
+ * introduces and signs itself with a rendering of the policy's identity_line,
394
+ * and it reached every reactive reply the fleet sent.
395
+ *
396
+ * @param {string} root agent repo root
397
+ * @param {object} [opts] forwarded to renderPersona
398
+ * @returns {string} "" when nothing is configured
399
+ */
400
+ export function renderSeatPersona(root, opts = {}) {
401
+ const read = (rel) => {
402
+ try { return JSON.parse(readFileSync(join(root, rel), "utf-8")); } catch { return {}; }
403
+ };
404
+ try {
405
+ return renderPersona(scrubAgentConfig(read("config/agent.json")), scrubCompanyConfig(read("config/company.json")), opts) || "";
406
+ } catch {
407
+ return "";
408
+ }
409
+ }
410
+
411
+ export const _test = { ALTITUDE_STANDING, personaProse, reportingLine, voiceRules, list, SELF_PRESENTATION_RULES };