@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,135 @@
1
+ /**
2
+ * brand-clause.test.mjs — generated imagery must not be branded for a company
3
+ * that does not exist.
4
+ *
5
+ * WHAT BROKE
6
+ * `buildDefaultPrompt()` appended, to EVERY illustration prompt on EVERY
7
+ * deployment: "IMPORTANT: This is for <one fictional company>, an
8
+ * institutional-grade AI asset management platform." — while `spec.style`,
9
+ * `spec.mood` and `spec.colorPalette` three lines above were already
10
+ * parameterised correctly. `gemini-image-client.mjs` then closed every
11
+ * refinement pass with "... and <that company>'s brand aesthetic
12
+ * requirements", re-asserting a brand the module cannot know.
13
+ *
14
+ * WHAT THESE TESTS PIN
15
+ * 1. GROUNDED — the brand sentence is built from config/company.json (name,
16
+ * tagline, description, industry) and config/brand-assets.yaml
17
+ * (usage_rules), which bin/maestro.mjs generates per agent.
18
+ * 2. EMPTY — nothing configured ⇒ NO brand clause is emitted at all. The
19
+ * craft direction (which names no company) survives, because it is true on
20
+ * any deployment. An unbranded illustration is a correct outcome.
21
+ *
22
+ * HOW IT RUNS
23
+ * generate-assets.mjs imports "dotenv/config" and gemini-image-client.mjs
24
+ * imports "@google/genai" — neither is a dependency of this package, so
25
+ * neither module can be imported in-process. The renderers are pure, so we
26
+ * lift their REAL source and evaluate it.
27
+ *
28
+ * Run: node --test scripts/media-generation/brand-clause.test.mjs
29
+ */
30
+
31
+ "use strict";
32
+
33
+ import { test } from "node:test";
34
+ import assert from "node:assert/strict";
35
+ import { readFileSync } from "node:fs";
36
+
37
+ const ASSETS_SRC = readFileSync(new URL("./generate-assets.mjs", import.meta.url), "utf8");
38
+ const GEMINI_SRC = readFileSync(new URL("./gemini-image-client.mjs", import.meta.url), "utf8");
39
+
40
+ function extractFunction(src, name, file) {
41
+ const re = new RegExp(`(?:export\\s+)?function\\s+${name}\\s*\\(`);
42
+ const m = re.exec(src);
43
+ assert.ok(m, `${file} no longer defines ${name}() — update this test deliberately`);
44
+ const open = src.indexOf("{", m.index + m[0].length - 1);
45
+ let depth = 0;
46
+ for (let i = open; i < src.length; i++) {
47
+ if (src[i] === "{") depth++;
48
+ else if (src[i] === "}" && --depth === 0) return src.slice(m.index, i + 1).replace(/^export\s+/, "");
49
+ }
50
+ throw new Error(`unbalanced braces extracting ${name}`);
51
+ }
52
+
53
+ const assetNames = ["configuredStr", "buildBrandClause", "buildDefaultPrompt"];
54
+ const assets = new Function(
55
+ `${assetNames.map((n) => extractFunction(ASSETS_SRC, n, "generate-assets.mjs")).join("\n\n")}\n` +
56
+ `return { ${assetNames.join(", ")} };`,
57
+ )();
58
+ const gemini = new Function(
59
+ `${extractFunction(GEMINI_SRC, "buildRefinementPrompt", "gemini-image-client.mjs")}\nreturn { buildRefinementPrompt };`,
60
+ )();
61
+
62
+ const { buildBrandClause, buildDefaultPrompt } = assets;
63
+ const { buildRefinementPrompt } = gemini;
64
+
65
+ const SPEC = { title: "Depot network", purpose: "Board deck cover", elements: ["a map"] };
66
+
67
+ // ── GROUNDED ────────────────────────────────────────────────────────────────
68
+
69
+ test("grounded: the brand sentence is built from config/company.json", () => {
70
+ const clause = buildBrandClause(
71
+ { name: "Meridian Freight", tagline: "Cold chain, unbroken", industry: "Logistics" },
72
+ {},
73
+ );
74
+ assert.equal(clause, "This is for Meridian Freight — Cold chain, unbroken.");
75
+ });
76
+
77
+ test("grounded: description or industry stands in when there is no tagline", () => {
78
+ assert.match(buildBrandClause({ name: "Meridian Freight", description: "Regional logistics." }, {}), /— Regional logistics\./);
79
+ assert.match(buildBrandClause({ name: "Meridian Freight", industry: "Logistics" }, {}), /— Logistics\./);
80
+ assert.equal(buildBrandClause({ name: "Meridian Freight" }, {}), "This is for Meridian Freight.");
81
+ });
82
+
83
+ test("grounded: config/brand-assets.yaml usage_rules pass through verbatim", () => {
84
+ const clause = buildBrandClause(
85
+ { name: "Meridian Freight" },
86
+ { usage_rules: { never: "stretch, rotate, recolour, or modify the assets" } },
87
+ );
88
+ assert.match(clause, /Never stretch, rotate, recolour, or modify the assets\./);
89
+ });
90
+
91
+ test("grounded: the brand clause reaches the prompt", () => {
92
+ const prompt = buildDefaultPrompt(SPEC, "This is for Meridian Freight — Cold chain, unbroken.");
93
+ assert.match(prompt, /IMPORTANT: This is for Meridian Freight — Cold chain, unbroken\./);
94
+ assert.match(prompt, /premium, restrained, and sophisticated/, "craft direction survives");
95
+ });
96
+
97
+ // ── EMPTY — no config, no brand claim ───────────────────────────────────────
98
+
99
+ test("empty: the scaffold's UNCONFIGURED company yields NO brand clause", () => {
100
+ assert.equal(buildBrandClause({ name: "UNCONFIGURED", tagline: "", description: "", industry: "" }, {}), "");
101
+ });
102
+
103
+ test("empty: a missing/garbage config yields NO brand clause", () => {
104
+ assert.equal(buildBrandClause({}, {}), "");
105
+ assert.equal(buildBrandClause(null, null), "");
106
+ assert.equal(buildBrandClause(undefined, undefined), "");
107
+ });
108
+
109
+ test("empty: with no brand clause the prompt names NO company at all", () => {
110
+ const prompt = buildDefaultPrompt(SPEC, "");
111
+ assert.doesNotMatch(prompt, /This is for/);
112
+ assert.doesNotMatch(prompt, /UNCONFIGURED/);
113
+ // The company-agnostic craft direction is still stated — it is true anywhere.
114
+ assert.match(prompt, /IMPORTANT: The illustration must feel premium, restrained, and sophisticated\./);
115
+ assert.match(prompt, /Create an illustration for: Depot network/);
116
+ });
117
+
118
+ test("empty: buildDefaultPrompt defaults to no brand clause when called with one argument", () => {
119
+ assert.doesNotMatch(buildDefaultPrompt(SPEC), /This is for/);
120
+ });
121
+
122
+ // ── The refinement pass re-asserts nothing ──────────────────────────────────
123
+
124
+ test("the refinement prompt preserves the brand STATED ABOVE, it does not name one", () => {
125
+ const out = buildRefinementPrompt("ORIGINAL PROMPT BODY", "fix the horizon line");
126
+ assert.match(out, /ORIGINAL PROMPT BODY/);
127
+ assert.match(out, /fix the horizon line/);
128
+ assert.match(out, /every brand and style requirement stated above/);
129
+ });
130
+
131
+ test("neither media-generation source carries a hardcoded brand", () => {
132
+ assert.doesNotMatch(ASSETS_SRC, /\bNorthwind\b/i);
133
+ assert.doesNotMatch(GEMINI_SRC, /\bNorthwind\b/i);
134
+ assert.doesNotMatch(ASSETS_SRC, /institutional-grade AI asset management/i);
135
+ });
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Gemini Image Generation Client for Northwind CEO Brain
2
+ * Gemini Image Generation Client
3
3
  *
