@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
@@ -71,23 +71,68 @@ try {
71
71
  // peers list. Prevents cross-agent message interception in shared channels
72
72
  // (ib-20260418-cross-agent-routing).
73
73
 
74
- const AGENT_DIR = process.env.AGENT_DIR || process.env.AGENT_ROOT || resolve(__dirname, "../..");
74
+ // Resolved lazily so a test (or a re-exec that re-points AGENT_DIR) sees the
75
+ // current value rather than whatever env the module happened to load under.
76
+ function agentDir() {
77
+ return process.env.AGENT_DIR || process.env.AGENT_ROOT || resolve(__dirname, "../..");
78
+ }
79
+
80
+ /**
81
+ * A config value the operator has not filled in yet. config/company.json ships
82
+ * `"name": "UNCONFIGURED"` in the scaffold and empty strings elsewhere, so BOTH
83
+ * shapes mean absent.
84
+ * @param {*} v @returns {string}
85
+ */
86
+ function configuredStr(v) {
87
+ const s = typeof v === "string" ? v.trim() : "";
88
+ // The scaffold ships SENTINELS, not blanks: config/company.json has
89
+ // "UNCONFIGURED", config/agent.json has "UNCONFIGURED" / "Unconfigured Agent"
90
+ // / "Unconfigured Role". A sentinel is an absence and must never be rendered.
91
+ if (!s || /^unconfigured\b/i.test(s)) return "";
92
+ return s;
93
+ }
94
+
95
+ /** Read config/company.json (the company SOT). Never throws. */
96
+ function loadCompanyConfig(dir) {
97
+ try {
98
+ return JSON.parse(readFileSync(resolve(dir, "config/company.json"), "utf-8"));
99
+ } catch {
100
+ return {};
101
+ }
102
+ }
75
103
 
76
- let _agentIdentity = null; // { name, slackId, role, fullName, principal: {…} }
104
+ let _agentIdentity = null; // { name, slackId, role, fullName, company, principal: {…} }
77
105
  let _peerAgents = []; // [{ name, slackId, role }] — all agents EXCEPT me
78
106
 
107
+ /** For tests: drop the cached registry so a fresh AGENT_DIR is re-read. */
108
+ export function _resetAgentRegistry() {
109
+ _agentIdentity = null;
110
+ _peerAgents = [];
111
+ }
112
+
79
113
  function loadAgentRegistry() {
80
114
  if (_agentIdentity) return; // already loaded
115
+ const dir = agentDir();
81
116
 
82
117
  // 1. Primary: load THIS agent's identity from config/agent.json (SOT).
83
118
  try {
84
- const me = JSON.parse(readFileSync(resolve(AGENT_DIR, "config/agent.json"), "utf-8"));
119
+ const me = JSON.parse(readFileSync(resolve(dir, "config/agent.json"), "utf-8"));
85
120
  _agentIdentity = {
86
- name: me.firstName,
87
- fullName: me.fullName,
121
+ // Scrubbed to "" when unset — each consumer then picks its own honest
122
+ // default (a generic noun in prose; NO match at all in the @-mention test,
123
+ // which must never widen because identity is missing).
124
+ name: configuredStr(me.firstName),
125
+ fullName: configuredStr(me.fullName),
88
126
  slackId: me.slackMemberId || "",
89
- role: me.title || "agent",
127
+ role: configuredStr(me.title),
90
128
  repo: me.repoSlug,
129
+ // The employer, from the SAME object the name and role come from — and
130
+ // config/company.json when agent.json leaves it blank. It used to be
131
+ // a hardcoded example-company literal in the prompt below while
132
+ // everything around it was interpolated. INTERNAL classification comes
133
+ // from CONFIG, never a hardcoded company (the same doctrine as
134
+ // scripts/poller/gmail-poller.mjs#internalDomains).
135
+ company: configuredStr(me.company) || configuredStr(loadCompanyConfig(dir).name),
91
136
  principal: me.principal
92
137
  ? {
93
138
  firstName: me.principal.firstName || "the principal",
@@ -99,13 +144,14 @@ function loadAgentRegistry() {
99
144
  } catch {
100
145
  // Backwards compatibility for repos that haven't migrated yet.
101
146
  try {
102
- const agentTs = readFileSync(resolve(AGENT_DIR, "config/agent.ts"), "utf-8");
147
+ const agentTs = readFileSync(resolve(dir, "config/agent.ts"), "utf-8");
103
148
  const nameMatch = agentTs.match(/firstName:\s*['"](\w+)['"]/);
104
149
  const slackMatch = agentTs.match(/slackMemberId:\s*['"]([^'"]+)['"]/);
105
150
  _agentIdentity = {
106
151
  name: nameMatch ? nameMatch[1] : "Agent",
107
152
  slackId: slackMatch ? slackMatch[1] : "",
108
153
  role: "agent",
154
+ company: configuredStr(loadCompanyConfig(dir).name),
109
155
  principal: { firstName: "the principal", fullName: "the principal", title: "principal" },
110
156
  };
111
157
  } catch {
@@ -113,6 +159,7 @@ function loadAgentRegistry() {
113
159
  name: "Agent",
114
160
  slackId: "",
115
161
  role: "agent",
162
+ company: configuredStr(loadCompanyConfig(dir).name),
116
163
  principal: { firstName: "the principal", fullName: "the principal", title: "principal" },
117
164
  };
118
165
  }
@@ -122,7 +169,7 @@ function loadAgentRegistry() {
122
169
  // maintains its own peer registry (shipped via `npx @cohortapp/agent-sdk upgrade`).
123
170
  let agents = [];
124
171
  try {
125
- const raw = readFileSync(resolve(AGENT_DIR, "config/known-agents.json"), "utf-8");
172
+ const raw = readFileSync(resolve(dir, "config/known-agents.json"), "utf-8");
126
173
  agents = JSON.parse(raw).agents || [];
127
174
  } catch { /* not present — proceed without peer filtering */ }
128
175
 
@@ -131,7 +178,7 @@ function loadAgentRegistry() {
131
178
  ? agents.filter(a => a.slackId !== mySlackId)
132
179
  : agents;
133
180
 
134
- console.log(`[classifier] Agent identity: ${_agentIdentity.name} (${_agentIdentity.slackId || "no slack ID"}), ${_peerAgents.length} peer agents`);
181
+ console.log(`[classifier] Agent identity: ${_agentIdentity.name || "(unconfigured)"} (${_agentIdentity.slackId || "no slack ID"}), ${_peerAgents.length} peer agents`);
135
182
  }
136
183
 
137
184
  const ANTHROPIC_MODEL = "claude-haiku-4-5-20251001";
@@ -142,10 +189,41 @@ const CLAUDE_CLI_TIMEOUT_MS = 30_000;
142
189
 
143
190
  // ── System prompt shared by both LLM classifiers ────────────────────────────
144
191
 
192
+ /**
193
+ * The classifier's identity sentence. Pure, so the omission rule is testable.
194
+ *
195
+ * The employer is INTERPOLATED FROM CONFIG or the clause is dropped entirely —
196
+ * it was a hardcoded example-company literal sitting between two interpolated
197
+ * fields. This
198
+ * is not cosmetic: the classifier's `summary` is carried verbatim into
199
+ * assurance.composeAck (text a human reads) and board-mirror's board row title,
200
+ * so a fabricated employer in its frame of reference leaks into the org record.
201
+ *
202
+ * @param {{name?:string, role?:string, company?:string, principal?:object}} id
203
+ * @returns {string}
204
+ */
205
+ export function renderClassifierIdentityLine(id) {
206
+ const agentName = configuredStr(id && id.name) || "the agent";
207
+ const agentRole = configuredStr(id && id.role) || "agent";
208
+ const company = configuredStr(id && id.company);
209
+ const p = (id && id.principal && typeof id.principal === "object") ? id.principal : {};
210
+ const pFull = configuredStr(p.fullName);
211
+ const pTitle = configuredStr(p.title);
212
+
213
+ const at = company ? ` at ${company}` : "";
214
+ const lines = [`You are a message classifier for ${agentName}, ${agentRole} (AI-operated)${at}.`];
215
+ // Only assert a principal when one is actually on record — the loader's
216
+ // "the principal" placeholder is an absence, not a person.
217
+ if (pFull && pFull !== "the principal") {
218
+ const who = pTitle && pTitle !== "principal" ? `${pTitle} ${pFull}` : pFull;
219
+ lines.push(`${agentName} is the autonomous executive command layer for ${who}.`);
220
+ }
221
+ return lines.join(" ");
222
+ }
223
+
145
224
  function buildSystemPrompt() {
146
225
  loadAgentRegistry();
147
- const agentName = _agentIdentity.name;
148
- const agentRole = _agentIdentity.role || "agent";
226
+ const agentName = configuredStr(_agentIdentity.name) || "the agent";
149
227
  const principalName = _agentIdentity.principal.firstName;
150
228
  const principalFullName = _agentIdentity.principal.fullName;
151
229
  const principalTitle = _agentIdentity.principal.title;
@@ -155,7 +233,7 @@ function buildSystemPrompt() {
155
233
  ? `\nKNOWN PEER AGENTS (other AI agents in the organisation — if a message @-mentions one of these by name or Slack ID, it is NOT directed at ${agentName}):\n${_peerAgents.map(a => `- ${a.name} (${a.role}, Slack: <@${a.slackId}>)`).join("\n")}\n`
156
234
  : "";
157
235
 
158
- return `You are a message classifier for ${agentName}, ${agentRole} (AI-operated) at Northwind. ${agentName} is the autonomous executive command layer for ${principalTitle} ${principalFullName}.
236
+ return `${renderClassifierIdentityLine(_agentIdentity)}
159
237
 
160
238
  Your job: classify each incoming message and return a JSON object with exactly these fields:
161
239
 
@@ -212,7 +290,7 @@ DIRECTED_AT_AGENT (CRITICAL — determines whether ${agentName} should respond.
212
290
 
213
291
  Context:
214
292
  - sender_privilege "ceo" = ${principalFullName} (always critical, always opus)
215
- - sender_privilege "leadership" = Sam (CFO/CIO/SEO), Jordan (GC), Casey (PartnerCo) — usually high priority
293
+ - sender_privilege "leadership" = executive-team senders (CFO, CIO, General Counsel, and equivalent function heads) — usually high priority
216
294
  - sender_privilege "team" = internal team members
217
295
  - sender_privilege "external" = partners, candidates, advisors, vendors
218
296
  - sender_privilege "unknown" = unrecognised sender
@@ -273,7 +351,7 @@ function formatItemPrompt(item) {
273
351
  ];
274
352
  // Indicate channel type so the classifier can assess directed-ness
275
353
  loadAgentRegistry();
276
- const agentName = _agentIdentity.name;
354
+ const agentName = configuredStr(_agentIdentity.name) || "the agent";
277
355
  if (item.is_dm) {
278
356
  parts.push(`Channel type: Direct message (1:1 with ${agentName})`);
279
357
  } else if (item.is_group) {
@@ -530,10 +608,13 @@ function isDirectedAtAgent(item) {
530
608
 
531
609
  // Explicit @mention or name mention in the message content
532
610
  const content = (item.content || "").toLowerCase();
533
- const myName = (_agentIdentity.name || "agent").toLowerCase();
611
+ // FAIL CLOSED on an unconfigured name. The old fallback was the bare word
612
+ // "agent", so an unnamed seat matched any message containing "agent" and
613
+ // treated it as addressed to itself. No name on record ⇒ no name match.
614
+ const myName = configuredStr(_agentIdentity.name).toLowerCase();
534
615
  const mySlackId = (_agentIdentity.slackId || "").toLowerCase();
535
616
 
536
- if (content.includes(myName)) return true;
617
+ if (myName && content.includes(myName)) return true;
537
618
  if (mySlackId && content.includes(mySlackId.toLowerCase())) return true;
538
619
 
539
620
  // Gmail/calendar messages are directed at the current agent (they're in its inbox)
@@ -630,7 +711,7 @@ function classifyWithRules(item) {
630
711
  }
631
712
 
632
713
  // Export for use in daemon's pre-classification gate
633
- export { isDirectedAtAgent, mentionsOtherAgent, loadAgentRegistry };
714
+ export { isDirectedAtAgent, mentionsOtherAgent, loadAgentRegistry, buildSystemPrompt };
634
715
 
635
716
  // ── Main export ─────────────────────────────────────────────────────────────
636
717