@cohortapp/agent-sdk 2.11.14 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +37 -22
- package/README.md +2 -0
- package/bin/maestro.mjs +113 -39
- package/bin/maestro.test.mjs +175 -5
- package/docs/guides/front-door-session.md +264 -0
- package/docs/guides/mac-mini.md +100 -28
- package/docs/guides/org-onboarding.md +1 -1
- package/docs/guides/setup-wizard.md +9 -5
- package/docs/runbooks/cohort-cutover.md +11 -1
- package/docs/runbooks/mac-mini-bootstrap.md +38 -63
- package/lib/cadence-bus-requeue.test.mjs +83 -0
- package/lib/cadence-bus.mjs +43 -7
- package/lib/channels/inbox-item.mjs +59 -2
- package/lib/cli/board.mjs +285 -0
- package/lib/cli/board.test.mjs +227 -0
- package/lib/cli/doctor-checks.mjs +441 -0
- package/lib/cli/doctor-checks.test.mjs +336 -0
- package/lib/cli/global-setup-extras.mjs +410 -0
- package/lib/cli/global-setup-extras.test.mjs +367 -0
- package/lib/cli/inbox.mjs +304 -0
- package/lib/cli/inbox.test.mjs +230 -0
- package/lib/cli/session-ack.mjs +63 -0
- package/lib/cli/session-ack.test.mjs +63 -0
- package/lib/cli/session.mjs +750 -0
- package/lib/cli/session.test.mjs +602 -0
- package/lib/collective/global-config.mjs +204 -6
- package/lib/collective/global-config.test.mjs +140 -0
- package/lib/collective/global-skills.mjs +145 -0
- package/lib/collective/global-skills.test.mjs +126 -0
- package/lib/collective/presence.mjs +4 -3
- package/lib/comms/send-gate.mjs +115 -0
- package/lib/comms/send-gate.test.mjs +113 -0
- package/lib/feature-init.mjs +2 -2
- package/lib/identity/persona.mjs +29 -0
- package/lib/identity/persona.test.mjs +26 -1
- package/lib/mcp/server.test.mjs +9 -4
- package/lib/model-router/spawn.test.mjs +21 -0
- package/lib/org/board-mine-cache.mjs +99 -0
- package/lib/org/board-mine-cache.test.mjs +53 -0
- package/lib/org/board.mjs +11 -0
- package/lib/org/board.test.mjs +11 -1
- package/lib/org/client.mjs +36 -0
- package/lib/org/client.test.mjs +46 -0
- package/lib/org/inbound/directedness.mjs +18 -2
- package/lib/org/inbound/directedness.test.mjs +58 -0
- package/lib/org/inbound/index.mjs +8 -1
- package/lib/org/inbound/index.test.mjs +22 -0
- package/lib/org/mesh-directives.test.mjs +110 -0
- package/lib/org/mesh.mjs +61 -1
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +52 -0
- package/lib/org/protocol.test.mjs +12 -1
- package/lib/org/registry.mjs +3 -2
- package/lib/org/tool-surface.mjs +120 -0
- package/lib/org/tool-surface.test.mjs +118 -5
- package/lib/security/external-content.mjs +1 -1
- package/lib/security/external-content.test.mjs +17 -0
- package/lib/session/config.mjs +137 -0
- package/lib/session/config.test.mjs +92 -0
- package/lib/session/feed-core.mjs +229 -0
- package/lib/session/feed-core.test.mjs +198 -0
- package/lib/session/first-run.mjs +126 -0
- package/lib/session/first-run.test.mjs +121 -0
- package/lib/session/frontdoor.mjs +266 -0
- package/lib/session/frontdoor.test.mjs +205 -0
- package/lib/session/handoffs.mjs +295 -0
- package/lib/session/handoffs.test.mjs +183 -0
- package/lib/session/identity.mjs +220 -0
- package/lib/session/identity.test.mjs +180 -0
- package/lib/session/inbox-claims.mjs +434 -0
- package/lib/session/inbox-claims.test.mjs +286 -0
- package/lib/session/launch-args.mjs +161 -0
- package/lib/session/launch-args.test.mjs +157 -0
- package/lib/session/liveness.mjs +174 -0
- package/lib/session/liveness.test.mjs +100 -0
- package/lib/session/status-summary.mjs +172 -0
- package/lib/session/status-summary.test.mjs +118 -0
- package/lib/session-permissions.mjs +39 -3
- package/lib/session-permissions.test.mjs +20 -0
- package/lib/setup/claude-probe.mjs +161 -24
- package/lib/setup/claude-probe.test.mjs +187 -0
- package/lib/setup/sections/learning.mjs +2 -1
- package/lib/setup/sections/model.mjs +104 -24
- package/lib/setup/sections/model.test.mjs +240 -0
- package/lib/setup/sections/org.mjs +27 -2
- package/lib/setup/sections/org.test.mjs +35 -2
- package/lib/setup/sections/verify.mjs +5 -0
- package/lib/setup/state.mjs +30 -10
- package/lib/setup/state.test.mjs +24 -1
- package/lib/singleton.js +11 -3
- package/lib/singleton.test.mjs +16 -0
- package/lib/subagents/lock.mjs +1 -1
- package/lib/telemetry/collect.mjs +270 -6
- package/lib/telemetry/collect.test.mjs +196 -1
- package/lib/upgrade/global-refresh.mjs +108 -0
- package/lib/upgrade/global-refresh.test.mjs +65 -0
- package/lib/upgrade/launchd-reconcile.mjs +327 -0
- package/lib/upgrade/launchd-reconcile.test.mjs +272 -0
- package/lib/upgrade/post-steps.mjs +151 -0
- package/lib/upgrade/post-steps.test.mjs +200 -0
- package/lib/upgrade/verify.mjs +215 -0
- package/lib/upgrade/verify.test.mjs +164 -0
- package/lib/voice/outbound.mjs +3 -2
- package/lib/voice/post-call-brief.mjs +2 -1
- package/lib/voice/session-rotation.mjs +6 -1
- package/lib/voice/session-rotation.test.mjs +114 -0
- package/package.json +3 -3
- package/plugins/maestro-skills/plugin.json +21 -1
- package/plugins/maestro-skills/skills/board-work.md +63 -0
- package/plugins/maestro-skills/skills/inbound-triage.md +80 -0
- package/plugins/maestro-skills/skills/main-session.md +102 -0
- package/plugins/maestro-skills/skills/peer-sessions.md +65 -0
- package/plugins/maestro-skills/skills/persona-discipline.md +75 -0
- package/scaffold/CLAUDE.md +34 -0
- package/scripts/ci/check-durable-write-seam.mjs +147 -0
- package/scripts/ci/check-durable-write-seam.test.mjs +90 -0
- package/scripts/ci/check.mjs +3 -0
- package/scripts/collective/hook-runner.mjs +39 -4
- package/scripts/collective/hook-runner.test.mjs +85 -2
- package/scripts/daemon/agent-daemon-board-mine.test.mjs +96 -0
- package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
- package/scripts/daemon/agent-daemon.mjs +141 -10
- package/scripts/daemon/agent-daemon.test.mjs +73 -0
- package/scripts/daemon/assurance-e2e.test.mjs +141 -6
- package/scripts/daemon/assurance.mjs +461 -37
- package/scripts/daemon/assurance.test.mjs +408 -43
- package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +334 -0
- package/scripts/daemon/cadence-consumer.mjs +254 -78
- package/scripts/daemon/cadence-handlers.mjs +53 -0
- package/scripts/daemon/classifier.mjs +1 -1
- package/scripts/daemon/dispatcher-resume.test.mjs +166 -0
- package/scripts/daemon/dispatcher.mjs +127 -19
- package/scripts/daemon/health.mjs +12 -1
- package/scripts/daemon/inbox-deferral-session.test.mjs +49 -0
- package/scripts/daemon/inbox-deferral.mjs +6 -0
- package/scripts/daemon/lib/self-echo.mjs +201 -0
- package/scripts/daemon/lib/self-echo.test.mjs +153 -0
- package/scripts/daemon/maestro-daemon.mjs +3 -0
- package/scripts/daemon/prompt-builder.mjs +9 -1
- package/scripts/daemon/prompt-builder.test.mjs +22 -0
- package/scripts/daemon/responder.mjs +61 -41
- package/scripts/daemon/sdk-version.mjs +51 -0
- package/scripts/daemon/sdk-version.test.mjs +31 -0
- package/scripts/hooks/pre-send-audit.sh +97 -4
- package/scripts/hooks/pre-send-audit.test.mjs +140 -1
- package/scripts/local-triggers/autoupdate.sh +243 -19
- package/scripts/local-triggers/autoupdate.test.mjs +488 -0
- package/scripts/local-triggers/generate-plists.sh +24 -1
- package/scripts/local-triggers/generate-plists.test.mjs +49 -11
- package/scripts/org/send-orgmail.first-contact.test.mjs +102 -0
- package/scripts/org/send-orgmail.mjs +27 -3
- package/scripts/poller/inbox-privilege-injection.test.mjs +167 -0
- package/scripts/poller/slack-poller.mjs +13 -1
- package/scripts/poller/utils.mjs +46 -1
- package/scripts/poller-launchd/install.sh +19 -11
- package/scripts/poller-launchd/install.test.mjs +243 -0
- package/scripts/poller-launchd/launchd-poller-wrapper.sh +92 -0
- package/scripts/poller-launchd/migrate.sh +66 -0
- package/scripts/poller-launchd/poller.plist.template +4 -2
- package/scripts/session/feed.mjs +237 -0
- package/scripts/session/feed.test.mjs +196 -0
- package/scripts/session/supervisor-sh.test.mjs +218 -0
- package/scripts/session/supervisor.mjs +328 -0
- package/scripts/session/supervisor.sh +141 -0
- package/scripts/session/supervisor.test.mjs +482 -0
- package/scripts/setup/configure-macos.sh +250 -55
- package/scripts/setup/configure-macos.test.mjs +306 -0
- package/scripts/setup/init-agent.sh +112 -7
- package/scripts/setup/init-agent.test.mjs +220 -1
- package/scripts/watchdog/memory-watchdog.sh +37 -1
- package/scripts/watchdog/memory-watchdog.test.mjs +64 -0
- package/scripts/setup/boot-claude-session.sh +0 -94
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for check-durable-write-seam.mjs — the durable-write effect boundary.
|
|
3
|
+
*
|
|
4
|
+
* Two things are worth testing and they are not the same thing:
|
|
5
|
+
* 1. The MATCHER's discrimination — that it fires on a direct durable-JSON
|
|
6
|
+
* write and stays silent on the three shapes that are already safe
|
|
7
|
+
* (temp-then-rename, injected `deps.writeFileSync`, non-JSON payloads).
|
|
8
|
+
* A guard that flags safe code gets suppressed, so this is the property
|
|
9
|
+
* that decides whether the guard survives contact with contributors.
|
|
10
|
+
* 2. The REGISTER's honesty against the real tree — that every sanctioned
|
|
11
|
+
* site still exists at the line it claims. This is what stops the register
|
|
12
|
+
* becoming a place stale names hide.
|
|
13
|
+
*
|
|
14
|
+
* @module scripts/ci/check-durable-write-seam.test
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it } from "node:test";
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
import {
|
|
23
|
+
findDirectDurableWrites,
|
|
24
|
+
SANCTIONED_DIRECT_WRITES,
|
|
25
|
+
} from "./check-durable-write-seam.mjs";
|
|
26
|
+
|
|
27
|
+
const REPO_ROOT = path.resolve(fileURLToPath(new URL(".", import.meta.url)), "..", "..");
|
|
28
|
+
|
|
29
|
+
/** The matcher, lifted verbatim from the guard so a line can be tested alone. */
|
|
30
|
+
function flags(line) {
|
|
31
|
+
if (!line.includes("writeFileSync(")) return false;
|
|
32
|
+
if (!line.includes("JSON.stringify")) return false;
|
|
33
|
+
if (/writeFileSync\(\s*(tmp|temp|tmpPath|tmpFile|fd)\b/.test(line)) return false;
|
|
34
|
+
if (/deps\.writeFileSync/.test(line)) return false;
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("check-durable-write-seam: what the matcher sees", () => {
|
|
39
|
+
it("flags a direct write of durable JSON to a final path", () => {
|
|
40
|
+
assert.equal(flags('writeFileSync(join(dir, "self.json"), JSON.stringify(entry, null, 2));'), true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("does NOT flag temp-then-rename — that IS the atomic pattern, hand-rolled", () => {
|
|
44
|
+
assert.equal(flags('writeFileSync(tmp, JSON.stringify(state, null, 2) + "\\n");'), false);
|
|
45
|
+
assert.equal(flags("writeFileSync(tmpPath, JSON.stringify(o));"), false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("does NOT flag the injected-dependency form — that is better than a shared helper, not worse", () => {
|
|
49
|
+
assert.equal(flags("const wr = deps.writeFileSync || writeFileSync; wr(p, JSON.stringify(o));"), false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("does NOT flag non-JSON payloads — audio, YAML, markdown, marker files", () => {
|
|
53
|
+
assert.equal(flags("writeFileSync(join(d, f), audio);"), false);
|
|
54
|
+
assert.equal(flags("writeFileSync(p, yaml.dump(doc));"), false);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("check-durable-write-seam: the register is honest about the real tree", () => {
|
|
59
|
+
const hits = findDirectDurableWrites();
|
|
60
|
+
const found = new Set(hits.map((h) => h.site));
|
|
61
|
+
|
|
62
|
+
it("finds at least one site — a matcher that matches nothing passes vacuously", () => {
|
|
63
|
+
assert.ok(hits.length > 0, "matcher found nothing; it is probably broken");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("every sanctioned site still exists at the line it claims", () => {
|
|
67
|
+
for (const site of Object.keys(SANCTIONED_DIRECT_WRITES)) {
|
|
68
|
+
assert.ok(found.has(site), `stale register entry: ${site}`);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("no unregistered direct durable write exists under lib/", () => {
|
|
73
|
+
const unregistered = [...found].filter((s) => !(s in SANCTIONED_DIRECT_WRITES)).sort();
|
|
74
|
+
assert.deepEqual(unregistered, []);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("every sanctioned entry carries a non-empty reason", () => {
|
|
78
|
+
for (const [site, reason] of Object.entries(SANCTIONED_DIRECT_WRITES)) {
|
|
79
|
+
assert.ok(reason && reason.length > 20, `register entry ${site} needs a real reason`);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("the sanctioned sites are all O_EXCL lock acquisition — the one shape rename would break", () => {
|
|
84
|
+
for (const site of Object.keys(SANCTIONED_DIRECT_WRITES)) {
|
|
85
|
+
const [rel, lineNo] = site.split(":");
|
|
86
|
+
const line = readFileSync(path.join(REPO_ROOT, rel), "utf8").split("\n")[Number(lineNo) - 1];
|
|
87
|
+
assert.match(line, /scheduleLock/, `${site} is not a lock write; it should not be exempt`);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
package/scripts/ci/check.mjs
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* - check-no-build-artifacts : no tracked .pyc/symlinks reach the tarball
|
|
21
21
|
* - check-docs-accuracy : README links resolve; counts/agent-refs WARN
|
|
22
22
|
* - check-subagent-frontmatter: every agents/*.md passes the registry validator
|
|
23
|
+
* - check-durable-write-seam : durable JSON writes under lib/ go through fs-atomic
|
|
23
24
|
*
|
|
24
25
|
* Usage: `node scripts/ci/check.mjs`
|
|
25
26
|
* exit 0 → all checks passed; exit 1 → one or more failed.
|
|
@@ -38,6 +39,7 @@ import { run as runNoResidualIdentity } from "./check-no-residual-identity.mjs";
|
|
|
38
39
|
import { run as runNoBuildArtifacts } from "./check-no-build-artifacts.mjs";
|
|
39
40
|
import { run as runDocsAccuracy } from "./check-docs-accuracy.mjs";
|
|
40
41
|
import { run as runSubagentFrontmatter } from "./check-subagent-frontmatter.mjs";
|
|
42
|
+
import { run as runDurableWriteSeam } from "./check-durable-write-seam.mjs";
|
|
41
43
|
|
|
42
44
|
/**
|
|
43
45
|
* The ordered list of guards this aggregator runs.
|
|
@@ -54,6 +56,7 @@ export const CHECKS = [
|
|
|
54
56
|
{ name: "check-no-build-artifacts", run: runNoBuildArtifacts },
|
|
55
57
|
{ name: "check-docs-accuracy", run: runDocsAccuracy },
|
|
56
58
|
{ name: "check-subagent-frontmatter", run: runSubagentFrontmatter },
|
|
59
|
+
{ name: "check-durable-write-seam", run: runDurableWriteSeam },
|
|
57
60
|
];
|
|
58
61
|
|
|
59
62
|
/**
|
|
@@ -76,8 +76,10 @@ async function main() {
|
|
|
76
76
|
const repo = basename(cwd || "");
|
|
77
77
|
|
|
78
78
|
if (event === "prime") {
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
const kind = classifyKind(input);
|
|
80
|
+
try { presence.register(SELF_ROOT, { sessionId, cwd, repo, kind }); } catch { /* */ }
|
|
81
|
+
if (denied) return; // never inject agent memory (or the persona) into a ring-fenced/client session
|
|
82
|
+
const parts = [];
|
|
81
83
|
try {
|
|
82
84
|
const recall = await import("../../lib/collective/recall.mjs");
|
|
83
85
|
const digest = recall.buildDigest(SELF_ROOT, {
|
|
@@ -86,8 +88,25 @@ async function main() {
|
|
|
86
88
|
halfLifeDays: config.recencyHalfLifeDays,
|
|
87
89
|
staleSec: config.presence && config.presence.staleSec,
|
|
88
90
|
});
|
|
89
|
-
|
|
90
|
-
} catch { /* */ }
|
|
91
|
+
if (digest) parts.push(digest);
|
|
92
|
+
} catch { /* the digest is best-effort; the primer below still runs */ }
|
|
93
|
+
// Front-door session (2026-09): the identity block + a one-line session
|
|
94
|
+
// status (main session liveness, peers, open handoffs, board-mine count
|
|
95
|
+
// from state/org/board-mine.json when the daemon has cached it). Any
|
|
96
|
+
// session on the machine — a home-dir `claude`, a peer, the main session
|
|
97
|
+
// itself — learns who it is from this. Silent when the repo has no
|
|
98
|
+
// identity yet (config/agent.json absent or unconfigured), and skipped
|
|
99
|
+
// for the daemon's own `claude --print` children (MAESTRO_DAEMON=1): their
|
|
100
|
+
// prompt-builder preamble already carries the identity, so the block
|
|
101
|
+
// would be ~400 duplicate tokens on every dispatch.
|
|
102
|
+
if (kind !== "daemon") {
|
|
103
|
+
try {
|
|
104
|
+
const primer = await buildFrontDoorPrimer(SELF_ROOT);
|
|
105
|
+
if (primer) parts.push(primer);
|
|
106
|
+
} catch { /* an unreadable state dir must never break a session start */ }
|
|
107
|
+
}
|
|
108
|
+
// ONE frame: Claude Code reads a single JSON object from a hook's stdout.
|
|
109
|
+
emitContext(parts.join("\n\n"));
|
|
91
110
|
return;
|
|
92
111
|
}
|
|
93
112
|
|
|
@@ -116,6 +135,22 @@ async function main() {
|
|
|
116
135
|
}
|
|
117
136
|
}
|
|
118
137
|
|
|
138
|
+
/** The identity block + status line for `prime`. "" when there is nothing to say. */
|
|
139
|
+
function buildFrontDoorPrimer(agentRoot) {
|
|
140
|
+
let agentJson = null;
|
|
141
|
+
try {
|
|
142
|
+
const p = join(agentRoot, "config", "agent.json");
|
|
143
|
+
if (existsSync(p)) agentJson = JSON.parse(readFileSync(p, "utf8"));
|
|
144
|
+
} catch {
|
|
145
|
+
agentJson = null; // an unconfigured/corrupt agent.json is "no identity yet"
|
|
146
|
+
}
|
|
147
|
+
if (!agentJson || !(agentJson.fullName || agentJson.firstName)) return "";
|
|
148
|
+
// Dynamic import so a hook on a repo that predates lib/session stays silent
|
|
149
|
+
// instead of failing the session start.
|
|
150
|
+
return import("../../lib/session/status-summary.mjs").then((mod) =>
|
|
151
|
+
mod.buildPrimerContext({ agentJson, agentRoot, status: mod.readSessionStatus(agentRoot, { now: Date.now() }) }));
|
|
152
|
+
}
|
|
153
|
+
|
|
119
154
|
function classifyKind(input) {
|
|
120
155
|
const src = String(input.source || "").toLowerCase();
|
|
121
156
|
if (src.includes("daemon") || process.env.MAESTRO_DAEMON === "1") return "daemon";
|
|
@@ -24,11 +24,14 @@ function tmpRoot() {
|
|
|
24
24
|
mkdirSync(join(root, "config"), { recursive: true });
|
|
25
25
|
return root;
|
|
26
26
|
}
|
|
27
|
-
function run(event, input, root) {
|
|
27
|
+
function run(event, input, root, extraEnv = {}) {
|
|
28
|
+
const env = { ...process.env, MAESTRO_COLLECTIVE_ROOT: root };
|
|
29
|
+
delete env.MAESTRO_DAEMON; // never inherit a daemon marker from the runner's own environment
|
|
30
|
+
Object.assign(env, extraEnv);
|
|
28
31
|
return spawnSync(process.execPath, [RUNNER, event], {
|
|
29
32
|
input: JSON.stringify(input),
|
|
30
33
|
encoding: "utf8",
|
|
31
|
-
env
|
|
34
|
+
env,
|
|
32
35
|
});
|
|
33
36
|
}
|
|
34
37
|
|
|
@@ -88,3 +91,83 @@ test("disabled config → fully silent no-op", () => {
|
|
|
88
91
|
assert.equal(r.stdout.trim(), "");
|
|
89
92
|
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
90
93
|
});
|
|
94
|
+
|
|
95
|
+
// ── WP-M4: the front-door primer (identity block + session status) ─────────
|
|
96
|
+
|
|
97
|
+
test("prime: injects the identity block + one-line session status + handoff/board counts when the repo has an identity", () => {
|
|
98
|
+
const root = tmpRoot();
|
|
99
|
+
try {
|
|
100
|
+
writeFileSync(join(root, "config", "agent.json"), JSON.stringify({
|
|
101
|
+
firstName: "Alex", fullName: "Alex Rivera", title: "Chief of Staff", company: "Acme",
|
|
102
|
+
principal: { fullName: "Jordan Lee", title: "CEO" }, repoSlug: "alex-ai",
|
|
103
|
+
}));
|
|
104
|
+
mkdirSync(join(root, "state", "session", "handoffs"), { recursive: true });
|
|
105
|
+
mkdirSync(join(root, "state", "org"), { recursive: true });
|
|
106
|
+
writeFileSync(join(root, "state", "session", "heartbeat.json"), JSON.stringify({ pid: 1, name: "alex-main", ts: Date.now() }));
|
|
107
|
+
writeFileSync(join(root, "state", "session", "handoffs", "t1.json"), "{}");
|
|
108
|
+
writeFileSync(join(root, "state", "session", "handoffs", "t2.json"), "{}");
|
|
109
|
+
writeFileSync(join(root, "state", "org", "board-mine.json"), JSON.stringify({ items: [{ itemId: "i1" }], fetchedAt: new Date().toISOString() }));
|
|
110
|
+
const r = run("prime", { session_id: "me", cwd: "/x/maestro" }, root);
|
|
111
|
+
assert.equal(r.status, 0);
|
|
112
|
+
const payload = JSON.parse(r.stdout);
|
|
113
|
+
const ctx = payload.hookSpecificOutput.additionalContext;
|
|
114
|
+
assert.match(ctx, /maestro:identity/);
|
|
115
|
+
assert.match(ctx, /Alex Rivera/);
|
|
116
|
+
assert.match(ctx, /Chief of Staff/);
|
|
117
|
+
assert.match(ctx, /`alex-main`/);
|
|
118
|
+
assert.match(ctx, /Session status: main session alex-main live/);
|
|
119
|
+
assert.match(ctx, /2 open handoffs/);
|
|
120
|
+
assert.match(ctx, /1 board item/);
|
|
121
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("prime: identity primer and memory digest are emitted together in ONE hook frame", () => {
|
|
125
|
+
const root = tmpRoot();
|
|
126
|
+
try {
|
|
127
|
+
writeFileSync(join(root, "config", "agent.json"), JSON.stringify({ firstName: "Alex", fullName: "Alex Rivera" }));
|
|
128
|
+
register(root, { sessionId: "other", cwd: "/x/openclaw", intent: "research" });
|
|
129
|
+
appendCards(root, [{ kind: "decision", title: "Shipped billing", salience: 0.9, ts: new Date().toISOString() }], { sessionId: "prev" });
|
|
130
|
+
const r = run("prime", { session_id: "me", cwd: "/x/maestro" }, root);
|
|
131
|
+
assert.equal(r.status, 0);
|
|
132
|
+
const frames = r.stdout.trim().split("\n").filter(Boolean);
|
|
133
|
+
assert.equal(frames.length, 1, "exactly one JSON frame on stdout");
|
|
134
|
+
const ctx = JSON.parse(frames[0]).hookSpecificOutput.additionalContext;
|
|
135
|
+
assert.match(ctx, /maestro:collective-digest/);
|
|
136
|
+
assert.match(ctx, /Shipped billing/);
|
|
137
|
+
assert.match(ctx, /maestro:identity/);
|
|
138
|
+
assert.match(ctx, /Session status: main session not running/);
|
|
139
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("prime: a denylisted cwd gets neither memory nor identity", () => {
|
|
143
|
+
const root = tmpRoot();
|
|
144
|
+
try {
|
|
145
|
+
writeFileSync(join(root, "config", "collective.yaml"), "repoDenylist: [client-secret]\n");
|
|
146
|
+
writeFileSync(join(root, "config", "agent.json"), JSON.stringify({ firstName: "Alex", fullName: "Alex Rivera" }));
|
|
147
|
+
const r = run("prime", { session_id: "me", cwd: "/work/client-secret" }, root);
|
|
148
|
+
assert.equal(r.status, 0);
|
|
149
|
+
assert.equal(r.stdout.trim(), "");
|
|
150
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("prime: the daemon's own `claude --print` children (MAESTRO_DAEMON=1) get the memory digest but NOT the identity primer", () => {
|
|
154
|
+
const root = tmpRoot();
|
|
155
|
+
try {
|
|
156
|
+
writeFileSync(join(root, "config", "agent.json"), JSON.stringify({ firstName: "Alex", fullName: "Alex Rivera" }));
|
|
157
|
+
// No digest material → a daemon child is fully silent.
|
|
158
|
+
const quiet = run("prime", { session_id: "d1", cwd: "/x/maestro" }, root, { MAESTRO_DAEMON: "1" });
|
|
159
|
+
assert.equal(quiet.status, 0);
|
|
160
|
+
assert.equal(quiet.stdout.trim(), "", "no identity block for a daemon child");
|
|
161
|
+
// With digest material → the digest still flows, the identity block still does not.
|
|
162
|
+
register(root, { sessionId: "other", cwd: "/x/openclaw", intent: "research" });
|
|
163
|
+
appendCards(root, [{ kind: "decision", title: "Shipped billing", salience: 0.9, ts: new Date().toISOString() }], { sessionId: "prev" });
|
|
164
|
+
const r = run("prime", { session_id: "d2", cwd: "/x/maestro" }, root, { MAESTRO_DAEMON: "1" });
|
|
165
|
+
assert.equal(r.status, 0);
|
|
166
|
+
const ctx = JSON.parse(r.stdout).hookSpecificOutput.additionalContext;
|
|
167
|
+
assert.match(ctx, /Shipped billing/);
|
|
168
|
+
assert.ok(!/maestro:identity/.test(ctx), "identity primer is skipped for daemon children");
|
|
169
|
+
// An interactive session on the same repo still gets it.
|
|
170
|
+
const i = run("prime", { session_id: "i1", cwd: "/x/maestro" }, root);
|
|
171
|
+
assert.match(JSON.parse(i.stdout).hookSpecificOutput.additionalContext, /maestro:identity/);
|
|
172
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
173
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-daemon-board-mine.test.mjs — the daemon caches hq `board.mine` to
|
|
3
|
+
* state/org/board-mine.json every 5 minutes (design §3.5, WP-M6).
|
|
4
|
+
*
|
|
5
|
+
* Drives the REAL refreshBoardMineCache with an injected listMine and a fixed
|
|
6
|
+
* clock under a tmp AGENT_DIR. Same `imapflow` loader stub as the other
|
|
7
|
+
* daemon tests.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { test, before } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { register } from "node:module";
|
|
13
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
|
|
17
|
+
const loaderSrc = `
|
|
18
|
+
export async function resolve(specifier, context, next) {
|
|
19
|
+
if (specifier === "imapflow") return { url: "imapflow-stub:main", shortCircuit: true };
|
|
20
|
+
return next(specifier, context);
|
|
21
|
+
}
|
|
22
|
+
export async function load(url, context, next) {
|
|
23
|
+
if (url === "imapflow-stub:main") return { format: "module", shortCircuit: true, source: "export class ImapFlow {}" };
|
|
24
|
+
return next(url, context);
|
|
25
|
+
}
|
|
26
|
+
`;
|
|
27
|
+
register(`data:text/javascript,${encodeURIComponent(loaderSrc)}`);
|
|
28
|
+
|
|
29
|
+
const AGENT_DIR = mkdtempSync(join(tmpdir(), "maestro-daemon-board-mine-"));
|
|
30
|
+
process.env.AGENT_DIR = AGENT_DIR;
|
|
31
|
+
process.env.MAESTRO_DAEMON_NO_AUTOSTART = "1";
|
|
32
|
+
|
|
33
|
+
const NOW = Date.parse("2026-09-08T12:00:00Z");
|
|
34
|
+
const CACHE = join(AGENT_DIR, "state", "org", "board-mine.json");
|
|
35
|
+
|
|
36
|
+
let daemon;
|
|
37
|
+
before(async () => {
|
|
38
|
+
mkdirSync(join(AGENT_DIR, "config"), { recursive: true });
|
|
39
|
+
mkdirSync(join(AGENT_DIR, "state", "sessions", "locks"), { recursive: true });
|
|
40
|
+
writeFileSync(join(AGENT_DIR, "config", "org.yaml"), "org:\n cohort:\n enabled: true\n base: https://os.example.test\n token: nlk_test_token\n orgId: org_test\n");
|
|
41
|
+
writeFileSync(join(AGENT_DIR, "config", "agent.json"), JSON.stringify({ firstName: "Nova", lastName: "Reyes" }));
|
|
42
|
+
const url = new URL("./agent-daemon.mjs", import.meta.url);
|
|
43
|
+
daemon = await import(`${url.href}?boardmine=${Math.random()}`);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("first call writes state/org/board-mine.json as {ts, items} from listMine; a call inside 5 min is a no-op; one at 5 min refreshes", async () => {
|
|
47
|
+
const calls = [];
|
|
48
|
+
const listMineImpl = async (o) => { calls.push(o); return [{ itemId: "i1", title: "Do X", col: "todo" }, null, "junk"]; };
|
|
49
|
+
const first = await daemon.refreshBoardMineCache({ now: NOW, listMineImpl });
|
|
50
|
+
assert.deepEqual(first, { refreshed: true, reason: "written", count: 1 });
|
|
51
|
+
assert.ok(existsSync(CACHE));
|
|
52
|
+
const doc = JSON.parse(readFileSync(CACHE, "utf8"));
|
|
53
|
+
assert.deepEqual(doc, { ts: "2026-09-08T12:00:00.000Z", items: [{ itemId: "i1", title: "Do X", col: "todo" }] });
|
|
54
|
+
assert.equal(calls.length, 1);
|
|
55
|
+
assert.equal(calls[0].base, "https://os.example.test", "the daemon's org.yaml connection is what listMine gets");
|
|
56
|
+
assert.equal(calls[0].orgId, "org_test");
|
|
57
|
+
|
|
58
|
+
const soon = await daemon.refreshBoardMineCache({ now: NOW + 4 * 60_000, listMineImpl });
|
|
59
|
+
assert.deepEqual(soon, { refreshed: false, reason: "fresh" });
|
|
60
|
+
assert.equal(calls.length, 1, "no hq call inside the interval");
|
|
61
|
+
|
|
62
|
+
const later = await daemon.refreshBoardMineCache({ now: NOW + 5 * 60_000, listMineImpl: async () => ({ ok: true, items: [] }) });
|
|
63
|
+
assert.deepEqual(later, { refreshed: true, reason: "written", count: 0 });
|
|
64
|
+
assert.deepEqual(JSON.parse(readFileSync(CACHE, "utf8")), { ts: "2026-09-08T12:05:00.000Z", items: [] }, "an empty board is a real answer");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("hq unreachable for one tick: the last known good items are KEPT and the document is marked stale with itemsAt; the next good read clears it", async () => {
|
|
68
|
+
await daemon.refreshBoardMineCache({ now: NOW + 10 * 60_000, listMineImpl: async () => [{ itemId: "keep-me" }] });
|
|
69
|
+
const failed = await daemon.refreshBoardMineCache({ now: NOW + 15 * 60_000, listMineImpl: async () => ({ ok: false, items: [], error: "http 503" }) });
|
|
70
|
+
assert.deepEqual(failed, { refreshed: true, reason: "kept-previous", count: 1 });
|
|
71
|
+
const doc = JSON.parse(readFileSync(CACHE, "utf8"));
|
|
72
|
+
assert.deepEqual(doc, { ts: "2026-09-08T12:15:00.000Z", items: [{ itemId: "keep-me" }], stale: true, itemsAt: "2026-09-08T12:10:00.000Z", error: "http 503" });
|
|
73
|
+
const ok = await daemon.refreshBoardMineCache({ now: NOW + 20 * 60_000, listMineImpl: async () => ({ ok: true, items: [{ itemId: "fresh" }] }) });
|
|
74
|
+
assert.deepEqual(ok, { refreshed: true, reason: "written", count: 1 });
|
|
75
|
+
assert.deepEqual(JSON.parse(readFileSync(CACHE, "utf8")), { ts: "2026-09-08T12:20:00.000Z", items: [{ itemId: "fresh" }] });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("a corrupt cache is refreshed; force bypasses the interval; the tick-loop call never throws when listMine rejects", async () => {
|
|
79
|
+
writeFileSync(CACHE, "{not json");
|
|
80
|
+
const r = await daemon.refreshBoardMineCache({ now: NOW + 30 * 60_000, listMineImpl: async () => [{ itemId: "z" }] });
|
|
81
|
+
assert.equal(r.refreshed, true);
|
|
82
|
+
const forced = await daemon.refreshBoardMineCache({ now: NOW + 30 * 60_000 + 1, force: true, listMineImpl: async () => [{ itemId: "y" }] });
|
|
83
|
+
assert.equal(forced.refreshed, true);
|
|
84
|
+
assert.deepEqual(JSON.parse(readFileSync(CACHE, "utf8")).items, [{ itemId: "y" }]);
|
|
85
|
+
await assert.rejects(daemon.refreshBoardMineCache({ now: NOW + 40 * 60_000, listMineImpl: async () => { throw new Error("boom"); } }), /boom/);
|
|
86
|
+
assert.deepEqual(JSON.parse(readFileSync(CACHE, "utf8")).items, [{ itemId: "y" }], "a failed refresh leaves the previous cache in place");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("an un-enrolled seat (org disabled) writes nothing", async () => {
|
|
90
|
+
writeFileSync(join(AGENT_DIR, "config", "org.yaml"), "org:\n cohort:\n enabled: false\n");
|
|
91
|
+
daemon._resetDaemonOrgCfgCache();
|
|
92
|
+
rmSync(CACHE, { force: true });
|
|
93
|
+
const r = await daemon.refreshBoardMineCache({ now: NOW + 60 * 60_000, listMineImpl: async () => [{ itemId: "no" }] });
|
|
94
|
+
assert.deepEqual(r, { refreshed: false, reason: "org-disabled" });
|
|
95
|
+
assert.ok(!existsSync(CACHE));
|
|
96
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-daemon-frontdoor.test.mjs — the daemon steps back from the Cohort
|
|
3
|
+
* inbox while the main session is live (design §3.1).
|
|
4
|
+
*
|
|
5
|
+
* Drives the REAL fast cohort scan (pollCohortFast → pollService) with a live
|
|
6
|
+
* heartbeat planted under the tmp AGENT_DIR and a fresh inbox item: the item
|
|
7
|
+
* must be left exactly as it arrived (no lock, no rename, no classification).
|
|
8
|
+
* The not-live branch is the pre-existing dispatch path, which would reach the
|
|
9
|
+
* Haiku classifier — it is covered by the pure gate tests instead.
|
|
10
|
+
*
|
|
11
|
+
* Same `imapflow` loader stub as agent-daemon.test.mjs.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { test, before } from "node:test";
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { register } from "node:module";
|
|
17
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readdirSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
|
|
21
|
+
const loaderSrc = `
|
|
22
|
+
export async function resolve(specifier, context, next) {
|
|
23
|
+
if (specifier === "imapflow") return { url: "imapflow-stub:main", shortCircuit: true };
|
|
24
|
+
return next(specifier, context);
|
|
25
|
+
}
|
|
26
|
+
export async function load(url, context, next) {
|
|
27
|
+
if (url === "imapflow-stub:main") return { format: "module", shortCircuit: true, source: "export class ImapFlow {}" };
|
|
28
|
+
return next(url, context);
|
|
29
|
+
}
|
|
30
|
+
`;
|
|
31
|
+
register(`data:text/javascript,${encodeURIComponent(loaderSrc)}`);
|
|
32
|
+
|
|
33
|
+
const AGENT_DIR = mkdtempSync(join(tmpdir(), "maestro-daemon-frontdoor-"));
|
|
34
|
+
process.env.AGENT_DIR = AGENT_DIR;
|
|
35
|
+
process.env.MAESTRO_DAEMON_NO_AUTOSTART = "1";
|
|
36
|
+
|
|
37
|
+
let daemon;
|
|
38
|
+
before(async () => {
|
|
39
|
+
mkdirSync(join(AGENT_DIR, "config"), { recursive: true });
|
|
40
|
+
mkdirSync(join(AGENT_DIR, "state", "inbox", "cohort"), { recursive: true });
|
|
41
|
+
mkdirSync(join(AGENT_DIR, "state", "session"), { recursive: true });
|
|
42
|
+
mkdirSync(join(AGENT_DIR, "state", "sessions", "locks"), { recursive: true });
|
|
43
|
+
writeFileSync(join(AGENT_DIR, "config", "org.yaml"), "org:\n cohort:\n enabled: true\n");
|
|
44
|
+
writeFileSync(join(AGENT_DIR, "config", "agent.json"), JSON.stringify({ firstName: "Nova", lastName: "Reyes" }));
|
|
45
|
+
const url = new URL("./agent-daemon.mjs", import.meta.url);
|
|
46
|
+
daemon = await import(`${url.href}?frontdoor=${Math.random()}`);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("live main session: pollCohortFast leaves a fresh cohort item untouched for the session", async () => {
|
|
50
|
+
writeFileSync(join(AGENT_DIR, "state", "session", "heartbeat.json"), JSON.stringify({ pid: process.pid, ts: new Date().toISOString() }));
|
|
51
|
+
const name = "2026-09-08T10-00-00-000Z-cohort-fd1.yaml";
|
|
52
|
+
writeFileSync(join(AGENT_DIR, "state", "inbox", "cohort", name), [
|
|
53
|
+
'id: "cohort-fd1"', 'service: "cohort"', 'channel: "dm/olivia"', 'channel_id: "ch1"', 'channel_kind: "DM"',
|
|
54
|
+
'sender: "Olivia Chen"', 'timestamp: "2026-09-08T10:00:00.000Z"', 'subject: ""', "content: |", " hello", 'raw_ref: "cohort-fd1"', "",
|
|
55
|
+
].join("\n"));
|
|
56
|
+
const n = await daemon.pollCohortFast();
|
|
57
|
+
assert.equal(n, 0, "nothing dispatched by the daemon");
|
|
58
|
+
assert.deepEqual(readdirSync(join(AGENT_DIR, "state", "inbox", "cohort")), [name], "the item is still a fresh .yaml");
|
|
59
|
+
assert.deepEqual(readdirSync(join(AGENT_DIR, "state", "sessions", "locks")), [], "no per-item lock taken");
|
|
60
|
+
});
|