@cohortapp/agent-sdk 2.9.1 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/.claude/commands/init-maestro.md +16 -9
  2. package/docs/guides/mac-mini.md +11 -1
  3. package/docs/runbooks/cohort-cutover.md +16 -0
  4. package/lib/channels/inbox-item.mjs +4 -0
  5. package/lib/comms/send-gate.mjs +23 -1
  6. package/lib/comms/send-gate.test.mjs +24 -0
  7. package/lib/mcp/server.test.mjs +16 -4
  8. package/lib/model-router/economics.mjs +53 -1
  9. package/lib/model-router/economics.test.mjs +76 -0
  10. package/lib/model-router/resolve.mjs +57 -4
  11. package/lib/model-router.mjs +95 -8
  12. package/lib/model-router.test.mjs +305 -5
  13. package/lib/org/client.mjs +58 -1
  14. package/lib/org/inbound/project.mjs +9 -5
  15. package/lib/org/messaging.mjs +6 -1
  16. package/lib/org/protocol.checksum +1 -1
  17. package/lib/org/protocol.mjs +176 -3
  18. package/lib/org/protocol.test.mjs +31 -2
  19. package/lib/org/resource-tools.mjs +317 -0
  20. package/lib/org/resource-tools.test.mjs +361 -0
  21. package/lib/org/tool-access.mjs +176 -0
  22. package/lib/org/tool-access.test.mjs +144 -0
  23. package/lib/org/tool-surface.mjs +431 -5
  24. package/lib/org/tool-surface.test.mjs +385 -8
  25. package/lib/org/ui-parity.mjs +196 -3
  26. package/lib/org/ui-parity.test.mjs +126 -7
  27. package/lib/tool-definitions.js +23 -2
  28. package/package.json +2 -2
  29. package/plugins/maestro-skills/.claude-plugin/marketplace.json +1 -1
  30. package/plugins/maestro-skills/plugin.json +4 -0
  31. package/plugins/maestro-skills/skills/venture-deliverables.md +176 -0
  32. package/policies/information-barriers.yaml +34 -7
  33. package/scripts/ci/check-no-residual-identity.mjs +281 -9
  34. package/scripts/ci/check-no-residual-identity.test.mjs +115 -2
  35. package/scripts/cloud-relay/voice/relay-identity.test.mjs +96 -0
  36. package/scripts/cloud-relay/voice/server.mjs +42 -2
  37. package/scripts/cost/track-claude-usage-pricing.test.mjs +183 -0
  38. package/scripts/cost/track-claude-usage.mjs +113 -4
  39. package/scripts/daemon/agent-daemon.mjs +150 -3
  40. package/scripts/daemon/agent-daemon.test.mjs +190 -0
  41. package/scripts/daemon/assurance.mjs +50 -16
  42. package/scripts/daemon/assurance.test.mjs +39 -1
  43. package/scripts/daemon/classifier-identity.test.mjs +137 -0
  44. package/scripts/daemon/classifier.mjs +98 -17
  45. package/scripts/daemon/deliver.mjs +457 -33
  46. package/scripts/daemon/deliver.test.mjs +564 -0
  47. package/scripts/daemon/prompt-builder-preamble.test.mjs +210 -0
  48. package/scripts/daemon/prompt-builder.mjs +264 -41
  49. package/scripts/daemon/prompt-builder.test.mjs +5 -5
  50. package/scripts/daemon/responder-history.test.mjs +18 -2
  51. package/scripts/daemon/responder.mjs +7 -1
  52. package/scripts/disclosure_boundaries.py +56 -5
  53. package/scripts/huddle/huddle-prompt.test.mjs +176 -0
  54. package/scripts/huddle/huddle-server.mjs +128 -13
  55. package/scripts/local-triggers/autoupdate.sh +83 -0
  56. package/scripts/local-triggers/generate-plists.sh +9 -0
  57. package/scripts/local-triggers/generate-plists.test.mjs +12 -10
  58. package/scripts/media-generation/brand-clause.test.mjs +135 -0
  59. package/scripts/media-generation/gemini-image-client.mjs +27 -9
  60. package/scripts/media-generation/generate-assets.mjs +102 -7
  61. package/scripts/pre-draft-context.py +91 -15
  62. package/scripts/spawn-session.sh +36 -6
  63. package/scripts/test-employer-grounding.py +348 -0
  64. package/scripts/validate_outbound.py +190 -26
