@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
@@ -1139,11 +1139,126 @@ class HuddleServer extends EventEmitter {
1139
1139
  // Huddle-specific system prompt
1140
1140
  // ---------------------------------------------------------------------------
1141
1141
 
1142
- function buildHuddleSystemPrompt() {
1142
+ /**
1143
+ * A config value the operator has not filled in yet.
1144
+ *
1145
+ * The scaffold ships `"name": "UNCONFIGURED"` in config/company.json and empty
1146
+ * strings/arrays everywhere else, so BOTH shapes mean "absent". Treating only
1147
+ * `""` as absent is what let "UNCONFIGURED" reach a prompt.
1148
+ *
1149
+ * @param {*} v
1150
+ * @returns {string} trimmed value, or "" when absent/unconfigured
1151
+ */
1152
+ export function configuredStr(v) {
1153
+ const s = typeof v === "string" ? v.trim() : (typeof v === "number" ? String(v) : "");
1154
+ if (!s) return "";
1155
+ if (s.toUpperCase() === "UNCONFIGURED") return "";
1156
+ return s;
1157
+ }
1158
+
1159
+ /** Normalise a config array to clean strings; drops blanks and UNCONFIGURED. */
1160
+ function configuredList(v) {
1161
+ if (!Array.isArray(v)) return [];
1162
+ return v
1163
+ .map((x) => (x && typeof x === "object" ? configuredStr(x.name) || configuredStr(x.title) : configuredStr(x)))
1164
+ .filter(Boolean);
1165
+ }
1166
+
1167
+ /**
1168
+ * Render the "about the company" section of the huddle prompt from
1169
+ * config/company.json — the SoT that lib/setup/enroll-from-cohort.mjs syncs
1170
+ * from Cohort.
1171
+ *
1172
+ * WHY THIS IS A FUNCTION AND NOT A STRING LITERAL
1173
+ * This block used to be five hardcoded bullets under an `ABOUT <X>:`
1174
+ * header naming one fictional company whose body then described a
1175
+ * DIFFERENT, equally fictional one (a head office, a jurisdiction count and
1176
+ * a licensing phase, none of them the header's). Two non-current identities
1177
+ * in one block is the tell that no code path ever grounded it. This text is SPOKEN ALOUD, on a huddle the server auto-joins
1178
+ * (`this.autoJoin = true`), to participants who can include people outside
1179
+ * the org, and the turn is persisted to logs/huddle/*.jsonl. A fabricated
1180
+ * employer said out loud to an outside participant cannot be retracted.
1181
+ *
1182
+ * FAIL CLOSED. Every line is conditional on a real value. When config/company.json
1183
+ * is untouched (the scaffold ships it as UNCONFIGURED + empty) this returns ""
1184
+ * and the prompt carries NO company section at all. Saying nothing about the
1185
+ * employer is correct behaviour; saying something invented is not.
1186
+ *
1187
+ * @param {object} co parsed config/company.json
1188
+ * @returns {string} the section (no trailing newline), or "" when nothing is known
1189
+ */
1190
+ export function renderHuddleCompanyBlock(co) {
1191
+ const c = co && typeof co === "object" ? co : {};
1192
+ const name = configuredStr(c.name);
1193
+ const legalName = configuredStr(c.legalName);
1194
+ const description = configuredStr(c.description);
1195
+ const industry = configuredStr(c.industry);
1196
+ const stage = configuredStr(c.stage);
1197
+ const jurisdictions = configuredList(c.jurisdictions);
1198
+ const products = configuredList(c.products);
1199
+ const priorities = configuredList(c.strategicPriorities);
1200
+
1201
+ const bullets = [];
1202
+ if (description) bullets.push(`- ${description}`);
1203
+ else if (industry) bullets.push(`- ${name || "The company"} operates in ${industry}`);
1204
+ if (industry && description) bullets.push(`- Industry: ${industry}`);
1205
+ if (legalName && legalName !== name) bullets.push(`- Legal entity: ${legalName}`);
1206
+ if (jurisdictions.length) bullets.push(`- Jurisdictions: ${jurisdictions.join(", ")}`);
1207
+ if (products.length) bullets.push(`- Products: ${products.join(", ")}`);
1208
+ if (stage) bullets.push(`- Stage: ${stage}`);
1209
+ if (priorities.length) bullets.push(`- Current priorities: ${priorities.join("; ")}`);
1210
+
1211
+ if (bullets.length === 0) return "";
1212
+ const header = name ? `ABOUT ${name.toUpperCase()}:` : "ABOUT THE COMPANY:";
1213
+ return [header, ...bullets].join("\n");
1214
+ }
1215
+
1216
+ /**
1217
+ * The identity line. Same omission rule as lib/identity/persona.mjs#renderPersona:
1218
+ * an unset field drops its clause rather than taking a plausible default. The
1219
+ * previous line interpolated `${a.company}` unconditionally, so an unconfigured
1220
+ * seat introduced itself as "… of ." on a live call.
1221
+ *
1222
+ * @param {object} a config/agent.json
1223
+ * @param {object} co config/company.json
1224
+ * @returns {string}
1225
+ */
1226
+ export function renderHuddleIdentityLine(a, co) {
1227
+ const name = configuredStr(a.fullName) ||
1228
+ [configuredStr(a.firstName), configuredStr(a.lastName)].filter(Boolean).join(" ").trim();
1229
+ const title = configuredStr(a.title);
1230
+ const company = configuredStr(a.company) || configuredStr(co && co.name);
1231
+ const principal = (a.principal && typeof a.principal === "object") ? a.principal : {};
1232
+ const pName = configuredStr(principal.fullName) || configuredStr(principal.firstName);
1233
+ const pTitle = configuredStr(principal.title);
1234
+
1235
+ const who = [name, title].filter(Boolean).join(", ");
1236
+ if (!who) {
1237
+ // Nothing on record. Do not invent a persona — state the role of the seat
1238
+ // in the conversation and nothing more.
1239
+ return "You are participating in a Slack huddle — a real-time voice conversation.";
1240
+ }
1241
+ const at = company ? ` at ${company}` : "";
1242
+ const lines = [`You are ${who}${at}.`];
1243
+ if (pName) lines.push(pTitle ? `You report to ${pName}, ${pTitle}.` : `You report to ${pName}.`);
1244
+ lines.push("You are participating in a Slack huddle — a real-time voice conversation.");
1245
+ return lines.join("\n");
1246
+ }
1247
+
1248
+ /** Read config/company.json from the agent repo. Never throws. */
1249
+ function loadCompany() {
1250
+ try {
1251
+ return JSON.parse(readFileSync(join(AGENT_REPO_DIR, "config/company.json"), "utf-8"));
1252
+ } catch {
1253
+ return {};
1254
+ }
1255
+ }
1256
+
1257
+ export function buildHuddleSystemPrompt() {
1143
1258
  const a = loadAgent();
1144
- const principal = a.principal || {};
1145
- return `You are ${a.fullName}, ${a.title} to ${principal.fullName || "the principal"}, ${principal.title || "the principal"} of ${a.company}.
1146
- You are participating in a Slack huddle — a real-time voice conversation.
1259
+ const co = loadCompany();
1260
+ const companyBlock = renderHuddleCompanyBlock(co);
1261
+ return `${renderHuddleIdentityLine(a, co)}
1147
1262
 
1148
1263
  PERSONALITY:
1149
1264
  - Sound like a thoughtful colleague, not an assistant. Full guide: policies/communication-style.md
@@ -1159,15 +1274,8 @@ BANNED OPENERS AND PHRASES (do not use):
1159
1274
  - "As an AI" / "As a language model" / "I should clarify that…"
1160
1275
  - "I hope this helps" / "Feel free to ask if you have any questions"
1161
1276
  - "Here's a comprehensive overview of…"
1162
- - Don't re-introduce yourself by listing your role at length. "Hi, this is ${a.firstName || "the agent"}" is enough.
1163
-
1164
- ABOUT ADAPTIC:
1165
- - Northwind is a global AI-native institutional asset management group
1166
- - Headquartered in DIFC, Dubai with entities across seven plus jurisdictions
1167
- - CEO and founder: the principal (resolved from config/agent.json)
1168
- - Building Northwind OS, an algorithmic trading platform
1169
- - Currently in regulatory licensing phase
1170
-
1277
+ - Don't re-introduce yourself by listing your role at length. "Hi, this is ${configuredStr(a.firstName) || "the agent"}" is enough.
1278
+ ${companyBlock ? `\n${companyBlock}\n` : ""}
1171
1279
  HUDDLE RULES:
1172
1280
  - Huddles are informal — keep responses short and natural
1173
1281
  - Listen more than you talk — do not dominate the conversation
@@ -1199,6 +1307,13 @@ const HUDDLE_SYSTEM_PROMPT = buildHuddleSystemPrompt();
1199
1307
 
1200
1308
  // ---------------------------------------------------------------------------
1201
1309
  // Main
1310
+ //
1311
+ // NOTE: this bootstrap deliberately runs on IMPORT, not behind an
1312
+ // `import.meta.url === process.argv[1]` entrypoint guard. Such a guard compares
1313
+ // a symlink-resolved path against an unresolved argv[1], so a repo reached
1314
+ // through a symlink would start NOTHING, silently, on a voice server that is
1315
+ // supposed to be listening. The prompt renderers are tested without importing
1316
+ // this module (see huddle-prompt.test.mjs) precisely so this stays as it is.
1202
1317
  // ---------------------------------------------------------------------------
1203
1318
 
1204
1319
  const server = new HuddleServer();
@@ -0,0 +1,83 @@
1
+ #!/bin/bash
2
+ # =============================================================================
3
+ # autoupdate.sh — keep @cohortapp/agent-sdk at @latest, health-gated + rollback
4
+ # =============================================================================
5
+ # Runs hourly via the ai.maestro.<first>-autoupdate launchd job. Policy is
6
+ # "auto to @latest": whenever npm's latest is a strictly-greater semver than the
7
+ # installed SDK, this installs it, smart-merges the copied framework files
8
+ # (`maestro upgrade`), restarts the daemon, and VERIFIES the daemon comes back
9
+ # healthy — rolling back to the prior version if it does not.
10
+ #
11
+ # Safety rails:
12
+ # - kill-switch: `touch ~/.maestro-no-autoupdate` (or $AGENT_DIR/.no-autoupdate)
13
+ # halts all auto-updates fleet-wide without unloading the job.
14
+ # - jitter 0-600s so 15 machines don't hit npm / restart in lockstep.
15
+ # - re-exec from a /tmp copy so `maestro upgrade` overwriting THIS file
16
+ # mid-run cannot corrupt the running shell.
17
+ # - health-gate + automatic rollback to the previous version on a bad release.
18
+ # =============================================================================
19
+ set -uo pipefail
20
+ export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${PATH:-}"
21
+
22
+ # Re-exec from a stable copy: `maestro upgrade` may overwrite this very file.
23
+ if [ "${MAESTRO_AUTOUPDATE_REEXEC:-}" != "1" ]; then
24
+ _tmp="$(mktemp "${TMPDIR:-/tmp}/maestro-autoupdate.XXXXXX")" || exit 0
25
+ cp "${BASH_SOURCE[0]}" "$_tmp" 2>/dev/null || exit 0
26
+ MAESTRO_AUTOUPDATE_REEXEC=1 exec /bin/bash "$_tmp" "$@"
27
+ fi
28
+
29
+ AGENT_DIR="${AGENT_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
30
+ cd "$AGENT_DIR" 2>/dev/null || exit 0
31
+ PKG="@cohortapp/agent-sdk"
32
+ LOG_DIR="$AGENT_DIR/logs/autoupdate"; mkdir -p "$LOG_DIR" 2>/dev/null
33
+ LOG="$LOG_DIR/$(date +%Y-%m-%d).log"
34
+ log(){ echo "[$(date -u +%FT%TZ)] $*" >> "$LOG" 2>/dev/null; }
35
+
36
+ # ── kill-switch ──────────────────────────────────────────────────────────────
37
+ if [ -e "$HOME/.maestro-no-autoupdate" ] || [ -e "$AGENT_DIR/.no-autoupdate" ]; then
38
+ log "SKIP: kill-switch present"; exit 0
39
+ fi
40
+
41
+ # ── jitter ───────────────────────────────────────────────────────────────────
42
+ J=$(( RANDOM % 601 )); log "wake; jitter ${J}s"; sleep "$J"
43
+
44
+ # ── version check ────────────────────────────────────────────────────────────
45
+ CUR="$(node -p "require('$AGENT_DIR/node_modules/$PKG/package.json').version" 2>/dev/null || echo 0.0.0)"
46
+ LATEST="$(npm view "$PKG" version 2>/dev/null || echo '')"
47
+ [ -z "$LATEST" ] && { log "npm view failed; skip (still $CUR)"; exit 0; }
48
+ if [ "$CUR" = "$LATEST" ] || [ "$(printf '%s\n%s\n' "$CUR" "$LATEST" | sort -V | tail -1)" != "$LATEST" ]; then
49
+ log "up to date ($CUR)"; exit 0
50
+ fi
51
+ log "UPDATE $CUR -> $LATEST"
52
+
53
+ DAEMON_PLIST="$(ls "$HOME"/Library/LaunchAgents/ai.maestro.*-daemon.plist 2>/dev/null | head -1)"
54
+ DAEMON_LABEL="$(basename "$DAEMON_PLIST" .plist 2>/dev/null)"
55
+ UID_="$(id -u)"
56
+ DLOG="$AGENT_DIR/logs/daemon/daemon-$(date +%Y-%m-%d).log"
57
+
58
+ restart_daemon(){ [ -n "$DAEMON_LABEL" ] && launchctl kickstart -k "gui/$UID_/$DAEMON_LABEL" >> "$LOG" 2>&1; }
59
+ apply_version(){ # $1 = version spec
60
+ npm install "$PKG@$1" --save >> "$LOG" 2>&1 || return 1
61
+ node "$AGENT_DIR/node_modules/$PKG/bin/maestro.mjs" upgrade --force-overwrite >> "$LOG" 2>&1 || log "WARN: upgrade nonzero for $1"
62
+ return 0
63
+ }
64
+ is_healthy(){ # daemon process alive AND a fresh boot/connect line
65
+ pgrep -f "$AGENT_DIR/scripts/daemon/maestro-daemon.mjs" >/dev/null 2>&1 || return 1
66
+ tail -80 "$DLOG" 2>/dev/null | grep -qE "org-mesh connected|daemon\] Running" || return 1
67
+ return 0
68
+ }
69
+
70
+ if ! apply_version "$LATEST"; then
71
+ log "npm install FAILED; aborting, staying on $CUR"; exit 1
72
+ fi
73
+ restart_daemon
74
+ sleep 30
75
+ if is_healthy; then
76
+ log "OK: healthy on $LATEST"
77
+ else
78
+ log "UNHEALTHY on $LATEST -> ROLLBACK to $CUR"
79
+ apply_version "$CUR" || log "WARN: rollback install nonzero"
80
+ restart_daemon
81
+ sleep 20
82
+ is_healthy && log "rolled back to $CUR (healthy)" || log "ROLLBACK health still failing on $CUR — needs operator"
83
+ fi
@@ -293,6 +293,15 @@ generate_plist "ai.maestro.${AGENT_FIRST}-slack-socket" \
293
293
  "${AGENT_DIR}/scripts/cadence/launchd-socket-mode-wrapper.sh" \
294
294
  "" "" "true"
295
295
 
296
+ # 2c. SDK auto-updater (hourly + in-script jitter). Keeps @cohortapp/agent-sdk at
297
+ # @latest: installs a strictly-newer published version, smart-merges the
298
+ # copied framework files (`maestro upgrade`), restarts the daemon, and rolls
299
+ # back if the daemon fails to come up healthy. Kill-switch:
300
+ # `touch ~/.maestro-no-autoupdate`. See scripts/local-triggers/autoupdate.sh.
301
+ generate_plist "ai.maestro.${AGENT_FIRST}-autoupdate" \
302
+ "${AGENT_DIR}/scripts/daemon/launchd-wrapper-generic.sh|${AGENT_DIR}/scripts/local-triggers/autoupdate.sh" \
303
+ "" "3600" ""
304
+
296
305
  # 3. Standard cadence triggers — derived from the canonical STANDARD_CADENCES
297
306
  # in lib/cadences.mjs (the single source of truth), NOT a second hardcoded
298
307
  # list. Previously these were hand-maintained here and drifted from the SoT
@@ -107,10 +107,10 @@ function listPlists(agentRoot) {
107
107
  // Tests
108
108
  // ---------------------------------------------------------------------------
109
109
 
110
- test("generator emits 23 standard plists with the agent's first name", async () => {
110
+ test("generator emits 24 standard plists with the agent's first name", async () => {
111
111
  // Standard inventory (cadence-bus v1 + slack-socket-mode v1), with NO
112
112
  // config/.cadence-plists.tsv present (no archetype cadences):
113
- // daemon, poll-relay, slack-socket (3 infra), PLUS one trigger plist per
113
+ // daemon, poll-relay, slack-socket, autoupdate (4 infra), PLUS one trigger plist per
114
114
  // STANDARD_CADENCES entry in lib/cadences.mjs (the SoT):
115
115
  // inbox-processor, backlog-executor, meeting-prep, meeting-action-capture,
116
116
  // daily-morning-brief, daily-midday-sweep, daily-evening-wrap,
@@ -120,7 +120,7 @@ test("generator emits 23 standard plists with the agent's first name", async ()
120
120
  // messaging-inbound cadence (1), PLUS the 2026-07 Directory & Design
121
121
  // stewards directory-hygiene + brand-steward (2), PLUS the D3 self-directed
122
122
  // loop goal-steward (1), PLUS the 2026-08 DR cadence nightly-backup (1).
123
- // = 3 infra + 20 trigger = 23 total.
123
+ // = 4 infra + 20 trigger = 24 total.
124
124
  // org-pulse used to be declared standard but was OMITTED by the old hardcoded
125
125
  // list (it never got a plist); deriving from the SoT fixes that drift.
126
126
  // The former weekly-* cadences are still ARCHETYPE-DRIVEN (function × altitude),
@@ -130,7 +130,9 @@ test("generator emits 23 standard plists with the agent's first name", async ()
130
130
  const r = runGenerator(root);
131
131
  assert.equal(r.status, 0, r.stderr);
132
132
  const plists = listPlists(root);
133
- assert.equal(plists.length, 23, `expected 23 standard plists; got ${plists.join(",")}`);
133
+ assert.equal(plists.length, 24, `expected 24 standard plists; got ${plists.join(",")}`);
134
+ assert.ok(plists.includes("ai.maestro.alice-autoupdate.plist"),
135
+ `autoupdate plist missing; got ${plists.join(",")}`);
134
136
  assert.ok(plists.includes("ai.maestro.alice-messaging-inbound.plist"),
135
137
  "messaging-inbound (SP10 standard cadence) must get a SoT-derived plist");
136
138
  assert.ok(plists.includes("ai.maestro.alice-nightly-backup.plist"),
@@ -203,7 +205,7 @@ test("archetype cadences from config/.cadence-plists.tsv emit extra trigger plis
203
205
  const r = runGenerator(root);
204
206
  assert.equal(r.status, 0, r.stderr);
205
207
  const plists = listPlists(root);
206
- assert.equal(plists.length, 25, `expected 23 standard + 2 archetype; got ${plists.join(",")}`);
208
+ assert.equal(plists.length, 26, `expected 24 standard + 2 archetype; got ${plists.join(",")}`);
207
209
  const dir = join(root, "scripts/local-triggers/plists");
208
210
  const eng = readFileSync(join(dir, "ai.maestro.erin-engineering-health.plist"), "utf-8");
209
211
  assert.match(eng, /<key>Weekday<\/key>\s*<integer>3<\/integer>/); // base64 schedule decoded
@@ -232,12 +234,12 @@ test("NO trigger plist invokes run-trigger.sh", async () => {
232
234
  const dir = join(root, "scripts/local-triggers/plists");
233
235
  for (const name of listPlists(root)) {
234
236
  const body = readFileSync(join(dir, name), "utf-8");
235
- // Exception: the daemon/poll-relay/slack-socket plists don't run cadence
236
- // triggers at all (the first two are KeepAlive workers; slack-socket is
237
- // a KeepAlive WSS listener). The trigger plists must invoke
238
- // enqueue-cadence-tick.mjs.
237
+ // Exception: the daemon/poll-relay/slack-socket/autoupdate plists don't run
238
+ // cadence triggers at all (daemon + slack-socket are KeepAlive workers;
239
+ // poll-relay is a 5s poller; autoupdate is an hourly SDK self-updater). The
240
+ // trigger plists must invoke enqueue-cadence-tick.mjs.
239
241
  if (name.endsWith("-daemon.plist") || name.endsWith("-poll-relay.plist") ||
240
- name.endsWith("-slack-socket.plist")) {
242
+ name.endsWith("-slack-socket.plist") || name.endsWith("-autoupdate.plist")) {
241
243
  continue;
242
244
  }
243
245
  assert.ok(!body.includes("run-trigger.sh"),
@@ -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
  };