@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
@@ -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
+ });
@@ -225,14 +225,25 @@ export function openObligations() {
225
225
  * object is gone. Content is truncated: this file is a debt record, not a
226
226
  * message archive.
227
227
  */
228
- function itemSnapshot(item) {
228
+ export function itemSnapshot(item) {
229
229
  return {
230
230
  id: item.id || null,
231
231
  raw_ref: item.raw_ref || null,
232
232
  message_id: item.message_id || null,
233
233
  service: item.service || null,
234
+ // `kind` IS AN ADDRESS TOO — `deliver.cohortSurfaceOf` falls back to it when
235
+ // `raw_ref` carries no surface name, and without it every such item
236
+ // re-routed from the sweep as a plain `dm` with no channel: undeliverable.
237
+ kind: item.kind || null,
234
238
  channel: item.channel || null,
235
239
  channel_id: item.channel_id || null,
240
+ // `scope_id` IS AN ADDRESS, not decoration. `deliver.cohortReplyRoute` reads
241
+ // it FIRST for every roomless Cohort surface (board, doc, decision). The
242
+ // sweep re-delivers from `rec.item`, so leaving it out meant the sweep
243
+ // routed a DIFFERENT item than the live path did — masked only by every
244
+ // hydrator happening to set `thread_id` too. Anything the routing table
245
+ // reads belongs in this snapshot.
246
+ scope_id: item.scope_id || null,
236
247
  thread_id: item.thread_id || null,
237
248
  sender: item.sender || null,
238
249
  sender_email: item.sender_email || null,
@@ -297,12 +308,25 @@ function variant(seed, n) {
297
308
  return h % n;
298
309
  }
299
310
 
300
- /** 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
+ */
301
325
  function topicClause(classResult, item) {
302
326
  const raw = (classResult && classResult.summary) || (item && item.subject) || "";
303
327
  const t = String(raw).trim().replace(/\s+/g, " ").replace(/[.!?]+$/, "");
304
328
  if (!t || t.length > 120) return null;
305
- return t.charAt(0).toLowerCase() + t.slice(1);
329
+ return t;
306
330
  }
307
331
 
308
332
  /** Rough, honest expectation. Derived from the measured distribution of real
@@ -316,23 +340,29 @@ export function expectedWindow(classResult) {
316
340
  }
317
341
 
318
342
  /**
319
- * The acknowledgement. Two or three sentences, warm, specific where it can be,
320
- * and — critically — it commits to coming back "either way". That clause is a
321
- * promise the rest of this module now actually keeps: failure notices, interim
322
- * 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.
323
355
  */
324
356
  export function composeAck(item, classResult = {}) {
325
357
  const topic = topicClause(classResult, item);
358
+ const aside = topic ? ` (${topic})` : "";
326
359
  const v = variant(obligationKey(item) || (item && item.sender), 3);
327
360
  const openers = [
328
- "No problem — let me look into this and sort it out.",
329
- "Got it — I'm on this now.",
330
- "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}.`,
331
364
  ];
332
- const parts = [openers[v]];
333
- if (topic) parts.push(`Picking up: ${topic}.`);
334
- 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.`);
335
- 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.`;
336
366
  }
337
367
 
338
368
  /**
@@ -348,13 +378,17 @@ function topicAside(rec) {
348
378
  return rec && rec.summary ? ` (${rec.summary})` : "";
349
379
  }
350
380
 
351
- /** 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. */
352
386
  export function composeProgress(rec, o = {}) {
353
387
  const now = Number.isFinite(o.now) ? o.now : Date.now();
354
388
  const mins = Math.max(1, Math.round((now - (rec.openedAt || now)) / 60_000));
355
389
  const topic = topicAside(rec);
356
390
  if ((rec.progressSent || 0) === 0) {
357
- 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.`;
358
392
  }
359
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.`;
360
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
+ });