@@ -0,0 +1,210 @@
1
+ /**
2
+ * prompt-builder-preamble.test.mjs — the fallback preamble must not FABRICATE
3
+ * authority.
4
+ *
5
+ * WHAT BROKE
6
+ * `buildFallbackPreamble()` used to hardcode an autonomy grant:
7
+ * "<Agent> sends autonomously: all internal messages, external operational
8
+ * messages, candidate comms, follow-ups, calendar coordination"
9
+ * read from nowhere. It fires when CLAUDE.md is unreadable OR its extracted
10
+ * sections total <= 200 chars — i.e. on a freshly scaffolded repo that has
11
+ * never had autonomy bands set — while driving a real `claude --print` session
12
+ * that can send real messages. It also stated "You are <name>, <title> at
13
+ * <company>" with `company` interpolated unconditionally.
14
+ *
15
+ * WHAT THESE TESTS PIN
16
+ * 1. GROUNDED PATH — with a real config/agent.json + config/company.json + a
17
+ * GENERATED policies/action-classification.yaml, every band in the preamble
18
+ * comes from those files.
19
+ * 2. EMPTY PATH — with the scaffold's UNCONFIGURED config and no generated
20
+ * policy, the preamble asserts NO employer, NO autonomy grant, and says
21
+ * draft-not-send. Nothing is invented.
22
+ * 3. The hand-written TEMPLATE action-classification.yaml (which grants "fully
23
+ * autonomous executive operator" to every seat) is treated as ABSENT.
24
+ *
25
+ * Run: node --test scripts/daemon/prompt-builder-preamble.test.mjs
26
+ */
27
+
28
+ "use strict";
29
+
30
+ import { test } from "node:test";
31
+ import assert from "node:assert/strict";
32
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
33
+ import { tmpdir } from "node:os";
34
+ import { join } from "node:path";
35
+
36
+ import {
37
+ buildFallbackPreamble,
38
+ loadSeatAutonomyPolicy,
39
+ renderAutonomySection,
40
+ } from "./prompt-builder.mjs";
41
+
42
+ /** A repo root with whatever files the case needs. */
43
+ function seat(files) {
44
+ const root = mkdtempSync(join(tmpdir(), "seat-"));
45
+ for (const [rel, body] of Object.entries(files)) {
46
+ const abs = join(root, rel);
47
+ mkdirSync(join(abs, ".."), { recursive: true });
48
+ writeFileSync(abs, body);
49
+ }
50
+ return root;
51
+ }
52
+
53
+ const SCAFFOLD_AGENT = JSON.stringify({
54
+ firstName: "UNCONFIGURED",
55
+ lastName: "AGENT",
56
+ fullName: "Unconfigured Agent",
57
+ title: "Unconfigured Role",
58
+ company: "",
59
+ principal: { firstName: "", fullName: "", title: "" },
60
+ responsibilities: [],
61
+ operatingPrinciples: [],
62
+ });
63
+ const SCAFFOLD_COMPANY = JSON.stringify({ name: "UNCONFIGURED", description: "", products: [] });
64
+
65
+ const REAL_AGENT = JSON.stringify({
66
+ firstName: "Robin",
67
+ lastName: "Okafor",
68
+ fullName: "Robin Okafor",
69
+ title: "VP Operations",
70
+ altitude: "vp",
71
+ company: "",
72
+ principal: { firstName: "Dana", fullName: "Dana Lee", title: "CEO" },
73
+ responsibilities: ["Run the ops cadence"],
74
+ operatingPrinciples: ["Close the loop on every commitment"],
75
+ });
76
+ const REAL_COMPANY = JSON.stringify({ name: "Meridian Freight", description: "Regional logistics." });
77
+
78
+ /** What scripts/setup/generate-autonomy.mjs writes — note archetype + delegation_breadth. */
79
+ const GENERATED_POLICY = `
80
+ archetype: operations@vp
81
+ autonomy_default: execute
82
+ delegation_breadth: moderate
83
+ action_levels:
84
+ execute_autonomous:
85
+ examples:
86
+ - Reschedule internal meetings
87
+ approval_preferred:
88
+ examples:
89
+ - Send a quote to a customer
90
+ escalate:
91
+ examples:
92
+ - Sign a supplier contract
93
+ escalate_to:
94
+ - principal
95
+ decision_rights:
96
+ - id: dr-1
97
+ scope: Carrier selection
98
+ authority: decide
99
+ `;
100
+
101
+ /** The hand-written framework TEMPLATE — no archetype, no delegation_breadth. */
102
+ const TEMPLATE_POLICY = `
103
+ action_levels:
104
+ execute_autonomous:
105
+ description: Operational actions the agent executes on its own authority.
106
+ examples:
107
+ - Send emails in the agent's voice to any recipient
108
+ - Send candidate communications (outreach, scheduling, offers, rejections)
109
+ `;
110
+
111
+ // ── 1. GROUNDED PATH ────────────────────────────────────────────────────────
112
+
113
+ test("grounded: identity, employer and every autonomy band come from the real files", () => {
114
+ const root = seat({
115
+ "config/agent.json": REAL_AGENT,
116
+ "config/company.json": REAL_COMPANY,
117
+ "policies/action-classification.yaml": GENERATED_POLICY,
118
+ });
119
+ const out = buildFallbackPreamble(root);
120
+
121
+ assert.match(out, /You are Robin Okafor, VP Operations at Meridian Freight\./);
122
+ assert.match(out, /You report to Dana Lee, CEO\./);
123
+ assert.match(out, /Close the loop on every commitment/, "operating principles come from config");
124
+
125
+ assert.match(out, /generated for archetype operations@vp/);
126
+ assert.match(out, /You act on your own authority: Reschedule internal meetings/);
127
+ assert.match(out, /You get agreement first: Send a quote to a customer/);
128
+ assert.match(out, /You escalate and do NOT act alone: Sign a supplier contract/);
129
+ assert.match(out, /Escalate to: principal/);
130
+ assert.match(out, /Decision right — Carrier selection \(decide\)/);
131
+
132
+ // The fabricated grant is gone, verbatim.
133
+ assert.doesNotMatch(out, /sends autonomously/i);
134
+ assert.doesNotMatch(out, /all internal messages, external operational messages/i);
135
+ assert.doesNotMatch(out, /Full autonomy, full accountability/i);
136
+
137
+ rmSync(root, { recursive: true, force: true });
138
+ });
139
+
140
+ // ── 2. EMPTY PATH — nothing configured means nothing asserted ────────────────
141
+
142
+ test("empty: an unconfigured seat gets NO employer and NO autonomy grant", () => {
143
+ const root = seat({
144
+ "config/agent.json": SCAFFOLD_AGENT,
145
+ "config/company.json": SCAFFOLD_COMPANY,
146
+ });
147
+ const out = buildFallbackPreamble(root);
148
+
149
+ // No invented employer anywhere — including the scaffold's own sentinel.
150
+ assert.doesNotMatch(out, /UNCONFIGURED/);
151
+ assert.doesNotMatch(out, / at \./, "no dangling 'at <blank>' clause");
152
+
153
+ // No grant of any kind.
154
+ assert.doesNotMatch(out, /sends autonomously/i);
155
+ assert.doesNotMatch(out, /You act on your own authority/);
156
+ assert.doesNotMatch(out, /escalates only/i);
157
+
158
+ // Fail closed, and say why.
159
+ assert.match(out, /NO AUTONOMY POLICY IS CONFIGURED FOR THIS SEAT/);
160
+ assert.match(out, /Draft, do not send/);
161
+ assert.match(out, /Nothing here grants that\./);
162
+
163
+ rmSync(root, { recursive: true, force: true });
164
+ });
165
+
166
+ test("empty: a repo with NO config at all still produces a safe, non-fabricated preamble", () => {
167
+ const root = seat({});
168
+ const out = buildFallbackPreamble(root);
169
+ assert.doesNotMatch(out, /You are .* at /, "no identity is asserted");
170
+ assert.match(out, /NO AUTONOMY POLICY IS CONFIGURED/);
171
+ assert.match(out, /Draft, do not send/);
172
+ rmSync(root, { recursive: true, force: true });
173
+ });
174
+
175
+ // ── 3. The template is NOT ground truth ─────────────────────────────────────
176
+
177
+ test("the framework TEMPLATE action-classification.yaml is treated as ABSENT", () => {
178
+ const root = seat({
179
+ "config/agent.json": REAL_AGENT,
180
+ "config/company.json": REAL_COMPANY,
181
+ "policies/action-classification.yaml": TEMPLATE_POLICY,
182
+ });
183
+ assert.equal(loadSeatAutonomyPolicy(root), null, "no archetype/delegation_breadth ⇒ not this seat's policy");
184
+
185
+ const out = buildFallbackPreamble(root);
186
+ assert.match(out, /NO AUTONOMY POLICY IS CONFIGURED FOR THIS SEAT/);
187
+ assert.doesNotMatch(
188
+ out,
189
+ /Send candidate communications/,
190
+ "the template's maximal grant must never reach a prompt",
191
+ );
192
+ rmSync(root, { recursive: true, force: true });
193
+ });
194
+
195
+ test("a GENERATED policy whose bands are all empty grants nothing", () => {
196
+ const section = renderAutonomySection({
197
+ archetype: "ops@vp",
198
+ delegation_breadth: "narrow",
199
+ action_levels: { execute_autonomous: { examples: [] }, approval_preferred: {}, escalate: {} },
200
+ decision_rights: [],
201
+ });
202
+ assert.match(section, /declares no bands/);
203
+ assert.match(section, /draft, do not send/i);
204
+ });
205
+
206
+ test("loadSeatAutonomyPolicy never throws on malformed YAML", () => {
207
+ const root = seat({ "policies/action-classification.yaml": "::: not: [valid" });
208
+ assert.equal(loadSeatAutonomyPolicy(root), null);
209
+ rmSync(root, { recursive: true, force: true });
210
+ });
@@ -5,8 +5,9 @@
5
5
 
