@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
@@ -523,3 +523,193 @@ test("OUTCOMES: the daemon hook never throws into the dispatch path", async () =
523
523
  assert.ok(res && typeof res.created === "number", "benign result, no throw");
524
524
  assert.equal(res.created, 0);
525
525
  });
526
+
527
+ // ===========================================================================
528
+ // ELECTION — the multi-AI storm fix. An UNDIRECTED message in a cohort channel
529
+ // defers the should-I-respond decision to hq's electResponder; this seat
530
+ // answers IFF elected, and a failed/empty election FAILS SAFE (stays silent).
531
+ // A DIRECT address (@mention / DM) skips the election entirely. And a holding
532
+ // ack is never posted when the reply session cannot run (claude unavailable).
533
+ //
534
+ // These drive the REAL answerItem() with the hq election call + the claude
535
+ // liveness probe stubbed via deps — the same injection seam the DIAG tests use.
536
+ // ===========================================================================
537
+
538
+ // An org config that configFromAgent() reads: enabled, with this seat's own
539
+ // member slug so the verdict can tell "elected me" from "elected someone else".
540
+ const ELECTION_ORG_CFG = {
541
+ org: { cohort: { enabled: true, base: "https://hq.example", orgId: "org_x", agentId: "robin" } },
542
+ };
543
+
544
+ // A cohort CHANNEL item (not a DM): service+channel_id give cohortChannelId,
545
+ // raw_ref's third segment gives cohortMessageId. Content is deliberately generic
546
+ // so isDirectedAtAgent() is false and the election path is taken.
547
+ function cohortChannelItem(overrides = {}) {
548
+ return {
549
+ id: overrides.id || "MSG-EL",
550
+ raw_ref: overrides.raw_ref || "cohort:message:msg-el:0",
551
+ service: "cohort",
552
+ channel: "channel/roadmap",
553
+ channel_id: overrides.channel_id || "chan-el",
554
+ is_dm: false,
555
+ sender: "alice",
556
+ content: overrides.content || "what should our Q3 priorities be?",
557
+ };
558
+ }
559
+
560
+ const ELECTED_CLASS = {
561
+ priority: "high", action: "respond", model: "opus",
562
+ summary: "roadmap discussion", category: "action_required", directed_at_agent: false,
563
+ };
564
+
565
+ test("ELECTION: the elected seat responds (dispatches) and the hq call carried {messageId, channelId}", async () => {
566
+ resetState();
567
+ delete process.env.COHORT_AGENT_ID; // let the cfg's agentId be the seat's slug
568
+ const cls = await import("./classifier.mjs");
569
+ cls._resetAgentRegistry();
570
+
571
+ const item = cohortChannelItem({ id: "MSG-EL-YES", raw_ref: "cohort:message:msg-yes:0", channel_id: "chan-yes" });
572
+ const electionCalls = [];
573
+ let dispatched = false;
574
+
575
+ const res = await daemon.answerItem(item, "cohort", item.raw_ref, "trace-el-yes", {
576
+ classify: async () => ({ ...ELECTED_CLASS, summary: "elected-yes topic" }),
577
+ isQuickReply: () => false,
578
+ sendHoldingMessage: async () => ({ sent: false, holdingText: null }),
579
+ dispatch: (_p, _it, _cr, _s, opts) => { dispatched = true; opts.onClose({ ok: true, code: 0 }); },
580
+ claudeAvailable: () => true,
581
+ orgCfg: ELECTION_ORG_CFG,
582
+ electResponder: async (params) => {
583
+ electionCalls.push(params);
584
+ return { ok: true, result: { election: "group", mode: "single", responders: [{ slug: "robin", dimension: "mandate" }], deferredToHuman: false } };
585
+ },
586
+ });
587
+
588
+ assert.equal(electionCalls.length, 1, "the election was consulted exactly once");
589
+ assert.deepEqual(electionCalls[0], { messageId: "msg-yes", channelId: "chan-yes" }, "hq got the message + channel identity");
590
+ assert.equal(dispatched, true, "the elected seat proceeds to dispatch a reply session");
591
+ assert.equal(res.path, "session", "answerItem took the session path");
592
+ });
593
+
594
+ test("ELECTION: a NON-elected seat stays silent on the same message (no dispatch)", async () => {
595
+ resetState();
596
+ delete process.env.COHORT_AGENT_ID;
597
+ const cls = await import("./classifier.mjs");
598
+ cls._resetAgentRegistry();
599
+
600
+ const item = cohortChannelItem({ id: "MSG-EL-NO", raw_ref: "cohort:message:msg-no:0", channel_id: "chan-no" });
601
+ const electionCalls = [];
602
+ let dispatched = false;
603
+ let acked = false;
604
+
605
+ const res = await daemon.answerItem(item, "cohort", item.raw_ref, "trace-el-no", {
606
+ classify: async () => ({ ...ELECTED_CLASS, summary: "not-elected topic" }),
607
+ isQuickReply: () => false,
608
+ sendHoldingMessage: async () => { acked = true; return { sent: true, holdingText: "on it" }; },
609
+ dispatch: (_p, _it, _cr, _s, opts) => { dispatched = true; opts.onClose({ ok: true, code: 0 }); },
610
+ claudeAvailable: () => true,
611
+ orgCfg: ELECTION_ORG_CFG,
612
+ // hq elected a DIFFERENT seat — this one is not in the responders list.
613
+ electResponder: async (params) => {
614
+ electionCalls.push(params);
615
+ return { ok: true, result: { election: "group", mode: "single", responders: [{ slug: "kai", dimension: "mandate" }], deferredToHuman: false } };
616
+ },
617
+ });
618
+
619
+ assert.equal(electionCalls.length, 1, "the election was consulted");
620
+ assert.equal(dispatched, false, "a non-elected seat must NOT dispatch");
621
+ assert.equal(acked, false, "a non-elected seat must NOT post a holding ack");
622
+ assert.equal(res.path, "filtered", "answerItem filtered the item");
623
+ assert.equal(res.reason, "not_elected", "…because it was not elected");
624
+ });
625
+
626
+ test("ELECTION: an election ERROR fails safe — the seat stays silent (never respond-to-all)", async () => {
627
+ resetState();
628
+ delete process.env.COHORT_AGENT_ID;
629
+ const cls = await import("./classifier.mjs");
630
+ cls._resetAgentRegistry();
631
+
632
+ const item = cohortChannelItem({ id: "MSG-EL-ERR", raw_ref: "cohort:message:msg-err:0", channel_id: "chan-err" });
633
+ let dispatched = false;
634
+
635
+ const res = await daemon.answerItem(item, "cohort", item.raw_ref, "trace-el-err", {
636
+ classify: async () => ({ ...ELECTED_CLASS, summary: "election-error topic" }),
637
+ isQuickReply: () => false,
638
+ sendHoldingMessage: async () => ({ sent: false, holdingText: null }),
639
+ dispatch: (_p, _it, _cr, _s, opts) => { dispatched = true; opts.onClose({ ok: true, code: 0 }); },
640
+ claudeAvailable: () => true,
641
+ orgCfg: ELECTION_ORG_CFG,
642
+ // hq is unreachable / throwing — the INVERSION: this must resolve to silence.
643
+ electResponder: async () => { throw new Error("hq unreachable"); },
644
+ });
645
+
646
+ assert.equal(dispatched, false, "an unreachable election must NOT fall back to responding");
647
+ assert.equal(res.path, "filtered", "answerItem filtered the item");
648
+ assert.equal(res.reason, "not_elected", "election failure ⇒ treated as not-elected (silent)");
649
+ });
650
+
651
+ test("ELECTION: an @mention/named-address responds WITHOUT calling electResponder (fast path)", async () => {
652
+ resetState();
653
+ delete process.env.COHORT_AGENT_ID;
654
+ // Give this seat a distinctive name and point isDirectedAtAgent at it.
655
+ mkdirSync(join(AGENT_DIR, "config"), { recursive: true });
656
+ writeFileSync(join(AGENT_DIR, "config", "agent.json"), JSON.stringify({ firstName: "Zephyr", slackMemberId: "" }));
657
+ const cls = await import("./classifier.mjs");
658
+ cls._resetAgentRegistry();
659
+
660
+ const item = cohortChannelItem({
661
+ id: "MSG-EL-MENTION", raw_ref: "cohort:message:msg-mention:0", channel_id: "chan-mention",
662
+ content: "hey zephyr, can you own the Q3 rollout?",
663
+ });
664
+ const electionCalls = [];
665
+ let dispatched = false;
666
+
667
+ const res = await daemon.answerItem(item, "cohort", item.raw_ref, "trace-el-mention", {
668
+ classify: async () => ({ ...ELECTED_CLASS, summary: "mention topic", directed_at_agent: true }),
669
+ isQuickReply: () => false,
670
+ sendHoldingMessage: async () => ({ sent: false, holdingText: null }),
671
+ dispatch: (_p, _it, _cr, _s, opts) => { dispatched = true; opts.onClose({ ok: true, code: 0 }); },
672
+ claudeAvailable: () => true,
673
+ orgCfg: ELECTION_ORG_CFG,
674
+ electResponder: async (params) => { electionCalls.push(params); return { ok: true, result: { responders: [] } }; },
675
+ });
676
+
677
+ assert.equal(electionCalls.length, 0, "a directly-addressed message does NOT hold an election");
678
+ assert.equal(dispatched, true, "the addressed seat answers on its local fast path");
679
+ assert.equal(res.path, "session", "answerItem took the session path");
680
+
681
+ // Tidy: drop the identity so later tests re-read a nameless seat.
682
+ rmSync(join(AGENT_DIR, "config", "agent.json"), { force: true });
683
+ cls._resetAgentRegistry();
684
+ });
685
+
686
+ test("FAIL-SAFE: no holding ack is posted when the reply session cannot run (claude unavailable)", async () => {
687
+ resetState();
688
+ // A slack DM — always directed, so this isolates the ack gate from the election.
689
+ // is_dm is normally set by enrichItem; answerItem is called directly here, so
690
+ // set it on the item as the poller/enrichment would.
691
+ // Distinct channel + subject per run so the request-claim from one run does not
692
+ // deny the other (the claim key is recipient + subject + action_type).
693
+ const mkItem = (tag) => ({ id: `MSG-ACK-${tag}`, raw_ref: `slack:DACK${tag}:1`, service: "slack", channel: "dm/ceo", channel_id: `DACK${tag}0001`, is_dm: true, sender: "ceo", content: "please draft the memo" });
694
+ const baseDeps = (spy, claudeUp, tag) => ({
695
+ classify: async () => ({ priority: "critical", action: "respond", model: "opus", summary: `draft memo ${tag}`, category: "action_required", directed_at_agent: true }),
696
+ isQuickReply: () => false,
697
+ sendHoldingMessage: async () => { spy.calls++; return { sent: true, holdingText: "on it" }; },
698
+ dispatch: (_p, _it, _cr, _s, opts) => { spy.dispatched = true; opts.onClose({ ok: true, code: 0 }); },
699
+ claudeAvailable: () => claudeUp,
700
+ });
701
+
702
+ // CONTROL: claude available ⇒ the ack path runs (holding message attempted).
703
+ const up = { calls: 0, dispatched: false };
704
+ await daemon.answerItem(mkItem("UP"), "slack", "slack:DACKUP:1", "trace-ack-up", baseDeps(up, true, "UP"));
705
+ assert.equal(up.calls, 1, "with claude available, a holding ack IS attempted");
706
+ assert.equal(up.dispatched, true, "control: the session is dispatched");
707
+
708
+ // TREATMENT: claude unavailable ⇒ NO ack is emitted, but the session still runs
709
+ // (the assurance sweep owns the outcome; the human just isn't promised a reply).
710
+ const down = { calls: 0, dispatched: false };
711
+ const res = await daemon.answerItem(mkItem("DN"), "slack", "slack:DACKDN:1", "trace-ack-down", baseDeps(down, false, "DN"));
712
+ assert.equal(down.calls, 0, "with claude unavailable, NO holding ack is posted before the working session");
713
+ assert.equal(down.dispatched, true, "the session is still dispatched");
714
+ assert.equal(res.path, "session", "answerItem took the session path");
715
+ });
@@ -308,12 +308,25 @@ function variant(seed, n) {
308
308
  return h % n;
309
309
  }
