@cohortapp/agent-sdk 2.10.0 → 2.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/init-maestro.md +16 -9
- package/docs/guides/mac-mini.md +11 -1
- package/docs/runbooks/cohort-cutover.md +16 -0
- package/lib/channels/inbox-item.mjs +12 -0
- package/lib/channels/inbox-item.test.mjs +33 -0
- package/lib/execution/disposition.mjs +13 -2
- package/lib/execution/disposition.test.mjs +19 -2
- package/lib/execution/pipeline.test.mjs +4 -1
- package/lib/mcp/server.test.mjs +16 -4
- package/lib/org/client.mjs +58 -1
- package/lib/org/messaging.mjs +5 -0
- package/lib/org/messaging.test.mjs +7 -0
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +98 -0
- package/lib/org/protocol.test.mjs +19 -2
- package/lib/org/resource-tools.mjs +317 -0
- package/lib/org/resource-tools.test.mjs +361 -0
- package/lib/org/tool-access.mjs +176 -0
- package/lib/org/tool-access.test.mjs +144 -0
- package/lib/org/tool-surface.mjs +431 -5
- package/lib/org/tool-surface.test.mjs +385 -8
- package/lib/org/ui-parity.mjs +196 -3
- package/lib/org/ui-parity.test.mjs +126 -7
- package/lib/tool-definitions.js +23 -2
- package/package.json +2 -2
- package/plugins/maestro-skills/.claude-plugin/marketplace.json +1 -1
- package/plugins/maestro-skills/plugin.json +4 -0
- package/plugins/maestro-skills/skills/venture-deliverables.md +176 -0
- package/policies/information-barriers.yaml +34 -7
- package/scripts/ci/check-no-residual-identity.mjs +281 -9
- package/scripts/ci/check-no-residual-identity.test.mjs +115 -2
- package/scripts/cloud-relay/voice/relay-identity.test.mjs +96 -0
- package/scripts/cloud-relay/voice/server.mjs +42 -2
- package/scripts/cost/track-claude-usage-pricing.test.mjs +183 -0
- package/scripts/cost/track-claude-usage.mjs +113 -4
- package/scripts/daemon/agent-daemon.mjs +212 -5
- package/scripts/daemon/agent-daemon.test.mjs +307 -0
- package/scripts/daemon/assurance.mjs +38 -15
- package/scripts/daemon/assurance.test.mjs +39 -1
- package/scripts/daemon/cadence-handlers.mjs +48 -5
- package/scripts/daemon/cadence-handlers.test.mjs +57 -2
- package/scripts/daemon/classifier-identity.test.mjs +137 -0
- package/scripts/daemon/classifier.mjs +98 -17
- package/scripts/daemon/inbox-deferral.mjs +49 -24
- package/scripts/daemon/inbox-deferral.test.mjs +39 -1
- package/scripts/daemon/prompt-builder-preamble.test.mjs +210 -0
- package/scripts/daemon/prompt-builder.mjs +264 -41
- package/scripts/daemon/prompt-builder.test.mjs +5 -5
- package/scripts/daemon/responder.mjs +9 -0
- package/scripts/disclosure_boundaries.py +56 -5
- package/scripts/huddle/huddle-prompt.test.mjs +176 -0
- package/scripts/huddle/huddle-server.mjs +128 -13
- package/scripts/local-triggers/autoupdate.sh +83 -0
- package/scripts/local-triggers/generate-plists.sh +9 -0
- package/scripts/local-triggers/generate-plists.test.mjs +12 -10
- package/scripts/media-generation/brand-clause.test.mjs +135 -0
- package/scripts/media-generation/gemini-image-client.mjs +27 -9
- package/scripts/media-generation/generate-assets.mjs +102 -7
- package/scripts/poller/inbox-scan-poller.mjs +7 -0
- package/scripts/poller/utils.mjs +11 -0
- package/scripts/pre-draft-context.py +91 -15
- package/scripts/spawn-session.sh +36 -6
- package/scripts/test-employer-grounding.py +348 -0
- package/scripts/validate_outbound.py +190 -26
|
@@ -445,6 +445,10 @@ test("messaging-inbound: pulls new directed items and routes them into the inbox
|
|
|
445
445
|
{ event: { cadence: "messaging-inbound" }, agentRoot: root, log: () => {} },
|
|
446
446
|
{
|
|
447
447
|
cfg: { org: { cohort: { enabled: true, base: "https://x", token: "t", agentId: "me" } } },
|
|
448
|
+
// Fresh root → no inbound cursor. Seed from the push-channel head so the
|
|
449
|
+
// pull starts at "now" (not genesis). The stub stands in for
|
|
450
|
+
// lib/org/push.readCursor.
|
|
451
|
+
pushHeadImpl: () => 5,
|
|
448
452
|
pullImpl: async () => ({ events: [ev1, ev2], nextCursor: 12 }),
|
|
449
453
|
toInboxImpl: (e) => ({ id: e.message_id, service: "cohort", content: e.text }),
|
|
450
454
|
writeImpl: (service, item) => written.push({ service, item }),
|
|
@@ -461,20 +465,67 @@ test("messaging-inbound: pulls new directed items and routes them into the inbox
|
|
|
461
465
|
assert.equal(JSON.parse(readFileSync(cursorFile, "utf8")).cursor, 12);
|
|
462
466
|
});
|
|
463
467
|
|
|
468
|
+
test("messaging-inbound: FIRST RUN seeds the inbound cursor from the push-channel head (no backlog replay)", async () => {
|
|
469
|
+
const root = tmpRoot();
|
|
470
|
+
let pulledCursor = null;
|
|
471
|
+
const res = await guardMessagingInbound(
|
|
472
|
+
{ event: { cadence: "messaging-inbound" }, agentRoot: root, log: () => {} },
|
|
473
|
+
{
|
|
474
|
+
cfg: { org: { cohort: { enabled: true, base: "https://x", token: "t" } } },
|
|
475
|
+
// The push channel has advanced to seq 4200. A fresh install must start
|
|
476
|
+
// there, NOT at 0 (which would replay the whole org ledger).
|
|
477
|
+
pushHeadImpl: () => 4200,
|
|
478
|
+
pullImpl: async ({ cursor }) => { pulledCursor = cursor; return { events: [], nextCursor: cursor }; },
|
|
479
|
+
toInboxImpl: () => ({}),
|
|
480
|
+
writeImpl: () => { throw new Error("should not write an item"); },
|
|
481
|
+
},
|
|
482
|
+
);
|
|
483
|
+
assert.equal(res.ok, true);
|
|
484
|
+
assert.equal(res.decision, "inline");
|
|
485
|
+
assert.equal(pulledCursor, 4200, "the pull started at the push head, not at genesis");
|
|
486
|
+
const cursorFile = join(root, "state", "messaging", "inbound-cursor.json");
|
|
487
|
+
assert.ok(existsSync(cursorFile), "the seeded head is persisted so a crash cannot fall back to genesis");
|
|
488
|
+
assert.equal(JSON.parse(readFileSync(cursorFile, "utf8")).cursor, 4200);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("messaging-inbound: FIRST RUN with no push head yet HOLDS the tick (never replays from genesis)", async () => {
|
|
492
|
+
const root = tmpRoot();
|
|
493
|
+
const res = await guardMessagingInbound(
|
|
494
|
+
{ event: { cadence: "messaging-inbound" }, agentRoot: root, log: () => {} },
|
|
495
|
+
{
|
|
496
|
+
cfg: { org: { cohort: { enabled: true, base: "https://x", token: "t" } } },
|
|
497
|
+
pushHeadImpl: () => null, // push channel has not established a head yet
|
|
498
|
+
pullImpl: async () => { throw new Error("must not pull before a head exists"); },
|
|
499
|
+
toInboxImpl: () => ({}),
|
|
500
|
+
writeImpl: () => { throw new Error("must not write"); },
|
|
501
|
+
},
|
|
502
|
+
);
|
|
503
|
+
assert.equal(res.ok, true);
|
|
504
|
+
assert.equal(res.decision, "inline");
|
|
505
|
+
assert.equal(res.routed, 0);
|
|
506
|
+
assert.match(res.reason, /awaiting push-channel head/);
|
|
507
|
+
assert.ok(!existsSync(join(root, "state", "messaging", "inbound-cursor.json")), "no cursor seeded while we wait for a head");
|
|
508
|
+
});
|
|
509
|
+
|
|
464
510
|
test("messaging-inbound: no new directed items → inline, routed 0, cursor unchanged", async () => {
|
|
465
511
|
const root = tmpRoot();
|
|
512
|
+
// A cursor already exists (not a first run), so seeding is skipped and the pull
|
|
513
|
+
// starts from the persisted seq. Nothing new → the cursor must not move.
|
|
514
|
+
const cursorFile = join(root, "state", "messaging", "inbound-cursor.json");
|
|
515
|
+
mkdirSync(join(root, "state", "messaging"), { recursive: true });
|
|
516
|
+
writeFileSync(cursorFile, JSON.stringify({ cursor: 9, updatedAt: "2026-01-01T00:00:00Z" }));
|
|
466
517
|
const res = await guardMessagingInbound(
|
|
467
518
|
{ event: { cadence: "messaging-inbound" }, agentRoot: root, log: () => {} },
|
|
468
519
|
{
|
|
469
520
|
cfg: { org: { cohort: { enabled: true, base: "https://x", token: "t" } } },
|
|
470
|
-
pullImpl: async ({ cursor }) => ({ events: [], nextCursor: cursor }
|
|
521
|
+
pullImpl: async ({ cursor }) => { assert.equal(cursor, 9, "pull starts from the persisted cursor"); return { events: [], nextCursor: cursor }; },
|
|
471
522
|
toInboxImpl: () => ({}),
|
|
472
523
|
writeImpl: () => { throw new Error("should not write"); },
|
|
473
524
|
},
|
|
474
525
|
);
|
|
475
526
|
assert.equal(res.decision, "inline");
|
|
476
527
|
assert.equal(res.routed, 0);
|
|
477
|
-
assert.
|
|
528
|
+
assert.equal(JSON.parse(readFileSync(cursorFile, "utf8")).cursor, 9, "cursor unchanged when nothing advanced");
|
|
478
529
|
});
|
|
479
530
|
|
|
480
531
|
test("messaging-inbound: org not configured → inline no-op (fail-open)", async () => {
|
|
@@ -496,6 +547,7 @@ test("messaging-inbound: MESSAGING_INBOUND_ESCALATE=1 escalates when items arriv
|
|
|
496
547
|
{ event: { cadence: "messaging-inbound" }, agentRoot: tmpRoot(), log: () => {} },
|
|
497
548
|
{
|
|
498
549
|
cfg: { org: { cohort: { enabled: true, base: "https://x", token: "t" } } },
|
|
550
|
+
pushHeadImpl: () => 1,
|
|
499
551
|
pullImpl: async () => ({ events: [{ message_id: "m1", text: "@me" }], nextCursor: 1 }),
|
|
500
552
|
toInboxImpl: (e) => ({ id: e.message_id }),
|
|
501
553
|
writeImpl: () => {},
|
|
@@ -514,6 +566,7 @@ test("messaging-inbound: fully fail-open (pull throws → inline, never throws)"
|
|
|
514
566
|
{ event: { cadence: "messaging-inbound" }, agentRoot: tmpRoot(), log: () => {} },
|
|
515
567
|
{
|
|
516
568
|
cfg: { org: { cohort: { enabled: true, base: "https://x", token: "t" } } },
|
|
569
|
+
pushHeadImpl: () => 3,
|
|
517
570
|
pullImpl: async () => { throw new Error("network down"); },
|
|
518
571
|
},
|
|
519
572
|
);
|
|
@@ -737,6 +790,7 @@ test("messaging-inbound (JOINT 3): the DEFAULT reader is wide — no family filt
|
|
|
737
790
|
{
|
|
738
791
|
cfg: { org: { cohort: { enabled: true, base: "https://x.test", token: "t" } } },
|
|
739
792
|
agentId: ME,
|
|
793
|
+
pushHeadImpl: () => 1, // fresh root: seed past genesis so the ledger read runs
|
|
740
794
|
fetchImpl,
|
|
741
795
|
toInboxImpl: (e) => ({ id: e.id, service: "cohort", kind: e.kind, content: e.text }),
|
|
742
796
|
writeImpl: (service, item) => written.push({ service, item }),
|
|
@@ -765,6 +819,7 @@ test("messaging-inbound (JOINT 3): MESSAGING_INBOUND_WIDE=0 falls back to the na
|
|
|
765
819
|
{
|
|
766
820
|
cfg: { org: { cohort: { enabled: true, base: "https://x.test", token: "t" } } },
|
|
767
821
|
agentId: "member-me",
|
|
822
|
+
pushHeadImpl: () => 1, // fresh root: seed past genesis so the read runs
|
|
768
823
|
// The narrow reader pins family=messaging,calling on its URL; assert the
|
|
769
824
|
// degradation is announced rather than inferred from an empty inbox.
|
|
770
825
|
fetchImpl: async () => ({ ok: true, status: 200, headers: { get: () => "application/json" }, text: async () => JSON.stringify({ events: [], nextCursor: 0 }), json: async () => ({ events: [], nextCursor: 0 }) }),
|
|
@@ -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
|
+
});
|
|
@@ -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
|
-
|
|
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(
|
|
119
|
+
const me = JSON.parse(readFileSync(resolve(dir, "config/agent.json"), "utf-8"));
|
|
85
120
|
_agentIdentity = {
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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" =
|
|
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
|
-
|
|
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
|
|
|
@@ -199,38 +199,63 @@ export function promoteDeferred(channel, agentRoot) {
|
|
|
199
199
|
matches.push({
|
|
200
200
|
file,
|
|
201
201
|
timestamp: readScalar(body, "timestamp") || "",
|
|
202
|
+
// THE SURFACE within the channel. `channel_id` names the ROOM; a single
|
|
203
|
+
// room carries several independent conversations at once — a directed 1:1
|
|
204
|
+
// DM thread AND the actor's own doc/board/decision events that resolve to
|
|
205
|
+
// the same channel_id. Bundling "latest-wins across the whole channel"
|
|
206
|
+
// folded those unrelated surfaces into one and silently dropped
|
|
207
|
+
// (`.processed-bundled`) a directed DM because a newer doc-comment event
|
|
208
|
+
// arrived in the same room. thread_context only ever carries the SAME
|
|
209
|
+
// thread's history, so bundling is sound ONLY within one surface. Key on
|
|
210
|
+
// thread_id, else the surface's own scope_id, else "" (a bare-channel
|
|
211
|
+
// burst — the original same-thread case, preserved).
|
|
212
|
+
surface:
|
|
213
|
+
readScalar(body, "thread_id") ||
|
|
214
|
+
readScalar(body, "scope_id") ||
|
|
215
|
+
"",
|
|
202
216
|
});
|
|
203
217
|
}
|
|
204
218
|
if (matches.length === 0) continue;
|
|
205
219
|
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const live = latest.file.replace(/\.deferred$/, "");
|
|
214
|
-
renameSync(join(inboxDir, latest.file), join(inboxDir, live));
|
|
215
|
-
result.promoted++;
|
|
216
|
-
result.service = service;
|
|
217
|
-
} catch {
|
|
218
|
-
// If the rename failed (race with another process), leave it
|
|
219
|
-
// as .deferred — the next session-close will retry.
|
|
220
|
+
// Group by surface within the channel, then bundle latest-wins WITHIN each
|
|
221
|
+
// group. Distinct surfaces each promote their own latest; none is dropped
|
|
222
|
+
// just because a newer message landed on a different surface in the room.
|
|
223
|
+
const groups = new Map();
|
|
224
|
+
for (const m of matches) {
|
|
225
|
+
if (!groups.has(m.surface)) groups.set(m.surface, []);
|
|
226
|
+
groups.get(m.surface).push(m);
|
|
220
227
|
}
|
|
221
228
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
229
|
+
for (const group of groups.values()) {
|
|
230
|
+
// Latest-wins: lex-sort ISO timestamps, take the most recent.
|
|
231
|
+
group.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
232
|
+
const latest = group[group.length - 1];
|
|
233
|
+
const older = group.slice(0, -1);
|
|
234
|
+
|
|
235
|
+
// Promote the latest back to live inbox.
|
|
226
236
|
try {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
result.bundled++;
|
|
237
|
+
const live = latest.file.replace(/\.deferred$/, "");
|
|
238
|
+
renameSync(join(inboxDir, latest.file), join(inboxDir, live));
|
|
239
|
+
result.promoted++;
|
|
240
|
+
result.service = service;
|
|
232
241
|
} catch {
|
|
233
|
-
//
|
|
242
|
+
// If the rename failed (race with another process), leave it
|
|
243
|
+
// as .deferred — the next session-close will retry.
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Mark older bursts as bundled — their content is already part of
|
|
247
|
+
// the latest item's thread_context, so they don't need their own
|
|
248
|
+
// session, but we preserve the file for audit.
|
|
249
|
+
for (const m of older) {
|
|
250
|
+
try {
|
|
251
|
+
renameSync(
|
|
252
|
+
join(inboxDir, m.file),
|
|
253
|
+
join(inboxDir, m.file.replace(/\.deferred$/, ".processed-bundled"))
|
|
254
|
+
);
|
|
255
|
+
result.bundled++;
|
|
256
|
+
} catch {
|
|
257
|
+
// Best-effort; missing file is fine.
|
|
258
|
+
}
|
|
234
259
|
}
|
|
235
260
|
}
|
|
236
261
|
}
|
|
@@ -12,11 +12,13 @@ function makeAgentRoot() {
|
|
|
12
12
|
return root;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
function writeInboxItem(root, name, { channel_id, timestamp, id = "test-id" }) {
|
|
15
|
+
function writeInboxItem(root, name, { channel_id, timestamp, id = "test-id", thread_id, scope_id }) {
|
|
16
16
|
const body = [
|
|
17
17
|
`id: "${id}"`,
|
|
18
18
|
`service: "slack"`,
|
|
19
19
|
`channel_id: "${channel_id}"`,
|
|
20
|
+
...(thread_id ? [`thread_id: "${thread_id}"`] : []),
|
|
21
|
+
...(scope_id ? [`scope_id: "${scope_id}"`] : []),
|
|
20
22
|
`timestamp: "${timestamp}"`,
|
|
21
23
|
`content: |`,
|
|
22
24
|
` body`,
|
|
@@ -118,6 +120,42 @@ test("promoteDeferred with multiple items: keeps latest, bundles rest", () => {
|
|
|
118
120
|
}
|
|
119
121
|
});
|
|
120
122
|
|
|
123
|
+
test("promoteDeferred bundles WITHIN a surface, not across the whole channel", () => {
|
|
124
|
+
// A directed 1:1 DM thread AND the actor's doc-comment events resolve to the
|
|
125
|
+
// same channel_id. Bundling latest-wins across the channel would drop the DM
|
|
126
|
+
// just because a newer doc event landed. Each surface must promote its own
|
|
127
|
+
// latest; nothing is bundled across surfaces.
|
|
128
|
+
const root = makeAgentRoot();
|
|
129
|
+
try {
|
|
130
|
+
// The directed DM (one message, thread T-DM).
|
|
131
|
+
writeInboxItem(root, "dm-1.yaml.deferred", {
|
|
132
|
+
channel_id: "room-1", thread_id: "T-DM", id: "dm-1",
|
|
133
|
+
timestamp: "2026-05-13T00:00:30Z",
|
|
134
|
+
});
|
|
135
|
+
// Two doc-comment events on the same room but a different surface (scope S-DOC),
|
|
136
|
+
// the later of which would have "won" under the old channel-wide bundling.
|
|
137
|
+
writeInboxItem(root, "doc-1.yaml.deferred", {
|
|
138
|
+
channel_id: "room-1", scope_id: "S-DOC", id: "doc-1",
|
|
139
|
+
timestamp: "2026-05-13T00:01:00Z",
|
|
140
|
+
});
|
|
141
|
+
writeInboxItem(root, "doc-2.yaml.deferred", {
|
|
142
|
+
channel_id: "room-1", scope_id: "S-DOC", id: "doc-2",
|
|
143
|
+
timestamp: "2026-05-13T00:02:00Z",
|
|
144
|
+
});
|
|
145
|
+
const r = promoteDeferred("room-1", root);
|
|
146
|
+
assert.equal(r.promoted, 2, "the DM and the latest doc event are BOTH promoted");
|
|
147
|
+
assert.equal(r.bundled, 1, "only the older doc event (same surface) is bundled");
|
|
148
|
+
const files = readdirSync(join(root, "state", "inbox", "slack")).sort();
|
|
149
|
+
assert.deepEqual(files, [
|
|
150
|
+
"dm-1.yaml", // the directed DM survives — NOT dropped
|
|
151
|
+
"doc-1.yaml.processed-bundled", // older doc event folded into doc-2
|
|
152
|
+
"doc-2.yaml", // latest doc event promoted
|
|
153
|
+
]);
|
|
154
|
+
} finally {
|
|
155
|
+
rmSync(root, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
121
159
|
test("promoteDeferred ignores items in other channels", () => {
|
|
122
160
|
const root = makeAgentRoot();
|
|
123
161
|
try {
|