@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
|
@@ -523,3 +523,310 @@ 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
|
+
});
|
|
716
|
+
|
|
717
|
+
// ===========================================================================
|
|
718
|
+
// is_dm END-TO-END — the Cohort channel_kind is the authoritative signal.
|
|
719
|
+
// ===========================================================================
|
|
720
|
+
|
|
721
|
+
test("enrichItem: Cohort channel_kind decides is_dm (DM yes, GROUP_DM no)", () => {
|
|
722
|
+
// A 1:1 DM skips the election gate; a GROUP_DM is multi-participant and must
|
|
723
|
+
// go through it. Cohort ids are cuids, so the kind is the only reliable signal.
|
|
724
|
+
const dm = daemon.enrichItem({ channel: "channel/x", channel_id: "cuid_abc", channel_kind: "DM" }, "cohort");
|
|
725
|
+
assert.equal(dm.is_dm, true, "channel_kind DM → is_dm true (skips election)");
|
|
726
|
+
const group = daemon.enrichItem({ channel: "channel/x", channel_id: "cuid_abc", channel_kind: "GROUP_DM" }, "cohort");
|
|
727
|
+
assert.equal(group.is_dm, false, "channel_kind GROUP_DM → is_dm false (goes through election)");
|
|
728
|
+
const pub = daemon.enrichItem({ channel: "channel/x", channel_id: "cuid_abc", channel_kind: "public" }, "cohort");
|
|
729
|
+
assert.equal(pub.is_dm, false, "any other explicit kind → not a 1:1 DM");
|
|
730
|
+
// No kind → fall back to the Slack-style id/label prefix heuristics.
|
|
731
|
+
assert.equal(daemon.enrichItem({ channel: "dm/ceo", channel_id: "D123" }, "slack").is_dm, true);
|
|
732
|
+
assert.equal(daemon.enrichItem({ channel: "channel/roadmap", channel_id: "C123" }, "slack").is_dm, false);
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
test("enrichItem: a WIDE-path Cohort 1:1 DM (is_dm:true, NO channel_kind, cuid id) stays is_dm through the whole chain", async () => {
|
|
736
|
+
// REGRESSION. The DEFAULT inbound reader is lib/org/inbound.pullWideInbound,
|
|
737
|
+
// whose projection (lib/org/inbound/project.mjs toMessageEvent) marks a 1:1 DM
|
|
738
|
+
// with is_dm:true + channel_type:"dm" but does NOT emit channel_kind (only the
|
|
739
|
+
// non-default MESSAGING_INBOUND_WIDE=0 narrow reader sets channel_kind). Cohort
|
|
740
|
+
// channel ids are cuids, so the Slack-style "dm/"/"D…" prefix heuristics never
|
|
741
|
+
// fire on them. Before the fix, enrichItem derived is_dm ONLY from an (absent)
|
|
742
|
+
// channel_kind or those (never-matching) heuristics and CLOBBERED the true
|
|
743
|
+
// is_dm to false — routing a genuine 1:1 DM through the group election gate,
|
|
744
|
+
// which then suppressed it. This drives the real chain end to end:
|
|
745
|
+
// toMessageEvent → eventToInboxItem → writeInboxItem → parseInboxItemYaml → enrichItem
|
|
746
|
+
// and asserts is_dm survives as true.
|
|
747
|
+
const { toMessageEvent } = await import("../../lib/org/inbound/project.mjs");
|
|
748
|
+
const { classifyEvent } = await import("../../lib/org/inbound/directedness.mjs");
|
|
749
|
+
const { eventToInboxItem } = await import("../../lib/channels/inbox-item.mjs");
|
|
750
|
+
const { writeInboxItem } = await import("../poller/utils.mjs");
|
|
751
|
+
const { parseInboxItemYaml } = await import("../poller/inbox-scan-poller.mjs");
|
|
752
|
+
|
|
753
|
+
const CUID = "clw9x7k4a0000abcd1234efgh"; // a real cuid: no "D…"/"dm/" prefix to key off
|
|
754
|
+
const cand = classifyEvent({
|
|
755
|
+
seq: 501, at: "2026-08-25T09:00:00.000Z", actor: "M-them",
|
|
756
|
+
family: "messaging", kind: "send", entity_id: "m501",
|
|
757
|
+
payload: { actor: "M-them", channelId: CUID, channelKind: "DM" },
|
|
758
|
+
});
|
|
759
|
+
const facts = { channels: new Map([[CUID, { id: CUID, kind: "DM", name: "them" }]]) };
|
|
760
|
+
const mev = toMessageEvent({
|
|
761
|
+
candidate: cand,
|
|
762
|
+
verdict: { surface: "dm", reason: "dm", directed: true },
|
|
763
|
+
hydrated: { ok: true, text: "ping", from: { id: "M-them", name: "Casey" }, channelId: CUID, channelLabel: "them" },
|
|
764
|
+
me: "M-me",
|
|
765
|
+
facts,
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
// The premise the regression rests on: the wide projection marks the DM but
|
|
769
|
+
// does NOT emit channel_kind.
|
|
770
|
+
assert.equal(mev.is_dm, true, "wide-path DM projects is_dm:true");
|
|
771
|
+
assert.equal(mev.channel_type, "dm");
|
|
772
|
+
assert.equal("channel_kind" in mev, false, "the DEFAULT wide reader emits NO channel_kind");
|
|
773
|
+
|
|
774
|
+
// eventToInboxItem carries is_dm through but (correctly) attaches no channel_kind.
|
|
775
|
+
const item = eventToInboxItem(mev);
|
|
776
|
+
assert.equal(item.is_dm, true);
|
|
777
|
+
assert.equal("channel_kind" in item, false);
|
|
778
|
+
|
|
779
|
+
// writeInboxItem → parseInboxItemYaml: the on-disk round-trip the poller does.
|
|
780
|
+
writeInboxItem("cohort", item, AGENT_DIR);
|
|
781
|
+
const dir = join(AGENT_DIR, "state", "inbox", "cohort");
|
|
782
|
+
const file = readdirSync(dir).find((f) => f.includes(item.id) && f.endsWith(".yaml"));
|
|
783
|
+
assert.ok(file, "the inbox item was written to disk");
|
|
784
|
+
const parsed = parseInboxItemYaml(readFileSync(join(dir, file), "utf8"));
|
|
785
|
+
assert.equal(parsed.is_dm, true, "is_dm survives the YAML round-trip");
|
|
786
|
+
assert.equal("channel_kind" in parsed, false, "and still no channel_kind on the wide path");
|
|
787
|
+
|
|
788
|
+
// enrichItem is the last hop before the election gate. It MUST honor the
|
|
789
|
+
// pre-existing boolean is_dm when channel_kind is absent, not clobber it.
|
|
790
|
+
const enriched = daemon.enrichItem(parsed, "cohort");
|
|
791
|
+
assert.equal(enriched.is_dm, true, "the wide-path 1:1 DM stays is_dm — it must SKIP the group election gate");
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
// ===========================================================================
|
|
795
|
+
// NO FALLTHROUGH ON PERMANENT FAILURE — a permanent quick-reply send failure
|
|
796
|
+
// (FORBIDDEN_SCOPE) must escalate, not spawn an autonomous session that would
|
|
797
|
+
// post UNRELATED work into a channel the seat may not write to.
|
|
798
|
+
// ===========================================================================
|
|
799
|
+
|
|
800
|
+
test("NO FALLTHROUGH: a PERMANENT quick-reply failure escalates instead of spawning a session", async () => {
|
|
801
|
+
resetState();
|
|
802
|
+
const item = { id: "MSG-PERM", raw_ref: "slack:DPERM:1", service: "slack", channel: "dm/ceo-perm", channel_id: "DPERM0001", is_dm: true, sender: "ceo", subject: "forbidden send", content: "please post this" };
|
|
803
|
+
let dispatched = false;
|
|
804
|
+
const res = await daemon.answerItem(item, "slack", "MSG-PERM", "trace-perm", {
|
|
805
|
+
classify: async () => ({ priority: "high", action: "respond", model: "haiku", summary: "quick answer", category: "action_required", directed_at_agent: true }),
|
|
806
|
+
isQuickReply: () => true,
|
|
807
|
+
sendQuickResponse: async () => ({ sent: false, permanent: true, code: "FORBIDDEN_SCOPE", error: "FORBIDDEN_SCOPE: blocked by send-gate" }),
|
|
808
|
+
dispatch: () => { dispatched = true; },
|
|
809
|
+
claudeAvailable: () => true,
|
|
810
|
+
});
|
|
811
|
+
assert.equal(dispatched, false, "a permanent send failure must NOT spawn an autonomous session");
|
|
812
|
+
assert.equal(res.path, "quick_reply_permanent");
|
|
813
|
+
assert.match(res.reason, /FORBIDDEN_SCOPE/);
|
|
814
|
+
const naDir = join(AGENT_DIR, "state", "obligations", "needs-attention");
|
|
815
|
+
assert.ok(existsSync(naDir) && readdirSync(naDir).length > 0, "a durable needs-attention escalation was written for an operator");
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
test("NO FALLTHROUGH: a TRANSIENT quick-reply failure still falls through to a full session", async () => {
|
|
819
|
+
resetState();
|
|
820
|
+
const item = { id: "MSG-TRAN", raw_ref: "slack:DTRAN:1", service: "slack", channel: "dm/ceo-tran", channel_id: "DTRAN0001", is_dm: true, sender: "ceo", subject: "transient send", content: "please post this" };
|
|
821
|
+
let dispatched = false;
|
|
822
|
+
const res = await daemon.answerItem(item, "slack", "MSG-TRAN", "trace-tran", {
|
|
823
|
+
classify: async () => ({ priority: "high", action: "respond", model: "haiku", summary: "quick answer", category: "action_required", directed_at_agent: true }),
|
|
824
|
+
isQuickReply: () => true,
|
|
825
|
+
sendQuickResponse: async () => ({ sent: false, error: "hq 503 blip" }), // transient: NO permanent flag
|
|
826
|
+
sendHoldingMessage: async () => ({ sent: true, holdingText: "on it" }),
|
|
827
|
+
dispatch: (_p, _it, _cr, _s, opts) => { dispatched = true; opts.onClose({ ok: true, code: 0 }); },
|
|
828
|
+
claudeAvailable: () => true,
|
|
829
|
+
});
|
|
830
|
+
assert.equal(dispatched, true, "a transient failure falls through to the full session, as before");
|
|
831
|
+
assert.equal(res.path, "session");
|
|
832
|
+
});
|
|
@@ -308,12 +308,25 @@ function variant(seed, n) {
|
|
|
308
308
|
return h % n;
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
-
/**
|
|
311
|
+
/**
|
|
312
|
+
* Trim the classifier's summary into a short topic phrase — casing preserved.
|
|
313
|
+
*
|
|
314
|
+
* This used to lowercase the first letter so the phrase could sit mid-sentence.
|
|
315
|
+
* Applied to a summary that starts with an acronym or a name, that manufactures
|
|
316
|
+
* a typo the requester reads as the agent's own: "Picking up: cEO checking in
|
|
317
|
+
* on work progress." went to a real human, first letter of "CEO" folded. No
|
|
318
|
+
* local rule can tell "Fix" (safe to downcase) from "CEO", "Q3" or a person's
|
|
319
|
+
* name (not safe), so the phrase is taken exactly as the classifier wrote it, and
|
|
320
|
+
* every slot that renders it — the parentheses here and in topicAside — is one
|
|
321
|
+
* where original casing reads correctly. The stored `rec.summary` flows into
|
|
322
|
+
* progress/failure/interrupted messages and the board-row title too, so the
|
|
323
|
+
* no-recasing rule protects all of them at once.
|
|
324
|
+
*/
|
|
312
325
|
function topicClause(classResult, item) {
|
|
313
326
|
const raw = (classResult && classResult.summary) || (item && item.subject) || "";
|
|
314
327
|
const t = String(raw).trim().replace(/\s+/g, " ").replace(/[.!?]+$/, "");
|
|
315
328
|
if (!t || t.length > 120) return null;
|
|
316
|
-
return t
|
|
329
|
+
return t;
|
|
317
330
|
}
|
|
318
331
|
|
|
319
332
|
/** Rough, honest expectation. Derived from the measured distribution of real
|
|
@@ -327,23 +340,29 @@ export function expectedWindow(classResult) {
|
|
|
327
340
|
}
|
|
328
341
|
|
|
329
342
|
/**
|
|
330
|
-
* The acknowledgement. Two
|
|
331
|
-
* and — critically — it commits to coming back "either way". That clause
|
|
332
|
-
* promise the rest of this module now actually keeps: failure notices,
|
|
333
|
-
* updates and the stale sweep all exist so it is never a lie.
|
|
343
|
+
* The acknowledgement. Two sentences, written the way a colleague would type
|
|
344
|
+
* them, and — critically — it commits to coming back "either way". That clause
|
|
345
|
+
* is a promise the rest of this module now actually keeps: failure notices,
|
|
346
|
+
* interim updates and the stale sweep all exist so it is never a lie. The time
|
|
347
|
+
* window is expectedWindow's — measured, not invented.
|
|
348
|
+
*
|
|
349
|
+
* The topic rides in parentheses at the end of the opener, never spliced into
|
|
350
|
+
* the sentence: the summary is an arbitrary phrase in arbitrary case, and the
|
|
351
|
+
* previous template — `Picking up: <lowercased summary>. It needs a bit of
|
|
352
|
+
* work, …` — showed the daemon's internal framing to the requester and mangled
|
|
353
|
+
* acronym-initial summaries on top (see topicClause). "Picking up:" is triage
|
|
354
|
+
* vocabulary; a human answering a human does not narrate their queue.
|
|
334
355
|
*/
|
|
335
356
|
export function composeAck(item, classResult = {}) {
|
|
336
357
|
const topic = topicClause(classResult, item);
|
|
358
|
+
const aside = topic ? ` (${topic})` : "";
|
|
337
359
|
const v = variant(obligationKey(item) || (item && item.sender), 3);
|
|
338
360
|
const openers = [
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
361
|
+
`No problem — I'm looking into this now${aside}.`,
|
|
362
|
+
`Got it — I'm on it now${aside}.`,
|
|
363
|
+
`Understood — I'm digging into this now${aside}.`,
|
|
342
364
|
];
|
|
343
|
-
|
|
344
|
-
if (topic) parts.push(`Picking up: ${topic}.`);
|
|
345
|
-
parts.push(`It needs a bit of work, so I'll come back to you here with an answer — ${expectedWindow(classResult)}, and I'll message you either way.`);
|
|
346
|
-
return parts.join(" ");
|
|
365
|
+
return `${openers[v]} I want to get this right, so I'll come back to you here with a full answer — ${expectedWindow(classResult)} — and you'll hear from me either way.`;
|
|
347
366
|
}
|
|
348
367
|
|
|
349
368
|
/**
|
|
@@ -359,13 +378,17 @@ function topicAside(rec) {
|
|
|
359
378
|
return rec && rec.summary ? ` (${rec.summary})` : "";
|
|
360
379
|
}
|
|
361
380
|
|
|
362
|
-
/** An interim update. Says how long it has been and that the work is live.
|
|
381
|
+
/** An interim update. Says how long it has been and that the work is live.
|
|
382
|
+
* The first update no longer claims "it's taking longer than I expected": it
|
|
383
|
+
* fires at PROGRESS_AFTER_MS (5 min), squarely inside the 10-20 minute window
|
|
384
|
+
* the acknowledgement promised for opus work minutes earlier — a claim the
|
|
385
|
+
* same human could see was false. */
|
|
363
386
|
export function composeProgress(rec, o = {}) {
|
|
364
387
|
const now = Number.isFinite(o.now) ? o.now : Date.now();
|
|
365
388
|
const mins = Math.max(1, Math.round((now - (rec.openedAt || now)) / 60_000));
|
|
366
389
|
const topic = topicAside(rec);
|
|
367
390
|
if ((rec.progressSent || 0) === 0) {
|
|
368
|
-
return `Still working on this${topic} — ${mins} minutes in
|
|
391
|
+
return `Still working on this${topic} — ${mins} minutes in. Nothing's stuck; I'll come back as soon as I have something worth sending.`;
|
|
369
392
|
}
|
|
370
393
|
return `Quick check-in: still on this${topic}, ${mins} minutes in. I haven't forgotten it — I'll follow up the moment it's done, or tell you if I can't finish it.`;
|
|
371
394
|
}
|
|
@@ -147,6 +147,26 @@ describe("composeAck", () => {
|
|
|
147
147
|
assert.ok(a.length > 40);
|
|
148
148
|
assert.match(a, /either way/i);
|
|
149
149
|
});
|
|
150
|
+
|
|
151
|
+
test("REGRESSION: an acronym-initial summary keeps its casing — no 'cEO'", () => {
|
|
152
|
+
// Owner screenshot, verbatim: "Picking up: cEO checking in on work
|
|
153
|
+
// progress." — topicClause downcased the first letter of whatever the
|
|
154
|
+
// classifier wrote so it could sit mid-sentence, and "CEO…" became a typo
|
|
155
|
+
// the requester reads as the agent's own. The summary is now carried
|
|
156
|
+
// exactly as written.
|
|
157
|
+
const a = composeAck(ITEM, { ...CLASS_COMPLEX, summary: "CEO checking in on work progress" });
|
|
158
|
+
assert.match(a, /CEO checking in on work progress/);
|
|
159
|
+
assert.doesNotMatch(a, /cEO/);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("reads like a colleague, not a template — no internal framing shown to the human", () => {
|
|
163
|
+
// "Picking up:" is triage vocabulary and "It needs a bit of work" is the
|
|
164
|
+
// daemon explaining its own dispatch decision. Both leaked the machinery at
|
|
165
|
+
// the person being acknowledged.
|
|
166
|
+
const a = composeAck(ITEM, CLASS_COMPLEX);
|
|
167
|
+
assert.doesNotMatch(a, /picking up:/i);
|
|
168
|
+
assert.doesNotMatch(a, /needs a bit of work/i);
|
|
169
|
+
});
|
|
150
170
|
});
|
|
151
171
|
|
|
152
172
|
// ---------------------------------------------------------------------------
|
|
@@ -203,6 +223,21 @@ describe("openAndAcknowledge", () => {
|
|
|
203
223
|
assert.equal(openObligations().length, 1, "the debt must be visible to the sweep");
|
|
204
224
|
});
|
|
205
225
|
|
|
226
|
+
test("the stored summary keeps the classifier's casing — every later message reads from it", async () => {
|
|
227
|
+
// `rec.summary` feeds topicAside (progress, failure, silent-success,
|
|
228
|
+
// interrupted), the sweep's compensating ack, and board-mirror's row title.
|
|
229
|
+
// A summary mangled at open time would resurface in all of them.
|
|
230
|
+
const t = fakeTransport();
|
|
231
|
+
const r = await openAndAcknowledge({
|
|
232
|
+
item: ITEM,
|
|
233
|
+
classResult: { ...CLASS_COMPLEX, summary: "CEO checking in on work progress" },
|
|
234
|
+
deps: { ackSender: t.ackSender },
|
|
235
|
+
});
|
|
236
|
+
const rec = readObligation(r.key);
|
|
237
|
+
assert.equal(rec.summary, "CEO checking in on work progress");
|
|
238
|
+
assert.match(composeProgress(rec, { now: Date.now() + 6 * 60_000 }), /\(CEO checking in on work progress\)/);
|
|
239
|
+
});
|
|
240
|
+
|
|
206
241
|
test("acknowledges ONCE — a re-delivered item does not re-announce itself", async () => {
|
|
207
242
|
const t = fakeTransport();
|
|
208
243
|
await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
|
|
@@ -362,6 +397,9 @@ describe("sweepObligations", () => {
|
|
|
362
397
|
const msg = t.sent.at(-1).text;
|
|
363
398
|
assert.match(msg, /still working/i);
|
|
364
399
|
assert.match(msg, /minutes in/i, "an interim update that omits how long it has been is not much of an update");
|
|
400
|
+
// Six minutes in is INSIDE the 10-20 minute window the ack just promised
|
|
401
|
+
// for opus work; claiming it here contradicts a message the same human read.
|
|
402
|
+
assert.doesNotMatch(msg, /longer than I expected/i);
|
|
365
403
|
});
|
|
366
404
|
|
|
367
405
|
test("interim updates are CAPPED — attentive, not noisy", async () => {
|
|
@@ -604,7 +642,7 @@ describe("attribution: which debt did that message actually discharge", () => {
|
|
|
604
642
|
const stats = await sweepObligations({ now: Date.now() + ACK_GRACE_MS + 1000, deps: { deliverImpl: t.impl } });
|
|
605
643
|
assert.equal(stats.closed, 0, "no session of ours had run — that message was not our answer");
|
|
606
644
|
assert.equal(stats.acked, 1, "instead the missing acknowledgement is finally sent");
|
|
607
|
-
assert.match(t.sent.at(-1).text, /
|
|
645
|
+
assert.match(t.sent.at(-1).text, /looking into this|on it now|digging into this/i);
|
|
608
646
|
});
|
|
609
647
|
});
|
|
610
648
|
|
|
@@ -667,16 +667,32 @@ async function guardDynamicJobs({ event, agentRoot }, opts = {}) {
|
|
|
667
667
|
|
|
668
668
|
const MESSAGING_CURSOR_REL = join("state", "messaging", "inbound-cursor.json");
|
|
669
669
|
|
|
670
|
-
/**
|
|
670
|
+
/**
|
|
671
|
+
* Read the org-messaging inbound cursor (seq).
|
|
672
|
+
*
|
|
673
|
+
* Returns `null` when there is NO cursor on disk (or it is corrupt/unreadable) —
|
|
674
|
+
* NOT 0. The distinction is load-bearing, exactly as it is for the push channel
|
|
675
|
+
* (lib/org/push.readCursor): `cursor=0` on the events read means "replay the
|
|
676
|
+
* ENTIRE org ledger from genesis", so a fresh install that started at 0 would
|
|
677
|
+
* wake on every historical message in the workspace. `null` is the caller's cue
|
|
678
|
+
* to SEED from the push channel head instead (see guardMessagingInbound), so a
|
|
679
|
+
* (re)install starts at "now" and replays nothing. A real, persisted cursor
|
|
680
|
+
* (always > 0 in practice — see writeMessagingCursor) is returned as-is.
|
|
681
|
+
*/
|
|
671
682
|
function readMessagingCursor(agentRoot) {
|
|
672
683
|
try {
|
|
673
684
|
const p = join(agentRoot, MESSAGING_CURSOR_REL);
|
|
674
|
-
if (!existsSync(p)) return
|
|
685
|
+
if (!existsSync(p)) return null;
|
|
675
686
|
const obj = JSON.parse(readFileSync(p, "utf8"));
|
|
676
687
|
const c = Number(obj && obj.cursor);
|
|
677
|
-
|
|
688
|
+
// A non-finite / non-positive cursor is corrupt, not "from genesis" — treat it
|
|
689
|
+
// as absent (seed from head) rather than replaying all history. Aligned with
|
|
690
|
+
// lib/org/push.readCursor (`n > 0`): a persisted cursor is always > 0 (seeded
|
|
691
|
+
// from a head that is null-or->0, then only advanced forward), so 0 is never a
|
|
692
|
+
// real watermark — accepting it would replay the entire org ledger.
|
|
693
|
+
return Number.isFinite(c) && c > 0 ? c : null;
|
|
678
694
|
} catch {
|
|
679
|
-
return
|
|
695
|
+
return null;
|
|
680
696
|
}
|
|
681
697
|
}
|
|
682
698
|
|
|
@@ -764,7 +780,34 @@ async function guardMessagingInbound({ event, agentRoot, log }, opts = {}) {
|
|
|
764
780
|
opts.toInboxImpl || (await import("../../lib/channels/inbox-item.mjs")).eventToInboxItem;
|
|
765
781
|
const writeInboxItem = opts.writeImpl || (await import("../poller/utils.mjs")).writeInboxItem;
|
|
766
782
|
|
|
767
|
-
|
|
783
|
+
let cursor = readMessagingCursor(agentRoot);
|
|
784
|
+
if (cursor == null) {
|
|
785
|
+
// FIRST RUN (or a corrupt cursor): no usable inbound cursor on disk. Do NOT
|
|
786
|
+
// start the poll lane at genesis — `events?cursor=0` replays the ENTIRE org
|
|
787
|
+
// ledger and wakes the fresh agent on every historical message. Seed from
|
|
788
|
+
// the PUSH CHANNEL HEAD instead: the SSE lane bootstraps at head and
|
|
789
|
+
// persists that seq to state/org/push-cursor.json, so it is the local
|
|
790
|
+
// "everything before this is history" watermark. Persist it as our starting
|
|
791
|
+
// cursor so a crash before the first advance cannot fall back to genesis.
|
|
792
|
+
let head = null;
|
|
793
|
+
try {
|
|
794
|
+
head = typeof opts.pushHeadImpl === "function"
|
|
795
|
+
? opts.pushHeadImpl(agentRoot)
|
|
796
|
+
: (await import("../../lib/org/push.mjs")).readCursor(agentRoot);
|
|
797
|
+
} catch { head = null; }
|
|
798
|
+
if (head == null) {
|
|
799
|
+
// Neither lane has established a head yet (truly fresh install, push not
|
|
800
|
+
// yet connected). Replaying the backlog is the ONE thing we must not do,
|
|
801
|
+
// so hold this tick — the push channel writes a head shortly and the next
|
|
802
|
+
// tick seeds from it. Never silent: the reason says exactly why 0 routed.
|
|
803
|
+
if (typeof log === "function") {
|
|
804
|
+
log("info", "[messaging-inbound] fresh install: no inbound cursor and no push-channel head yet — holding this tick to avoid replaying the org backlog");
|
|
805
|
+
}
|
|
806
|
+
return { ok: true, decision: "inline", cadence: event.cadence, reason: "awaiting push-channel head to seed inbound cursor (fresh install)", routed: 0 };
|
|
807
|
+
}
|
|
808
|
+
cursor = Number(head);
|
|
809
|
+
writeMessagingCursor(agentRoot, cursor);
|
|
810
|
+
}
|
|
768
811
|
// Pass the agent id explicitly. It is what suppresses the agent's OWN
|
|
769
812
|
// outbound echoes — without it `me` is null, every echo-filter branch is
|
|
770
813
|
// skipped, and the agent re-ingests its own replies as fresh inbound (a
|