@cohortapp/agent-sdk 2.15.0 → 2.17.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/.env.example +5 -2
- package/docs/guides/front-door-session.md +16 -5
- package/docs/guides/poller-daemon-setup.md +53 -2
- package/lib/assurance/plan-note.mjs +251 -0
- package/lib/assurance/plan-note.test.mjs +234 -0
- package/lib/assurance/room-budget.mjs +497 -0
- package/lib/assurance/room-budget.test.mjs +486 -0
- package/lib/assurance/tier.mjs +166 -0
- package/lib/assurance/tier.test.mjs +174 -0
- package/lib/comms/receipts.mjs +17 -1
- package/lib/context/budget.mjs +327 -0
- package/lib/context/budget.test.mjs +252 -0
- package/lib/context/history-scope.mjs +138 -0
- package/lib/context/history-scope.test.mjs +79 -0
- package/lib/model-router/economics.mjs +9 -0
- package/lib/model-router/resolve.mjs +6 -0
- package/lib/org/inbound/facts.mjs +4 -2
- package/lib/org/inbound/hydrate.mjs +555 -51
- package/lib/org/inbound/hydrate.test.mjs +456 -1
- package/package.json +3 -1
- package/plugins/maestro-skills/skills/inbound-triage.md +52 -24
- package/plugins/maestro-skills/skills/main-session.md +6 -4
- package/scripts/daemon/agent-daemon.mjs +35 -7
- package/scripts/daemon/agent-daemon.test.mjs +23 -6
- package/scripts/daemon/assurance-e2e.test.mjs +75 -19
- package/scripts/daemon/assurance.mjs +663 -159
- package/scripts/daemon/assurance.test.mjs +820 -140
- package/scripts/daemon/context-compiler.mjs +52 -21
- package/scripts/daemon/context-compiler.test.mjs +106 -0
- package/scripts/daemon/deliver.mjs +7 -4
- package/scripts/daemon/dispatcher-session-continuity.test.mjs +365 -0
- package/scripts/daemon/dispatcher.mjs +210 -9
- package/scripts/daemon/lib/session-router.mjs +310 -42
- package/scripts/daemon/lib/session-router.test.mjs +260 -1
- package/scripts/daemon/prompt-builder.mjs +160 -16
- package/scripts/daemon/prompt-builder.test.mjs +287 -7
- package/scripts/daemon/responder-history.test.mjs +37 -1
- package/scripts/daemon/responder.mjs +79 -72
|
@@ -15,7 +15,18 @@
|
|
|
15
15
|
import { test } from "node:test";
|
|
16
16
|
import assert from "node:assert/strict";
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
hydrate,
|
|
20
|
+
clip,
|
|
21
|
+
renderThread,
|
|
22
|
+
priorComments,
|
|
23
|
+
isCommentKind,
|
|
24
|
+
htmlToText,
|
|
25
|
+
clampDocHtml,
|
|
26
|
+
DOC_HTML_MAX_CHARS,
|
|
27
|
+
describeBoardKind,
|
|
28
|
+
describeApprovalKind,
|
|
29
|
+
} from "./hydrate.mjs";
|
|
19
30
|
import { classifyEvent } from "./directedness.mjs";
|
|
20
31
|
|
|
21
32
|
const ME = "M-me";
|
|
@@ -46,7 +57,9 @@ function facts(over = {}) {
|
|
|
46
57
|
me: ME,
|
|
47
58
|
myNames: [],
|
|
48
59
|
channels: new Map(),
|
|
60
|
+
visibleChannelIds: null,
|
|
49
61
|
channelPages: new Map(),
|
|
62
|
+
myEvents: null,
|
|
50
63
|
myThreadRootIds: new Set(),
|
|
51
64
|
tasks: null,
|
|
52
65
|
decisions: null,
|
|
@@ -451,3 +464,445 @@ test("event-kind phrasing is stable and total", () => {
|
|
|
451
464
|
assert.match(describeApprovalKind("approval.requested"), /waiting on you/);
|
|
452
465
|
assert.match(describeApprovalKind("approval.rejected"), /REJECTED/);
|
|
453
466
|
});
|
|
467
|
+
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
// the slice(0,-1) guard — four surfaces used to lose their newest comment
|
|
470
|
+
//
|
|
471
|
+
// `renderThread(comments.slice(0,-1))` assumed the event was always a comment.
|
|
472
|
+
// It is not: item.assigned, item.moved, file.shared and decision.signed are
|
|
473
|
+
// events ABOUT an entity that HAS a thread, and for those the newest comment is
|
|
474
|
+
// context, not the trigger. On a freshly assigned task with one comment, that
|
|
475
|
+
// comment was the whole of the context and the agent saw none of it.
|
|
476
|
+
// ---------------------------------------------------------------------------
|
|
477
|
+
|
|
478
|
+
test("item.assigned KEEPS its newest comment — it is context, not the trigger", async () => {
|
|
479
|
+
const f = facts({ tasks: new Map([["T-1", { id: "T-1", title: "Ship the thing", col: "todo" }]]) });
|
|
480
|
+
const io = fakeIo({
|
|
481
|
+
"board.taskComments": () => okFrame({ comments: [{ id: "c1", authorName: "Casey", body: "the client needs this by Friday" }] }),
|
|
482
|
+
});
|
|
483
|
+
const c = classifyEvent(ev({ family: "board", kind: "item.assigned", entity_id: "T-1", payload: { actor: THEM, assignee: ME } }));
|
|
484
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "task_assigned", reason: "assignee" }, facts: f, io });
|
|
485
|
+
|
|
486
|
+
assert.equal(h.ok, true);
|
|
487
|
+
assert.match(h.threadContext, /Casey: the client needs this by Friday/);
|
|
488
|
+
assert.equal(h.isReply, true);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("item.moved keeps its newest comment", async () => {
|
|
492
|
+
const f = facts({ tasks: new Map([["T-2", { id: "T-2", title: "Rework", col: "blocked" }]]) });
|
|
493
|
+
const io = fakeIo({
|
|
494
|
+
"board.taskComments": () => okFrame({ comments: [{ id: "c9", authorName: "Casey", body: "moving this back, the numbers are wrong" }] }),
|
|
495
|
+
});
|
|
496
|
+
const c = classifyEvent(ev({ family: "board", kind: "item.moved", entity_id: "T-2", payload: { actor: THEM } }));
|
|
497
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "task_comment", reason: "assignee" }, facts: f, io });
|
|
498
|
+
assert.match(h.threadContext, /the numbers are wrong/);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("file.shared keeps its newest comment", async () => {
|
|
502
|
+
const f = facts({ fileAcl: new Map([["F-1", { ownerId: ME, shared: true, name: "Q3 plan", kind: "DOC" }]]) });
|
|
503
|
+
const io = fakeIo({
|
|
504
|
+
"files.comments": () => okFrame({ comments: [{ id: "k1", authorName: "Casey", body: "read section two first" }] }),
|
|
505
|
+
"files.docRead": () => okFrame({ html: "<p>Q3 plan</p>" }),
|
|
506
|
+
});
|
|
507
|
+
const c = classifyEvent(ev({ family: "files", kind: "file.shared", entity_id: "F-1", payload: { actor: THEM, name: "Q3 plan" } }));
|
|
508
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "doc_comment", reason: "shared" }, facts: f, io });
|
|
509
|
+
assert.match(h.threadContext, /Casey: read section two first/);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test("decision.signed keeps its newest comment", async () => {
|
|
513
|
+
const f = facts({ decisions: new Map([["D-1", { id: "D-1", title: "Pick a vendor", status: "signed" }]]) });
|
|
514
|
+
const io = fakeIo({
|
|
515
|
+
"decision.listComments": () => okFrame({ comments: [{ id: "d1", authorName: "Casey", body: "signing on the understanding we revisit in Q4" }] }),
|
|
516
|
+
});
|
|
517
|
+
const c = classifyEvent(ev({ family: "decision", kind: "decision.signed", entity_id: "D-1", payload: { actor: THEM, signedBy: THEM } }));
|
|
518
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "decision", reason: "voice" }, facts: f, io });
|
|
519
|
+
assert.match(h.threadContext, /revisit in Q4/);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
test("a comment event still drops its OWN comment from the context", async () => {
|
|
523
|
+
const f = facts({ tasks: new Map([["T-3", { id: "T-3", title: "Ship it" }]]) });
|
|
524
|
+
const io = fakeIo({
|
|
525
|
+
"board.taskComments": () => okFrame({ comments: [
|
|
526
|
+
{ id: "c1", authorName: "Isla", body: "first pass is up" },
|
|
527
|
+
{ id: "c2", authorName: "Casey", body: "any update?" },
|
|
528
|
+
] }),
|
|
529
|
+
});
|
|
530
|
+
const c = classifyEvent(ev({ family: "board", kind: "item.commented", entity_id: "T-3", payload: { actor: THEM } }));
|
|
531
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "task_comment", reason: "assignee" }, facts: f, io });
|
|
532
|
+
assert.match(h.threadContext, /first pass is up/);
|
|
533
|
+
assert.doesNotMatch(h.threadContext, /any update\?/, "the trigger is not its own context");
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test("the payload's commentId drops the RIGHT comment when a newer one raced in", async () => {
|
|
537
|
+
const f = facts({ tasks: new Map([["T-4", { id: "T-4", title: "Ship it" }]]) });
|
|
538
|
+
const io = fakeIo({
|
|
539
|
+
"board.taskComments": () => okFrame({ comments: [
|
|
540
|
+
{ id: "c1", authorName: "Isla", body: "first pass is up" },
|
|
541
|
+
{ id: "c2", authorName: "Casey", body: "the trigger comment" },
|
|
542
|
+
{ id: "c3", authorName: "Dana", body: "landed while we were reading" },
|
|
543
|
+
] }),
|
|
544
|
+
});
|
|
545
|
+
const c = classifyEvent(ev({ family: "board", kind: "item.commented", entity_id: "T-4", payload: { actor: THEM, commentId: "c2" } }));
|
|
546
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "task_comment", reason: "assignee" }, facts: f, io });
|
|
547
|
+
assert.match(h.threadContext, /first pass is up/);
|
|
548
|
+
assert.match(h.threadContext, /landed while we were reading/);
|
|
549
|
+
assert.doesNotMatch(h.threadContext, /the trigger comment/);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
// ---------------------------------------------------------------------------
|
|
553
|
+
// pure guards
|
|
554
|
+
// ---------------------------------------------------------------------------
|
|
555
|
+
|
|
556
|
+
test("priorComments never mutates the caller's array", () => {
|
|
557
|
+
const rows = [{ id: "a" }, { id: "b" }];
|
|
558
|
+
const out = priorComments(rows, { kind: "item.commented" });
|
|
559
|
+
assert.equal(rows.length, 2);
|
|
560
|
+
assert.equal(out.length, 1);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test("isCommentKind knows the six kinds that ARE comments", () => {
|
|
564
|
+
for (const k of ["item.commented", "board.commented", "file.commented", "doc.comment", "decision.comment", "decision.adjustment_requested"]) {
|
|
565
|
+
assert.equal(isCommentKind(k), true, k);
|
|
566
|
+
}
|
|
567
|
+
for (const k of ["item.assigned", "item.moved", "file.shared", "decision.signed", "", null]) {
|
|
568
|
+
assert.equal(isCommentKind(k), false, String(k));
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("htmlToText keeps the prose and drops the markup", () => {
|
|
573
|
+
assert.equal(htmlToText("<h1>Title</h1><p>One & two</p><p>Three</p>"), "Title\nOne & two\nThree");
|
|
574
|
+
assert.equal(htmlToText("<script>bad()</script><p>ok</p>"), "ok");
|
|
575
|
+
assert.equal(htmlToText(null), "");
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
// ---------------------------------------------------------------------------
|
|
579
|
+
// call — participants, the room, and the transcript
|
|
580
|
+
// ---------------------------------------------------------------------------
|
|
581
|
+
|
|
582
|
+
test("a call carries the roster, the room's recent messages and the transcript", async () => {
|
|
583
|
+
const msgs = [
|
|
584
|
+
{ id: "m1", authorId: THEM, authorName: "Casey", body: "can we talk through the pricing", createdAt: "2026-08-11T09:00:00.000Z" },
|
|
585
|
+
{ id: "m2", authorId: ME, authorName: "Isla", body: "yes — starting a huddle", createdAt: "2026-08-11T09:01:00.000Z" },
|
|
586
|
+
];
|
|
587
|
+
const f = facts({ visibleChannelIds: new Set(["C-eng"]) });
|
|
588
|
+
f.channels.set("C-eng", { id: "C-eng", kind: "PUBLIC", name: "engineering" });
|
|
589
|
+
f.channelPages.set("C-eng", { byId: new Map(msgs.map((m) => [m.id, m])), messages: msgs });
|
|
590
|
+
const io = fakeIo({
|
|
591
|
+
"calling.getDetails": () => okFrame({ callId: "CALL-1", live: true, participantCount: 2, participants: [
|
|
592
|
+
{ kind: "MEMBER", memberId: THEM, inCall: true },
|
|
593
|
+
{ kind: "GUEST", contactId: "contact-9", inCall: true },
|
|
594
|
+
] }),
|
|
595
|
+
"calling.getTranscript": () => okFrame({ lines: [
|
|
596
|
+
{ id: "l1", memberName: "Casey", text: "the list price is wrong on tier two", at: "2026-08-11T09:02:00.000Z" },
|
|
597
|
+
] }),
|
|
598
|
+
});
|
|
599
|
+
const c = classifyEvent(ev({ family: "calling", kind: "start", entity_id: "CALL-1", payload: { actor: THEM, channelId: "C-eng", topic: "pricing" } }));
|
|
600
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "call", reason: "member" }, facts: f, io });
|
|
601
|
+
|
|
602
|
+
assert.equal(h.ok, true);
|
|
603
|
+
assert.match(h.text, /Call invite: pricing/);
|
|
604
|
+
assert.match(h.text, new RegExp(`In the call: ${THEM}, contact-9`));
|
|
605
|
+
assert.match(h.threadContext, /Casey: can we talk through the pricing/);
|
|
606
|
+
assert.match(h.threadContext, /Call transcript so far:/);
|
|
607
|
+
assert.match(h.threadContext, /the list price is wrong on tier two/);
|
|
608
|
+
assert.equal(io.calls.filter((k) => k.method === "messaging.history").length, 0, "the page was already in hand");
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test("a call whose probes all fail still hydrates — fail-open, never blank", async () => {
|
|
612
|
+
const f = facts({ visibleChannelIds: new Set(["C-eng"]) });
|
|
613
|
+
f.channels.set("C-eng", { id: "C-eng", kind: "PUBLIC", name: "engineering" });
|
|
614
|
+
const io = fakeIo({}); // every method NOT_FOUND
|
|
615
|
+
const c = classifyEvent(ev({ family: "calling", kind: "start", entity_id: "CALL-2", payload: { actor: THEM, channelId: "C-eng", topic: "standup" } }));
|
|
616
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "call", reason: "member" }, facts: f, io });
|
|
617
|
+
|
|
618
|
+
assert.equal(h.ok, true);
|
|
619
|
+
assert.match(h.text, /Call invite: standup/);
|
|
620
|
+
assert.equal(h.threadContext, null);
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
test("a call in a room outside my roster is never paged for history", async () => {
|
|
624
|
+
const f = facts({ visibleChannelIds: new Set(["C-mine"]) });
|
|
625
|
+
const io = fakeIo({ "calling.getDetails": () => okFrame({ participants: [] }) });
|
|
626
|
+
const c = classifyEvent(ev({ family: "calling", kind: "start", entity_id: "CALL-3", payload: { actor: THEM, channelId: "C-not-mine", topic: "x" } }));
|
|
627
|
+
await hydrate({ candidate: c, verdict: { surface: "call", reason: "invited" }, facts: f, io });
|
|
628
|
+
assert.equal(io.calls.some((k) => k.method === "messaging.history"), false, "a room outside the roster must never be touched");
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
test("an UNREADABLE roster is not permission — no room is paged when visibleChannelIds is null", async () => {
|
|
632
|
+
// The guard read `visible instanceof Set && !visible.has(id)`, so the null
|
|
633
|
+
// case — the roster call failed, or has not run — fell straight through into
|
|
634
|
+
// `messaging.history` on a channel id that arrived on the EVENT PAYLOAD.
|
|
635
|
+
// facts.mjs enforces the opposite (`if (!visible) … return`) and this file's
|
|
636
|
+
// own docstring claimed it; now the code does too.
|
|
637
|
+
const f = facts({ visibleChannelIds: null });
|
|
638
|
+
const io = fakeIo({ "calling.getDetails": () => okFrame({ participants: [] }) });
|
|
639
|
+
const c = classifyEvent(ev({ family: "calling", kind: "start", entity_id: "CALL-4", payload: { actor: THEM, channelId: "C-PRIVATE-NOT-MINE", topic: "x" } }));
|
|
640
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "call", reason: "invited" }, facts: f, io });
|
|
641
|
+
assert.equal(h.ok, true, "declining a read is not a failed hydration");
|
|
642
|
+
assert.equal(
|
|
643
|
+
io.calls.some((k) => k.method === "messaging.history"),
|
|
644
|
+
false,
|
|
645
|
+
"an unknown roster must decline, not page an arbitrary payload-supplied channel",
|
|
646
|
+
);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
test("an UNREADABLE roster is not permission — pinned files are not listed either", async () => {
|
|
650
|
+
const f = facts({ visibleChannelIds: null });
|
|
651
|
+
f.channels.set("C-PRIVATE-NOT-MINE", { id: "C-PRIVATE-NOT-MINE", kind: "PRIVATE", name: "board" });
|
|
652
|
+
const io = fakeIo({ "file.listComments": () => okFrame({ comments: [
|
|
653
|
+
{ id: "fc1", authorId: THEM, authorName: "Casey", body: "@Isla thoughts?" },
|
|
654
|
+
] }) });
|
|
655
|
+
const c = classifyEvent(ev({
|
|
656
|
+
family: "file", kind: "comment", entity_id: "FK-1",
|
|
657
|
+
payload: { actor: THEM, channelId: "C-PRIVATE-NOT-MINE", fileKey: "FK-1", body: "@Isla thoughts?", mentions: [ME] },
|
|
658
|
+
}));
|
|
659
|
+
await hydrate({ candidate: c, verdict: { surface: "chatFile", reason: "mention" }, facts: f, io });
|
|
660
|
+
assert.equal(io.calls.some((k) => k.method === "file.listPinned"), false);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
// ---------------------------------------------------------------------------
|
|
664
|
+
// approval / escalation / handoff / calendar
|
|
665
|
+
// ---------------------------------------------------------------------------
|
|
666
|
+
|
|
667
|
+
test("an approval carries the thread of the item it is an approval OF", async () => {
|
|
668
|
+
const f = facts({
|
|
669
|
+
approvals: new Map([["A-1", { id: "A-1", status: "pending", requester: THEM, actionClass: "financial", itemId: "T-9" }]]),
|
|
670
|
+
tasks: new Map([["T-9", { id: "T-9", title: "Wire the retainer", col: "doing", priority: 1 }]]),
|
|
671
|
+
});
|
|
672
|
+
const io = fakeIo({
|
|
673
|
+
"board.taskComments": () => okFrame({ comments: [
|
|
674
|
+
{ id: "c1", authorName: "Casey", body: "legal cleared this on Tuesday" },
|
|
675
|
+
] }),
|
|
676
|
+
});
|
|
677
|
+
const c = classifyEvent(ev({ family: "approval", kind: "approval.requested", entity_id: "A-1", payload: { actor: THEM, requester: THEM, itemId: "T-9", actionClass: "financial" } }));
|
|
678
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "approval", reason: "approver" }, facts: f, io });
|
|
679
|
+
|
|
680
|
+
assert.equal(h.ok, true);
|
|
681
|
+
assert.match(h.text, /Board item "Wire the retainer" — doing · priority 1/);
|
|
682
|
+
assert.match(h.threadContext, /legal cleared this on Tuesday/);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
test("an approval on a DECISION falls back to the decision thread, and only then", async () => {
|
|
686
|
+
const f = facts({ approvals: new Map([["A-2", { id: "A-2", status: "pending", requester: THEM, itemId: "D-7" }]]) });
|
|
687
|
+
const io = fakeIo({
|
|
688
|
+
"decision.listComments": () => okFrame({ comments: [{ id: "c1", authorName: "Casey", body: "we ruled out the cheaper vendor" }] }),
|
|
689
|
+
});
|
|
690
|
+
const c = classifyEvent(ev({ family: "approval", kind: "approval.requested", entity_id: "A-2", payload: { actor: THEM, requester: THEM, itemId: "D-7" } }));
|
|
691
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "approval", reason: "approver" }, facts: f, io });
|
|
692
|
+
|
|
693
|
+
assert.match(h.threadContext, /we ruled out the cheaper vendor/);
|
|
694
|
+
assert.equal(io.calls.filter((k) => k.method === "board.taskComments").length, 1, "board is tried first, once");
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
test("an approval whose board thread is EMPTY does not pay for a second probe", async () => {
|
|
698
|
+
const f = facts({ approvals: new Map([["A-3", { id: "A-3", status: "pending", requester: THEM, itemId: "T-8" }]]) });
|
|
699
|
+
const io = fakeIo({ "board.taskComments": () => okFrame({ comments: [] }) });
|
|
700
|
+
const c = classifyEvent(ev({ family: "approval", kind: "approval.requested", entity_id: "A-3", payload: { actor: THEM, requester: THEM, itemId: "T-8" } }));
|
|
701
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "approval", reason: "approver" }, facts: f, io });
|
|
702
|
+
|
|
703
|
+
assert.equal(h.threadContext, null);
|
|
704
|
+
assert.equal(io.calls.some((k) => k.method === "decision.listComments"), false, "an empty thread is an answer, not a refusal");
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test("an escalation carries the escalated task's row and its comments", async () => {
|
|
708
|
+
const f = facts({
|
|
709
|
+
escalations: new Map([["E-1", { id: "E-1", title: "Blocked on credentials", severity: "high", taskId: "T-5" }]]),
|
|
710
|
+
tasks: new Map([["T-5", { id: "T-5", title: "Migrate the vault", col: "blocked", priority: 0 }]]),
|
|
711
|
+
});
|
|
712
|
+
const io = fakeIo({
|
|
713
|
+
"board.taskComments": () => okFrame({ comments: [{ id: "c1", authorName: "Casey", body: "the rotation script needs an admin key" }] }),
|
|
714
|
+
});
|
|
715
|
+
const c = classifyEvent(ev({ family: "escalation", kind: "escalation.created", entity_id: "E-1", payload: { actor: THEM, taskId: "T-5", severity: "high" } }));
|
|
716
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "escalation", reason: "assignee" }, facts: f, io });
|
|
717
|
+
|
|
718
|
+
assert.equal(h.ok, true);
|
|
719
|
+
assert.match(h.text, /Board item "Migrate the vault" — blocked · priority 0/);
|
|
720
|
+
assert.match(h.threadContext, /needs an admin key/);
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
test("an escalation whose task thread is unreadable still hydrates", async () => {
|
|
724
|
+
const f = facts({ escalations: new Map([["E-2", { id: "E-2", title: "Stuck", severity: "low", taskId: "T-6" }]]) });
|
|
725
|
+
const io = fakeIo({});
|
|
726
|
+
const c = classifyEvent(ev({ family: "escalation", kind: "escalation.created", entity_id: "E-2", payload: { actor: THEM, taskId: "T-6" } }));
|
|
727
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "escalation", reason: "assignee" }, facts: f, io });
|
|
728
|
+
assert.equal(h.ok, true);
|
|
729
|
+
assert.equal(h.threadContext, null);
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
test("a handoff carries the originating task's thread", async () => {
|
|
733
|
+
const f = facts({ tasks: new Map([["T-7", { id: "T-7", title: "Draft the board pack", col: "doing", detail: "for Thursday" }]]) });
|
|
734
|
+
const io = fakeIo({
|
|
735
|
+
"board.taskComments": () => okFrame({ comments: [
|
|
736
|
+
{ id: "c1", authorName: "Casey", body: "sections 1-3 are done, 4 needs the latest numbers" },
|
|
737
|
+
] }),
|
|
738
|
+
});
|
|
739
|
+
const c = classifyEvent(ev({ family: "handoff", kind: "handoff.offered", entity_id: "R-1", payload: { actor: THEM, to: ME, from: THEM, itemId: "T-7", intent: "take over section 4" } }));
|
|
740
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "handoff", reason: "named" }, facts: f, io });
|
|
741
|
+
|
|
742
|
+
assert.equal(h.ok, true);
|
|
743
|
+
assert.match(h.text, /Intent: take over section 4/);
|
|
744
|
+
assert.match(h.text, /Board item "Draft the board pack" — doing/);
|
|
745
|
+
assert.match(h.threadContext, /4 needs the latest numbers/);
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
test("a calendar event carries the room it was agreed in and earlier occurrences", async () => {
|
|
749
|
+
const msgs = [{ id: "m1", authorId: THEM, authorName: "Casey", body: "let's move the weekly to Thursdays", createdAt: "2026-08-10T09:00:00.000Z" }];
|
|
750
|
+
const f = facts({
|
|
751
|
+
visibleChannelIds: new Set(["C-ops"]),
|
|
752
|
+
myEvents: new Map([
|
|
753
|
+
["E-now", { id: "E-now", title: "Ops weekly", startsAt: "2026-08-13T09:00:00.000Z", channelId: "C-ops" }],
|
|
754
|
+
["E-old", { id: "E-old", title: "Ops weekly", startsAt: "2026-08-06T09:00:00.000Z", location: "Room 2" }],
|
|
755
|
+
["E-other", { id: "E-other", title: "Board review", startsAt: "2026-08-05T09:00:00.000Z" }],
|
|
756
|
+
]),
|
|
757
|
+
});
|
|
758
|
+
f.channels.set("C-ops", { id: "C-ops", kind: "PUBLIC", name: "ops" });
|
|
759
|
+
f.channelPages.set("C-ops", { byId: new Map(msgs.map((m) => [m.id, m])), messages: msgs });
|
|
760
|
+
const io = fakeIo({});
|
|
761
|
+
const c = classifyEvent(ev({ family: "calendar", kind: "event.rescheduled", entity_id: "E-now", payload: { actor: THEM, channelId: "C-ops", title: "Ops weekly" } }));
|
|
762
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "calendar", reason: "attendee" }, facts: f, io });
|
|
763
|
+
|
|
764
|
+
assert.equal(h.ok, true);
|
|
765
|
+
assert.match(h.threadContext, /move the weekly to Thursdays/);
|
|
766
|
+
assert.match(h.threadContext, /Earlier in this series:/);
|
|
767
|
+
assert.match(h.threadContext, /2026-08-06T09:00:00.000Z · Room 2/);
|
|
768
|
+
assert.doesNotMatch(h.threadContext, /Board review/, "a different meeting is not this series");
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
test("a calendar event with no room and no series carries no invented context", async () => {
|
|
772
|
+
const f = facts({ myEvents: new Map([["E-1", { id: "E-1", title: "1:1", startsAt: "2026-08-13T09:00:00.000Z" }]]) });
|
|
773
|
+
const io = fakeIo({});
|
|
774
|
+
const c = classifyEvent(ev({ family: "calendar", kind: "event.created", entity_id: "E-1", payload: { actor: THEM, calendarMemberId: ME, title: "1:1" } }));
|
|
775
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "calendar", reason: "owner" }, facts: f, io });
|
|
776
|
+
assert.equal(h.ok, true);
|
|
777
|
+
assert.equal(h.threadContext, null);
|
|
778
|
+
assert.equal(io.calls.length, 0);
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
// ---------------------------------------------------------------------------
|
|
782
|
+
// artifact bodies — the agent must have READ the thing it is commenting on
|
|
783
|
+
// ---------------------------------------------------------------------------
|
|
784
|
+
|
|
785
|
+
test("a doc comment carries the document body, bounded", async () => {
|
|
786
|
+
const f = facts({ fileAcl: new Map([["F-2", { ownerId: ME, shared: true, name: "Pricing memo", kind: "DOC" }]]) });
|
|
787
|
+
const io = fakeIo({
|
|
788
|
+
"files.comments": () => okFrame({ comments: [{ id: "k1", authorName: "Casey", body: "is the tier-two number right?" }] }),
|
|
789
|
+
"files.docRead": () => okFrame({ html: `<h1>Pricing memo</h1><p>Tier two is ${"$"}4,800 per seat.</p>` }),
|
|
790
|
+
});
|
|
791
|
+
const c = classifyEvent(ev({ family: "files", kind: "doc.comment", entity_id: "F-2", payload: { actor: THEM, name: "Pricing memo", commentId: "k1" } }));
|
|
792
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "doc_comment", reason: "owner" }, facts: f, io });
|
|
793
|
+
|
|
794
|
+
assert.equal(h.ok, true);
|
|
795
|
+
assert.match(h.text, /Document:/);
|
|
796
|
+
assert.match(h.text, /Tier two is \$4,800 per seat\./);
|
|
797
|
+
assert.match(h.text, /is the tier-two number right\?/);
|
|
798
|
+
assert.doesNotMatch(h.text, /<p>/, "markup must not reach the model");
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
test("a doc body that is too long is clipped, and the comment still survives", async () => {
|
|
802
|
+
const f = facts({ fileAcl: new Map([["F-3", { ownerId: ME, shared: true, name: "Long", kind: "DOC" }]]) });
|
|
803
|
+
const io = fakeIo({
|
|
804
|
+
"files.comments": () => okFrame({ comments: [{ id: "k1", authorName: "Casey", body: "THE QUESTION" }] }),
|
|
805
|
+
"files.docRead": () => okFrame({ html: `<p>${"w".repeat(20000)}</p>` }),
|
|
806
|
+
});
|
|
807
|
+
const c = classifyEvent(ev({ family: "files", kind: "doc.comment", entity_id: "F-3", payload: { actor: THEM, commentId: "k1" } }));
|
|
808
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "doc_comment", reason: "owner" }, facts: f, io });
|
|
809
|
+
|
|
810
|
+
assert.ok(h.text.length < 6000, `doc body was not bounded (${h.text.length} chars)`);
|
|
811
|
+
assert.match(h.text, /THE QUESTION/, "the comment being answered must survive the doc body");
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
test("clampDocHtml bounds what a pull holds and converts", () => {
|
|
815
|
+
// files.docRead has no `limit` on the wire (ui-parity documents `{ fileId }`),
|
|
816
|
+
// so the only place this can be bounded is the instant it lands — before
|
|
817
|
+
// htmlToText walks it and before the per-pull cache keeps it. Measured before
|
|
818
|
+
// the clamp: 8 MB of HTML → 7,435,349 characters converted to retain 4,000.
|
|
819
|
+
assert.equal(clampDocHtml("<p>short</p>"), "<p>short</p>");
|
|
820
|
+
assert.equal(clampDocHtml("x".repeat(DOC_HTML_MAX_CHARS + 10)).length, DOC_HTML_MAX_CHARS);
|
|
821
|
+
assert.equal(clampDocHtml(null), "");
|
|
822
|
+
assert.ok(DOC_HTML_MAX_CHARS > 4000, "the clamp must leave room for a full text budget after markup");
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
test("a HUGE doc is clamped before it is converted, and never exceeds the shared ceiling", async () => {
|
|
826
|
+
// Two separate bounds, and the second one used to be missing entirely.
|
|
827
|
+
// 1. the raw HTML is sliced the instant it lands, so an 8 MB document does
|
|
828
|
+
// not walk 7.4 M characters through htmlToText to retain 4,000;
|
|
829
|
+
// 2. the JOINED text is clipped like every other surface's — hydrateDoc had
|
|
830
|
+
// dropped its outer clip(), making it the one hydrator that could hand
|
|
831
|
+
// `item.content` more than the shared clamp allows.
|
|
832
|
+
// The name is unbounded on the wire (it rides `c.ids.name` / the ACL row), so
|
|
833
|
+
// it is the part that proves the OUTER clip is doing work rather than sitting
|
|
834
|
+
// above a ceiling the parts can never reach.
|
|
835
|
+
const f = facts({ fileAcl: new Map([["F-BIG", { ownerId: ME, shared: true, name: "N".repeat(50_000), kind: "DOC" }]]) });
|
|
836
|
+
const huge = `<p>${"w".repeat(2_000_000)}</p>`;
|
|
837
|
+
const io = fakeIo({
|
|
838
|
+
"files.comments": () => okFrame({ comments: [{ id: "k1", authorName: "Casey", body: "z".repeat(9000) }] }),
|
|
839
|
+
"files.docRead": () => okFrame({ html: huge }),
|
|
840
|
+
});
|
|
841
|
+
const c = classifyEvent(ev({ family: "files", kind: "doc.comment", entity_id: "F-BIG", payload: { actor: THEM, commentId: "k1" } }));
|
|
842
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "doc_comment", reason: "owner" }, facts: f, io });
|
|
843
|
+
|
|
844
|
+
assert.equal(h.ok, true);
|
|
845
|
+
assert.ok(h.text.length <= 5601, `hydrateDoc must honour its declared ceiling (${h.text.length} chars)`);
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
test("a SHEET is never asked for a doc body — hq would only refuse", async () => {
|
|
849
|
+
const f = facts({ fileAcl: new Map([["F-4", { ownerId: ME, shared: true, name: "Model", kind: "SHEET" }]]) });
|
|
850
|
+
const io = fakeIo({ "files.comments": () => okFrame({ comments: [{ id: "k1", authorName: "Casey", body: "row 12?" }] }) });
|
|
851
|
+
const c = classifyEvent(ev({ family: "files", kind: "doc.comment", entity_id: "F-4", payload: { actor: THEM, commentId: "k1" } }));
|
|
852
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "doc_comment", reason: "owner" }, facts: f, io });
|
|
853
|
+
|
|
854
|
+
assert.equal(h.ok, true);
|
|
855
|
+
assert.equal(io.calls.some((k) => k.method === "files.docRead"), false);
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
test("an unreadable doc body leaves the comment thread intact", async () => {
|
|
859
|
+
const f = facts({ fileAcl: new Map([["F-5", { ownerId: ME, shared: true, name: "Memo", kind: "DOC" }]]) });
|
|
860
|
+
const io = fakeIo({ "files.comments": () => okFrame({ comments: [{ id: "k1", authorName: "Casey", body: "thoughts?" }] }) });
|
|
861
|
+
const c = classifyEvent(ev({ family: "files", kind: "doc.comment", entity_id: "F-5", payload: { actor: THEM, commentId: "k1" } }));
|
|
862
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "doc_comment", reason: "owner" }, facts: f, io });
|
|
863
|
+
|
|
864
|
+
assert.equal(h.ok, true);
|
|
865
|
+
assert.match(h.text, /thoughts\?/);
|
|
866
|
+
assert.doesNotMatch(h.text, /Document:/);
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
test("a file comment names the file — type and size, not just an opaque key", async () => {
|
|
870
|
+
const f = facts({ visibleChannelIds: new Set(["C-eng"]) });
|
|
871
|
+
f.channels.set("C-eng", { id: "C-eng", kind: "PUBLIC", name: "engineering" });
|
|
872
|
+
const io = fakeIo({
|
|
873
|
+
"file.listComments": () => okFrame({ comments: [{ id: "fc1", authorName: "Casey", body: "page 3 is stale" }] }),
|
|
874
|
+
"file.listPinned": () => okFrame({ files: [
|
|
875
|
+
{ id: "pf1", fileKey: "fk-1", name: "q3-forecast.pdf", mimeType: "application/pdf", sizeBytes: 1258291 },
|
|
876
|
+
] }),
|
|
877
|
+
});
|
|
878
|
+
const c = classifyEvent(ev({ family: "file", kind: "file.commented", entity_id: "fc1", payload: { actor: THEM, fileKey: "fk-1", channelId: "C-eng" } }));
|
|
879
|
+
const h = await hydrate({ candidate: c, verdict: { surface: "file_comment", reason: "member" }, facts: f, io });
|
|
880
|
+
|
|
881
|
+
assert.equal(h.ok, true);
|
|
882
|
+
assert.match(h.text, /Comment on file q3-forecast\.pdf/);
|
|
883
|
+
assert.match(h.text, /File: q3-forecast\.pdf · application\/pdf · 1\.2 MB/);
|
|
884
|
+
assert.match(h.text, /page 3 is stale/);
|
|
885
|
+
assert.equal(h.subjectDetail, "q3-forecast.pdf");
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
test("a file in a room outside my roster is never asked about", async () => {
|
|
889
|
+
const f = facts({ visibleChannelIds: new Set(["C-mine"]) });
|
|
890
|
+
const io = fakeIo({ "file.listComments": () => okFrame({ comments: [{ id: "fc1", authorName: "Casey", body: "x" }] }) });
|
|
891
|
+
const c = classifyEvent(ev({ family: "file", kind: "file.commented", entity_id: "fc1", payload: { actor: THEM, fileKey: "fk-2", channelId: "C-not-mine" } }));
|
|
892
|
+
await hydrate({ candidate: c, verdict: { surface: "file_comment", reason: "member" }, facts: f, io });
|
|
893
|
+
assert.equal(io.calls.some((k) => k.method === "file.listPinned"), false);
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
test("two comments on files in ONE room share a single listPinned read", async () => {
|
|
897
|
+
const f = facts({ visibleChannelIds: new Set(["C-eng"]) });
|
|
898
|
+
const io = fakeIo({
|
|
899
|
+
"file.listComments": () => okFrame({ comments: [{ id: "fc1", authorName: "Casey", body: "x" }] }),
|
|
900
|
+
"file.listPinned": () => okFrame({ files: [] }),
|
|
901
|
+
});
|
|
902
|
+
const cache = new Map();
|
|
903
|
+
for (const key of ["fk-a", "fk-b"]) {
|
|
904
|
+
const c = classifyEvent(ev({ family: "file", kind: "file.commented", entity_id: `c-${key}`, payload: { actor: THEM, fileKey: key, channelId: "C-eng" } }));
|
|
905
|
+
await hydrate({ candidate: c, verdict: { surface: "file_comment", reason: "member" }, facts: f, io, cache });
|
|
906
|
+
}
|
|
907
|
+
assert.equal(io.calls.filter((k) => k.method === "file.listPinned").length, 1);
|
|
908
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cohortapp/agent-sdk",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.17.0",
|
|
4
4
|
"description": "Cohort Agent SDK — autonomous AI colleague runtime. Deploy senior AI colleagues on dedicated Mac minis, wired to the Cohort operating surface.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
"./singleton": "./lib/singleton.js",
|
|
19
19
|
"./cadence-bus": "./lib/cadence-bus.mjs",
|
|
20
20
|
"./fs-atomic": "./lib/fs-atomic.mjs",
|
|
21
|
+
"./context/budget": "./lib/context/budget.mjs",
|
|
22
|
+
"./context/history-scope": "./lib/context/history-scope.mjs",
|
|
21
23
|
"./org/protocol": "./lib/org/protocol.mjs",
|
|
22
24
|
"./org/client": "./lib/org/client.mjs",
|
|
23
25
|
"./org/ui-parity": "./lib/org/ui-parity.mjs",
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: inbound-triage
|
|
3
|
-
description: Decide, for each inbound Cohort event, whether to answer in this turn,
|
|
3
|
+
description: Decide, for each inbound Cohort event, whether to answer in this turn, file it on the board and run a workflow, or hand it to a peer session — and whether the person hears anything before the answer (usually not). Use when a feed `inbound` line arrives, when reading `maestro inbox list`, or when you are unsure whether an ask is a reply or a task.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Inbound triage
|
|
7
7
|
|
|
8
|
-
Every inbound event is one of three things. Decide in the first turn
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
Every inbound event is one of three things. Decide in the first turn. Whether
|
|
9
|
+
you SAY anything in that turn is a separate question, and the default answer is
|
|
10
|
+
no: see "The one-interim rule" at the foot of this skill.
|
|
11
11
|
|
|
12
12
|
## The three outcomes
|
|
13
13
|
|
|
@@ -17,12 +17,10 @@ or an acknowledgement before you do anything else.
|
|
|
17
17
|
--text "…"` → `maestro inbox done <id>`. No board row: the ledger on the
|
|
18
18
|
server already records that you answered.
|
|
19
19
|
|
|
20
|
-
2. **
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
2. **File it, work here.** The ask needs real work — reading, drafting,
|
|
21
|
+
building, several steps — but you can finish it in this session inside an
|
|
22
|
+
hour or two without blocking the front door. In ONE turn:
|
|
23
23
|
- `maestro inbox claim <id>`
|
|
24
|
-
- `maestro inbox reply <id> --text "On it — I'll have <X> to you by <when>."`
|
|
25
|
-
(specific deliverable, specific time; never "I'll look into it")
|
|
26
24
|
- `maestro board track <id> --stage accepted --title "<what you took on>"
|
|
27
25
|
--why "<one line: why this is more than a reply>"`
|
|
28
26
|
- then run the work — a `Workflow` when it has distinct steps, plain tool
|
|
@@ -34,10 +32,10 @@ or an acknowledgement before you do anything else.
|
|
|
34
32
|
(or `messaging_send` to the thread), then `maestro board track <id>
|
|
35
33
|
--stage done` and `maestro inbox done <id>`.
|
|
36
34
|
|
|
37
|
-
3. **
|
|
38
|
-
(
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
3. **File it, hand to a peer.** Same as 2, but the work is long (hours), heavy
|
|
36
|
+
(a repo build, a large research pass), or would block you from answering the
|
|
37
|
+
next person. After the `--stage accepted` track — and, if the ask is big
|
|
38
|
+
enough to warrant one, the plan (below) — `maestro session spawn --name <slug> "<prompt>"`
|
|
41
39
|
with a prompt that names the deliverable, the channel and thread to report
|
|
42
40
|
to, the inbox id, and the instruction to `SendMessage` you a two-line
|
|
43
41
|
status when done. You stay the one who talks to the human; the peer talks to
|
|
@@ -54,14 +52,14 @@ or an acknowledgement before you do anything else.
|
|
|
54
52
|
space's board. You do not choose the board.
|
|
55
53
|
- Will it take longer than the next inbound can wait? → peer.
|
|
56
54
|
- Is it a question you should not answer alone (a commitment, spend, an
|
|
57
|
-
external promise)? →
|
|
58
|
-
|
|
55
|
+
external promise)? → file with `--stage blocked` and `notify` your principal,
|
|
56
|
+
and say in-channel what you need and from whom. That is a message with
|
|
57
|
+
content in it, so it is always worth sending; do not guess.
|
|
59
58
|
|
|
60
59
|
## Special topics
|
|
61
60
|
|
|
62
|
-
- **Calls** (`topic: call`):
|
|
63
|
-
|
|
64
|
-
you do not handle audio here.
|
|
61
|
+
- **Calls** (`topic: call`): answer in the channel — join if you are free now,
|
|
62
|
+
or propose a time. Either is a real answer, not an acknowledgement.
|
|
65
63
|
- **Comments on files/boards**: reply in the thread of the comment, not in a
|
|
66
64
|
DM; a comment that asks for a change to a document is outcome 2.
|
|
67
65
|
- **Inbound email** (`surface: email`): the same three outcomes; reply through
|
|
@@ -71,10 +69,40 @@ or an acknowledgement before you do anything else.
|
|
|
71
69
|
the same message re-delivered after a crash — check the thread before you
|
|
72
70
|
answer twice.
|
|
73
71
|
|
|
74
|
-
## The
|
|
72
|
+
## The one-interim rule
|
|
75
73
|
|
|
76
|
-
Whatever the outcome, the person hears from you in the turn the event
|
|
77
|
-
arrived.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
74
|
+
~~"Whatever the outcome, the person hears from you in the turn the event
|
|
75
|
+
arrived."~~ — struck 2026-09-12. Measured over fourteen days in
|
|
76
|
+
`org_default_adaptic`: of 10,667 agent messages, 3,069 (28.8 %) were opening
|
|
77
|
+
acknowledgements and 961 (9.0 %) were progress nags, and 272 of those
|
|
78
|
+
acknowledgements were never followed by a substantive reply inside an hour.
|
|
79
|
+
Agent-to-human volume went from 2:1 to 158:1 in a fortnight, 80 % of it into one
|
|
80
|
+
channel.
|
|
81
|
+
|
|
82
|
+
**Silence is the default. A message before the answer is never a reflex; it is
|
|
83
|
+
either absent or it is a plan.**
|
|
84
|
+
|
|
85
|
+
- **Outcome 1 (reply now):** the reply IS the acknowledgement. Nothing before it.
|
|
86
|
+
- **Outcome 2 or 3, ordinary size:** say nothing. Claim it, file it, do it, and
|
|
87
|
+
let the result be the first thing they read. A person who has been waiting two
|
|
88
|
+
minutes has lost nothing; a person who got "On it" and then nothing for an
|
|
89
|
+
hour has lost their trust in you.
|
|
90
|
+
- **Outcome 2 or 3, large enough that the shape matters** (a workflow, a peer
|
|
91
|
+
team, a multi-day research pass, anything where the approach is itself a
|
|
92
|
+
decision): post the PLAN, once, as the first step of doing the work — what you
|
|
93
|
+
will do, in what order, and what comes back. Three bullets at most, in your own
|
|
94
|
+
words about this specific ask.
|
|
95
|
+
- **Never** a generic opener. `On it`, `Looking into it`, `Checking`, `One
|
|
96
|
+
moment`, `Got it`, `Will do`, `Working on it`, `Taking a look`, `Digging in`:
|
|
97
|
+
the daemon's sanitiser now refuses every one of these outright, and so should
|
|
98
|
+
you.
|
|
99
|
+
- **Never** a promise you have no mechanism to keep. "I'll come back as soon as
|
|
100
|
+
I've got something" is only sayable because the obligation ledger will in fact
|
|
101
|
+
chase it; do not say it about work that has no such ledger behind it.
|
|
102
|
+
- **At most one** such message per channel per fifteen minutes, whatever else is
|
|
103
|
+
in flight there. If a teammate or the daemon has already spoken in that room
|
|
104
|
+
inside the window, you have had your turn.
|
|
105
|
+
|
|
106
|
+
The persona rules (`persona-discipline`) apply to a plan exactly as to a reply.
|
|
107
|
+
The daemon's half of the same policy — tiers, the room budget, the sanitiser —
|
|
108
|
+
is `docs/guides/poller-daemon-setup.md` §2.6a.
|
|
@@ -43,8 +43,9 @@ Each line is a JSON object with a `type`. Handle it in the turn it arrives.
|
|
|
43
43
|
|
|
44
44
|
- `inbound` — `{id, surface, topic, from, channelId, threadId, preview, path}`.
|
|
45
45
|
`maestro inbox show <id>` for the full item, then follow the
|
|
46
|
-
`inbound-triage` skill: reply now, or
|
|
47
|
-
|
|
46
|
+
`inbound-triage` skill: reply now, or `maestro board track <id> --stage
|
|
47
|
+
accepted` + work it, or spawn a peer. Saying something BEFORE the answer is
|
|
48
|
+
the exception, not the rule — see that skill's one-interim rule. Claim it first
|
|
48
49
|
(`maestro inbox claim <id>`) so the daemon's sweep does not re-deliver it,
|
|
49
50
|
reply with `maestro inbox reply <id> --text "…"`, and close with
|
|
50
51
|
`maestro inbox done <id>`. The reply command is the reply lane: it runs the
|
|
@@ -67,8 +68,9 @@ Each line is a JSON object with a `type`. Handle it in the turn it arrives.
|
|
|
67
68
|
disk.
|
|
68
69
|
|
|
69
70
|
Do not batch: an inbound line that sits while you finish something else is a
|
|
70
|
-
person waiting.
|
|
71
|
-
finish the other thing.
|
|
71
|
+
person waiting. DECIDE it in the turn it arrives (see `inbound-triage`) — claim
|
|
72
|
+
it, file it, start it — then finish the other thing. Deciding in the same turn
|
|
73
|
+
is the rule; speaking in the same turn is not.
|
|
72
74
|
|
|
73
75
|
## The idle loop
|
|
74
76
|
|