310
310
 
311
- /** Trim the classifier's summary into a clause that reads naturally mid-sentence. */
311
+ /**
312
+ * Trim the classifier's summary into a short topic phrase — casing preserved.
313
+ *
314
+ * This used to lowercase the first letter so the phrase could sit mid-sentence.
315
+ * Applied to a summary that starts with an acronym or a name, that manufactures
316
+ * a typo the requester reads as the agent's own: "Picking up: cEO checking in
317
+ * on work progress." went to a real human, first letter of "CEO" folded. No
318
+ * local rule can tell "Fix" (safe to downcase) from "CEO", "Q3" or a person's
319
+ * name (not safe), so the phrase is taken exactly as the classifier wrote it, and
320
+ * every slot that renders it — the parentheses here and in topicAside — is one
321
+ * where original casing reads correctly. The stored `rec.summary` flows into
322
+ * progress/failure/interrupted messages and the board-row title too, so the
323
+ * no-recasing rule protects all of them at once.
324
+ */
312
325
  function topicClause(classResult, item) {
313
326
  const raw = (classResult && classResult.summary) || (item && item.subject) || "";
314
327
  const t = String(raw).trim().replace(/\s+/g, " ").replace(/[.!?]+$/, "");
315
328
  if (!t || t.length > 120) return null;
316
- return t.charAt(0).toLowerCase() + t.slice(1);
329
+ return t;
317
330
  }
