@cohortapp/agent-sdk 2.10.0 → 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 (50) 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/mcp/server.test.mjs +16 -4
  5. package/lib/org/client.mjs +58 -1
  6. package/lib/org/protocol.checksum +1 -1
  7. package/lib/org/protocol.mjs +98 -0
  8. package/lib/org/protocol.test.mjs +19 -2
  9. package/lib/org/resource-tools.mjs +317 -0
  10. package/lib/org/resource-tools.test.mjs +361 -0
  11. package/lib/org/tool-access.mjs +176 -0
  12. package/lib/org/tool-access.test.mjs +144 -0
  13. package/lib/org/tool-surface.mjs +431 -5
  14. package/lib/org/tool-surface.test.mjs +385 -8
  15. package/lib/org/ui-parity.mjs +196 -3
  16. package/lib/org/ui-parity.test.mjs +126 -7
  17. package/lib/tool-definitions.js +23 -2
  18. package/package.json +2 -2
  19. package/plugins/maestro-skills/.claude-plugin/marketplace.json +1 -1
  20. package/plugins/maestro-skills/plugin.json +4 -0
  21. package/plugins/maestro-skills/skills/venture-deliverables.md +176 -0
  22. package/policies/information-barriers.yaml +34 -7
  23. package/scripts/ci/check-no-residual-identity.mjs +281 -9
  24. package/scripts/ci/check-no-residual-identity.test.mjs +115 -2
  25. package/scripts/cloud-relay/voice/relay-identity.test.mjs +96 -0
  26. package/scripts/cloud-relay/voice/server.mjs +42 -2
  27. package/scripts/cost/track-claude-usage-pricing.test.mjs +183 -0
  28. package/scripts/cost/track-claude-usage.mjs +113 -4
  29. package/scripts/daemon/agent-daemon.mjs +150 -3
  30. package/scripts/daemon/agent-daemon.test.mjs +190 -0
  31. package/scripts/daemon/assurance.mjs +38 -15
  32. package/scripts/daemon/assurance.test.mjs +39 -1
  33. package/scripts/daemon/classifier-identity.test.mjs +137 -0
  34. package/scripts/daemon/classifier.mjs +98 -17
  35. package/scripts/daemon/prompt-builder-preamble.test.mjs +210 -0
  36. package/scripts/daemon/prompt-builder.mjs +264 -41
  37. package/scripts/daemon/prompt-builder.test.mjs +5 -5
  38. package/scripts/disclosure_boundaries.py +56 -5
  39. package/scripts/huddle/huddle-prompt.test.mjs +176 -0
  40. package/scripts/huddle/huddle-server.mjs +128 -13
  41. package/scripts/local-triggers/autoupdate.sh +83 -0
  42. package/scripts/local-triggers/generate-plists.sh +9 -0
  43. package/scripts/local-triggers/generate-plists.test.mjs +12 -10
  44. package/scripts/media-generation/brand-clause.test.mjs +135 -0
  45. package/scripts/media-generation/gemini-image-client.mjs +27 -9
  46. package/scripts/media-generation/generate-assets.mjs +102 -7
  47. package/scripts/pre-draft-context.py +91 -15
  48. package/scripts/spawn-session.sh +36 -6
  49. package/scripts/test-employer-grounding.py +348 -0
  50. package/scripts/validate_outbound.py +190 -26
@@ -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
  });
@@ -9,8 +9,8 @@ Generates dynamic disclosure boundary guidance for a given recipient by:
9
9
  recent sensitive context, and excluded domains active
10
10
 
11
11
  Usage (CLI):
12
- python3 scripts/disclosure_boundaries.py --recipient casey-morgan
13
- python3 scripts/disclosure_boundaries.py --recipient alex-chen --hours 24
12
+ python3 scripts/disclosure_boundaries.py --recipient <profile-slug>
13
+ python3 scripts/disclosure_boundaries.py --recipient <profile-slug> --hours 24
14
14
 
15
15
  Exit codes:
16
16
  0 = always (profile not found prints to stderr and exits 0)
@@ -35,6 +35,57 @@ USER_PROFILES_DIR = REPO_ROOT / "memory" / "profiles" / "users"
35
35
  PROVENANCE_INDEX = REPO_ROOT / "memory" / "provenance" / "index.jsonl"
36
36
 
37
37
 
