@cohortapp/agent-sdk 2.11.15 → 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.
Files changed (168) hide show
  1. package/.env.example +37 -22
  2. package/README.md +2 -0
  3. package/bin/maestro.mjs +113 -39
  4. package/bin/maestro.test.mjs +175 -5
  5. package/docs/guides/front-door-session.md +264 -0
  6. package/docs/guides/mac-mini.md +100 -28
  7. package/docs/guides/org-onboarding.md +1 -1
  8. package/docs/guides/setup-wizard.md +9 -5
  9. package/docs/runbooks/cohort-cutover.md +11 -1
  10. package/docs/runbooks/mac-mini-bootstrap.md +38 -63
  11. package/lib/cadence-bus-requeue.test.mjs +83 -0
  12. package/lib/cadence-bus.mjs +43 -7
  13. package/lib/channels/inbox-item.mjs +59 -2
  14. package/lib/cli/board.mjs +285 -0
  15. package/lib/cli/board.test.mjs +227 -0
  16. package/lib/cli/doctor-checks.mjs +441 -0
  17. package/lib/cli/doctor-checks.test.mjs +336 -0
  18. package/lib/cli/global-setup-extras.mjs +410 -0
  19. package/lib/cli/global-setup-extras.test.mjs +367 -0
  20. package/lib/cli/inbox.mjs +304 -0
  21. package/lib/cli/inbox.test.mjs +230 -0
  22. package/lib/cli/session-ack.mjs +63 -0
  23. package/lib/cli/session-ack.test.mjs +63 -0
  24. package/lib/cli/session.mjs +750 -0
  25. package/lib/cli/session.test.mjs +602 -0
  26. package/lib/collective/global-config.mjs +204 -6
  27. package/lib/collective/global-config.test.mjs +140 -0
  28. package/lib/collective/global-skills.mjs +145 -0
  29. package/lib/collective/global-skills.test.mjs +126 -0
  30. package/lib/collective/presence.mjs +4 -3
  31. package/lib/comms/send-gate.mjs +115 -0
  32. package/lib/comms/send-gate.test.mjs +113 -0
  33. package/lib/feature-init.mjs +2 -2
  34. package/lib/mcp/server.test.mjs +9 -4
  35. package/lib/model-router/spawn.test.mjs +21 -0
  36. package/lib/org/board-mine-cache.mjs +99 -0
  37. package/lib/org/board-mine-cache.test.mjs +53 -0
  38. package/lib/org/board.mjs +11 -0
  39. package/lib/org/board.test.mjs +11 -1
  40. package/lib/org/client.mjs +36 -0
  41. package/lib/org/client.test.mjs +46 -0
  42. package/lib/org/inbound/directedness.mjs +18 -2
  43. package/lib/org/inbound/directedness.test.mjs +58 -0
  44. package/lib/org/inbound/index.mjs +8 -1
  45. package/lib/org/inbound/index.test.mjs +22 -0
  46. package/lib/org/mesh-directives.test.mjs +110 -0
  47. package/lib/org/mesh.mjs +61 -1
  48. package/lib/org/protocol.checksum +1 -1
  49. package/lib/org/protocol.mjs +52 -0
  50. package/lib/org/protocol.test.mjs +12 -1
  51. package/lib/org/registry.mjs +3 -2
  52. package/lib/org/tool-surface.mjs +120 -0
  53. package/lib/org/tool-surface.test.mjs +118 -5
  54. package/lib/security/external-content.mjs +1 -1
  55. package/lib/security/external-content.test.mjs +17 -0
  56. package/lib/session/config.mjs +137 -0
  57. package/lib/session/config.test.mjs +92 -0
  58. package/lib/session/feed-core.mjs +229 -0
  59. package/lib/session/feed-core.test.mjs +198 -0
  60. package/lib/session/first-run.mjs +126 -0
  61. package/lib/session/first-run.test.mjs +121 -0
  62. package/lib/session/frontdoor.mjs +266 -0
  63. package/lib/session/frontdoor.test.mjs +205 -0
  64. package/lib/session/handoffs.mjs +295 -0
  65. package/lib/session/handoffs.test.mjs +183 -0
  66. package/lib/session/identity.mjs +220 -0
  67. package/lib/session/identity.test.mjs +180 -0
  68. package/lib/session/inbox-claims.mjs +434 -0
  69. package/lib/session/inbox-claims.test.mjs +286 -0
  70. package/lib/session/launch-args.mjs +161 -0
  71. package/lib/session/launch-args.test.mjs +157 -0
  72. package/lib/session/liveness.mjs +174 -0
  73. package/lib/session/liveness.test.mjs +100 -0
  74. package/lib/session/status-summary.mjs +172 -0
  75. package/lib/session/status-summary.test.mjs +118 -0
  76. package/lib/session-permissions.mjs +39 -3
  77. package/lib/session-permissions.test.mjs +20 -0
  78. package/lib/setup/claude-probe.mjs +161 -24
  79. package/lib/setup/claude-probe.test.mjs +187 -0
  80. package/lib/setup/sections/learning.mjs +2 -1
  81. package/lib/setup/sections/model.mjs +104 -24
  82. package/lib/setup/sections/model.test.mjs +240 -0
  83. package/lib/setup/sections/org.mjs +27 -2
  84. package/lib/setup/sections/org.test.mjs +35 -2
  85. package/lib/setup/sections/verify.mjs +5 -0
  86. package/lib/setup/state.mjs +30 -10
  87. package/lib/setup/state.test.mjs +24 -1
  88. package/lib/singleton.js +11 -3
  89. package/lib/singleton.test.mjs +16 -0
  90. package/lib/subagents/lock.mjs +1 -1
  91. package/lib/telemetry/collect.mjs +270 -6
  92. package/lib/telemetry/collect.test.mjs +196 -1
  93. package/lib/upgrade/global-refresh.mjs +108 -0
  94. package/lib/upgrade/global-refresh.test.mjs +65 -0
  95. package/lib/upgrade/launchd-reconcile.mjs +327 -0
  96. package/lib/upgrade/launchd-reconcile.test.mjs +272 -0
  97. package/lib/upgrade/post-steps.mjs +151 -0
  98. package/lib/upgrade/post-steps.test.mjs +200 -0
  99. package/lib/upgrade/verify.mjs +215 -0
  100. package/lib/upgrade/verify.test.mjs +164 -0
  101. package/lib/voice/outbound.mjs +3 -2
  102. package/lib/voice/post-call-brief.mjs +2 -1
  103. package/lib/voice/session-rotation.mjs +6 -1
  104. package/lib/voice/session-rotation.test.mjs +114 -0
  105. package/package.json +3 -3
  106. package/plugins/maestro-skills/plugin.json +21 -1
  107. package/plugins/maestro-skills/skills/board-work.md +63 -0
  108. package/plugins/maestro-skills/skills/inbound-triage.md +80 -0
  109. package/plugins/maestro-skills/skills/main-session.md +102 -0
  110. package/plugins/maestro-skills/skills/peer-sessions.md +65 -0
  111. package/plugins/maestro-skills/skills/persona-discipline.md +75 -0
  112. package/scaffold/CLAUDE.md +24 -0
  113. package/scripts/ci/check-durable-write-seam.mjs +147 -0
  114. package/scripts/ci/check-durable-write-seam.test.mjs +90 -0
  115. package/scripts/ci/check.mjs +3 -0
  116. package/scripts/collective/hook-runner.mjs +39 -4
  117. package/scripts/collective/hook-runner.test.mjs +85 -2
  118. package/scripts/daemon/agent-daemon-board-mine.test.mjs +96 -0
  119. package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
  120. package/scripts/daemon/agent-daemon.mjs +141 -10
  121. package/scripts/daemon/agent-daemon.test.mjs +73 -0
  122. package/scripts/daemon/assurance-e2e.test.mjs +141 -6
  123. package/scripts/daemon/assurance.mjs +461 -37
  124. package/scripts/daemon/assurance.test.mjs +408 -43
  125. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +334 -0
  126. package/scripts/daemon/cadence-consumer.mjs +254 -78
  127. package/scripts/daemon/cadence-handlers.mjs +53 -0
  128. package/scripts/daemon/classifier.mjs +1 -1
  129. package/scripts/daemon/dispatcher-resume.test.mjs +166 -0
  130. package/scripts/daemon/dispatcher.mjs +127 -19
  131. package/scripts/daemon/health.mjs +12 -1
  132. package/scripts/daemon/inbox-deferral-session.test.mjs +49 -0
  133. package/scripts/daemon/inbox-deferral.mjs +6 -0
  134. package/scripts/daemon/lib/self-echo.mjs +201 -0
  135. package/scripts/daemon/lib/self-echo.test.mjs +153 -0
  136. package/scripts/daemon/maestro-daemon.mjs +3 -0
  137. package/scripts/daemon/responder.mjs +51 -40
  138. package/scripts/daemon/sdk-version.mjs +51 -0
  139. package/scripts/daemon/sdk-version.test.mjs +31 -0
  140. package/scripts/hooks/pre-send-audit.sh +97 -4
  141. package/scripts/hooks/pre-send-audit.test.mjs +140 -1
  142. package/scripts/local-triggers/autoupdate.sh +243 -19
  143. package/scripts/local-triggers/autoupdate.test.mjs +488 -0
  144. package/scripts/local-triggers/generate-plists.sh +24 -1
  145. package/scripts/local-triggers/generate-plists.test.mjs +49 -11
  146. package/scripts/org/send-orgmail.first-contact.test.mjs +102 -0
  147. package/scripts/org/send-orgmail.mjs +27 -3
  148. package/scripts/poller/inbox-privilege-injection.test.mjs +167 -0
  149. package/scripts/poller/slack-poller.mjs +13 -1
  150. package/scripts/poller/utils.mjs +46 -1
  151. package/scripts/poller-launchd/install.sh +19 -11
  152. package/scripts/poller-launchd/install.test.mjs +243 -0
  153. package/scripts/poller-launchd/launchd-poller-wrapper.sh +92 -0
  154. package/scripts/poller-launchd/migrate.sh +66 -0
  155. package/scripts/poller-launchd/poller.plist.template +4 -2
  156. package/scripts/session/feed.mjs +237 -0
  157. package/scripts/session/feed.test.mjs +196 -0
  158. package/scripts/session/supervisor-sh.test.mjs +218 -0
  159. package/scripts/session/supervisor.mjs +328 -0
  160. package/scripts/session/supervisor.sh +141 -0
  161. package/scripts/session/supervisor.test.mjs +482 -0
  162. package/scripts/setup/configure-macos.sh +250 -55
  163. package/scripts/setup/configure-macos.test.mjs +306 -0
  164. package/scripts/setup/init-agent.sh +112 -7
  165. package/scripts/setup/init-agent.test.mjs +220 -1
  166. package/scripts/watchdog/memory-watchdog.sh +37 -1
  167. package/scripts/watchdog/memory-watchdog.test.mjs +64 -0
  168. package/scripts/setup/boot-claude-session.sh +0 -94