318
331
 
319
332
  /** Rough, honest expectation. Derived from the measured distribution of real
@@ -327,23 +340,29 @@ export function expectedWindow(classResult) {
327
340
  }
328
341
 
329
342
  /**
330
- * The acknowledgement. Two or three sentences, warm, specific where it can be,
331
- * and — critically — it commits to coming back "either way". That clause is a
332
- * promise the rest of this module now actually keeps: failure notices, interim
333
- * updates and the stale sweep all exist so it is never a lie.
343
+ * The acknowledgement. Two sentences, written the way a colleague would type
344
+ * them, and — critically — it commits to coming back "either way". That clause
345
+ * is a promise the rest of this module now actually keeps: failure notices,
346
+ * interim updates and the stale sweep all exist so it is never a lie. The time
347
+ * window is expectedWindow's — measured, not invented.
348
+ *
349
+ * The topic rides in parentheses at the end of the opener, never spliced into
350
+ * the sentence: the summary is an arbitrary phrase in arbitrary case, and the
351
+ * previous template — `Picking up: <lowercased summary>. It needs a bit of
352
+ * work, …` — showed the daemon's internal framing to the requester and mangled
353
+ * acronym-initial summaries on top (see topicClause). "Picking up:" is triage
354
+ * vocabulary; a human answering a human does not narrate their queue.
334
355
  */
