@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.
- package/.claude/commands/init-maestro.md +16 -9
- package/docs/guides/mac-mini.md +11 -1
- package/docs/runbooks/cohort-cutover.md +16 -0
- package/lib/mcp/server.test.mjs +16 -4
- package/lib/org/client.mjs +58 -1
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +98 -0
- package/lib/org/protocol.test.mjs +19 -2
- package/lib/org/resource-tools.mjs +317 -0
- package/lib/org/resource-tools.test.mjs +361 -0
- package/lib/org/tool-access.mjs +176 -0
- package/lib/org/tool-access.test.mjs +144 -0
- package/lib/org/tool-surface.mjs +431 -5
- package/lib/org/tool-surface.test.mjs +385 -8
- package/lib/org/ui-parity.mjs +196 -3
- package/lib/org/ui-parity.test.mjs +126 -7
- package/lib/tool-definitions.js +23 -2
- package/package.json +2 -2
- package/plugins/maestro-skills/.claude-plugin/marketplace.json +1 -1
- package/plugins/maestro-skills/plugin.json +4 -0
- package/plugins/maestro-skills/skills/venture-deliverables.md +176 -0
- package/policies/information-barriers.yaml +34 -7
- package/scripts/ci/check-no-residual-identity.mjs +281 -9
- package/scripts/ci/check-no-residual-identity.test.mjs +115 -2
- package/scripts/cloud-relay/voice/relay-identity.test.mjs +96 -0
- package/scripts/cloud-relay/voice/server.mjs +42 -2
- package/scripts/cost/track-claude-usage-pricing.test.mjs +183 -0
- package/scripts/cost/track-claude-usage.mjs +113 -4
- package/scripts/daemon/agent-daemon.mjs +150 -3
- package/scripts/daemon/agent-daemon.test.mjs +190 -0
- package/scripts/daemon/assurance.mjs +38 -15
- package/scripts/daemon/assurance.test.mjs +39 -1
- package/scripts/daemon/classifier-identity.test.mjs +137 -0
- package/scripts/daemon/classifier.mjs +98 -17
- package/scripts/daemon/prompt-builder-preamble.test.mjs +210 -0
- package/scripts/daemon/prompt-builder.mjs +264 -41
- package/scripts/daemon/prompt-builder.test.mjs +5 -5
- package/scripts/disclosure_boundaries.py +56 -5
- package/scripts/huddle/huddle-prompt.test.mjs +176 -0
- package/scripts/huddle/huddle-server.mjs +128 -13
- package/scripts/local-triggers/autoupdate.sh +83 -0
- package/scripts/local-triggers/generate-plists.sh +9 -0
- package/scripts/local-triggers/generate-plists.test.mjs +12 -10
- package/scripts/media-generation/brand-clause.test.mjs +135 -0
- package/scripts/media-generation/gemini-image-client.mjs +27 -9
- package/scripts/media-generation/generate-assets.mjs +102 -7
- package/scripts/pre-draft-context.py +91 -15
- package/scripts/spawn-session.sh +36 -6
- package/scripts/test-employer-grounding.py +348 -0
- 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
|
-
|
|
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
|
|
1145
|
-
|
|
1146
|
-
|
|
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
|
|
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 (
|
|
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
|
-
// =
|
|
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,
|
|
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,
|
|
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
|
|
236
|
-
// triggers at all (
|
|
237
|
-
// a
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
};
|