@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.
- 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/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 +24 -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/responder.mjs +51 -40
- 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/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
|
// ---------------------------------------------------------------------------
|
package/lib/org/board.test.mjs
CHANGED
|
@@ -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
|
+
});
|
package/lib/org/client.mjs
CHANGED
|
@@ -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.
|