@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.
- 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 +4 -0
- package/lib/comms/send-gate.mjs +23 -1
- package/lib/comms/send-gate.test.mjs +24 -0
- package/lib/mcp/server.test.mjs +16 -4
- package/lib/model-router/economics.mjs +53 -1
- package/lib/model-router/economics.test.mjs +76 -0
- package/lib/model-router/resolve.mjs +57 -4
- package/lib/model-router.mjs +95 -8
- package/lib/model-router.test.mjs +305 -5
- package/lib/org/client.mjs +58 -1
- package/lib/org/inbound/project.mjs +9 -5
- package/lib/org/messaging.mjs +6 -1
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +176 -3
- package/lib/org/protocol.test.mjs +31 -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 +150 -3
- package/scripts/daemon/agent-daemon.test.mjs +190 -0
- package/scripts/daemon/assurance.mjs +50 -16
- package/scripts/daemon/assurance.test.mjs +39 -1
- package/scripts/daemon/classifier-identity.test.mjs +137 -0
- package/scripts/daemon/classifier.mjs +98 -17
- package/scripts/daemon/deliver.mjs +457 -33
- package/scripts/daemon/deliver.test.mjs +564 -0
- 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-history.test.mjs +18 -2
- package/scripts/daemon/responder.mjs +7 -1
- 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/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
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
|
320
|
-
* and — critically — it commits to coming back "either way". That clause
|
|
321
|
-
* promise the rest of this module now actually keeps: failure notices,
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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
|
|
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, /
|
|
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
|
+
});
|