38
+ # ─── Employer identity (config/company.json — the SOT) ───────────────────────
39
+ # The clearance summary states a recipient's relationship to the agent's
40
+ # EMPLOYER. That employer used to be a hardcoded name in the sentence, so every
41
+ # deployment's clearance summaries named a company that does not exist. It now
42
+ # comes from config/company.json, and when nothing is configured the sentence
43
+ # says "the company" rather than inventing one.
44
+ def _load_company() -> dict:
45
+ """Read config/company.json. Never raises."""
46
+ try:
47
+ with open(REPO_ROOT / "config" / "company.json") as f:
48
+ data = json.load(f)
49
+ return data if isinstance(data, dict) else {}
50
+ except Exception:
51
+ return {}
52
+
53
+
54
+ def _configured(value) -> str:
55
+ """Trimmed config string, or '' when absent/UNCONFIGURED (both mean unset)."""
56
+ if not isinstance(value, str):
57
+ return ''
58
+ v = value.strip()
59
+ if not v or v.upper() == 'UNCONFIGURED':
60
+ return ''
61
+ return v
62
+
63
+
64
+ COMPANY_NAME = _configured(_load_company().get('name'))
65
+
66
+
67
+ def legacy_relationship_value(profile: dict) -> str:
68
+ """Read a profile's flat "relationship to the employer" field.
69
+
70
+ Canonical key: `relationship_to_company`. Profiles written before this was
71
+ de-hardcoded used `relationship_to_<employer-name>`, so ANY
72
+ `relationship_to_*` key is accepted rather than pinning one company name
73
+ into the reader.
74
+ """
75
+ if not isinstance(profile, dict):
76
+ return ''
77
+ primary = profile.get('relationship_to_company')
78
+ if isinstance(primary, str) and primary.strip():
79
+ return primary
80
+ for key in sorted(profile.keys()):
81
+ if not isinstance(key, str) or not key.startswith('relationship_to_'):
82
+ continue
83
+ value = profile.get(key)
84
+ if isinstance(value, str) and value.strip():
85
+ return value
86
+ return ''
87
+
88
+
38
89
  # ─── YAML Parser ─────────────────────────────────────────────────────────────
39
90
  # Custom minimal YAML parser — no PyYAML dependency (consistent with repo pattern).
40
91
  # Handles: top-level scalars, nested mappings, inline lists [a, b, c],
@@ -427,7 +478,7 @@ def _build_clearance_summary(profile: dict, boundaries: dict) -> str:
427
478
  name = profile.get('name') or profile.get('preferred_name') or 'Unknown'
428
479
  role = profile.get('role') or ''
429
480
  org = profile.get('organisation') or ''
430
- relationship = profile.get('relationship_to_northwind') or ''
481
+ relationship = legacy_relationship_value(profile)
431
482
  domains_of_interest = boundaries.get('domains_of_interest') or []
432
483
  excluded_domains = boundaries.get('excluded_domains') or []
433
484
  conflicts = boundaries.get('conflicts') or []
@@ -446,7 +497,7 @@ def _build_clearance_summary(profile: dict, boundaries: dict) -> str:
446
497
  if org:
447
498
  parts.append(f"Organisation: {org}.")
448
499
  if relationship:
449
- parts.append(f"Relationship to Northwind: {relationship}.")
500
+ parts.append(f"Relationship to {COMPANY_NAME or 'the company'}: {relationship}.")
450
501
 
451
502
  if domains_of_interest:
452
503
  parts.append(f"Permitted domains: {', '.join(str(d) for d in domains_of_interest)}.")
@@ -536,7 +587,7 @@ def main():
536
587
  parser.add_argument(
537
588
  '--recipient',
538
589
  required=True,
539
- help="Recipient slug (e.g. casey-morgan)",
590
+ help="Recipient profile slug (memory/profiles/users/<slug>.yaml)",
540
591
  )
