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