335
356
  export function composeAck(item, classResult = {}) {
336
357
  const topic = topicClause(classResult, item);
358
+ const aside = topic ? ` (${topic})` : "";
337
359
  const v = variant(obligationKey(item) || (item && item.sender), 3);
338
360
  const openers = [
339
- "No problem — let me look into this and sort it out.",
340
- "Got it — I'm on this now.",
341
- "Understood — let me dig into this.",
361
+ `No problem — I'm looking into this now${aside}.`,
362
+ `Got it — I'm on it now${aside}.`,
363
+ `Understood — I'm digging into this now${aside}.`,
342
364
  ];
343
- const parts = [openers[v]];
344
- if (topic) parts.push(`Picking up: ${topic}.`);
345
- parts.push(`It needs a bit of work, so I'll come back to you here with an answer — ${expectedWindow(classResult)}, and I'll message you either way.`);
346
- return parts.join(" ");
365
+ return `${openers[v]} I want to get this right, so I'll come back to you here with a full answer — ${expectedWindow(classResult)} — and you'll hear from me either way.`;
347
366
  }
348
367
 
349
368
  /**
@@ -359,13 +378,17 @@ function topicAside(rec) {
359
378
  return rec && rec.summary ? ` (${rec.summary})` : "";
360
379
  }
361
380
 
362
- /** An interim update. Says how long it has been and that the work is live. */
381
+ /** An interim update. Says how long it has been and that the work is live.
382
+ * The first update no longer claims "it's taking longer than I expected": it
383
+ * fires at PROGRESS_AFTER_MS (5 min), squarely inside the 10-20 minute window
384
+ * the acknowledgement promised for opus work minutes earlier — a claim the
385
+ * same human could see was false. */
363
386
  export function composeProgress(rec, o = {}) {
364
387
  const now = Number.isFinite(o.now) ? o.now : Date.now();
365
388
  const mins = Math.max(1, Math.round((now - (rec.openedAt || now)) / 60_000));
366
389
  const topic = topicAside(rec);
367
390
  if ((rec.progressSent || 0) === 0) {
368
- return `Still working on this${topic} — ${mins} minutes in, and it's taking longer than I expected. Nothing's stuck; I'll come back as soon as I have something worth sending.`;
391
+ return `Still working on this${topic} — ${mins} minutes in. Nothing's stuck; I'll come back as soon as I have something worth sending.`;
369
392
  }
370
393
  return `Quick check-in: still on this${topic}, ${mins} minutes in. I haven't forgotten it — I'll follow up the moment it's done, or tell you if I can't finish it.`;
371
394
  }