6
6
  import { readFileSync, readdirSync } from "fs";
7
7
  import { join } from "path";
8
+ import { createRequire } from "node:module";
8
9
  import { compileContext } from "./context-compiler.mjs";
9
- import { loadPersonaBlock } from "../../lib/identity/persona.mjs";
10
+ import { renderPersona } from "../../lib/identity/persona.mjs";
10
11
  import { wrapExternalContent } from "../../lib/security/external-content.mjs";
11
12
  import { isEnabled as orgEnabled } from "../../lib/org/client.mjs";
12
13
  import { recall as orgRecall } from "../../lib/org/knowledge.mjs";
@@ -14,12 +15,22 @@ import { outcomeSourceShareable } from "./session-outcomes.mjs";
14
15
 
15
16
  const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.url).pathname, "../..");
16
17
 
17
- // Load agent identity from canonical SOT for use in prompts and fallback preamble.
18
+ // js-yaml, synchronously. The autonomy policy is read on the fallback-preamble
19
+ // path, which is sync by contract; the org-config reader below can afford the
20
+ // dynamic import, this cannot. js-yaml is a declared dependency of this package.
21
+ const _require = createRequire(import.meta.url);
22
+ const yaml = _require("js-yaml");
23
+
24
+ // Load agent identity from canonical SOT. Used for the backlog-item owner label.
25
+ // The fallback carries ONLY a generic first name — it used to also carry
26
+ // `company: "the company"` and a fabricated principal, which is a claim about
27
+ // the seat that nothing read. Identity for prompts goes through
28
+ // renderSeatPersona() below, which omits an unset field rather than filling it.
18
29
  function loadAgent() {
19
30
  try {
20
31
  return JSON.parse(readFileSync(join(AGENT_REPO_DIR, "config/agent.json"), "utf-8"));
21
32
  } catch {
22
- return { firstName: "Agent", fullName: "Agent", title: "agent", company: "the company", principal: { firstName: "principal", fullName: "the principal", title: "principal" } };
33
+ return { firstName: "agent" };
23
34
  }
24
35
  }
