@cohortapp/agent-sdk 2.10.0 → 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/mcp/server.test.mjs +16 -4
- package/lib/org/client.mjs +58 -1
- 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 +150 -3
- package/scripts/daemon/agent-daemon.test.mjs +190 -0
- package/scripts/daemon/assurance.mjs +38 -15
- 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/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/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
|
+
});
|
|
@@ -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
|
|
|
@@ -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
|
+
});
|