@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
|
@@ -20,9 +20,10 @@
|
|
|
20
20
|
"use strict";
|
|
21
21
|
|
|
22
22
|
import {
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
108
|
+
writeJsonAtomic(path, rec);
|
|
108
109
|
return true;
|
|
109
110
|
} catch {
|
|
110
111
|
return false;
|
package/lib/comms/send-gate.mjs
CHANGED
|
@@ -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
|
+
});
|
package/lib/feature-init.mjs
CHANGED
|
@@ -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
|
-
|
|
119
|
+
writeJsonAtomic(statePath, state);
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
// ---------------------------------------------------------------------------
|
package/lib/identity/persona.mjs
CHANGED
|
@@ -130,6 +130,35 @@ function voiceRules(a) {
|
|
|
130
130
|
];
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Message-craft doctrine — the "how you shape an outbound message" half of the
|
|
135
|
+
* voice, injected into BOTH prompt-assembly paths so the two planes write the
|
|
136
|
+
* same way: the full-session prompt (scripts/daemon/prompt-builder.mjs
|
|
137
|
+
* #buildPrompt) and the quick-reply system prompt (scripts/daemon/responder.mjs
|
|
138
|
+
* #realGenerateResponse).
|
|
139
|
+
*
|
|
140
|
+
* Deliberately a separately-exported CONSTANT rather than part of voiceRules():
|
|
141
|
+
* the responder does not render the persona block — it calls neither renderPersona
|
|
142
|
+
* nor voiceRules — so folding it in would reach only full sessions and leave every
|
|
143
|
+
* reactive quick reply unshaped. As framework code it also survives per-seat
|
|
144
|
+
* CLAUDE.md drift, where the same doctrine lives as human-readable scaffold data.
|
|
145
|
+
*
|
|
146
|
+
* Phrased "distil by default; structure only when the message must be large" so it
|
|
147
|
+
* does NOT contradict the scaffold's `No unsolicited structure` rule
|
|
148
|
+
* (scaffold/CLAUDE.md `## Communication Rules`): headings/bullets are for the
|
|
149
|
+
* genuinely large message, never decoration on a small one. The attach-don't-dump
|
|
150
|
+
* bullet leans on mechanisms already sanctioned elsewhere in the prompt (the PDF
|
|
151
|
+
* builder, Slack upload, email --attachment) and on the standing "never reference a
|
|
152
|
+
* local file path" rule.
|
|
153
|
+
*/
|
|
154
|
+
export const MESSAGE_CRAFT = [
|
|
155
|
+
"How you shape a message:",
|
|
156
|
+
"- Distil to what matters, by default. Lead with the answer, the decision, or the ask; cut throat-clearing, restatement of the question, and background the reader already has. Most replies are a few sentences — send those as a few sentences, and keep them scannable.",
|
|
157
|
+
"- Offer depth, don't front-load it. Give the sharp version and add \"I can go deeper on X if useful\" rather than dumping every detail pre-emptively.",
|
|
158
|
+
"- When a message genuinely must be long, FORMAT it for legibility: a short intro line, then short paragraphs, a heading, or a few bullets for the parts that actually are a list. Structure serves a large message; it never decorates a small one, and it is never a wall of text.",
|
|
159
|
+
"- When the content is genuinely verbose — a full memo or report, a long analysis, a large table or dataset — do NOT dump it into the chat. Attach it as a document and put a two-to-three-line summary plus the attachment in the message: generate a branded PDF (scripts/pdf-generation/build-document.mjs, or `npm run pdf:memo -- --input <file.md>`) or upload the file (Slack: scripts/slack-upload-v2.py; email: send with --attachment). Never reference a local file path in an outbound message.",
|
|
160
|
+
].join("\n");
|
|
161
|
+
|
|
133
162
|
/**
|
|
134
163
|
* Render the persona block from resolved config. Pure — no I/O, never throws.
|
|
135
164
|
*
|
|
@@ -4,7 +4,7 @@ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
|
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
|
|
7
|
-
import { renderPersona, loadPersonaBlock } from "./persona.mjs";
|
|
7
|
+
import { renderPersona, loadPersonaBlock, MESSAGE_CRAFT } from "./persona.mjs";
|
|
8
8
|
|
|
9
9
|
const AGENT = {
|
|
10
10
|
firstName: "Jamie",
|
|
@@ -100,6 +100,31 @@ test("junk values in list fields are dropped, not rendered", () => {
|
|
|
100
100
|
assert.doesNotMatch(out, /\[object Object\]/);
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
// The message-craft doctrine is a separately-exported constant (NOT part of the
|
|
104
|
+
// persona block) precisely so the responder — which never renders the persona —
|
|
105
|
+
// can inject the identical string. These pin the substance both planes rely on.
|
|
106
|
+
test("MESSAGE_CRAFT carries the distil-by-default / format-when-large / attach-when-verbose doctrine", () => {
|
|
107
|
+
// Distil by default; most replies are short.
|
|
108
|
+
assert.match(MESSAGE_CRAFT, /Distil to what matters, by default/);
|
|
109
|
+
assert.match(MESSAGE_CRAFT, /Lead with the answer, the decision, or the ask/);
|
|
110
|
+
// Offer depth on request rather than front-loading.
|
|
111
|
+
assert.match(MESSAGE_CRAFT, /Offer depth, don't front-load it/);
|
|
112
|
+
// Format only when the message must be large — never a wall of text.
|
|
113
|
+
assert.match(MESSAGE_CRAFT, /when a message genuinely must be long, FORMAT it/i);
|
|
114
|
+
assert.match(MESSAGE_CRAFT, /never a wall of text/);
|
|
115
|
+
// Attach verbose content with a short summary rather than dumping it.
|
|
116
|
+
assert.match(MESSAGE_CRAFT, /do NOT dump it into the chat/);
|
|
117
|
+
assert.match(MESSAGE_CRAFT, /two-to-three-line summary/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("MESSAGE_CRAFT names the REAL attach mechanisms and forbids local paths", () => {
|
|
121
|
+
assert.match(MESSAGE_CRAFT, /scripts\/pdf-generation\/build-document\.mjs/);
|
|
122
|
+
assert.match(MESSAGE_CRAFT, /npm run pdf:memo/);
|
|
123
|
+
assert.match(MESSAGE_CRAFT, /scripts\/slack-upload-v2\.py/);
|
|
124
|
+
assert.match(MESSAGE_CRAFT, /--attachment/);
|
|
125
|
+
assert.match(MESSAGE_CRAFT, /Never reference a local file path/);
|
|
126
|
+
});
|
|
127
|
+
|
|
103
128
|
test("loadPersonaBlock reads an agent repo, and fails open on a broken config", () => {
|
|
104
129
|
const dir = mkdtempSync(join(tmpdir(), "persona-"));
|
|
105
130
|
try {
|
package/lib/mcp/server.test.mjs
CHANGED
|
@@ -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
|
-
|
|
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 →
|
|
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,
|
|
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
|
// ---------------------------------------------------------------------------
|