@@ -20,9 +20,10 @@
20
20
  "use strict";
21
21
 
22
22
  import {
23
- writeFileSync, readFileSync, unlinkSync, readdirSync, mkdirSync, existsSync,
23
+ readFileSync, unlinkSync, readdirSync, mkdirSync, existsSync,
24
24
  } from "node:fs";
25
25
  import { join, basename } from "node:path";
26
+ import { writeJsonAtomic } from "../fs-atomic.mjs";
26
27
 
27
28
  const PRESENCE_REL = "state/collective/presence";
28
29
  const DEFAULT_STALE_SEC = 300;
@@ -84,7 +85,7 @@ export function register(agentRoot, info = {}) {
84
85
  status: info.status || "active",
85
86
  kind: info.kind || "interactive",
86
87
  };
87
- writeFileSync(path, JSON.stringify(rec, null, 2));
88
+ writeJsonAtomic(path, rec);
88
89
  return true;
89
90
  } catch {
90
91
  return false;
@@ -104,7 +105,7 @@ export function heartbeat(agentRoot, sessionId, updates = {}) {
104
105
  rec.lastBeat = nowIso(updates.nowMs);
105
106
  if (updates.intent !== undefined) rec.intent = String(updates.intent).slice(0, 200);
106
107
  if (updates.status !== undefined) rec.status = updates.status;
107
- writeFileSync(path, JSON.stringify(rec, null, 2));
108
+ writeJsonAtomic(path, rec);
108
109
  return true;
109
110
  } catch {
110
111
  return false;
@@ -92,6 +92,31 @@ export const BANNED_OPENERS = Object.freeze([
92
92
  "Great question!",
93
93
  ]);
94
94
 
95
+ /* ───────────────────────────── persona discipline ────────────────────────── */
96
+ // Front-door session design (2026-09-08 §3.6): the agent is ONE persona to
97
+ // every human; parallel sessions, sub-sessions, subagents and workflows are
98
+ // internal workings and may never be named in outbound text, nor may "Claude
99
+ // Code" or "a language model". Kept in sync with scripts/hooks/pre-send-audit.sh
100
+ // PERSONA_PATTERNS so the PreToolUse hook and this in-process gate reject the
101
+ // same set. "subagent"/"sub-agent" is blocked only when followed by
102
+ // said|reported|session — the bare noun in an internal-process sentence is
103
+ // tolerated; attributing speech to a machine part is not.
104
+ export const PERSONA_PATTERNS = Object.freeze([
105
+ /claude\s+code/i,
106
+ /claude\s+sessions?/i,
107
+ /sub-sessions?/i,
108
+ /sub-?agents?\s+(?:said|reported|sessions?)/i,
109
+ /workflow\s+agents?/i,
110
+ /as\s+an\s+ai\b/i,
111
+ /language\s+model/i,
112
+ ]);
113
+
114
+ /** Where the persona screen reads the agent's identity + peer registry from. */
115
+ const PERSONA_FILES = Object.freeze({
116
+ agent: "config/agent.json",
117
+ peers: "state/session/peers.json",
118
+ });
119
+
95
120
  /* ────────────────────────── recipient classification ─────────────────────── */
96
121
 
97
122
  /**
@@ -262,6 +287,84 @@ export function screenBannedPhrases(text) {
262
287
  return null;
263
288
  }
264
289
 
290
+ function escapeRe(str) {
291
+ return String(str).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
292
+ }
293
+
294
+ /**
295
+ * (a2) Persona-leak screen. Pure; no IO. Returns a block reason (naming the
296
+ * offending phrase so the caller can rewrite in its own voice) or null.
297
+ *
298
+ * Session names are BOUNDED, not guessed: `<first>-main` is always internal,
299
+ * and any other name is internal only when it is in `sessionNames` (the
300
+ * registry `maestro session spawn` keeps in state/session/peers.json). A
301
+ * colleague called Marc-Antoine, the URL slug james-kirkland or the mailbox
302
+ * ethan-miller@ therefore pass, while `alex-main` / a registered `alex-deck`
303
+ * do not. Matching is word-bounded on [A-Za-z0-9_-] and case-insensitive.
304
+ *
305
+ * @param {string} text
306
+ * @param {{first?:string, sessionNames?:string[]}} [o]
307
+ * @returns {string|null}
308
+ */
309
+ export function screenPersona(text, o = {}) {
310
+ const t = typeof text === "string" ? text : "";
311
+ if (!t) return null;
312
+ for (const re of PERSONA_PATTERNS) {
313
+ const m = re.exec(t);
314
+ if (m) {
315
+ return `persona leak "${m[0]}" — rewrite in your own voice (my team / a colleague); never name the machinery`;
316
+ }
317
+ }
318
+ const first = String(o.first || "").trim().toLowerCase().replace(/[^a-z0-9-]+/g, "");
319
+ const names = new Set();
320
+ if (first) names.add(`${first}-main`);
321
+ for (const n of Array.isArray(o.sessionNames) ? o.sessionNames : []) {
322
+ const v = String(n || "").trim().toLowerCase();
323
+ if (v) names.add(v);
324
+ }
325
+ for (const name of names) {
326
+ const re = new RegExp(`(^|[^A-Za-z0-9_-])(${escapeRe(name)})(?![A-Za-z0-9_-])`, "i");
327
+ const m = re.exec(t);
328
+ if (m) {
329
+ return `persona leak "${m[2]}" is an internal session name — say "my team" or "a colleague" instead`;
330
+ }
331
+ }
332
+ return null;
333
+ }
334
+
335
+ /**
336
+ * Resolve the persona inputs from disk (fail-open: anything unreadable is
337
+ * simply absent, and the name check is then skipped while the phrase check
338
+ * still runs). Explicit values win over the files.
339
+ */
340
+ function personaContext(fs, agentRoot, { agentFirst, sessionNames }) {
341
+ let first = typeof agentFirst === "string" ? agentFirst : "";
342
+ if (!first) {
343
+ const a = readPolicy(fs, agentRoot, PERSONA_FILES.agent);
344
+ if (a.ok) {
345
+ try {
346
+ const doc = JSON.parse(a.text) || {};
347
+ first = String(doc.firstName || String(doc.fullName || "").split(/\s+/)[0] || "");
348
+ } catch { first = ""; }
349
+ }
350
+ }
351
+ let names = Array.isArray(sessionNames) ? sessionNames : null;
352
+ if (!names) {
353
+ names = [];
354
+ const p = readPolicy(fs, agentRoot, PERSONA_FILES.peers);
355
+ if (p.ok) {
356
+ try {
357
+ const doc = JSON.parse(p.text);
358
+ const list = Array.isArray(doc) ? doc : doc && Array.isArray(doc.peers) ? doc.peers : [];
359
+ for (const peer of list) {
360
+ if (peer && typeof peer === "object" && typeof peer.name === "string" && peer.name) names.push(peer.name);
361
+ }
362
+ } catch { /* a corrupt registry is an empty one */ }
363
+ }
364
+ }
365
+ return { first, sessionNames: names };
366
+ }
367
+
265
368
  /**
266
369
  * (b) AI-disclosure screen. Resolves the active posture for the recipient's
267
370
  * jurisdiction from ai-disclosure.yaml and enforces two things:
@@ -431,6 +534,8 @@ export function screenAllowlist(recipient, allowlist) {
431
534
  * @param {boolean} [args.firstContact=true] is this the first message in the convo
432
535
  * @param {string[]} [args.internalDomains] email domains treated as internal
433
536
  * @param {string[]} [args.allowlist] recipient allowlist (opt-in)
537
+ * @param {string} [args.agentFirst] agent first name for the session-name check (default: agentRoot/config/agent.json)
538
+ * @param {string[]} [args.sessionNames] internal session names (default: agentRoot/state/session/peers.json)
434
539
  * @param {object} [args.fs] injected { readFileSync, existsSync } for tests
435
540
  * @returns {Promise<{allow:boolean, reason:string, redactedText?:string}>}
436
541
  */
@@ -443,6 +548,8 @@ export async function screenOutbound({
443
548
  firstContact = true,
444
549
  internalDomains = [],
445
550
  allowlist = undefined,
551
+ agentFirst = undefined,
552
+ sessionNames = undefined,
446
553
  fs: injectedFs = undefined,
447
554
  } = {}) {
448
555
  const fs = makeFs(injectedFs);
@@ -482,6 +589,14 @@ export async function screenOutbound({
482
589
  const bannedReason = screenBannedPhrases(text);
483
590
  if (bannedReason) return blocked(bannedReason);
484
591
 
592
+ // (a2) Persona discipline — pure over the identity + peer registry read
593
+ // here (fail-open: no identity → the name check is skipped, the
594
+ // phrase check still runs). This is what makes `maestro inbox reply`,
595
+ // the daemon's delivery and the org MCP tools persona-safe without a
596
+ // shell hook in the loop.
597
+ const personaReason = screenPersona(text, personaContext(fs, agentRoot, { agentFirst, sessionNames }));
598
+ if (personaReason) return blocked(personaReason);
599
+
485
600
  // (b) AI-disclosure. The truthfulness invariant is enforced from the
486
601
  // embedded denial list even without the file; posture resolution needs
487
602
  // the file. Missing file → invariant still runs; posture defaults apply.
@@ -24,6 +24,7 @@ import { tmpdir } from "node:os";
24
24
  import {
25
25
  screenOutbound,
26
26
  screenBannedPhrases,
27
+ screenPersona,
27
28
  screenDisclosure,
28
29
  screenBarriers,
29
30
  screenAllowlist,
@@ -655,3 +656,115 @@ describe("screenOutbound — real-disk read path", () => {
655
656
  }
656
657
  });
657
658
  });
659
+
660
+ /* ─── persona discipline (front-door session §3.6) ─────────────────────────
661
+ * The agent is one persona to every human. The machinery — Claude Code, a
662
+ * session, a sub-session, a subagent that "said", a workflow agent, the model,
663
+ * an internal session name — never appears in outbound text. This screen runs
664
+ * inside screenOutbound so EVERY lane gets it: the daemon's delivery
665
+ * (`maestro inbox reply`), lib/org/messaging.sendMessage, the org MCP tools
666
+ * (messaging_send, email_send, email_draft_send, org_rpc) and the adapters —
667
+ * not only the PreToolUse shell hook.
668
+ */
669
+
670
+ describe("screenPersona (unit)", () => {
671
+ for (const [label, text] of [
672
+ ["Claude Code", "I'll have Claude Code pick that up this afternoon."],
673
+ ["Claude session", "A separate claude session is running the numbers."],
674
+ ["sub-session", "I kicked off a sub-session for the deck."],
675
+ ["subagent said", "The subagent said the deploy finished."],
676
+ ["sub-agent reported", "My sub-agent reported two blockers."],
677
+ ["subagent session", "The subagent session is still going."],
678
+ ["workflow agent", "The workflow agent will file the summary."],
679
+ ["language model", "Every language model has limits, mine included."],
680
+ ]) {
681
+ it(`blocks "${label}" and names the phrase`, () => {
682
+ const reason = screenPersona(text);
683
+ assert.ok(reason, `expected a block for "${label}"`);
684
+ assert.match(reason, /persona leak/i);
685
+ assert.match(reason.toLowerCase(), new RegExp(label.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\s+/g, "\\s+")));
686
+ });
687
+ }
688
+
689
+ it("session names are BOUNDED: <first>-main always, other <first>-<x> only when registered as a peer", () => {
690
+ assert.ok(screenPersona("Check with alex-main for the latest.", { first: "alex" }));
691
+ assert.ok(screenPersona("Ask ALEX-MAIN.", { first: "alex" }), "case-insensitive");
692
+ assert.equal(screenPersona("alex-research has the draft ready.", { first: "alex" }), null, "unregistered peer name: not a session name");
693
+ assert.ok(screenPersona("alex-research has the draft ready.", { first: "alex", sessionNames: ["alex-research"] }));
694
+ assert.ok(screenPersona("alex-research has the draft ready.", { sessionNames: ["alex-research"] }), "names work without a first name");
695
+ });
696
+
697
+ it("does not block a colleague's hyphenated name, a URL slug or an email local part that starts with the first name", () => {
698
+ for (const [first, text] of [
699
+ ["marc", "Marc-Antoine confirmed the numbers."],
700
+ ["james", "See https://os.cohortapp.com/spaces/james-kirkland for the thread."],
701
+ ["ethan", "Loop in ethan-miller@example.com on the invoice."],
702
+ ["alex", "Alexandra-Maine sent the invoice."],
703
+ ["alex", "alex-mainland is the venue."],
704
+ ]) {
705
+ assert.equal(screenPersona(text, { first }), null, `"${text}" must pass for first=${first}`);
706
+ }
707
+ });
708
+
709
+ it("colleague phrasing, the bare noun 'subagents', and the calendar sense of 'session' all pass", () => {
710
+ for (const text of [
711
+ "One of my analysts is on it; I'll have the numbers by 3.",
712
+ "My team has the deck in review — sending the draft after lunch.",
713
+ "We use subagents internally for the heavy lifting.",
714
+ "The session on Thursday (the board session) starts at 9.",
715
+ "",
716
+ ]) {
717
+ assert.equal(screenPersona(text, { first: "alex" }), null, `"${text}" must pass`);
718
+ }
719
+ assert.equal(screenPersona(null), null);
720
+ });
721
+ });
722
+
723
+ describe("screenOutbound — persona", () => {
724
+ const PERSONA_FILES = {
725
+ ...GOOD_FILES,
726
+ "config/agent.json": JSON.stringify({ firstName: "Alex", fullName: "Alex Rivera" }),
727
+ "state/session/peers.json": JSON.stringify([{ name: "alex-deck", muxName: "maestro-alex-deck", purpose: "deck" }]),
728
+ };
729
+
730
+ it("blocks a persona leak for an internal Cohort recipient (the `maestro inbox reply` lane)", async () => {
731
+ const res = await screenOutbound({
732
+ channel: "cohort",
733
+ recipient: "clx0123456789abcdefghij",
734
+ text: "My sub-session is on it, check with alex-main.",
735
+ agentRoot: ROOT,
736
+ firstContact: false,
737
+ fs: memFs(PERSONA_FILES),
738
+ });
739
+ assert.equal(res.allow, false);
740
+ assert.match(res.reason, /persona leak/i);
741
+ assert.match(res.reason, /sub-session/i);
742
+ });
743
+
744
+ it("reads the first name from config/agent.json and the peer names from state/session/peers.json under agentRoot", async () => {
745
+ const fs = memFs(PERSONA_FILES);
746
+ const main = await screenOutbound({ channel: "cohort", recipient: "clx0123456789abcdefghij", text: "alex-main will chase it.", agentRoot: ROOT, firstContact: false, fs });
747
+ assert.equal(main.allow, false);
748
+ assert.match(main.reason, /alex-main/);
749
+ const peer = await screenOutbound({ channel: "cohort", recipient: "clx0123456789abcdefghij", text: "alex-deck has the slides.", agentRoot: ROOT, firstContact: false, fs });
750
+ assert.equal(peer.allow, false, "a registered peer name is a session name");
751
+ const other = await screenOutbound({ channel: "cohort", recipient: "clx0123456789abcdefghij", text: "alex-research is not a peer here.", agentRoot: ROOT, firstContact: false, fs });
752
+ assert.equal(other.allow, true, "an unregistered <first>-x is ordinary text");
753
+ });
754
+
755
+ it("explicit agentFirst / sessionNames override the disk read; no identity on disk → phrase gate still applies, name gate skipped", async () => {
756
+ const fs = memFs(GOOD_FILES);
757
+ const named = await screenOutbound({ channel: "cohort", recipient: "clx0123456789abcdefghij", text: "robin-main will chase it.", agentRoot: ROOT, firstContact: false, fs, agentFirst: "Robin" });
758
+ assert.equal(named.allow, false);
759
+ const none = await screenOutbound({ channel: "cohort", recipient: "clx0123456789abcdefghij", text: "robin-main will chase it.", agentRoot: ROOT, firstContact: false, fs });
760
+ assert.equal(none.allow, true, "no first name resolvable → the name check is skipped (fail-open on that one check)");
761
+ const phrase = await screenOutbound({ channel: "cohort", recipient: "clx0123456789abcdefghij", text: "Claude Code is on it.", agentRoot: ROOT, firstContact: false, fs });
762
+ assert.equal(phrase.allow, false, "the phrase gate needs no identity");
763
+ });
764
+
765
+ it("applies to external email too", async () => {
766
+ const res = await screenOutbound({ channel: "email", recipient: "graham@kpm.com", text: "Our workflow agent will send the summary. I am an AI assistant, for the record.", agentRoot: ROOT, fs: memFs(PERSONA_FILES) });
767
+ assert.equal(res.allow, false);
768
+ assert.match(res.reason, /persona leak/i);
769
+ });
770
+ });
@@ -39,10 +39,10 @@ import {
39
39
  existsSync,
40
40
  mkdirSync,
41
41
  readFileSync,
42
- writeFileSync,
43
42
  } from "node:fs";
44
43
  import { join, resolve, dirname } from "node:path";
45
44
  import { spawnSync } from "node:child_process";
45
+ import { writeJsonAtomic } from "./fs-atomic.mjs";
46
46
 
47
47
  export const FEATURE_REGISTRY_RELATIVE = "framework-features.json";
48
48
  export const AGENT_STATE_RELATIVE = ".maestro/features.json";
@@ -116,7 +116,7 @@ export function loadAgentState(agentRoot) {
116
116
  export function saveAgentState(agentRoot, state) {
117
117
  const statePath = getStatePath(agentRoot);
118
118
  mkdirSync(dirname(statePath), { recursive: true });
119
- writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n");
119
+ writeJsonAtomic(statePath, state);
120
120
  }
121
121
 
122
122
  // ---------------------------------------------------------------------------
@@ -166,19 +166,24 @@ test("tools/list: full active surface (email tools present — family is vendore
166
166
  // other desk block, these eight are GENERATED from the vendored protocol
167
167
  // declaration (lib/org/resource-tools.mjs), so this transport gets them, and
168
168
  // every future resource method, without a second hand-written table.
169
- assert.equal(tools.length, 141, "email + artifact + desk families vendored → 141 tools");
169
+ // 141 → 144: the front-door session (2026-09). board_mine / board_track /
170
+ // session_status — "my tasks across every board" was unreadable from any
171
+ // plane (board.ready is unassigned-only), the inbound→board seam had no
172
+ // accepted/done ends, and nothing could tell a session whether the main
173
+ // session was alive.
174
+ assert.equal(tools.length, 144, "email + artifact + desk + front-door families vendored → 144 tools");
170
175
  const names = tools.map((t) => t.name);
171
- for (const expected of ["org_whoami", "org_describe", "org_rpc", "org_read", "messaging_send", "task_assign", "board_ready", "email_send", "email_inbox", "artifact_create", "artifact_act", "artifact_catalog", "email_mailboxes", "email_draft_send", "files_list", "calendar_find_a_time", "crm_list_deals", "books_reports", "meetings_recap_file", "resource_list", "resource_attach_file"]) {
176
+ for (const expected of ["org_whoami", "org_describe", "org_rpc", "org_read", "messaging_send", "task_assign", "board_ready", "board_mine", "board_track", "session_status", "email_send", "email_inbox", "artifact_create", "artifact_act", "artifact_catalog", "email_mailboxes", "email_draft_send", "files_list", "calendar_find_a_time", "crm_list_deals", "books_reports", "meetings_recap_file", "resource_list", "resource_attach_file"]) {
172
177
  assert.ok(names.includes(expected), `${expected} listed`);
173
178
  }
174
179
  assert.ok(tools.every((t) => t.inputSchema && t.inputSchema.type === "object"));
175
180
  h.server.stop();
176
181
  });
177
182
 
178
- test("tools/list honours the email gate (emailAvailable:false → 136 tools)", async () => {
183
+ test("tools/list honours the email gate (emailAvailable:false → 139 tools)", async () => {
179
184
  const h = harness({ emailAvailable: false });
180
185
  const r = await h.request("tools/list", {});
181
- assert.equal(r.result.tools.length, 136, "the 5 own-mailbox email tools drop out");
186
+ assert.equal(r.result.tools.length, 139, "the 5 own-mailbox email tools drop out");
182
187
  const names = new Set(r.result.tools.map((t) => t.name));
183
188
  // The five own-mailbox tools (email:true) are gated out …
184
189
  for (const gated of ["email_send", "email_inbox", "email_message", "email_thread", "email_mark_read"]) {
@@ -423,3 +423,24 @@ test("DeepSeek session retarget env is built when DeepSeek is the cheap candidat
423
423
  // Foreign keys scrubbed.
424
424
  assert.equal(seenEnv.MOONSHOT_API_KEY, undefined);
425
425
  });
426
+
427
+ test("buildChildEnv KEEPS CLAUDE_CODE_OAUTH_TOKEN — the subscription token is not a foreign credential (§3.1)", () => {
428
+ // The scrub regex ends in `_AUTH_TOKEN$`; `_OAUTH_TOKEN` must not match it,
429
+ // otherwise every daemon spawn on the oauth-token path would silently fall
430
+ // back to an expired keychain login. Pinned here so a "tidy" of the regex
431
+ // cannot re-open F1.
432
+ const base = {
433
+ PATH: "/usr/bin",
434
+ CLAUDE_CODE_OAUTH_TOKEN: "sk-ant-oat01-example",
435
+ MAESTRO_PREFER_SUBSCRIPTION_AUTH: "1",
436
+ SOME_AUTH_TOKEN: "tok",
437
+ };
438
+ const stock = buildChildEnv(base, {});
439
+ assert.equal(stock.CLAUDE_CODE_OAUTH_TOKEN, "sk-ant-oat01-example");
440
+ assert.equal(stock.MAESTRO_PREFER_SUBSCRIPTION_AUTH, "1");
441
+ assert.equal(stock.SOME_AUTH_TOKEN, undefined, "a real *_AUTH_TOKEN is still scrubbed");
442
+ // A retarget (third-party session) keeps it too: the token is harmless to a
443
+ // foreign base URL and the ANTHROPIC_API_KEY="" discipline is what fences it.
444
+ const retarget = buildChildEnv(base, { ANTHROPIC_BASE_URL: "https://api.moonshot.ai/anthropic", ANTHROPIC_API_KEY: "" });
445
+ assert.equal(retarget.CLAUDE_CODE_OAUTH_TOKEN, "sk-ant-oat01-example");
446
+ });
@@ -0,0 +1,99 @@
1
+ /**
2
+ * lib/org/board-mine-cache.mjs — the pure half of the daemon's board-mine
3
+ * cache (design 2026-09-08 §3.5; left over from WP-M2/M4).
4
+ *
5
+ * The daemon asks hq `board.mine` (`lib/org/client.mjs#listMine`, fail-open
6
+ * `[]`) every five minutes and writes the answer to
7
+ * `state/org/board-mine.json` as `{ts, items:[...]}`. Three readers already
8
+ * exist: the SessionStart primer (`lib/session/status-summary.mjs`), the
9
+ * `session_status` MCP tool and `maestro board mine` (`lib/cli/board.mjs`,
10
+ * which falls back to this cache when the live read is empty). This module
11
+ * decides WHEN to refresh and WHAT to write; the daemon does the I/O.
12
+ *
13
+ * @module lib/org/board-mine-cache
14
+ */
15
+
16
+ "use strict";
17
+
18
+ /** Five minutes, per the design. */
19
+ export const REFRESH_INTERVAL_MS = 5 * 60_000;
20
+
21
+ /** Where the daemon writes the cache, relative to the agent root. */
22
+ export const BOARD_MINE_REL = "state/org/board-mine.json";
23
+
24
+ /**
25
+ * Should the cache be refreshed now? Pure. `lastTs` is the `ts` of the last
26
+ * write (ISO string or epoch ms; null/invalid = never), `now` is epoch ms.
27
+ * A clock that went backwards (lastTs in the future) also refreshes, so a
28
+ * bad timestamp can never freeze the cache.
29
+ * @param {string|number|null|undefined} lastTs
30
+ * @param {number} now
31
+ * @param {number} [intervalMs]
32
+ * @returns {boolean}
33
+ */
34
+ export function shouldRefresh(lastTs, now, intervalMs = REFRESH_INTERVAL_MS) {
35
+ const last = toMs(lastTs);
36
+ if (last === null) return true;
37
+ if (last > now) return true;
38
+ return now - last >= intervalMs;
39
+ }
40
+
41
+ /**
42
+ * The document to write. Pure. Non-array or non-object items are dropped so a
43
+ * malformed server answer cannot poison the readers.
44
+ * @param {unknown} items what listMine() returned
45
+ * @param {number} now epoch ms
46
+ * @returns {{ts:string, items:object[]}}
47
+ */
48
+ export function render(items, now) {
49
+ const list = Array.isArray(items) ? items.filter((it) => it && typeof it === "object" && !Array.isArray(it)) : [];
50
+ return { ts: new Date(now).toISOString(), items: list };
51
+ }
52
+
53
+ /**
54
+ * What to write after a refresh attempt. Pure. A successful read renders the
55
+ * fresh items; a FAILED read keeps the previous document's items (last known
56
+ * good) and marks the document `stale: true` with `itemsAt` = when those
57
+ * items were actually fetched — so one unreachable hq tick does not turn the
58
+ * SessionStart primer, session_status and `maestro board mine` into "zero
59
+ * items" for five minutes. With nothing previous, a failed read renders
60
+ * empty-and-stale.
61
+ * @param {{ok:boolean, items?:unknown, error?:string}|unknown[]|null|undefined} result listMineResult() (an array is treated as ok)
62
+ * @param {unknown} prev the current cache document (or null)
63
+ * @param {number} now epoch ms
64
+ * @returns {{ts:string, items:object[], stale?:true, itemsAt?:string|null, error?:string}}
65
+ */
66
+ export function renderRefresh(result, prev, now) {
67
+ const r = Array.isArray(result) ? { ok: true, items: result } : (result && typeof result === "object" ? result : { ok: false, items: [] });
68
+ if (r.ok) return render(r.items, now);
69
+ const prevDoc = prev && typeof prev === "object" && !Array.isArray(prev) ? prev : null;
70
+ const kept = render(prevDoc ? prevDoc.items : [], now);
71
+ const itemsAt = prevDoc ? (prevDoc.stale && prevDoc.itemsAt !== undefined ? prevDoc.itemsAt : cacheTs(prevDoc)) : null;
72
+ const doc = { ...kept, stale: true, itemsAt: itemsAt || null };
73
+ if (r.error) doc.error = String(r.error).slice(0, 200);
74
+ return doc;
75
+ }
76
+
77
+ /**
78
+ * The `ts` of an existing cache document, or null. Tolerates the pre-M6
79
+ * `{fetchedAt}` spelling and a bare array (no timestamp → refresh).
80
+ * @param {unknown} doc
81
+ * @returns {string|null}
82
+ */
83
+ export function cacheTs(doc) {
84
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null;
85
+ if (typeof doc.ts === "string") return doc.ts;
86
+ if (typeof doc.fetchedAt === "string") return doc.fetchedAt;
87
+ return null;
88
+ }
89
+
90
+ function toMs(v) {
91
+ if (typeof v === "number" && Number.isFinite(v)) return v;
92
+ if (typeof v === "string" && v.trim()) {
93
+ const n = Date.parse(v);
94
+ if (Number.isFinite(n)) return n;
95
+ }
96
+ return null;
97
+ }
98
+
99
+ export default { shouldRefresh, render, renderRefresh, cacheTs, REFRESH_INTERVAL_MS, BOARD_MINE_REL };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * board-mine-cache.test.mjs — the pure half of the daemon's 5-minute
3
+ * state/org/board-mine.json cache.
4
+ */
5
+
6
+ import { test } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { shouldRefresh, render, renderRefresh, cacheTs, REFRESH_INTERVAL_MS, BOARD_MINE_REL } from "./board-mine-cache.mjs";
9
+
10
+ const NOW = Date.parse("2026-09-08T12:00:00Z");
11
+
12
+ test("shouldRefresh: never written → yes; younger than 5 min → no; 5 min or older → yes; a future ts → yes", () => {
13
+ assert.equal(shouldRefresh(null, NOW), true);
14
+ assert.equal(shouldRefresh(undefined, NOW), true);
15
+ assert.equal(shouldRefresh("garbage", NOW), true);
16
+ assert.equal(shouldRefresh("2026-09-08T11:56:00Z", NOW), false);
17
+ assert.equal(shouldRefresh(NOW - REFRESH_INTERVAL_MS + 1, NOW), false);
18
+ assert.equal(shouldRefresh("2026-09-08T11:55:00Z", NOW), true);
19
+ assert.equal(shouldRefresh(NOW - REFRESH_INTERVAL_MS, NOW), true);
20
+ assert.equal(shouldRefresh("2026-09-08T12:30:00Z", NOW), true, "a clock that went backwards must not freeze the cache");
21
+ assert.equal(shouldRefresh("2026-09-08T11:59:30Z", NOW, 10_000), true, "interval is a parameter");
22
+ assert.equal(REFRESH_INTERVAL_MS, 300_000);
23
+ assert.equal(BOARD_MINE_REL, "state/org/board-mine.json");
24
+ });
25
+
26
+ test("render: {ts, items} with only object items kept; a non-array answer renders as empty", () => {
27
+ const doc = render([{ itemId: "i1", title: "Do X" }, null, "junk", [1], { itemId: "i2" }], NOW);
28
+ assert.deepEqual(doc, { ts: "2026-09-08T12:00:00.000Z", items: [{ itemId: "i1", title: "Do X" }, { itemId: "i2" }] });
29
+ assert.deepEqual(render(undefined, NOW), { ts: "2026-09-08T12:00:00.000Z", items: [] });
30
+ assert.deepEqual(render({ items: [{ itemId: "x" }] }, NOW).items, [], "an object is not the items array (listMine already unwraps)");
31
+ });
32
+
33
+ test("cacheTs reads ts, tolerates the earlier fetchedAt spelling, and is null for a bare array or junk", () => {
34
+ assert.equal(cacheTs({ ts: "2026-09-08T11:59:00Z", items: [] }), "2026-09-08T11:59:00Z");
35
+ assert.equal(cacheTs({ fetchedAt: "2026-09-08T11:59:00Z", items: [] }), "2026-09-08T11:59:00Z");
36
+ assert.equal(cacheTs([{ itemId: "x" }]), null);
37
+ assert.equal(cacheTs(null), null);
38
+ assert.equal(cacheTs({ items: [] }), null);
39
+ });
40
+
41
+ test("renderRefresh: a successful read renders fresh items; a FAILED read keeps the previous items, marks stale and remembers when they were fetched", () => {
42
+ const prev = { ts: "2026-09-08T11:55:00.000Z", items: [{ itemId: "i1" }] };
43
+ assert.deepEqual(renderRefresh({ ok: true, items: [{ itemId: "i2" }] }, prev, NOW), { ts: "2026-09-08T12:00:00.000Z", items: [{ itemId: "i2" }] });
44
+ assert.deepEqual(renderRefresh([{ itemId: "i3" }], prev, NOW), { ts: "2026-09-08T12:00:00.000Z", items: [{ itemId: "i3" }] }, "a bare array (the old listMine shape) is a success");
45
+ assert.deepEqual(renderRefresh({ ok: true, items: [] }, prev, NOW), { ts: "2026-09-08T12:00:00.000Z", items: [] }, "an empty board is a real answer, not a failure");
46
+ const failed = renderRefresh({ ok: false, items: [], error: "http 503" }, prev, NOW);
47
+ assert.deepEqual(failed, { ts: "2026-09-08T12:00:00.000Z", items: [{ itemId: "i1" }], stale: true, itemsAt: "2026-09-08T11:55:00.000Z", error: "http 503" });
48
+ const failedAgain = renderRefresh({ ok: false, items: [] }, failed, NOW + 300_000);
49
+ assert.equal(failedAgain.itemsAt, "2026-09-08T11:55:00.000Z", "a second failure keeps the ORIGINAL fetch time, not the last stale write");
50
+ assert.deepEqual(failedAgain.items, [{ itemId: "i1" }]);
51
+ assert.deepEqual(renderRefresh({ ok: false, items: [] }, null, NOW), { ts: "2026-09-08T12:00:00.000Z", items: [], stale: true, itemsAt: null }, "nothing previous → empty and stale");
52
+ assert.deepEqual(renderRefresh(null, prev, NOW).items, [{ itemId: "i1" }], "a null result is a failure");
53
+ });
package/lib/org/board.mjs CHANGED
@@ -128,6 +128,17 @@ export function ready(opts = {}) {
128
128
  return resolveClient(opts).listReady(rpcOpts(opts));
129
129
  }
130
130
 
131
+ /**
132
+ * Read THIS agent's items across every board (GET /v1/board.mine — assignee
133
+ * or reviewer = caller). Returns the items array, or [] on any failure
134
+ * (fail-open; an hq without the read yet answers 404 → []).
135
+ * @param {object} [opts] - { client?, base, token, fetchImpl? }
136
+ * @returns {Promise<object[]>}
137
+ */
138
+ export function mine(opts = {}) {
139
+ return resolveClient(opts).listMine(rpcOpts(opts));
140
+ }
141
+
131
142
  // ---------------------------------------------------------------------------
132
143
  // Read-only YAML projection writer
133
144
  // ---------------------------------------------------------------------------
@@ -14,7 +14,7 @@ import { tmpdir } from "node:os";
14
14
  import { join } from "node:path";
15
15
 
16
16
  import {
17
- createItem, claimItem, completeItem, decompose, assign, link, ready,
17
+ createItem, claimItem, completeItem, decompose, assign, link, ready, mine,
18
18
  buildProjection, writeProjection, writeReadyProjection,
19
19
  } from "./board.mjs";
20
20
  import { okFrame, errFrame, BOARD_STATUS } from "./protocol.mjs";
@@ -38,6 +38,7 @@ function fakeClient(map = {}) {
38
38
  boardAssign: make("boardAssign"),
39
39
  boardLink: make("boardLink"),
40
40
  listReady: make("listReady"),
41
+ listMine: make("listMine"),
41
42
  };
42
43
  }
43
44
 
@@ -175,3 +176,12 @@ test("writeReadyProjection: down server (empty ready) writes an empty projection
175
176
  assert.equal(doc.count, 0);
176
177
  } finally { rmSync(root, { recursive: true, force: true }); }
177
178
  });
179
+
180
+ test("mine: routes to client.listMine, forwarding conn opts (fail-open [] handled by the client)", async () => {
181
+ const client = fakeClient({ listMine: () => [{ itemId: "m1" }] });
182
+ assert.deepEqual(await mine({ client, ...CONN }), [{ itemId: "m1" }]);
183
+ // listMine takes ONE argument (the conn opts), like listReady.
184
+ const c = client.calls.find((x) => x.name === "listMine");
185
+ assert.equal(c.params.base, CONN.base);
186
+ assert.equal(c.params.token, CONN.token);
187
+ });
@@ -694,6 +694,42 @@ export async function listReady(o = {}) {
694
694
  return [];
695
695
  }
696
696
 
697
+ /**
698
+ * List every board item assigned to (or under review by) THIS agent across all
699
+ * boards and workstreams (GET /v1/board.mine — hq 2026-09, front-door session
700
+ * §3.5). Returns the items array, or [] on any failure (fail-open): an older hq
701
+ * without the read answers 404 and this returns [], same as listReady.
702
+ * Item shape: {itemId, title, boardId, boardName, channelId, workstreamId, col,
703
+ * stage, priority, dueAt, updatedAt, url}.
704
+ * @param {object} o - { base, token, orgId?, fetchImpl? }
705
+ * @returns {Promise<object[]>}
706
+ */
707
+ export async function listMine(o = {}) {
708
+ return (await listMineResult(o)).items;
709
+ }
710
+
711
+ /**
712
+ * `listMine` with the failure kept visible: `{ok:false, items:[]}` when hq
713
+ * could not be read, `{ok:true, items}` otherwise (an empty board is
714
+ * `ok:true`). The daemon's board-mine cache needs the difference so one
715
+ * unreachable tick does not overwrite the last known good answer with [].
716
+ * Never throws.
717
+ * @param {object} o - { base, token, orgId, fetchImpl? }
718
+ * @returns {Promise<{ok:boolean, items:object[], error?:string}>}
719
+ */
720
+ export async function listMineResult(o = {}) {
721
+ try {
722
+ const r = await read("board.mine", o);
723
+ if (!r.ok) return { ok: false, items: [], error: (r.error && r.error.message) || `http ${r.status || 0}` };
724
+ if (!r.payload) return { ok: true, items: [] };
725
+ if (Array.isArray(r.payload)) return { ok: true, items: r.payload };
726
+ if (Array.isArray(r.payload.items)) return { ok: true, items: r.payload.items };
727
+ return { ok: true, items: [] };
728
+ } catch (err) {
729
+ return { ok: false, items: [], error: String((err && err.message) || err) }; // read() never throws; belt-and-braces for a throwing fetchImpl
730
+ }
731
+ }
732
+
697
733
  /**
698
734
  * Fetch the event slice (GET /v1/events?cursor=N&limit=500). Returns the events
699
735
  * array (or [] on failure). The caller pipes these to verify.verifyEvents.