@cohortapp/agent-sdk 2.11.14 → 2.12.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 +37 -22
- package/README.md +2 -0
- package/bin/maestro.mjs +113 -39
- package/bin/maestro.test.mjs +175 -5
- package/docs/guides/front-door-session.md +264 -0
- package/docs/guides/mac-mini.md +100 -28
- package/docs/guides/org-onboarding.md +1 -1
- package/docs/guides/setup-wizard.md +9 -5
- package/docs/runbooks/cohort-cutover.md +11 -1
- package/docs/runbooks/mac-mini-bootstrap.md +38 -63
- package/lib/cadence-bus-requeue.test.mjs +83 -0
- package/lib/cadence-bus.mjs +43 -7
- package/lib/channels/inbox-item.mjs +59 -2
- package/lib/cli/board.mjs +285 -0
- package/lib/cli/board.test.mjs +227 -0
- package/lib/cli/doctor-checks.mjs +441 -0
- package/lib/cli/doctor-checks.test.mjs +336 -0
- package/lib/cli/global-setup-extras.mjs +410 -0
- package/lib/cli/global-setup-extras.test.mjs +367 -0
- package/lib/cli/inbox.mjs +304 -0
- package/lib/cli/inbox.test.mjs +230 -0
- package/lib/cli/session-ack.mjs +63 -0
- package/lib/cli/session-ack.test.mjs +63 -0
- package/lib/cli/session.mjs +750 -0
- package/lib/cli/session.test.mjs +602 -0
- package/lib/collective/global-config.mjs +204 -6
- package/lib/collective/global-config.test.mjs +140 -0
- package/lib/collective/global-skills.mjs +145 -0
- package/lib/collective/global-skills.test.mjs +126 -0
- package/lib/collective/presence.mjs +4 -3
- package/lib/comms/send-gate.mjs +115 -0
- package/lib/comms/send-gate.test.mjs +113 -0
- package/lib/feature-init.mjs +2 -2
- package/lib/identity/persona.mjs +29 -0
- package/lib/identity/persona.test.mjs +26 -1
- package/lib/mcp/server.test.mjs +9 -4
- package/lib/model-router/spawn.test.mjs +21 -0
- package/lib/org/board-mine-cache.mjs +99 -0
- package/lib/org/board-mine-cache.test.mjs +53 -0
- package/lib/org/board.mjs +11 -0
- package/lib/org/board.test.mjs +11 -1
- package/lib/org/client.mjs +36 -0
- package/lib/org/client.test.mjs +46 -0
- package/lib/org/inbound/directedness.mjs +18 -2
- package/lib/org/inbound/directedness.test.mjs +58 -0
- package/lib/org/inbound/index.mjs +8 -1
- package/lib/org/inbound/index.test.mjs +22 -0
- package/lib/org/mesh-directives.test.mjs +110 -0
- package/lib/org/mesh.mjs +61 -1
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +52 -0
- package/lib/org/protocol.test.mjs +12 -1
- package/lib/org/registry.mjs +3 -2
- package/lib/org/tool-surface.mjs +120 -0
- package/lib/org/tool-surface.test.mjs +118 -5
- package/lib/security/external-content.mjs +1 -1
- package/lib/security/external-content.test.mjs +17 -0
- package/lib/session/config.mjs +137 -0
- package/lib/session/config.test.mjs +92 -0
- package/lib/session/feed-core.mjs +229 -0
- package/lib/session/feed-core.test.mjs +198 -0
- package/lib/session/first-run.mjs +126 -0
- package/lib/session/first-run.test.mjs +121 -0
- package/lib/session/frontdoor.mjs +266 -0
- package/lib/session/frontdoor.test.mjs +205 -0
- package/lib/session/handoffs.mjs +295 -0
- package/lib/session/handoffs.test.mjs +183 -0
- package/lib/session/identity.mjs +220 -0
- package/lib/session/identity.test.mjs +180 -0
- package/lib/session/inbox-claims.mjs +434 -0
- package/lib/session/inbox-claims.test.mjs +286 -0
- package/lib/session/launch-args.mjs +161 -0
- package/lib/session/launch-args.test.mjs +157 -0
- package/lib/session/liveness.mjs +174 -0
- package/lib/session/liveness.test.mjs +100 -0
- package/lib/session/status-summary.mjs +172 -0
- package/lib/session/status-summary.test.mjs +118 -0
- package/lib/session-permissions.mjs +39 -3
- package/lib/session-permissions.test.mjs +20 -0
- package/lib/setup/claude-probe.mjs +161 -24
- package/lib/setup/claude-probe.test.mjs +187 -0
- package/lib/setup/sections/learning.mjs +2 -1
- package/lib/setup/sections/model.mjs +104 -24
- package/lib/setup/sections/model.test.mjs +240 -0
- package/lib/setup/sections/org.mjs +27 -2
- package/lib/setup/sections/org.test.mjs +35 -2
- package/lib/setup/sections/verify.mjs +5 -0
- package/lib/setup/state.mjs +30 -10
- package/lib/setup/state.test.mjs +24 -1
- package/lib/singleton.js +11 -3
- package/lib/singleton.test.mjs +16 -0
- package/lib/subagents/lock.mjs +1 -1
- package/lib/telemetry/collect.mjs +270 -6
- package/lib/telemetry/collect.test.mjs +196 -1
- package/lib/upgrade/global-refresh.mjs +108 -0
- package/lib/upgrade/global-refresh.test.mjs +65 -0
- package/lib/upgrade/launchd-reconcile.mjs +327 -0
- package/lib/upgrade/launchd-reconcile.test.mjs +272 -0
- package/lib/upgrade/post-steps.mjs +151 -0
- package/lib/upgrade/post-steps.test.mjs +200 -0
- package/lib/upgrade/verify.mjs +215 -0
- package/lib/upgrade/verify.test.mjs +164 -0
- package/lib/voice/outbound.mjs +3 -2
- package/lib/voice/post-call-brief.mjs +2 -1
- package/lib/voice/session-rotation.mjs +6 -1
- package/lib/voice/session-rotation.test.mjs +114 -0
- package/package.json +3 -3
- package/plugins/maestro-skills/plugin.json +21 -1
- package/plugins/maestro-skills/skills/board-work.md +63 -0
- package/plugins/maestro-skills/skills/inbound-triage.md +80 -0
- package/plugins/maestro-skills/skills/main-session.md +102 -0
- package/plugins/maestro-skills/skills/peer-sessions.md +65 -0
- package/plugins/maestro-skills/skills/persona-discipline.md +75 -0
- package/scaffold/CLAUDE.md +34 -0
- package/scripts/ci/check-durable-write-seam.mjs +147 -0
- package/scripts/ci/check-durable-write-seam.test.mjs +90 -0
- package/scripts/ci/check.mjs +3 -0
- package/scripts/collective/hook-runner.mjs +39 -4
- package/scripts/collective/hook-runner.test.mjs +85 -2
- package/scripts/daemon/agent-daemon-board-mine.test.mjs +96 -0
- package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
- package/scripts/daemon/agent-daemon.mjs +141 -10
- package/scripts/daemon/agent-daemon.test.mjs +73 -0
- package/scripts/daemon/assurance-e2e.test.mjs +141 -6
- package/scripts/daemon/assurance.mjs +461 -37
- package/scripts/daemon/assurance.test.mjs +408 -43
- package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +334 -0
- package/scripts/daemon/cadence-consumer.mjs +254 -78
- package/scripts/daemon/cadence-handlers.mjs +53 -0
- package/scripts/daemon/classifier.mjs +1 -1
- package/scripts/daemon/dispatcher-resume.test.mjs +166 -0
- package/scripts/daemon/dispatcher.mjs +127 -19
- package/scripts/daemon/health.mjs +12 -1
- package/scripts/daemon/inbox-deferral-session.test.mjs +49 -0
- package/scripts/daemon/inbox-deferral.mjs +6 -0
- package/scripts/daemon/lib/self-echo.mjs +201 -0
- package/scripts/daemon/lib/self-echo.test.mjs +153 -0
- package/scripts/daemon/maestro-daemon.mjs +3 -0
- package/scripts/daemon/prompt-builder.mjs +9 -1
- package/scripts/daemon/prompt-builder.test.mjs +22 -0
- package/scripts/daemon/responder.mjs +61 -41
- package/scripts/daemon/sdk-version.mjs +51 -0
- package/scripts/daemon/sdk-version.test.mjs +31 -0
- package/scripts/hooks/pre-send-audit.sh +97 -4
- package/scripts/hooks/pre-send-audit.test.mjs +140 -1
- package/scripts/local-triggers/autoupdate.sh +243 -19
- package/scripts/local-triggers/autoupdate.test.mjs +488 -0
- package/scripts/local-triggers/generate-plists.sh +24 -1
- package/scripts/local-triggers/generate-plists.test.mjs +49 -11
- package/scripts/org/send-orgmail.first-contact.test.mjs +102 -0
- package/scripts/org/send-orgmail.mjs +27 -3
- package/scripts/poller/inbox-privilege-injection.test.mjs +167 -0
- package/scripts/poller/slack-poller.mjs +13 -1
- package/scripts/poller/utils.mjs +46 -1
- package/scripts/poller-launchd/install.sh +19 -11
- package/scripts/poller-launchd/install.test.mjs +243 -0
- package/scripts/poller-launchd/launchd-poller-wrapper.sh +92 -0
- package/scripts/poller-launchd/migrate.sh +66 -0
- package/scripts/poller-launchd/poller.plist.template +4 -2
- package/scripts/session/feed.mjs +237 -0
- package/scripts/session/feed.test.mjs +196 -0
- package/scripts/session/supervisor-sh.test.mjs +218 -0
- package/scripts/session/supervisor.mjs +328 -0
- package/scripts/session/supervisor.sh +141 -0
- package/scripts/session/supervisor.test.mjs +482 -0
- package/scripts/setup/configure-macos.sh +250 -55
- package/scripts/setup/configure-macos.test.mjs +306 -0
- package/scripts/setup/init-agent.sh +112 -7
- package/scripts/setup/init-agent.test.mjs +220 -1
- package/scripts/watchdog/memory-watchdog.sh +37 -1
- package/scripts/watchdog/memory-watchdog.test.mjs +64 -0
- package/scripts/setup/boot-claude-session.sh +0 -94
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* self-echo.test.mjs — the sender==self match that three production recurrences
|
|
3
|
+
* (2026-08-25 / 08-27 / 08-30) proved the old inline check could not make.
|
|
4
|
+
*
|
|
5
|
+
* Run: node --test scripts/daemon/lib/self-echo.test.mjs
|
|
6
|
+
*
|
|
7
|
+
* The load-bearing case is the REAL event shape: cohort inbox items carry the
|
|
8
|
+
* display name verbatim (`sender: "Isla Roselli"`), while the old filter
|
|
9
|
+
* compared against `fullName.toLowerCase().replace(/\s+/g, "-")` —
|
|
10
|
+
* "isla-roselli" — so the echo sailed through and the agent acked itself.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { test, describe, beforeEach } from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
|
|
19
|
+
import { loadSelfIdentity, isSelfSender, normalizeName, logSelfEchoDrop, _resetSelfIdentity } from "./self-echo.mjs";
|
|
20
|
+
|
|
21
|
+
const ROOT = mkdtempSync(join(tmpdir(), "self-echo-test-"));
|
|
22
|
+
mkdirSync(join(ROOT, "config"), { recursive: true });
|
|
23
|
+
writeFileSync(join(ROOT, "config", "agent.json"), JSON.stringify({
|
|
24
|
+
firstName: "Isla",
|
|
25
|
+
fullName: "Isla Roselli",
|
|
26
|
+
memberId: "cmqh0tcml004ihhl6zev6ocb5",
|
|
27
|
+
email: "isla@example.com",
|
|
28
|
+
org: { cohort: { agentId: "A016" } },
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
beforeEach(() => _resetSelfIdentity());
|
|
32
|
+
|
|
33
|
+
const me = () => loadSelfIdentity(ROOT);
|
|
34
|
+
|
|
35
|
+
describe("normalizeName", () => {
|
|
36
|
+
test("folds case and the separator zoo to one comparable form", () => {
|
|
37
|
+
for (const v of ["Isla Roselli", "isla roselli", "isla-roselli", "Isla_Roselli", "isla.roselli", " Isla Roselli "]) {
|
|
38
|
+
assert.equal(normalizeName(v), "isla roselli", `"${v}" must normalise`);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("isSelfSender — the shapes real events actually carry", () => {
|
|
44
|
+
test("REGRESSION: the verbatim display name — the exact 25/27/30 Aug echo shape — matches", () => {
|
|
45
|
+
// Field-for-field the 30 Aug production echo (cmtfqjwvv01kype01t002t8jf):
|
|
46
|
+
const echo = {
|
|
47
|
+
id: "cohort-cmtfqjwvv01kype01t002t8jf",
|
|
48
|
+
service: "cohort",
|
|
49
|
+
channel: "dm-a016-a039",
|
|
50
|
+
sender: "Isla Roselli",
|
|
51
|
+
raw_ref: "cohort:dm:cmtfqjwvv01kype01t002t8jf:192025",
|
|
52
|
+
event_kind: "send",
|
|
53
|
+
is_dm: true,
|
|
54
|
+
};
|
|
55
|
+
assert.equal(isSelfSender(echo, me()), true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("the OLD filter's dashed form and other separator variants also match", () => {
|
|
59
|
+
for (const s of ["isla-roselli", "Isla_Roselli", "isla roselli"]) {
|
|
60
|
+
assert.equal(isSelfSender({ sender: s }, me()), true, `"${s}" is this agent`);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("REGRESSION: the BARE first name alone is NOT self — it collides with every namesake", () => {
|
|
65
|
+
// "Isla" as a display name is any colleague, contact or CC'd person who
|
|
66
|
+
// shares the first name. Matching it dropped their genuine inbound.
|
|
67
|
+
assert.equal(isSelfSender({ sender: "Isla" }, me()), false);
|
|
68
|
+
assert.equal(isSelfSender({ sender: "isla" }, me()), false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("REGRESSION: a NON-matching id EXONERATES a colliding display name", () => {
|
|
72
|
+
// A namesake — display name "Isla Roselli", but a DIFFERENT member cuid.
|
|
73
|
+
// The id positively identifies the sender as someone else; the name rung
|
|
74
|
+
// must never be reached, so this is NOT self and must not be dropped.
|
|
75
|
+
assert.equal(
|
|
76
|
+
isSelfSender({ sender: "Isla Roselli", sender_id: "cmDIFFERENT00000000000000" }, me()),
|
|
77
|
+
false,
|
|
78
|
+
"an id that resolves to someone else outranks the name collision",
|
|
79
|
+
);
|
|
80
|
+
assert.equal(
|
|
81
|
+
isSelfSender({ sender: "isla-roselli", author_id: "cmDIFFERENT00000000000000" }, me()),
|
|
82
|
+
false,
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("REGRESSION: a NON-matching email exonerates a colliding display name too", () => {
|
|
87
|
+
assert.equal(
|
|
88
|
+
isSelfSender({ sender: "Isla Roselli", sender_email: "other.isla@example.com" }, me()),
|
|
89
|
+
false,
|
|
90
|
+
);
|
|
91
|
+
// …while the OWN email still matches regardless of the display name.
|
|
92
|
+
assert.equal(
|
|
93
|
+
isSelfSender({ sender: "totally different name", sender_email: "isla@example.com" }, me()),
|
|
94
|
+
true,
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("a member cuid on any id-ish field matches (strongest evidence)", () => {
|
|
99
|
+
for (const item of [
|
|
100
|
+
{ sender: "someone", sender_id: "cmqh0tcml004ihhl6zev6ocb5" },
|
|
101
|
+
{ sender: "someone", author_id: "CMQH0TCML004IHHL6ZEV6OCB5" },
|
|
102
|
+
{ sender: "someone", from: { id: "cmqh0tcml004ihhl6zev6ocb5" } },
|
|
103
|
+
]) {
|
|
104
|
+
assert.equal(isSelfSender(item, me()), true);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("the org seat slug matches too", () => {
|
|
109
|
+
assert.equal(isSelfSender({ sender: "x", sender_id: "A016" }, me()), true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("own email matches (gmail/orgmail lanes)", () => {
|
|
113
|
+
assert.equal(isSelfSender({ sender: "Isla via mail", sender_email: "isla@example.com" }, me()), true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("genuine colleagues NEVER match — no false drops", () => {
|
|
117
|
+
for (const s of ["Jordan Blake", "Candace Wong", "Isla Fisher", "roselli", "isla2", "ceo"]) {
|
|
118
|
+
assert.equal(isSelfSender({ sender: s }, me()), false, `"${s}" must not be dropped`);
|
|
119
|
+
}
|
|
120
|
+
assert.equal(isSelfSender({ sender: "Candace Wong", sender_id: "cmOTHER000000000000000000" }, me()), false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("an UNCONFIGURED identity matches nothing — a half-set-up repo drops no inbound", () => {
|
|
124
|
+
const bare = mkdtempSync(join(tmpdir(), "self-echo-bare-"));
|
|
125
|
+
try {
|
|
126
|
+
const id = loadSelfIdentity(bare);
|
|
127
|
+
assert.equal(isSelfSender({ sender: "Isla Roselli" }, id), false);
|
|
128
|
+
assert.equal(isSelfSender({ sender: "Agent" }, id), false);
|
|
129
|
+
} finally {
|
|
130
|
+
rmSync(bare, { recursive: true, force: true });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("logSelfEchoDrop — every drop is auditable", () => {
|
|
136
|
+
test("appends one JSONL row per drop under logs/daemon/", () => {
|
|
137
|
+
const item = { id: "e-1", raw_ref: "cohort:dm:e-1:42", sender: "Isla Roselli", channel: "dm-a016-a039", service: "cohort", event_kind: "send" };
|
|
138
|
+
logSelfEchoDrop(item, { stage: "poll", service: "cohort", agentRoot: ROOT });
|
|
139
|
+
logSelfEchoDrop(item, { stage: "process", service: "cohort", agentRoot: ROOT });
|
|
140
|
+
const rows = readFileSync(join(ROOT, "logs", "daemon", "self-echo-drops.jsonl"), "utf-8")
|
|
141
|
+
.split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
142
|
+
assert.equal(rows.length, 2);
|
|
143
|
+
assert.equal(rows[0].stage, "poll");
|
|
144
|
+
assert.equal(rows[1].stage, "process");
|
|
145
|
+
assert.equal(rows[0].raw_ref, "cohort:dm:e-1:42");
|
|
146
|
+
assert.equal(rows[0].sender, "Isla Roselli");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("never throws, even for a junk item", () => {
|
|
150
|
+
assert.doesNotThrow(() => logSelfEchoDrop(null, { agentRoot: ROOT }));
|
|
151
|
+
assert.doesNotThrow(() => logSelfEchoDrop(undefined, { agentRoot: join(ROOT, "no", "such", "deep") }));
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -32,6 +32,9 @@ const AGENT_DIR = process.env.AGENT_DIR || resolve(__dirname, "../..");
|
|
|
32
32
|
|
|
33
33
|
process.env.AGENT_DIR = AGENT_DIR;
|
|
34
34
|
process.env.AGENT_ROOT = AGENT_DIR;
|
|
35
|
+
// Inherited by every `claude --print` child: scripts/collective/hook-runner.mjs
|
|
36
|
+
// classifies those sessions as "daemon" (presence kind; no identity primer).
|
|
37
|
+
process.env.MAESTRO_DAEMON = "1";
|
|
35
38
|
|
|
36
39
|
// ---------------------------------------------------------------------------
|
|
37
40
|
// 0. Environment — BEFORE anything reads a credential
|
|
@@ -7,7 +7,7 @@ import { readFileSync, readdirSync } from "fs";
|
|
|
7
7
|
import { join } from "path";
|
|
8
8
|
import { createRequire } from "node:module";
|
|
9
9
|
import { compileContext } from "./context-compiler.mjs";
|
|
10
|
-
import { renderPersona } from "../../lib/identity/persona.mjs";
|
|
10
|
+
import { renderPersona, MESSAGE_CRAFT } from "../../lib/identity/persona.mjs";
|
|
11
11
|
import { wrapExternalContent } from "../../lib/security/external-content.mjs";
|
|
12
12
|
import { isEnabled as orgEnabled } from "../../lib/org/client.mjs";
|
|
13
13
|
import { recall as orgRecall } from "../../lib/org/knowledge.mjs";
|
|
@@ -876,6 +876,14 @@ export async function buildPrompt(item, classResult, options = {}) {
|
|
|
876
876
|
parts.push(SKILLS_GUIDANCE);
|
|
877
877
|
parts.push("");
|
|
878
878
|
|
|
879
|
+
// 1c. Message-craft doctrine — the shared "how you shape an outbound message"
|
|
880
|
+
// rule (distil by default; format only when the message must be large; attach
|
|
881
|
+
// rather than dump genuinely verbose content). Exported from persona.mjs and
|
|
882
|
+
// injected into the responder's quick-reply prompt too, so reactive replies and
|
|
883
|
+
// full sessions write outbound prose the same way.
|
|
884
|
+
parts.push(MESSAGE_CRAFT);
|
|
885
|
+
parts.push("");
|
|
886
|
+
|
|
879
887
|
// 1a. Holding message warning — TOP OF PROMPT so Claude sees it before action instructions.
|
|
880
888
|
// This is the most critical instruction in the prompt: prevents double-replies.
|
|
881
889
|
// We repeat it at section 7a as well, immediately before the action block.
|
|
@@ -212,6 +212,28 @@ test("buildPrompt caps the number of injected org facts (bounded)", async () =>
|
|
|
212
212
|
});
|
|
213
213
|
});
|
|
214
214
|
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Message-craft doctrine — the shared "how you shape an outbound message" rule
|
|
217
|
+
// (distil by default; format only when the message must be large; attach
|
|
218
|
+
// genuinely verbose content). The SAME constant leads the responder's quick-reply
|
|
219
|
+
// system prompt, so reactive replies and full sessions write outbound prose the
|
|
220
|
+
// same way.
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
test("buildPrompt injects the message-craft doctrine (distil / format / attach)", async () => {
|
|
224
|
+
writeOrgConfig(null);
|
|
225
|
+
const prompt = await buildPrompt(ITEM, CLASS, { type: "inbox" });
|
|
226
|
+
assert.match(prompt, /How you shape a message:/);
|
|
227
|
+
assert.match(prompt, /Distil to what matters, by default/);
|
|
228
|
+
assert.match(prompt, /when a message genuinely must be long, FORMAT it/i);
|
|
229
|
+
assert.match(prompt, /do NOT dump it into the chat/);
|
|
230
|
+
// The real attach mechanism, not a vague "share it somewhere".
|
|
231
|
+
assert.match(prompt, /scripts\/pdf-generation\/build-document\.mjs/);
|
|
232
|
+
assert.match(prompt, /scripts\/slack-upload-v2\.py/);
|
|
233
|
+
// The rest of the prompt is unchanged.
|
|
234
|
+
assert.ok(prompt.includes("--- INCOMING MESSAGE ---"));
|
|
235
|
+
});
|
|
236
|
+
|
|
215
237
|
test("buildPrompt states the acknowledgement was sent when it actually was", async () => {
|
|
216
238
|
const prompt = await buildPrompt(ITEM, CLASS, {
|
|
217
239
|
type: "inbox",
|
|
@@ -10,14 +10,12 @@
|
|
|
10
10
|
* 2. ACKNOWLEDGEMENTS: for complex items that need a full session, an immediate
|
|
11
11
|
* holding message so the sender knows the work has started.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* concat and one HTTP call, which cannot time out, cannot be starved of
|
|
20
|
-
* memory, and cannot be refused by a spend cap.
|
|
13
|
+
* This one is generated by assurance.generateAck — one bespoke haiku line
|
|
14
|
+
* under a tight (~5s) cap — and on ANY generation failure it sends NOTHING.
|
|
15
|
+
* That is a deliberate inversion (CEO directive 2026-08-30) of the previous
|
|
16
|
+
* canned rotation ("On it." / "Looking now."), which itself replaced a 60s
|
|
17
|
+
* model spawn that lost its race 20 times in 32. The failure budget is now
|
|
18
|
+
* owned by the assurance sweep, not by boilerplate.
|
|
21
19
|
*
|
|
22
20
|
* Migrated off `@anthropic-ai/sdk` per CEO directive (Slack DM
|
|
23
21
|
* D099N1JGKRQ, 2026-04-27 09:38Z + 11:33Z): all agent daemon model
|
|
@@ -56,10 +54,18 @@ import { rungById } from "../../lib/execution/route.mjs";
|
|
|
56
54
|
// the property the acknowledgement path needed and did not have.
|
|
57
55
|
import { deliver, deliverWithRetry, resolveSlackChannel } from "./deliver.mjs";
|
|
58
56
|
import { startTyping, stopTyping } from "./typing-registry.mjs";
|
|
59
|
-
// The acknowledgement is
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
57
|
+
// The acknowledgement is GENERATED again — one bespoke haiku line under a
|
|
58
|
+
// ~5s cap — but with the failure mode inverted from the old 60s-spawn era:
|
|
59
|
+
// generation failure now means SILENCE (send nothing), never a canned
|
|
60
|
+
// placeholder. CEO directive 2026-08-30; see assurance.generateAck.
|
|
61
|
+
import { generateAck } from "./assurance.mjs";
|
|
62
|
+
// Shared message-craft doctrine (distil by default; format only when the message
|
|
63
|
+
// must be large; attach genuinely verbose content rather than dumping it). The
|
|
64
|
+
// SAME constant leads the full-session prompt in prompt-builder.mjs, so a reactive
|
|
65
|
+
// quick reply and a full inbox/backlog session shape outbound prose identically.
|
|
66
|
+
// This path never renders the persona block, which is why the doctrine is a
|
|
67
|
+
// separately-exported constant rather than part of voiceRules().
|
|
68
|
+
import { MESSAGE_CRAFT } from "../../lib/identity/persona.mjs";
|
|
63
69
|
|
|
64
70
|
const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.url).pathname, "../..");
|
|
65
71
|
const SONNET_MODEL = "claude-sonnet-4-6";
|
|
@@ -688,11 +694,10 @@ export function setGenerateResponseForTests(fn) {
|
|
|
688
694
|
* Generate an ANSWER. Only an answer.
|
|
689
695
|
*
|
|
690
696
|
* This used to take an `isHolding` flag and, when set, spend a model call
|
|
691
|
-
* writing an acknowledgement. That branch
|
|
692
|
-
*
|
|
693
|
-
*
|
|
694
|
-
*
|
|
695
|
-
* seconds when it worked and total silence when it did not.
|
|
697
|
+
* writing an acknowledgement. That branch stays gone: acknowledgements live in
|
|
698
|
+
* assurance.generateAck (their own model, cap and silence-on-failure rules),
|
|
699
|
+
* and the flag is deliberately not kept "just in case", because keeping it is
|
|
700
|
+
* how it comes back.
|
|
696
701
|
*/
|
|
697
702
|
async function realGenerateResponse(item, classResult) {
|
|
698
703
|
const preamble = loadPreamble();
|
|
@@ -708,7 +713,9 @@ If it's informational, acknowledge appropriately.
|
|
|
708
713
|
|
|
709
714
|
Keep responses focused — 1-4 sentences for simple items, up to a short paragraph for more nuanced ones.
|
|
710
715
|
Match the sender's tone and urgency level.
|
|
711
|
-
${profile ? `\nSender profile:\n${profile}` : ""}
|
|
716
|
+
${profile ? `\nSender profile:\n${profile}` : ""}
|
|
717
|
+
|
|
718
|
+
${MESSAGE_CRAFT}`;
|
|
712
719
|
|
|
713
720
|
const conversationHistory = await loadConversationHistory(item);
|
|
714
721
|
|
|
@@ -966,26 +973,29 @@ export async function sendQuickResponse(item, classResult, routed = null) {
|
|
|
966
973
|
* Acknowledge an ask that is about to become a long-running session, and return
|
|
967
974
|
* the text so the session's own prompt can see what the human was already told.
|
|
968
975
|
*
|
|
969
|
-
*
|
|
976
|
+
* THE ACK IS GENERATED AGAIN — WITH THE FAILURE MODE INVERTED.
|
|
970
977
|
*
|
|
971
|
-
*
|
|
972
|
-
* `claude --print` child under a 60-second hard cap to compose two sentences of
|
|
973
|
-
* courtesy. On a box at 95-99% memory that spawn lost its race 20 times in 32;
|
|
974
|
-
* every loss threw, was caught below, logged as `holding_message_error`, and
|
|
975
|
-
* told the requester nothing at all — while a session ran for a median of 14.7
|
|
976
|
-
* minutes behind a typing indicator the human reads as "she's replying".
|
|
978
|
+
* History, because this path has now swung twice and the reasons matter:
|
|
977
979
|
*
|
|
978
|
-
*
|
|
979
|
-
*
|
|
980
|
-
*
|
|
981
|
-
*
|
|
982
|
-
*
|
|
983
|
-
*
|
|
980
|
+
* 1. Originally `_generateResponse(item, classResult, true)` spawned a cold
|
|
981
|
+
* `claude --print` child under a 60-second cap. On a box at 95-99% memory
|
|
982
|
+
* it lost that race 20 times in 32, and every loss was silent — the
|
|
983
|
+
* requester got nothing while a 15-45 minute session ran behind a typing
|
|
984
|
+
* indicator.
|
|
985
|
+
* 2. The fix made the ack a canned rotation (assurance.composeAck: "On it." /
|
|
986
|
+
* "Looking now." / "On it — digging in now.") that could never fail.
|
|
987
|
+
* It also could never sound like anyone. In the 08-2026 self-echo
|
|
988
|
+
* incidents two agents traded those exact canned lines at each other, and
|
|
989
|
+
* the CEO's verdict (2026-08-30) was that a reflex string is worse than
|
|
990
|
+
* no ack at all.
|
|
984
991
|
*
|
|
985
|
-
*
|
|
986
|
-
*
|
|
987
|
-
* this
|
|
988
|
-
*
|
|
992
|
+
* So: assurance.generateAck produces ONE bespoke line from the actual thread
|
|
993
|
+
* via the cheapest model under a tight (~5s, env-tunable) cap, and on ANY
|
|
994
|
+
* generation failure this function sends NOTHING and says so in the log. The
|
|
995
|
+
* durability story is unchanged — the caller opened the obligation before
|
|
996
|
+
* calling this, the assurance sweep retries generation once within
|
|
997
|
+
* ASSURANCE_ACK_GRACE_MS, and the progress/failure notices still guarantee the
|
|
998
|
+
* human is never left in long-term silence.
|
|
989
999
|
*
|
|
990
1000
|
* @returns {{ sent: boolean, holdingText: string|null }} result
|
|
991
1001
|
*/
|
|
@@ -993,8 +1003,8 @@ export async function sendHoldingMessage(item, classResult) {
|
|
|
993
1003
|
const startTime = Date.now();
|
|
994
1004
|
|
|
995
1005
|
try {
|
|
996
|
-
|
|
997
|
-
|
|
1006
|
+
// Channel + dedup gates FIRST — they are free, and a model call spent on a
|
|
1007
|
+
// message the dedup registry would refuse anyway is a pure waste.
|
|
998
1008
|
if (item.service === "slack") {
|
|
999
1009
|
const channel = resolveSlackChannel(item);
|
|
1000
1010
|
if (!channel) {
|
|
@@ -1011,9 +1021,19 @@ export async function sendHoldingMessage(item, classResult) {
|
|
|
1011
1021
|
}
|
|
1012
1022
|
}
|
|
1013
1023
|
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1024
|
+
const text = await generateAck(item, classResult);
|
|
1025
|
+
if (text == null) {
|
|
1026
|
+
// Deliberate silence — never a canned fallback. The obligation the caller
|
|
1027
|
+
// opened stays un-acked; the assurance sweep owns the (bounded) retry.
|
|
1028
|
+
console.warn(`[responder] holding ack generation failed for ${item.sender} — sending nothing (silence over boilerplate, by design)`);
|
|
1029
|
+
logResponse({ type: "holding_ack_generation_failed", sender: item.sender, service: item.service });
|
|
1030
|
+
counters.bump("send.blocked", { stage: "ack_generation", service: item.service || "unknown", kind: "holding" });
|
|
1031
|
+
return { sent: false, holdingText: null, error: "ack_generation_failed", genFailed: true };
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// Bounded retry on the SEND. A courtesy message that gives up on one
|
|
1035
|
+
// dropped socket is how silence happens; ~3 seconds of retry in front of a
|
|
1036
|
+
// waiting human is proportionate.
|
|
1017
1037
|
const delivered = await deliverWithRetry(item, text, { kind: "ack" });
|
|
1018
1038
|
const sendResult = {
|
|
1019
1039
|
sent: delivered.sent,
|
|
@@ -1044,7 +1064,7 @@ export async function sendHoldingMessage(item, classResult) {
|
|
|
1044
1064
|
service: item.service,
|
|
1045
1065
|
duration_ms: duration,
|
|
1046
1066
|
text_length: text.length,
|
|
1047
|
-
generated:
|
|
1067
|
+
generated: true,
|
|
1048
1068
|
attempts: delivered.attempts || 1,
|
|
1049
1069
|
...sendResult,
|
|
1050
1070
|
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/daemon/sdk-version.mjs — which SDK version is the code THIS daemon
|
|
3
|
+
* process is running?
|
|
4
|
+
*
|
|
5
|
+
* The daemon runs from the agent dir's COPIED scripts/daemon, not from
|
|
6
|
+
* node_modules. autoupdate installs the next version into node_modules first
|
|
7
|
+
* and merges the files a minute or two later; a daemon that (re)starts inside
|
|
8
|
+
* that window would report the node_modules version while executing the old
|
|
9
|
+
* copies, and `maestro upgrade --verify` would go green on a lie. So the
|
|
10
|
+
* order of trust is the version of the COPIED tree first:
|
|
11
|
+
*
|
|
12
|
+
* 1. .maestro/shipped-manifest.json `sdkVersion` — written by the upgrade
|
|
13
|
+
* that copied the files (2.12+), i.e. the version of scripts/daemon/*;
|
|
14
|
+
* 2. node_modules/@cohortapp/agent-sdk/package.json — a pre-2.12 manifest
|
|
15
|
+
* has no sdkVersion; the window above is then the only inaccuracy left;
|
|
16
|
+
* 3. the package this file itself ships in (a framework checkout).
|
|
17
|
+
*
|
|
18
|
+
* Read once at daemon start, never again (the dashboard must say what the
|
|
19
|
+
* process IS, not what node_modules became meanwhile).
|
|
20
|
+
*
|
|
21
|
+
* @module scripts/daemon/sdk-version
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
"use strict";
|
|
25
|
+
|
|
26
|
+
import { readFileSync } from "node:fs";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
|
|
29
|
+
export const SDK_PACKAGE = "@cohortapp/agent-sdk";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Pure over an injected reader. `readJson(path)` returns the parsed file or
|
|
33
|
+
* throws / returns null; every source is fail-open.
|
|
34
|
+
* @param {{agentDir:string, ownPackagePath?:string, readJson?:(p:string)=>any}} o
|
|
35
|
+
* @returns {{version:string, source:"manifest"|"node_modules"|"package"|"none"}}
|
|
36
|
+
*/
|
|
37
|
+
export function resolveRunningSdkVersion(o = {}) {
|
|
38
|
+
const readJson = o.readJson || ((p) => JSON.parse(readFileSync(p, "utf8")));
|
|
39
|
+
const tryRead = (p) => { try { return readJson(p); } catch { return null; /* absent/corrupt → next source */ } };
|
|
40
|
+
const manifest = tryRead(join(o.agentDir, ".maestro", "shipped-manifest.json"));
|
|
41
|
+
if (manifest && typeof manifest.sdkVersion === "string" && manifest.sdkVersion) return { version: manifest.sdkVersion, source: "manifest" };
|
|
42
|
+
const dep = tryRead(join(o.agentDir, "node_modules", ...SDK_PACKAGE.split("/"), "package.json"));
|
|
43
|
+
if (dep && dep.name === SDK_PACKAGE && typeof dep.version === "string") return { version: dep.version, source: "node_modules" };
|
|
44
|
+
if (o.ownPackagePath) {
|
|
45
|
+
const own = tryRead(o.ownPackagePath);
|
|
46
|
+
if (own && own.name === SDK_PACKAGE && typeof own.version === "string") return { version: own.version, source: "package" };
|
|
47
|
+
}
|
|
48
|
+
return { version: "", source: "none" };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default { resolveRunningSdkVersion, SDK_PACKAGE };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sdk-version.test.mjs — the daemon's own-version resolution prefers the
|
|
3
|
+
* version of the COPIED tree (the manifest) over node_modules.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { resolveRunningSdkVersion, SDK_PACKAGE } from "./sdk-version.mjs";
|
|
9
|
+
|
|
10
|
+
function reader(files) {
|
|
11
|
+
return (p) => { if (p in files) return files[p]; throw new Error(`ENOENT ${p}`); };
|
|
12
|
+
}
|
|
13
|
+
const A = "/seat/ivy-ai";
|
|
14
|
+
const MANIFEST = `${A}/.maestro/shipped-manifest.json`;
|
|
15
|
+
const DEP = `${A}/node_modules/@cohortapp/agent-sdk/package.json`;
|
|
16
|
+
const OWN = "/sdk/package.json";
|
|
17
|
+
|
|
18
|
+
test("the manifest's sdkVersion wins: node_modules may already hold the NEXT version while the copied scripts are still the old one", () => {
|
|
19
|
+
const r = resolveRunningSdkVersion({ agentDir: A, ownPackagePath: OWN, readJson: reader({
|
|
20
|
+
[MANIFEST]: { version: 1, sdkVersion: "2.11.17" },
|
|
21
|
+
[DEP]: { name: SDK_PACKAGE, version: "2.12.0" },
|
|
22
|
+
}) });
|
|
23
|
+
assert.deepEqual(r, { version: "2.11.17", source: "manifest" });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("a pre-2.12 manifest (no sdkVersion) or none → node_modules; then the package this file ships in; then empty", () => {
|
|
27
|
+
assert.deepEqual(resolveRunningSdkVersion({ agentDir: A, readJson: reader({ [MANIFEST]: { version: 1, files: [] }, [DEP]: { name: SDK_PACKAGE, version: "2.11.15" } }) }), { version: "2.11.15", source: "node_modules" });
|
|
28
|
+
assert.deepEqual(resolveRunningSdkVersion({ agentDir: A, ownPackagePath: OWN, readJson: reader({ [OWN]: { name: SDK_PACKAGE, version: "2.12.0" } }) }), { version: "2.12.0", source: "package" });
|
|
29
|
+
assert.deepEqual(resolveRunningSdkVersion({ agentDir: A, ownPackagePath: OWN, readJson: reader({ [OWN]: { name: "ivy-ai", version: "1.0.0" } }) }), { version: "", source: "none" }, "an agent's own package.json is not the SDK");
|
|
30
|
+
assert.deepEqual(resolveRunningSdkVersion({ agentDir: A, readJson: () => { throw new Error("EACCES"); } }), { version: "", source: "none" }, "fail-open");
|
|
31
|
+
});
|
|
@@ -6,10 +6,20 @@
|
|
|
6
6
|
# recipient allowlist sanity check, and enforces hourly + daily rate caps.
|
|
7
7
|
#
|
|
8
8
|
# Usage: pre-send-audit.sh <channel_type>
|
|
9
|
-
# channel_type: slack | slack-draft | gmail | whatsapp
|
|
9
|
+
# channel_type: slack | slack-draft | gmail | whatsapp | cohort
|
|
10
|
+
#
|
|
11
|
+
# `cohort` is the PreToolUse channel `maestro global-setup` wires into
|
|
12
|
+
# ~/.claude/settings.json for EVERY mcp__cohort outbound tool (messaging_send,
|
|
13
|
+
# messaging_send_voice_note, email_send, email_draft_send, org_rpc) — the
|
|
14
|
+
# persona gate below (section a2) is what the front-door session relies on to
|
|
15
|
+
# never expose its own machinery to a human (design spec 2026-09-08 §3.6). The
|
|
16
|
+
# same patterns run in-process in lib/comms/send-gate.mjs (screenPersona) for
|
|
17
|
+
# the lanes no hook fronts: `maestro inbox reply`, the daemon's delivery, the
|
|
18
|
+
# adapters. Keep the two lists in sync.
|
|
10
19
|
#
|
|
11
20
|
# Stdin: Claude Code PreToolUse JSON payload:
|
|
12
|
-
# { "tool_name": "...", "tool_input": { "text"|"message"|"body": "...",
|
|
21
|
+
# { "tool_name": "...", "tool_input": { "text"|"message"|"body"|"bodyText": "...",
|
|
22
|
+
# "subject": "...", "params": { …an org_rpc body… },
|
|
13
23
|
# "to"|"recipient"|"channel": "..." }, ... }
|
|
14
24
|
#
|
|
15
25
|
# Exit codes:
|
|
@@ -33,7 +43,7 @@ deny() {
|
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
CHANNEL_TYPE="${1:-}"
|
|
36
|
-
[ -n "$CHANNEL_TYPE" ] || deny "Channel type required (slack|slack-draft|gmail|whatsapp)"
|
|
46
|
+
[ -n "$CHANNEL_TYPE" ] || deny "Channel type required (slack|slack-draft|gmail|whatsapp|cohort)"
|
|
37
47
|
|
|
38
48
|
# jq is mandatory — without it we cannot inspect content, so refuse to send.
|
|
39
49
|
command -v jq >/dev/null 2>&1 || deny "jq not available — cannot screen outbound content (fail closed)"
|
|
@@ -50,15 +60,20 @@ fi
|
|
|
50
60
|
# Extract the outbound text. Claude Code wraps the tool args under
|
|
51
61
|
# tool_input; for robustness we also look at the top level. We concatenate
|
|
52
62
|
# every plausible content field so a banned phrase anywhere is caught.
|
|
63
|
+
# `params` is where org_rpc carries an outbound body; `bodyText`/`subject` are
|
|
64
|
+
# the mail-desk (email_draft_send / email_send) content fields.
|
|
53
65
|
CONTENT=$(printf '%s' "$TOOL_INPUT" | jq -r '
|
|
54
|
-
[ .tool_input // {} , . ]
|
|
66
|
+
[ .tool_input // {} , ( .tool_input.params? | if type=="object" then . else {} end ) , . ]
|
|
55
67
|
| map(
|
|
56
68
|
( .text? // empty ),
|
|
57
69
|
( .message? // empty ),
|
|
58
70
|
( .body? // empty ),
|
|
71
|
+
( .bodyText? // empty ),
|
|
72
|
+
( .subject? // empty ),
|
|
59
73
|
( .content? // empty ),
|
|
60
74
|
( .blocks? | if type=="array" or type=="object" then tojson else empty end )
|
|
61
75
|
)
|
|
76
|
+
| map(if type=="string" then . else tojson end)
|
|
62
77
|
| flatten
|
|
63
78
|
| map(select(. != null and . != ""))
|
|
64
79
|
| join("\n")
|
|
@@ -110,6 +125,84 @@ if printf '%s' "$CONTENT" | grep -Eiq '(^|[[:cntrl:]])[[:space:]]*(Certainly!|Ab
|
|
|
110
125
|
deny "Banned filler opener (Certainly!/Absolutely!/Great question!) — see policies/communication-style.md"
|
|
111
126
|
fi
|
|
112
127
|
|
|
128
|
+
# --- (a2) persona-leak screen ----------------------------------------------
|
|
129
|
+
# The agent is one persona to the humans it talks to; parallel sessions,
|
|
130
|
+
# sub-sessions, subagents and workflows are internal workings. Outbound text
|
|
131
|
+
# may not name them, and may not name Claude Code / a language model. The
|
|
132
|
+
# denial carries the offending phrase so the session rewrites in its own voice
|
|
133
|
+
# ("my team", "a colleague", "one of my analysts").
|
|
134
|
+
#
|
|
135
|
+
# Patterns are case-insensitive extended regexes. "subagent"/"sub-agent" is
|
|
136
|
+
# blocked only when it is followed by said|reported|session — the bare noun in
|
|
137
|
+
# an internal-process sentence is tolerated, the attribution of speech to a
|
|
138
|
+
# machine part is not.
|
|
139
|
+
PERSONA_PATTERNS=(
|
|
140
|
+
'claude[[:space:]]+code'
|
|
141
|
+
'claude[[:space:]]+sessions?'
|
|
142
|
+
'sub-sessions?'
|
|
143
|
+
'sub-?agents?[[:space:]]+(said|reported|sessions?)'
|
|
144
|
+
'workflow[[:space:]]+agents?'
|
|
145
|
+
'as[[:space:]]+an[[:space:]]+ai\b'
|
|
146
|
+
'language[[:space:]]+model'
|
|
147
|
+
)
|
|
148
|
+
for pat in "${PERSONA_PATTERNS[@]}"; do
|
|
149
|
+
hit=$(printf '%s' "$CONTENT" | grep -Eio -- "$pat" 2>/dev/null | head -n 1) || true
|
|
150
|
+
if [ -n "$hit" ]; then
|
|
151
|
+
deny "Persona leak in outbound message: \"$hit\" — rewrite in your own voice (my team / a colleague), never name the machinery"
|
|
152
|
+
fi
|
|
153
|
+
done
|
|
154
|
+
|
|
155
|
+
# Session names are internal identifiers and must never reach a human — but
|
|
156
|
+
# the rule is BOUNDED, not guessed: `<first>-main` is always internal; any
|
|
157
|
+
# other name is internal only when it is a registered peer
|
|
158
|
+
# ($AGENT_ROOT/state/session/peers.json, written by `maestro session spawn`,
|
|
159
|
+
# an array or {peers:[…]} of {name,…}) or listed in MAESTRO_SESSION_NAMES
|
|
160
|
+
# (comma-separated). A colleague called Marc-Antoine, the slug james-kirkland
|
|
161
|
+
# in a URL or the mailbox ethan-miller@ are ordinary text.
|
|
162
|
+
#
|
|
163
|
+
# The first name resolves, in order, from MAESTRO_AGENT_FIRST,
|
|
164
|
+
# $AGENT_ROOT/config/agent.json (firstName, else the first word of fullName)
|
|
165
|
+
# and the machine pointer ~/.claude/maestro-agent.json (agentName). When none
|
|
166
|
+
# resolves the `<first>-main` check is skipped — the phrase gate above still
|
|
167
|
+
# applies — because a wrong guess would block legitimate text.
|
|
168
|
+
FIRST="${MAESTRO_AGENT_FIRST:-}"
|
|
169
|
+
if [ -z "$FIRST" ]; then
|
|
170
|
+
for cand in "${AGENT_ROOT:-}/config/agent.json" "${AGENT_DIR:-}/config/agent.json" "$HOME/.claude/maestro-agent.json"; do
|
|
171
|
+
[ -f "$cand" ] || continue
|
|
172
|
+
FIRST=$(jq -r '(.firstName // .agentName // .fullName // "") | tostring | split(" ")[0] | ascii_downcase' "$cand" 2>/dev/null) || FIRST=""
|
|
173
|
+
[ -n "$FIRST" ] && [ "$FIRST" != "null" ] && break
|
|
174
|
+
FIRST=""
|
|
175
|
+
done
|
|
176
|
+
fi
|
|
177
|
+
FIRST=$(printf '%s' "$FIRST" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9-')
|
|
178
|
+
|
|
179
|
+
SESSION_NAMES=()
|
|
180
|
+
[ -n "$FIRST" ] && SESSION_NAMES+=("${FIRST}-main")
|
|
181
|
+
for peers in "${AGENT_ROOT:-}/state/session/peers.json" "${AGENT_DIR:-}/state/session/peers.json"; do
|
|
182
|
+
[ -f "$peers" ] || continue
|
|
183
|
+
# A corrupt registry is an empty one: this must never fail the whole send closed.
|
|
184
|
+
while IFS= read -r pn; do
|
|
185
|
+
[ -n "$pn" ] && SESSION_NAMES+=("$pn")
|
|
186
|
+
done < <(jq -r '(if type=="array" then . elif type=="object" then (.peers // []) else [] end) | .[] | select(type=="object") | (.name // empty) | tostring | ascii_downcase' "$peers" 2>/dev/null || true)
|
|
187
|
+
break
|
|
188
|
+
done
|
|
189
|
+
if [ -n "${MAESTRO_SESSION_NAMES:-}" ]; then
|
|
190
|
+
IFS=',' read -r -a extra_names <<< "$MAESTRO_SESSION_NAMES"
|
|
191
|
+
# bash 3.2 (macOS) + set -u: an empty array expands as "unbound" — use the ${a[@]+"${a[@]}"} idiom.
|
|
192
|
+
for en in ${extra_names[@]+"${extra_names[@]}"}; do
|
|
193
|
+
en=$(printf '%s' "$en" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
|
|
194
|
+
[ -n "$en" ] && SESSION_NAMES+=("$en")
|
|
195
|
+
done
|
|
196
|
+
fi
|
|
197
|
+
for sname in ${SESSION_NAMES[@]+"${SESSION_NAMES[@]}"}; do
|
|
198
|
+
sname_re=$(printf '%s' "$sname" | sed 's/[.[\*^$()+?{|]/\\&/g')
|
|
199
|
+
# Word-bounded on [A-Za-z0-9_-]: "alex-main" hits, "alex-mainland" / "alex-main-street" do not.
|
|
200
|
+
name_hit=$(printf '%s' "$CONTENT" | grep -Eio -- "(^|[^[:alnum:]_-])${sname_re}([^[:alnum:]_-]|$)" 2>/dev/null | head -n 1 | sed -E 's/^[^[:alnum:]_-]//; s/[^[:alnum:]_-]$//') || true
|
|
201
|
+
if [ -n "$name_hit" ]; then
|
|
202
|
+
deny "Persona leak in outbound message: \"$name_hit\" is an internal session name — say \"my team\" or \"a colleague\" instead"
|
|
203
|
+
fi
|
|
204
|
+
done
|
|
205
|
+
|
|
113
206
|
# --- (b) recipient allowlist sanity check ----------------------------------
|
|
114
207
|
# Only enforced when a recipient is present AND an allowlist file exists.
|
|
115
208
|
# Allowlist file: one entry (channel id, email, handle) per line, '#' comments.
|