25
36
 
@@ -128,10 +139,94 @@ let cachedPreamble = null;
128
139
  // back to a generic-assistant register.
129
140
  let cachedPersona = null;
130
141
 
142
+ /**
143
+ * A config value the operator has not filled in yet.
144
+ *
145
+ * The scaffold does not ship empty strings for everything — it ships SENTINELS:
146
+ * config/company.json has `"name": "UNCONFIGURED"`, config/agent.json has
147
+ * `"firstName": "UNCONFIGURED"`, `"fullName": "Unconfigured Agent"`,
148
+ * `"title": "Unconfigured Role"`. lib/identity/persona.mjs#renderPersona omits
149
+ * EMPTY fields, which is right, but a sentinel is not empty — so an untouched
150
+ * scaffold rendered "You are Unconfigured Agent, Unconfigured Role at
151
+ * UNCONFIGURED." into a live prompt. Treat the sentinel as absence.
152
+ *
153
+ * @param {*} v
154
+ * @returns {string} the trimmed value, or "" when absent/sentinel
155
+ */
156
+ function configuredStr(v) {
157
+ const s = typeof v === "string" ? v.trim() : "";
158
+ if (!s) return "";
159
+ if (/^unconfigured\b/i.test(s)) return "";
160
+ return s;
161
+ }
162
+
163
+ /**
164
+ * Strip scaffold sentinels out of config/agent.json before it is rendered, so
165
+ * `renderPersona`'s omit-when-unset rule actually fires on a fresh repo.
166
+ * @param {object} a
167
+ * @returns {object}
168
+ */
169
+ function scrubAgentConfig(a) {
170
+ const src = a && typeof a === "object" ? a : {};
171
+ const out = { ...src };
172
+ for (const k of ["firstName", "lastName", "fullName", "title", "company", "companyDescription", "persona", "background", "bio"]) {
173
+ if (k in out) out[k] = configuredStr(out[k]);
174
+ }
175
+ // A surname with no first name and no full name is not an identity — better
176
+ // to render no name at all than "You are AGENT." (the scaffold ships
177
+ // firstName "UNCONFIGURED" / lastName "AGENT").
178
+ if (!out.firstName && !out.fullName) out.lastName = "";
179
+ if (src.principal && typeof src.principal === "object") {
180
+ const p = { ...src.principal };
181
+ for (const k of ["firstName", "lastName", "fullName", "title"]) p[k] = configuredStr(p[k]);
182
+ out.principal = p;
183
+ }
184
+ return out;
185
+ }
186
+
187
+ /** Same, for config/company.json. */
188
+ function scrubCompanyConfig(c) {
189
+ const src = c && typeof c === "object" ? c : {};
190
+ const out = { ...src };
191
+ for (const k of ["name", "legalName", "description", "tagline", "industry", "stage"]) {
192
+ if (k in out) out[k] = configuredStr(out[k]);
193
+ }
194
+ return out;
195
+ }
196
+
197
+ /** Read + scrub both config files. Never throws. @returns {{agent:object, company:object}} */
198
+ function readSeatConfig(root) {
199
+ const read = (rel) => {
200
+ try { return JSON.parse(readFileSync(join(root, rel), "utf-8")); } catch { return {}; }
201
+ };
202
+ return {
203
+ agent: scrubAgentConfig(read("config/agent.json")),
204
+ company: scrubCompanyConfig(read("config/company.json")),
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Render the persona block for a seat — config/agent.json + config/company.json
210
+ * through lib/identity/persona.mjs#renderPersona, with scaffold sentinels
211
+ * scrubbed first so an unconfigured field is OMITTED rather than asserted.
212
+ *
213
+ * @param {string} root agent repo root
214
+ * @param {object} [opts] forwarded to renderPersona
215
+ * @returns {string} "" when nothing is configured
216
+ */
217
+ export function renderSeatPersona(root, opts = {}) {
218
+ try {
219
+ const { agent, company } = readSeatConfig(root);
220
+ return renderPersona(agent, company, opts) || "";
221
+ } catch {
222
+ return "";
223
+ }
224
+ }
225
+
131
226
  /** Render (once per process) the persona block from config/agent.json. */
132
227
  function loadPersona() {
133
228
  if (cachedPersona !== null) return cachedPersona;
134
- cachedPersona = loadPersonaBlock(AGENT_REPO_DIR) || "";
229
+ cachedPersona = renderSeatPersona(AGENT_REPO_DIR);
135
230
  return cachedPersona;
136
231
  }
137
232
 
@@ -148,7 +243,7 @@ function loadPreamble() {
148
243
  cachedPreamble = extractPreamble(raw);
149
244
  } catch (err) {
150
245
  console.error(`[prompt-builder] Failed to read CLAUDE.md: ${err.message}`);
151
- cachedPreamble = FALLBACK_PREAMBLE;
246
+ cachedPreamble = fallbackPreamble();
152
247
  }
153
248
  return cachedPreamble;
154
249
  }
@@ -196,44 +291,172 @@ function extractPreamble(raw) {
196
291
 
197
292
  // If extraction got something reasonable, use it; otherwise fall back
198
293
  if (extracted.length > 200) return extracted;
199
- return FALLBACK_PREAMBLE;
294
+ return fallbackPreamble();
295
+ }
296
+
297
+ /**
298
+ * Read the seat's GENERATED autonomy policy, or null.
299
+ *
300
+ * Provenance check, not just a file check. `policies/action-classification.yaml`
301
+ * exists in two very different shapes:
302
+ *
303
+ * - the hand-written TEMPLATE that ships with the framework, whose header says
304
+ * "the agent is a fully autonomous executive operator" for every seat; and
305
+ * - the per-seat artefact `scripts/setup/generate-autonomy.mjs` renders from
306
+ * `lib/autonomy.mjs#buildActionClassification` for THIS agent's archetype
307
+ * (function × altitude), which is the only one that describes a real grant.
308
+ *
309
+ * Only the generated artefact carries `archetype` + `delegation_breadth`, so we
310
+ * require both. A template copy is treated as ABSENT — inheriting another
311
+ * seat's maximal grant is precisely the fabrication this function exists to stop.
312
+ *
313
+ * Never throws; returns null on any read/parse failure.
314
+ *
315
+ * @param {string} [root=AGENT_REPO_DIR]
316
+ * @returns {object|null}
317
+ */
318
+ export function loadSeatAutonomyPolicy(root = AGENT_REPO_DIR) {
319
+ try {
320
+ const raw = readFileSync(join(root, "policies/action-classification.yaml"), "utf-8");
321
+ const doc = yaml.load(raw);
322
+ if (!doc || typeof doc !== "object") return null;
323
+ if (!doc.archetype || !doc.delegation_breadth) return null;
324
+ return doc;
325
+ } catch {
326
+ return null;
327
+ }
328
+ }
329
+
330
+ /** Clean string[] from a config list; drops blanks. */
331
+ function strList(v, max = 12) {
332
+ if (!Array.isArray(v)) return [];
333
+ return v.map((x) => (typeof x === "string" ? x.trim() : "")).filter(Boolean).slice(0, max);
334
+ }
335
+
336
+ /**
337
+ * Render what this seat may actually do, from the generated policy — or, when
338
+ * no policy has been generated, a fail-closed restraint.
339
+ *
340
+ * WHY THIS REPLACED A LITERAL
341
+ * The previous text granted the seat authority to send, unreviewed, "all
342
+ * internal messages, external operational messages, candidate comms,
343
+ * follow-ups, calendar coordination" and to escalate only four narrow
344
+ * categories. NONE of that was read from anywhere. It is a GOVERNANCE claim,
345
+ * not a naming one, and it fired on exactly the population least entitled to
346
+ * it: this preamble is used when CLAUDE.md is unreadable OR its extracted
347
+ * sections total <= 200 chars, i.e. on a freshly scaffolded repo that has
348
+ * never had autonomy bands set — while driving a real `claude --print`
349
+ * session that can send real messages.
350
+ *
351
+ * So: bands come from the seat's generated policy or they are not asserted.
352
+ * With no policy the correct posture is LESS authority, not more — the
353
+ * preamble says draft-and-confirm and says why. That is a statement about the
354
+ * ABSENCE of a configured grant, not an invented grant.
355
+ *
356
+ * @param {object|null} policy result of loadSeatAutonomyPolicy()
357
+ * @returns {string}
358
+ */
359
+ export function renderAutonomySection(policy) {
360
+ if (!policy) {
361
+ return [
362
+ "Authority (NO AUTONOMY POLICY IS CONFIGURED FOR THIS SEAT):",
363
+ "- `policies/action-classification.yaml` has not been generated for you (run `maestro upgrade`, which runs scripts/setup/generate-autonomy.mjs).",
364
+ "- Until it has been, assume NO standing authority to act outward. Draft, do not send. Prepare the message, document or calendar change and put it in front of your principal for a decision.",
365
+ "- Do not make commitments, send outbound communications, or take irreversible actions on your own authority on the strength of this prompt. Nothing here grants that.",
366
+ ].join("\n");
367
+ }
368
+
369
+ const levels = (policy.action_levels && typeof policy.action_levels === "object") ? policy.action_levels : {};
370
+ const green = strList(levels.execute_autonomous && levels.execute_autonomous.examples);
371
+ const amber = strList(levels.approval_preferred && levels.approval_preferred.examples);
372
+ const red = strList(levels.escalate && levels.escalate.examples);
373
+ const escalateTo = strList(levels.escalate && levels.escalate.escalate_to, 4);
374
+ const rights = Array.isArray(policy.decision_rights) ? policy.decision_rights.slice(0, 8) : [];
375
+
376
+ const out = [
377
+ `Authority (from policies/action-classification.yaml, generated for archetype ${policy.archetype}):`,
378
+ ];
379
+ if (policy.autonomy_default) out.push(`- Default posture: ${policy.autonomy_default}`);
380
+ if (green.length) out.push(`- You act on your own authority: ${green.join("; ")}`);
381
+ if (amber.length) out.push(`- You get agreement first: ${amber.join("; ")}`);
382
+ if (red.length) out.push(`- You escalate and do NOT act alone: ${red.join("; ")}`);
383
+ if (escalateTo.length) out.push(`- Escalate to: ${escalateTo.join(", ")}`);
384
+ for (const d of rights) {
385
+ if (!d || typeof d !== "object") continue;
386
+ const scope = typeof d.scope === "string" ? d.scope.trim() : "";
387
+ const authority = typeof d.authority === "string" ? d.authority.trim() : "";
388
+ if (!scope) continue;
389
+ out.push(`- Decision right — ${scope}${authority ? ` (${authority})` : ""}`);
390
+ }
391
+ // A generated policy with every band empty asserts nothing. Say so rather
392
+ // than implying the empty bands are a grant.
393
+ if (out.length === 1) {
394
+ out.push("- The generated policy declares no bands. Treat that as no standing authority: draft, do not send.");
395
+ }
396
+ return out.join("\n");
397
+ }
398
+
399
+ /**
400
+ * Preamble used ONLY when CLAUDE.md is unreadable or effectively empty.
401
+ *
402
+ * Everything seat-specific is rendered from the source of truth:
403
+ * identity/company/responsibilities/operating principles → config/agent.json +
404
+ * config/company.json via lib/identity/persona.mjs#renderPersona (which omits
405
+ * an unset field rather than faking it);
406
+ * authority → the seat's generated action-classification policy.
407
+ *
408
+ * What remains hardcoded is framework craft doctrine that makes NO claim about
409
+ * this seat's employer or its authority, so it is honest on any deployment.
410
+ *
411
+ * @returns {string}
412
+ */
413
+ export function buildFallbackPreamble(root = AGENT_REPO_DIR) {
414
+ const parts = [];
415
+
416
+ // Identity, company, responsibilities and operating principles — all from
417
+ // config. Voice rules are omitted here because buildPrompt() already leads
418
+ // with the full persona block; this is the degraded path, not a second copy.
419
+ const persona = renderSeatPersona(root, { includeVoiceRules: false });
420
+ if (persona) parts.push(persona);
421
+
422
+ parts.push(renderAutonomySection(loadSeatAutonomyPolicy(root)));
423
+
424
+ parts.push(
425
+ [
426
+ "How you work:",
427
+ "- Follow-through over brilliance — track every commitment until it closes",
428
+ "- Concise over comprehensive — sharp recommendations, not exhaustive reports",
429
+ "- Evidence over opinion — cite sources, and say when you do not know",
430
+ "- Audit everything — every action is logged",
431
+ ].join("\n"),
432
+ );
433
+
434
+ parts.push(
435
+ [
436
+ "Document Sharing (CRITICAL):",
437
+ "- NEVER reference local file paths in outbound communications",
438
+ "- Upload to shared storage or generate a branded PDF for sharing",
439
+ "- Inline short content directly in messages",
440
+ "- Never attach raw .md or .yaml files",
441
+ ].join("\n"),
442
+ );
443
+
444
+ return parts.join("\n\n");
445
+ }
446
+
447
+ // Computed lazily (and cached) rather than at module load: the autonomy policy
448
+ // and config/agent.json are read from disk, and a test that points AGENT_DIR at
449
+ // a fixture must not be beaten to it by import-time evaluation.
450
+ let _fallbackPreamble = null;
451
+ function fallbackPreamble() {
452
+ if (_fallbackPreamble === null) _fallbackPreamble = buildFallbackPreamble();
453
+ return _fallbackPreamble;
200
454
  }
201
455
 
202
- // Generic preamble derived from config/agent.json. This is only used when
203
- // CLAUDE.md is unreadable; ordinarily the daemon pulls the full identity
204
- // block straight from the agent's CLAUDE.md.
205
- function buildFallbackPreamble() {
206
- const a = loadAgent();
207
- const principal = a.principal || {};
208
- const principalName = principal.fullName || "the principal";
209
- const principalTitle = principal.title || "principal";
210
- // "<title> to <principal>" only reads correctly for assistant-shaped titles;
211
- // for a functional seat ("SVP AI Systems & Agent Platform") it turns the role
212
- // into a service relationship. State the seat, then the reporting line.
213
- return `You are ${a.fullName}, ${a.title} at ${a.company}. You report to ${principalName}, ${principalTitle}.
214
- You operate as the autonomous executive command layer for the company.
215
-
216
- ${a.companyDescription || a.company}.
217
-
218
- Operating Principles:
219
- 1. Follow-through over brilliance — track every commitment until it closes
220
- 2. Concise over comprehensive — sharp recommendations, not exhaustive reports
221
- 3. Evidence over opinion — cite sources
222
- 4. Bias to action — act decisively on operational matters; escalate only strategic commitments
223
- 5. Audit everything — every action is logged
224
- 6. Full autonomy, full accountability
225
-
226
- Autonomy Model:
227
- - ${a.firstName} sends autonomously: all internal messages, external operational messages, candidate comms, follow-ups, calendar coordination
228
- - ${a.firstName} escalates only: binding legal/financial obligations, regulatory submissions, acquisition deal terms, public statements
229
-
230
- Document Sharing (CRITICAL):
231
- - NEVER reference local file paths in outbound communications
232
- - Upload to Google Drive or generate branded PDF for sharing
233
- - Inline short content directly in messages
234
- - Never attach raw .md or .yaml files`;
456
+ /** For tests: drop the cached fallback preamble. */
457
+ export function _resetFallbackPreamble() {
458
+ _fallbackPreamble = null;
235
459
  }
236
- const FALLBACK_PREAMBLE = buildFallbackPreamble();
237
460
 
238
461
  /**
239
462
  * Proactive self-learning habit (WS3). Injected near the top of every reactive /
@@ -531,7 +754,7 @@ function buildBacklogContext(queueItem) {
531
754
  lines.push(`Title: ${queueItem.title || "untitled"}`);
532
755
  lines.push(`Status: ${queueItem.status || "open"}`);
533
756
  lines.push(`Priority: ${queueItem.priority || "normal"}`);
534
- lines.push(`Owner: ${queueItem.owner || loadAgent().firstName.toLowerCase()}`);
757
+ lines.push(`Owner: ${queueItem.owner || configuredStr(loadAgent().firstName).toLowerCase() || "agent"}`);
535
758
  if (queueItem.source) lines.push(`Source: ${queueItem.source}`);
536
759
  if (queueItem.source_ref) lines.push(`Source ref: ${queueItem.source_ref}`);
537
760
  // ── what to act ON, and with WHAT ────────────────────────────────────────
@@ -215,18 +215,18 @@ test("buildPrompt caps the number of injected org facts (bounded)", async () =>
215
215
  test("buildPrompt states the acknowledgement was sent when it actually was", async () => {
216
216
  const prompt = await buildPrompt(ITEM, CLASS, {
217
217
  type: "inbox",
218
- holdingMessage: "Understood — let me dig into this.",
218
+ holdingMessage: "Understood — I'm digging into this now (the noted issues). I want to get this right, so I'll come back to you here with a full answer — usually within 10-20 minutes — and you'll hear from me either way.",
219
219
  holdingSent: true,
220
220
  });
221
221
  assert.match(prompt, /A HOLDING MESSAGE has ALREADY been sent/);
222
- assert.ok(prompt.includes("Understood — let me dig into this."));
222
+ assert.ok(prompt.includes("Understood — I'm digging into this now (the noted issues). I want to get this right, so I'll come back to you here with a full answer — usually within 10-20 minutes — and you'll hear from me either way."));
223
223
  assert.ok(!/DELIVERY FAILED/.test(prompt), "no failure framing on a delivered ack");
224
224
  });
225
225
 
226
226
  test("buildPrompt does NOT claim delivery when the acknowledgement failed to send", async () => {
227
227
  const prompt = await buildPrompt(ITEM, CLASS, {
228
228
  type: "inbox",
229
- holdingMessage: "Understood — let me dig into this.",
229
+ holdingMessage: "Understood — I'm digging into this now (the noted issues). I want to get this right, so I'll come back to you here with a full answer — usually within 10-20 minutes — and you'll hear from me either way.",
230
230
  holdingSent: false,
231
231
  });
232
232
  // The lie: telling the session a human already heard from us when they did not.
@@ -242,13 +242,13 @@ test("buildPrompt does NOT claim delivery when the acknowledgement failed to sen
242
242
  assert.match(prompt, /DELIVERY FAILED/);
243
243
  assert.match(prompt, /received NOTHING/);
244
244
  // The composed text is still shown — it is what the sender would have seen.
245
- assert.ok(prompt.includes("Understood — let me dig into this."));
245
+ assert.ok(prompt.includes("Understood — I'm digging into this now (the noted issues). I want to get this right, so I'll come back to you here with a full answer — usually within 10-20 minutes — and you'll hear from me either way."));
246
246
  });
247
247
 
248
248
  test("buildPrompt treats an unspecified holdingSent as delivered (back-compat)", async () => {
249
249
  const prompt = await buildPrompt(ITEM, CLASS, {
250
250
  type: "inbox",
251
- holdingMessage: "Understood — let me dig into this.",
251
+ holdingMessage: "Understood — I'm digging into this now (the noted issues). I want to get this right, so I'll come back to you here with a full answer — usually within 10-20 minutes — and you'll hear from me either way.",
252
252
  });
253
253
  assert.match(prompt, /A HOLDING MESSAGE has ALREADY been sent/);
254
254
  });
@@ -76,6 +76,22 @@ test("cohort: an empty channel is null, not an empty transcript", async () => {
76
76
  assert.equal(text, null);
77
77
  });
78
78
 
79
+ test("cohort: a roomless surface never asks messaging.history for its LABEL", async () => {
80
+ // A board / doc / decision item has a blank `channel_id` by design and its
81
+ // `channel` is the human label (`task/<title>`). This used to fall back to
82
+ // that label, so the history read asked hq for a channel named after a task
83
+ // title and got nothing — the same label-for-an-id substitution that had the
84
+ // REPLY posted to a task title. The entity's own thread already rides on the
85
+ // item as `thread_context`, so there is nothing to fetch here.
86
+ const seen = [];
87
+ const text = await loadConversationHistory(
88
+ { sender: "Dana Okafor", service: "cohort", kind: "task_comment", channel: "task/Roll out tier-3 guardrails", channel_id: "", content: "?" },
89
+ { fetchHistory: async (params) => { seen.push(params); return []; }, loadOrgConfig: () => ({}) },
90
+ );
91
+ assert.equal(seen.length, 0, "no history call may be made with a label");
92
+ assert.equal(text, null);
93
+ });
94
+
79
95
  test("cohort: a failing history call degrades to null and never throws", async () => {
80
96
  const text = await loadConversationHistory(cohortItem, {
81
97
  fetchHistory: async () => { throw new Error("cohort unreachable"); },
@@ -132,7 +148,7 @@ test("cohort: falls back to the local mirror when the server is unreachable", as
132
148
  );
133
149
 
134
150
  const text = await loadConversationHistory(
135
- { sender: "Dana Okafor", service: "cohort", channel: "chan-1", content: "?" },
151
+ { sender: "Dana Okafor", service: "cohort", channel: "chan-1", channel_id: "chan-1", content: "?" },
136
152
  {
137
153
  agentRepoDir: root,
138
154
  loadOrgConfig: () => ({}),
@@ -154,7 +170,7 @@ test("cohort: server history wins over the local mirror", async () => {
154
170
  );
155
171
 
156
172
  const text = await loadConversationHistory(
157
- { sender: "Dana Okafor", service: "cohort", channel: "chan-1", content: "?" },
173
+ { sender: "Dana Okafor", service: "cohort", channel: "chan-1", channel_id: "chan-1", content: "?" },
158
174
  {
159
175
  agentRepoDir: root,
160
176
  loadOrgConfig: () => ({}),
@@ -556,7 +556,13 @@ const COHORT_HISTORY_CHARS = 400;
556
556
  * Fails open to null on every path. No amount of history is worth failing a send.
557
557
  */
558
558
  async function loadCohortHistory(item, deps = {}) {
559
- const channel = item.channel_id || item.channel || "";
559
+ // `channel_id` ONLY. `item.channel` is the display label (`task/<title>`,
560
+ // `doc/<name>`) and `messaging.history` wants a channel id — the same
561
+ // label-for-an-id substitution that had board replies posted to a task's
562
+ // title. A surface with no room simply has no channel history: the board /
563
+ // doc / decision hydrators already put the entity's OWN thread on the item as
564
+ // `thread_context`, so nothing is lost by not asking for a room's.
565
+ const channel = item.channel_id || "";
560
566
  if (!channel) return null;
561
567
 
562
568
  try {