4
4
  * Generates branded illustrations, diagrams, and visual assets for
5
5
  * board materials, investor presentations, and corporate communications.
@@ -140,14 +140,20 @@ async function generateViaImagen(ai, model, prompt, aspectRatio) {
140
140
  }
141
141
 
142
142
  /**
143
- * Generate a refined image by re-generating with enhanced prompt
143
+ * Build the refinement prompt. Pure, so the wording is testable.
144
+ *
145
+ * The closing sentence used to name one specific company's brand aesthetic on
146
+ * every refinement, for every deployment. The originalPrompt ALREADY carries whatever brand direction the
147
+ * caller resolved from config/company.json + config/brand-assets.yaml
148
+ * (generate-assets.mjs#buildBrandClause), so the correct instruction here is to
149
+ * preserve THAT — not to re-assert a brand this module cannot know.
150
+ *
151
+ * @param {string} originalPrompt
152
+ * @param {string} refinementInstructions
153
+ * @returns {string}
144
154
  */
145
- export async function generateImageGeminiWithRefinement(
146
- originalPrompt,
147
- refinementInstructions,
148
- options = {},
149
- ) {
150
- const enhancedPrompt = `${originalPrompt}
155
+ export function buildRefinementPrompt(originalPrompt, refinementInstructions) {
156
+ return `${originalPrompt}
151
157
 
152
158
  ---
153
159
 
@@ -158,7 +164,18 @@ ${refinementInstructions}
158
164
  ---
159
165
 
160
166
  Generate a corrected version addressing ALL refinement instructions above while maintaining
161
- the original composition, concept, and Northwind brand aesthetic requirements.`;
167
+ the original composition, concept, and every brand and style requirement stated above.`;
168
+ }
169
+
170
+ /**
171
+ * Generate a refined image by re-generating with enhanced prompt
172
+ */
173
+ export async function generateImageGeminiWithRefinement(
174
+ originalPrompt,
175
+ refinementInstructions,
176
+ options = {},
177
+ ) {
178
+ const enhancedPrompt = buildRefinementPrompt(originalPrompt, refinementInstructions);
162
179
 
163
180
  console.log(
164
181
  ` [ImageGen] Regenerating with refinement instructions (${refinementInstructions.length} chars)`,
@@ -170,4 +187,5 @@ the original composition, concept, and Northwind brand aesthetic requirements.`;
170
187
  export default {
171
188
  generateImageGemini,
172
189
  generateImageGeminiWithRefinement,
190
+ buildRefinementPrompt,
173
191
  };
@@ -1,11 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Visual Asset Generator for Northwind CEO Brain
4
+ * Visual Asset Generator
5
5
  *
6
6
  * Generates branded illustrations, diagrams, and videos for
7
7
  * board materials, investor presentations, and corporate communications.
8
8
  *
9
+ * BRAND CONTEXT IS READ, NOT WRITTEN. The brand sentence appended to every
10
+ * image prompt comes from config/brand-assets.yaml (generated per agent by
11
+ * bin/maestro.mjs) and config/company.json (the SOT synced from Cohort). When
12
+ * neither is populated NO brand clause is emitted at all — an unbranded
13
+ * illustration is a correct outcome; one branded for a company that does not
14
+ * exist is not.
15
+ *
9
16
  * Ported from ~/company-web/app/scripts/media-generation/generate-visual-assets.mjs
10
17
  *
11
18
  * Usage:
@@ -37,6 +44,76 @@ const LOG_FILE = path.join(__dirname, "generation-log.json");
37
44
 
38
45
  const RATE_LIMIT_DELAY = 15000; // 15s between requests
39
46
 
47
+ // ─── Brand context (config/company.json + config/brand-assets.yaml) ───
48
+
49
+ /** Trimmed config string, or "" when absent/UNCONFIGURED (both mean unset). */
50
+ export function configuredStr(v) {
51
+ const s = typeof v === "string" ? v.trim() : "";
52
+ if (!s || s.toUpperCase() === "UNCONFIGURED") return "";
53
+ return s;
54
+ }
55
+
56
+ /**
57
+ * Build the brand sentence appended to an image prompt.
58
+ *
59
+ * Pure, so the empty case is testable. Every clause is conditional: with an
60
+ * unpopulated config/company.json this returns "" and the caller appends
61
+ * nothing. It previously hardcoded one company's positioning into every
62
+ * generated asset on every deployment.
63
+ *
64
+ * @param {object} company parsed config/company.json
65
+ * @param {object} [brand] parsed config/brand-assets.yaml
66
+ * @returns {string} the sentence, or "" when nothing is configured
67
+ */
68
+ export function buildBrandClause(company, brand) {
69
+ const co = company && typeof company === "object" ? company : {};
70
+ const br = brand && typeof brand === "object" ? brand : {};
71
+
72
+ const name = configuredStr(co.name);
73
+ const tagline = configuredStr(co.tagline);
74
+ const industry = configuredStr(co.industry);
75
+ const description = configuredStr(co.description);
76
+
77
+ const parts = [];
78
+ if (name) {
79
+ const gloss = tagline || description || industry;
80
+ parts.push(gloss ? `This is for ${name} — ${gloss}.` : `This is for ${name}.`);
81
+ } else if (industry) {
82
+ parts.push(`This is for a company in ${industry}.`);
83
+ }
84
+
85
+ // usage_rules from config/brand-assets.yaml are the operator's own stated
86
+ // rules — pass them through verbatim rather than paraphrasing them.
87
+ const rules = br.usage_rules && typeof br.usage_rules === "object" ? br.usage_rules : {};
88
+ const never = configuredStr(rules.never);
89
+ if (never) parts.push(`Never ${never}.`);
90
+
91
+ if (parts.length === 0) return "";
92
+ return parts.join(" ");
93
+ }
94
+
95
+ /** Read config/company.json. Never throws. */
96
+ async function loadCompanyConfig() {
97
+ try {
98
+ return JSON.parse(await fs.readFile(path.join(ROOT, "config/company.json"), "utf-8"));
99
+ } catch {
100
+ return {};
101
+ }
102
+ }
103
+
104
+ /** Read config/brand-assets.yaml. Never throws; {} when js-yaml or file is absent. */
105
+ async function loadBrandAssets() {
106
+ try {
107
+ const raw = await fs.readFile(path.join(ROOT, "config/brand-assets.yaml"), "utf-8");
108
+ const m = await import("js-yaml");
109
+ const yaml = m && m.default ? m.default : m;
110
+ const doc = yaml.load(raw);
111
+ return doc && typeof doc === "object" ? doc : {};
112
+ } catch {
113
+ return {};
114
+ }
115
+ }
116
+
40
117
  // ─── Helpers ───
41
118
 
42
119
  async function loadPromptSpec(type, slug) {
@@ -95,7 +172,7 @@ async function generateIllustration(slug) {
95
172
 
96
173
  await fs.mkdir(outputDir, { recursive: true });
97
174
 
98
- const prompt = spec.prompt || buildDefaultPrompt(spec);
175
+ const prompt = spec.prompt || buildDefaultPrompt(spec, await loadBrandContext());
99
176
  const aspectRatio = spec.aspectRatio || "16:9";
100
177
 
101
178
  const result = await generateImageGemini(prompt, { aspectRatio });
@@ -121,7 +198,21 @@ async function generateIllustration(slug) {
121
198
  return outputPath;
122
199
  }
123
200
 
124
- function buildDefaultPrompt(spec) {
201
+ /**
202
+ * Resolve the brand clause once from the two real sources. Returns "" when
203
+ * neither is populated — the caller then emits no brand clause at all.
204
+ * @returns {Promise<string>}
205
+ */
206
+ async function loadBrandContext() {
207
+ const [company, brand] = await Promise.all([loadCompanyConfig(), loadBrandAssets()]);
208
+ return buildBrandClause(company, brand);
209
+ }
210
+
211
+ /**
212
+ * @param {object} spec the prompt spec
213
+ * @param {string} [brandClause] rendered brand sentence; "" omits the clause
214
+ */
215
+ export function buildDefaultPrompt(spec, brandClause = "") {
125
216
  const parts = [
126
217
  `Create an illustration for: ${spec.title}`,
127
218
  `Purpose: ${spec.purpose}`,
@@ -136,9 +227,13 @@ function buildDefaultPrompt(spec) {
136
227
  );
137
228
  }
138
229
 
139
- parts.push(
140
- "IMPORTANT: This is for Northwind, an institutional-grade AI asset management platform. The illustration must feel premium, restrained, and sophisticated. Avoid cartoon, isometric, or generic SaaS illustration styles.",
141
- );
230
+ // Craft direction is framework doctrine and names no company, so it is always
231
+ // safe to state. The BRAND clause is only added when the config actually
232
+ // carries one — no config, no brand sentence.
233
+ const craft =
234
+ "IMPORTANT: The illustration must feel premium, restrained, and sophisticated. " +
235
+ "Avoid cartoon, isometric, or generic SaaS illustration styles.";
236
+ parts.push(brandClause ? `IMPORTANT: ${brandClause} ${craft.slice("IMPORTANT: ".length)}` : craft);
142
237
 
143
238
  return parts.join("\n\n");
144
239
  }
@@ -228,7 +323,7 @@ async function main() {
228
323
 
229
324
  if (args.length === 0 || args.includes("--help")) {
230
325
  console.log(`
231
- Northwind Visual Asset Generator
326
+ Visual Asset Generator
232
327
 
233
328
  Usage:
234
329
  --illustration <slug> Generate a single illustration
@@ -367,22 +367,98 @@ def lookup_entity(name_or_email):
367
367
  # ─────────────────────────────────────────────
368
368
  # Relationship classification
369
369
  # ─────────────────────────────────────────────
370
- ADAPTIC_IDS = {
371
- 'company:northwind-group', 'company:northwind-holdings',
372
- 'company:northwind-am-difc', 'company:northwind-technologies',
373
- 'company:northwind-group-investments', 'company:northwind-investments',
374
- 'company:northwind-inc', 'company:northwind-ai-uk',
375
- 'company:northwind-am-uk', 'company:northwind-am-cayman',
376
- 'company:northwind-am-asia', 'company:northwind-asia',
377
- 'company:northwind-am-eu', 'company:northwind-am-us',
378
- 'company:northwind-am-ksa', 'company:northwind-ksa',
379
- }
370
+ # Employer identity — resolved ONCE at load from config/company.json (the SOT
371
+ # lib/setup/enroll-from-cohort.mjs syncs from Cohort).
372
+ #
373
+ # This used to be sixteen hardcoded entity ids for the framework's example
374
+ # employer plus a matching `startswith` prefix test, so on any real deployment
375
+ # NOTHING matched: every colleague classified as external, and the pre-draft
376
+ # brief handed the model a wrong relationship label for its own teammates.
377
+ #
378
+ # FAIL CLOSED: with no employer configured the id set is EMPTY and
379
+ # `is_own_entity()` is always False — people resolve as external/unknown, which
380
+ # is the conservative direction for a pre-send context brief.
381
+ def _load_company() -> dict:
382
+ """Read config/company.json. Never raises."""
383
+ try:
384
+ with open(REPO_ROOT / "config" / "company.json") as f:
385
+ data = json.load(f)
386
+ return data if isinstance(data, dict) else {}
387
+ except Exception:
388
+ return {}
389
+
390
+
391
+ def _configured(value) -> str:
392
+ """Trimmed config string, or '' when absent/UNCONFIGURED (both mean unset)."""
393
+ if not isinstance(value, str):
394
+ return ''
395
+ v = value.strip()
396
+ if not v or v.upper() == 'UNCONFIGURED':
397
+ return ''
398
+ return v
399
+
400
+
401
+ _COMPANY = _load_company()
402
+ COMPANY_NAME = _configured(_COMPANY.get('name'))
403
+ COMPANY_LEGAL_NAME = _configured(_COMPANY.get('legalName'))
404
+ COMPANY_DOMAIN = _configured(_COMPANY.get('domain'))
405
+
406
+
407
+ def _slugify(value: str) -> str:
408
+ """'Acme Holdings, Ltd.' -> 'acme-holdings-ltd'. '' for junk."""
409
+ if not value:
410
+ return ''
411
+ return re.sub(r'[^a-z0-9]+', '-', value.lower()).strip('-')
412
+
413
+
414
+ def _resolve_own_entity_ids():
415
+ """(ids, prefixes) for the agent's own employer. See the note above.
416
+
417
+ An explicit `own_entity_ids` list in config/company.json wins; otherwise ids
418
+ are derived from name / legalName / domain, with prefix reach from the
419
+ shortest stem so a group's subsidiaries resolve without enumeration.
420
+ """
421
+ explicit = _COMPANY.get('own_entity_ids')
422
+ if isinstance(explicit, list):
423
+ ids = {str(x).strip() for x in explicit if isinstance(x, str) and str(x).strip()}
424
+ if ids:
425
+ return ids, set()
426
+
427
+ stems = set()
428
+ for raw in (COMPANY_NAME, COMPANY_LEGAL_NAME):
429
+ slug = _slugify(raw)
430
+ if slug:
431
+ stems.add(slug)
432
+ if COMPANY_DOMAIN:
433
+ host = COMPANY_DOMAIN.lower().strip().split('/')[0]
434
+ host = host[4:] if host.startswith('www.') else host
435
+ slug = _slugify(host.split('.')[0])
436
+ if slug:
437
+ stems.add(slug)
438
+
439
+ ids = {'company:' + s for s in stems}
440
+ # Every stem is also a prefix so a group's subsidiaries resolve without
441
+ # enumeration; `own_entity_ids` in config/company.json overrides both.
442
+ prefixes = {'company:' + s for s in stems}
443
+ return ids, prefixes
444
+
445
+
446
+ OWN_ENTITY_IDS, OWN_ENTITY_PREFIXES = _resolve_own_entity_ids()
447
+
448
+
449
+ def is_own_entity(obj) -> bool:
450
+ """True when an entity-index object id denotes the agent's own employer."""
451
+ if not obj or not isinstance(obj, str):
452
+ return False
453
+ if obj in OWN_ENTITY_IDS:
454
+ return True
455
+ return any(obj.startswith(p) for p in OWN_ENTITY_PREFIXES)
380
456
 
381
457
 
382
458
  def classify_relationship(entity_id):
383
- """Classify a person or company's relationship to Northwind.
459
+ """Classify a person or company's relationship to the agent's employer.
384
460
 
385
- Returns dict: relationship_type, is_internal, relationships_to_northwind.
461
+ Returns dict: relationship_type, is_internal, relationships_to_company.
386
462
  """
387
463
  idx = load_entity_index()
388
464
  rels = idx['rels_by_subject'].get(entity_id, [])
@@ -394,7 +470,7 @@ def classify_relationship(entity_id):
394
470
  for rel in rels:
395
471
  obj = rel.get('object', '')
396
472
  pred = rel.get('predicate', '')
397
- if obj in ADAPTIC_IDS or obj.startswith('company:northwind'):
473
+ if is_own_entity(obj):
398
474
  company_rels.append(rel)
399
475
  if pred in ('employed_by', 'directs'):
400
476
  is_internal = True
@@ -419,7 +495,7 @@ def classify_relationship(entity_id):
419
495
  return {
420
496
  'relationship_type': rel_type,
421
497
  'is_internal': is_internal,
422
- 'relationships_to_northwind': company_rels,
498
+ 'relationships_to_company': company_rels,
423
499
  }
424
500
 
425
501
 
@@ -813,7 +889,7 @@ def retrieve_context(recipient, topic=None):
813
889
  'scope': r.get('scope', ''),
814
890
  'notes': r.get('notes', ''),
815
891
  }
816
- for r in rel_info['relationships_to_northwind']
892
+ for r in rel_info['relationships_to_company']
817
893
  ],
818
894
  },
819
895
  'communication': {
@@ -8,17 +8,31 @@
8
8
  set -e
9
9
 
10
10
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11
- SOPHIE_AI_DIR="$(dirname "$SCRIPT_DIR")"
11
+ # The agent repo root. Renamed from the old instance-specific variable name;
12
+ # bin/maestro.mjs already exports AGENT_DIR (and treats the old name as an
13
+ # alias of it), so honouring the environment first is the correct precedence.
14
+ AGENT_DIR="${AGENT_DIR:-$(dirname "$SCRIPT_DIR")}"
15
+
16
+ # Real identity for the sub-session prompt. config/agent.env is generated from
17
+ # config/agent.json by scripts/setup/generate-agent-env.mjs on every
18
+ # `maestro upgrade` — it exists precisely so shell scripts can read the SOT.
19
+ # Absent or unset fields stay EMPTY and their clause is dropped below; the
20
+ # prompt never names an employer the config does not have.
21
+ if [ -f "$AGENT_DIR/config/agent.env" ]; then
22
+ # shellcheck disable=SC1091
23
+ . "$AGENT_DIR/config/agent.env"
24
+ fi
25
+
12
26
  SESSION_ID="${1:?Session ID required}"
13
27
  TASK_FILE="${2:?Task file path required}"
14
28
  TIME_BUDGET="${3:-600}" # Default 10 minutes
15
29
  TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
16
30
 
17
- SESSION_DIR="$SOPHIE_AI_DIR/outputs/sessions/$SESSION_ID"
18
- LOG_FILE="$SOPHIE_AI_DIR/logs/sessions/$SESSION_ID.log"
31
+ SESSION_DIR="$AGENT_DIR/outputs/sessions/$SESSION_ID"
32
+ LOG_FILE="$AGENT_DIR/logs/sessions/$SESSION_ID.log"
19
33
 
20
34
  # Validate
21
- if [ -f "$SOPHIE_AI_DIR/.emergency-stop" ]; then
35
+ if [ -f "$AGENT_DIR/.emergency-stop" ]; then
22
36
  echo "[$TIMESTAMP] BLOCKED: Emergency stop is active" >&2
23
37
  exit 1
24
38
  fi
@@ -72,12 +86,28 @@ echo "[$TIMESTAMP] Spawning session $SESSION_ID (budget: ${TIME_BUDGET}s)" | tee
72
86
  # MAESTRO_SESSION_BACKEND=claudraband — persistent sessions via claudraband daemon
73
87
  SESSION_BACKEND="${MAESTRO_SESSION_BACKEND:-claude}"
74
88
 
89
+ # Identity clause — assembled from config/agent.env, omitting whatever is unset.
90
+ # "UNCONFIGURED" is what the scaffold ships, so it counts as unset too.
91
+ _agent_name="${AGENT_FULL_NAME:-}"
92
+ _company="${COMPANY_NAME:-}"
93
+ # `set -e` is on: use `if`, not `[ … ] && …` (a false test would exit the script).
94
+ if [ "$_agent_name" = "UNCONFIGURED" ]; then _agent_name=""; fi
95
+ if [ "$_company" = "UNCONFIGURED" ]; then _company=""; fi
96
+
97
+ if [ -n "$_agent_name" ] && [ -n "$_company" ]; then
98
+ SESSION_IDENTITY="IMPORTANT: You are a sub-session spawned for $_agent_name at $_company."
99
+ elif [ -n "$_agent_name" ]; then
100
+ SESSION_IDENTITY="IMPORTANT: You are a sub-session spawned for $_agent_name."
101
+ else
102
+ SESSION_IDENTITY="IMPORTANT: You are a sub-session spawned by an agent."
103
+ fi
104
+
75
105
  SESSION_PROMPT="$TASK_CONTENT
76
106
 
77
- IMPORTANT: You are a sub-session spawned for a Northwind AI agent.
107
+ $SESSION_IDENTITY
78
108
  Write your output to: $SESSION_DIR/output.md
79
109
  When complete, write 'DONE' to: $SESSION_DIR/complete
80
- Working directory: $SOPHIE_AI_DIR
110
+ Working directory: $AGENT_DIR
81
111
  Do NOT send any communications. Only produce the requested output."
82
112
 
83
113
  (