@@ -147,6 +147,26 @@ describe("composeAck", () => {
147
147
  assert.ok(a.length > 40);
148
148
  assert.match(a, /either way/i);
149
149
  });
150
+
151
+ test("REGRESSION: an acronym-initial summary keeps its casing — no 'cEO'", () => {
152
+ // Owner screenshot, verbatim: "Picking up: cEO checking in on work
153
+ // progress." — topicClause downcased the first letter of whatever the
154
+ // classifier wrote so it could sit mid-sentence, and "CEO…" became a typo
155
+ // the requester reads as the agent's own. The summary is now carried
156
+ // exactly as written.
157
+ const a = composeAck(ITEM, { ...CLASS_COMPLEX, summary: "CEO checking in on work progress" });
158
+ assert.match(a, /CEO checking in on work progress/);
159
+ assert.doesNotMatch(a, /cEO/);
160
+ });
161
+
162
+ test("reads like a colleague, not a template — no internal framing shown to the human", () => {
163
+ // "Picking up:" is triage vocabulary and "It needs a bit of work" is the
164
+ // daemon explaining its own dispatch decision. Both leaked the machinery at
165
+ // the person being acknowledged.
166
+ const a = composeAck(ITEM, CLASS_COMPLEX);
167
+ assert.doesNotMatch(a, /picking up:/i);
168
+ assert.doesNotMatch(a, /needs a bit of work/i);
169
+ });
150
170
  });
151
171
 
152
172
  // ---------------------------------------------------------------------------
@@ -203,6 +223,21 @@ describe("openAndAcknowledge", () => {
203
223
  assert.equal(openObligations().length, 1, "the debt must be visible to the sweep");
204
224
  });
205
225
 