541
592
  parser.add_argument(
542
593
  '--hours',
@@ -0,0 +1,176 @@
1
+ /**
2
+ * huddle-prompt.test.mjs — the huddle system prompt is SPOKEN ALOUD. It must
3
+ * never say anything the config does not know.
4
+ *
5
+ * WHAT BROKE
6
+ * `buildHuddleSystemPrompt()` carried a five-bullet block headed
7
+ * a shout-case `ABOUT <ONE-COMPANY>:` header whose body described a
8
+ * completely different, equally
9
+ * fictional company (a head office, a jurisdiction count, a product, a
10
+ * licensing phase). Two non-current identities in one block is the tell that
11
+ * no code path ever grounded it. The server `autoJoin`s incoming huddle
12
+ * invitations, participants can include people outside the org, and every turn
13
+ * is persisted to logs/huddle/*.jsonl — so this was a fabricated employer said
14
+ * out loud to outsiders and then written to disk. The identity line above it
15
+ * also interpolated `${a.company}` unconditionally, so an unconfigured seat
16
+ * introduced itself as "… of .".
17
+ *
18
+ * WHAT THESE TESTS PIN
19
+ * 1. GROUNDED — every bullet in the company block traces to a field of
20
+ * config/company.json (name, legalName, description, industry, stage,
21
+ * jurisdictions, products, strategicPriorities), one for one.
22
+ * 2. EMPTY — an unpopulated (or scaffold-UNCONFIGURED) config produces NO
23
+ * company block AT ALL and no dangling "at <blank>" clause. Saying nothing
24
+ * about the employer is the correct outcome.
25
+ *
26
+ * HOW IT RUNS
27
+ * huddle-server.mjs imports `dotenv` and `@anthropic-ai/sdk`, neither of which
28
+ * is a dependency of this package (only better-sqlite3 + js-yaml are), so the
29
+ * module cannot be imported in-process here. The renderers are pure and
30
+ * dependency-free, so we lift their REAL source out of the shipped file and
31
+ * evaluate that — the text under test is exactly the text that ships, and
32
+ * deleting/renaming a renderer fails this file rather than silently skipping.
33
+ *
34
+ * Run: node --test scripts/huddle/huddle-prompt.test.mjs
35
+ */
36
+
37
+ "use strict";
38
+
39
+ import { test } from "node:test";
40
+ import assert from "node:assert/strict";
41
+ import { readFileSync } from "node:fs";
42
+
43
+ const SRC = readFileSync(new URL("./huddle-server.mjs", import.meta.url), "utf8");
44
+
45
+ /**
46
+ * Slice one top-level `function NAME(...) { ... }` out of a source file by
47
+ * brace matching. Throws when the function is absent — a rename or deletion is
48
+ * a test failure, not a skip.
49
+ */
50
+ function extractFunction(src, name) {
51
+ const re = new RegExp(`(?:export\\s+)?function\\s+${name}\\s*\\(`);
52
+ const m = re.exec(src);
53
+ assert.ok(m, `huddle-server.mjs no longer defines ${name}() — update this test deliberately`);
54
+ const open = src.indexOf("{", m.index + m[0].length - 1);
55
+ let depth = 0;
56
+ for (let i = open; i < src.length; i++) {
57
+ const c = src[i];
58
+ if (c === "{") depth++;
59
+ else if (c === "}") {
60
+ depth--;
61
+ if (depth === 0) return src.slice(m.index, i + 1).replace(/^export\s+/, "");
62
+ }
63
+ }
64
+ throw new Error(`unbalanced braces extracting ${name}`);
65
+ }
66
+
67
+ const names = ["configuredStr", "configuredList", "renderHuddleCompanyBlock", "renderHuddleIdentityLine"];
68
+ const body = names.map((n) => extractFunction(SRC, n)).join("\n\n");
69
+ // eslint-disable-next-line no-new-func
70
+ const mod = new Function(`${body}\nreturn { ${names.join(", ")} };`)();
71
+ const { renderHuddleCompanyBlock, renderHuddleIdentityLine } = mod;
72
+
73
+ // ── 1. GROUNDED ─────────────────────────────────────────────────────────────
74
+
75
+ const REAL_COMPANY = {
76
+ name: "Meridian Freight",
77
+ legalName: "Meridian Freight Holdings Ltd",
78
+ description: "A regional logistics operator moving temperature-controlled freight.",
79
+ industry: "Logistics",
80
+ stage: "Series B",
81
+ jurisdictions: ["Ireland", "United Kingdom"],
82
+ products: ["Meridian Cold Chain", "Meridian Track"],
83
+ strategicPriorities: ["Open the Cork depot", "Cut empty-mile rate below 12%"],
84
+ };
85
+
86
+ test("grounded: every bullet traces to a config/company.json field", () => {
87
+ const block = renderHuddleCompanyBlock(REAL_COMPANY);
88
+ assert.match(block, /^ABOUT MERIDIAN FREIGHT:/);
89
+ assert.match(block, /A regional logistics operator moving temperature-controlled freight\./);
90
+ assert.match(block, /Industry: Logistics/);
91
+ assert.match(block, /Legal entity: Meridian Freight Holdings Ltd/);
92
+ assert.match(block, /Jurisdictions: Ireland, United Kingdom/);
93
+ assert.match(block, /Products: Meridian Cold Chain, Meridian Track/);
94
+ assert.match(block, /Stage: Series B/);
95
+ assert.match(block, /Current priorities: Open the Cork depot; Cut empty-mile rate below 12%/);
96
+ });
97
+
98
+ test("grounded: a partially-filled config renders only the fields it has", () => {
99
+ const block = renderHuddleCompanyBlock({ name: "Meridian Freight", industry: "Logistics" });
100
+ assert.match(block, /^ABOUT MERIDIAN FREIGHT:/);
101
+ assert.match(block, /Meridian Freight operates in Logistics/);
102
+ assert.doesNotMatch(block, /Jurisdictions:/);
103
+ assert.doesNotMatch(block, /Products:/);
104
+ assert.doesNotMatch(block, /Stage:/);
105
+ assert.doesNotMatch(block, /Current priorities:/);
106
+ });
107
+
108
+ test("grounded: the identity line names the real employer and principal", () => {
109
+ const line = renderHuddleIdentityLine(
110
+ {
111
+ fullName: "Robin Okafor",
112
+ title: "VP Operations",
113
+ company: "",
114
+ principal: { fullName: "Dana Lee", title: "CEO" },
115
+ },
116
+ REAL_COMPANY,
117
+ );
118
+ assert.match(line, /^You are Robin Okafor, VP Operations at Meridian Freight\./m);
119
+ assert.match(line, /^You report to Dana Lee, CEO\./m);
120
+ assert.match(line, /Slack huddle/);
121
+ });
122
+
123
+ // ── 2. EMPTY — no config, no claim ──────────────────────────────────────────
124
+
125
+ test("empty: the scaffold's UNCONFIGURED company yields NO company block at all", () => {
126
+ const scaffold = {
127
+ name: "UNCONFIGURED",
128
+ legalName: "",
129
+ description: "",
130
+ industry: "",
131
+ stage: "",
132
+ jurisdictions: [],
133
+ products: [],
134
+ strategicPriorities: [],
135
+ };
136
+ assert.equal(renderHuddleCompanyBlock(scaffold), "", "silence is the correct outcome");
137
+ });
138
+
139
+ test("empty: a missing/garbage config yields NO company block", () => {
140
+ assert.equal(renderHuddleCompanyBlock({}), "");
141
+ assert.equal(renderHuddleCompanyBlock(null), "");
142
+ assert.equal(renderHuddleCompanyBlock(undefined), "");
143
+ assert.equal(renderHuddleCompanyBlock("nonsense"), "");
144
+ });
145
+
146
+ test("empty: no employer ⇒ the identity line drops the 'at <company>' clause", () => {
147
+ const line = renderHuddleIdentityLine(
148
+ { fullName: "Robin Okafor", title: "VP Operations", company: "", principal: {} },
149
+ { name: "UNCONFIGURED" },
150
+ );
151
+ assert.match(line, /^You are Robin Okafor, VP Operations\.$/m);
152
+ assert.doesNotMatch(line, / at /);
153
+ assert.doesNotMatch(line, /UNCONFIGURED/);
154
+ assert.doesNotMatch(line, /You report to/, "no principal on record ⇒ no reporting line");
155
+ });
156
+
157
+ test("empty: no identity at all ⇒ no persona is asserted, only the setting", () => {
158
+ const line = renderHuddleIdentityLine({}, {});
159
+ assert.equal(line, "You are participating in a Slack huddle — a real-time voice conversation.");
160
+ });
161
+
162
+ // ── 3. The fabricated block is gone from the shipped source ─────────────────
163
+
164
+ test("the shipped prompt carries no invented employer, jurisdiction or product", () => {
165
+ assert.doesNotMatch(SRC, /\bNorthwind\b/i);
166
+ // No shout-case literal company header of ANY name survives — the header is
167
+ // built as `ABOUT ${name.toUpperCase()}:` from config, so a literal one is a
168
+ // regression by construction.
169
+ assert.doesNotMatch(SRC, /ABOUT [A-Z]{4,}:/);
170
+ assert.doesNotMatch(SRC, /\bDIFC\b/);
171
+ assert.doesNotMatch(SRC, /Currently in regulatory licensing phase/i);
172
+ assert.doesNotMatch(SRC, /global AI-native institutional asset management/i);
173
+ // The company block must be a render call, not a literal.
174
+ assert.match(SRC, /renderHuddleCompanyBlock\(co\)/);
175
+ assert.match(SRC, /\$\{companyBlock \? /, "the block is conditional on there being one");
176
+ });