226
+ test("the stored summary keeps the classifier's casing — every later message reads from it", async () => {
227
+ // `rec.summary` feeds topicAside (progress, failure, silent-success,
228
+ // interrupted), the sweep's compensating ack, and board-mirror's row title.
229
+ // A summary mangled at open time would resurface in all of them.
230
+ const t = fakeTransport();
231
+ const r = await openAndAcknowledge({
232
+ item: ITEM,
233
+ classResult: { ...CLASS_COMPLEX, summary: "CEO checking in on work progress" },
234
+ deps: { ackSender: t.ackSender },
235
+ });
236
+ const rec = readObligation(r.key);
237
+ assert.equal(rec.summary, "CEO checking in on work progress");
238
+ assert.match(composeProgress(rec, { now: Date.now() + 6 * 60_000 }), /\(CEO checking in on work progress\)/);
239
+ });
240
+
206
241
  test("acknowledges ONCE — a re-delivered item does not re-announce itself", async () => {
207
242
  const t = fakeTransport();
208
243
  await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
@@ -362,6 +397,9 @@ describe("sweepObligations", () => {
362
397
  const msg = t.sent.at(-1).text;
363
398
  assert.match(msg, /still working/i);
364
399
  assert.match(msg, /minutes in/i, "an interim update that omits how long it has been is not much of an update");
400
+ // Six minutes in is INSIDE the 10-20 minute window the ack just promised
401
+ // for opus work; claiming it here contradicts a message the same human read.
402
+ assert.doesNotMatch(msg, /longer than I expected/i);
365
403
  });
366
404
 
367
405
  test("interim updates are CAPPED — attentive, not noisy", async () => {
@@ -604,7 +642,7 @@ describe("attribution: which debt did that message actually discharge", () => {
604
642
  const stats = await sweepObligations({ now: Date.now() + ACK_GRACE_MS + 1000, deps: { deliverImpl: t.impl } });
605
643
  assert.equal(stats.closed, 0, "no session of ours had run — that message was not our answer");
606
644
  assert.equal(stats.acked, 1, "instead the missing acknowledgement is finally sent");
607
- assert.match(t.sent.at(-1).text, /let me look into this|I'm on this now|let me dig into this/i);
645
+ assert.match(t.sent.at(-1).text, /looking into this|on it now|digging into this/i);
608
646
  });
609
647
  });
610
648
 
@@ -0,0 +1,137 @@
1
+ /**
2
+ * classifier-identity.test.mjs — the classifier's frame of reference must be
3
+ * READ, not asserted.
4
+ *
5
+ * WHAT BROKE
6
+ * `buildSystemPrompt()` opened with:
7
+ * "You are a message classifier for ${agentName}, ${agentRole} (AI-operated)
8
+ * at Northwind."
9
+ * — a hardcoded employer sitting between two interpolated fields, on a file
10
+ * that already loads config/agent.json two functions above. It matters more
11
+ * than a naming slip: the classifier's `summary` is carried verbatim into
12
+ * assurance.composeAck (text a waiting human reads) and board-mirror's board
13
+ * row title, so a fabricated employer in the classifier's frame leaks into the
14
+ * human ack and the org record.
15
+ *
16
+ * WHAT THESE TESTS PIN
17
+ * 1. GROUNDED — the employer comes from config/agent.json, and from
18
+ * config/company.json when agent.json leaves `company` blank.
19
+ * 2. EMPTY — no employer configured ⇒ the "at <company>" clause is OMITTED
20
+ * entirely. No sentinel, no default, no invented name.
21
+ * 3. No principal on record ⇒ no principal sentence at all.
22
+ *
23
+ * Run: node --test scripts/daemon/classifier-identity.test.mjs
24
+ */
25
+
26
+ "use strict";
27
+
28
+ import { test } from "node:test";
29
+ import assert from "node:assert/strict";
30
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
31
+ import { tmpdir } from "node:os";
32
+ import { join } from "node:path";
33
+
34
+ import { renderClassifierIdentityLine, buildSystemPrompt, _resetAgentRegistry } from "./classifier.mjs";
35
+
36
+ function seat(agent, company) {
37
+ const root = mkdtempSync(join(tmpdir(), "clf-"));
38
+ mkdirSync(join(root, "config"), { recursive: true });
39
+ if (agent) writeFileSync(join(root, "config/agent.json"), JSON.stringify(agent));
40
+ if (company) writeFileSync(join(root, "config/company.json"), JSON.stringify(company));
41
+ return root;
42
+ }
43
+
44
+ // ── 1. GROUNDED ─────────────────────────────────────────────────────────────
45
+
46
+ test("grounded: the employer is interpolated from config", () => {
47
+ const line = renderClassifierIdentityLine({
48
+ name: "Robin",
49
+ role: "VP Operations",
50
+ company: "Meridian Freight",
51
+ principal: { fullName: "Dana Lee", title: "CEO" },
52
+ });
53
+ assert.equal(
54
+ line,
55
+ "You are a message classifier for Robin, VP Operations (AI-operated) at Meridian Freight. " +
56
+ "Robin is the autonomous executive command layer for CEO Dana Lee.",
57
+ );
58
+ });
59
+
60
+ test("grounded: config/company.json supplies the employer when agent.json leaves it blank", () => {
61
+ const root = seat(
62
+ { firstName: "Robin", fullName: "Robin Okafor", title: "VP Operations", company: "" },
63
+ { name: "Meridian Freight" },
64
+ );
65
+ const prev = process.env.AGENT_DIR;
66
+ process.env.AGENT_DIR = root;
67
+ _resetAgentRegistry();
68
+ try {
69
+ // The whole system prompt, built by the real loader against real files.
70
+ const prompt = buildSystemPrompt();
71
+ assert.match(prompt, /^You are a message classifier for Robin, VP Operations \(AI-operated\) at Meridian Freight\./);
72
+ } finally {
73
+ if (prev === undefined) delete process.env.AGENT_DIR; else process.env.AGENT_DIR = prev;
74
+ _resetAgentRegistry();
75
+ rmSync(root, { recursive: true, force: true });
76
+ }
77
+ });
78
+
79
+ // ── 2. EMPTY — omit, never fake ─────────────────────────────────────────────
80
+
81
+ test("empty: an unconfigured seat's system prompt names NO employer", () => {
82
+ const root = seat(
83
+ { firstName: "UNCONFIGURED", lastName: "AGENT", fullName: "Unconfigured Agent", title: "Unconfigured Role", company: "" },
84
+ { name: "UNCONFIGURED" },
85
+ );
86
+ const prev = process.env.AGENT_DIR;
87
+ process.env.AGENT_DIR = root;
88
+ _resetAgentRegistry();
89
+ try {
90
+ const prompt = buildSystemPrompt();
91
+ assert.doesNotMatch(prompt.split("\n")[0], / at /, "no employer clause at all");
92
+ assert.doesNotMatch(prompt, /UNCONFIGURED/);
93
+ } finally {
94
+ if (prev === undefined) delete process.env.AGENT_DIR; else process.env.AGENT_DIR = prev;
95
+ _resetAgentRegistry();
96
+ rmSync(root, { recursive: true, force: true });
97
+ }
98
+ });
99
+
100
+ test("empty: no employer configured ⇒ the 'at <company>' clause is dropped", () => {
101
+ const line = renderClassifierIdentityLine({ name: "Robin", role: "VP Operations", company: "" });
102
+ assert.equal(line, "You are a message classifier for Robin, VP Operations (AI-operated).");
103
+ assert.doesNotMatch(line, / at /);
104
+ });
105
+
106
+ test("empty: the scaffold's UNCONFIGURED sentinel counts as absent", () => {
107
+ const line = renderClassifierIdentityLine({ name: "Robin", role: "VP Operations", company: "UNCONFIGURED" });
108
+ assert.doesNotMatch(line, /UNCONFIGURED/);
109
+ assert.doesNotMatch(line, / at /);
110
+ });
111
+
112
+ test("empty: no principal on record ⇒ no principal sentence", () => {
113
+ const withNone = renderClassifierIdentityLine({ name: "Robin", role: "VP", principal: {} });
114
+ assert.doesNotMatch(withNone, /command layer/);
115
+
116
+ // The loader's "the principal" string is an ABSENCE marker, not a person.
117
+ const withPlaceholder = renderClassifierIdentityLine({
118
+ name: "Robin",
119
+ role: "VP",
120
+ principal: { firstName: "the principal", fullName: "the principal", title: "principal" },
121
+ });
122
+ assert.doesNotMatch(withPlaceholder, /command layer/);
123
+ });
124
+
125
+ test("empty: nothing at all still yields a coherent, non-fabricated line", () => {
126
+ const line = renderClassifierIdentityLine({});
127
+ assert.equal(line, "You are a message classifier for the agent, agent (AI-operated).");
128
+ });
129
+
130
+ // ── 3. The literal is gone from the source ──────────────────────────────────
131
+
132
+ test("the classifier source carries no hardcoded employer", async () => {
133
+ const { readFileSync } = await import("node:fs");
134
+ const src = readFileSync(new URL("./classifier.mjs", import.meta.url), "utf8");
135
+ assert.doesNotMatch(src, /\bNorthwind\b/i);
136
+ assert.doesNotMatch(src, /\bPartnerCo\b/i);
137
+ });