@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
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.
|
package/lib/org/client.test.mjs
CHANGED
|
@@ -35,6 +35,9 @@ import {
|
|
|
35
35
|
claimItem,
|
|
36
36
|
completeItem,
|
|
37
37
|
listReady,
|
|
38
|
+
listMine,
|
|
39
|
+
listMineResult,
|
|
40
|
+
trackWork,
|
|
38
41
|
fetchEvents,
|
|
39
42
|
fetchSnapshot,
|
|
40
43
|
publishEntry,
|
|
@@ -1158,3 +1161,46 @@ test("loadOrgConfig: a PRESENT but unparseable org.yaml WARNS (never silently un
|
|
|
1158
1161
|
assert.match(warnings.join("\n"), /could not be loaded/, "the operator must be told");
|
|
1159
1162
|
assert.match(warnings.join("\n"), /NOT enrolled/, "and told what it means");
|
|
1160
1163
|
});
|
|
1164
|
+
|
|
1165
|
+
// ── WP-M4: board.mine + board.track passthrough (front-door session §3.5) ──
|
|
1166
|
+
|
|
1167
|
+
test("listMineResult: keeps the failure visible — {ok:false} on an error frame or a throwing transport, {ok:true, items:[]} for an empty board", async () => {
|
|
1168
|
+
assert.deepEqual(await listMineResult({ base: BASE, token: TOKEN, fetchImpl: fakeFetch(() => ({ body: [{ itemId: "i1" }] })) }), { ok: true, items: [{ itemId: "i1" }] });
|
|
1169
|
+
assert.deepEqual(await listMineResult({ base: BASE, token: TOKEN, fetchImpl: fakeFetch(() => ({ body: { items: [] } })) }), { ok: true, items: [] }, "an empty board is a real answer");
|
|
1170
|
+
const failed = await listMineResult({ base: BASE, token: TOKEN, fetchImpl: fakeFetch(() => ({ ok: false, status: 503, body: { error: { code: "UNAVAILABLE", message: "hq down" } } })) });
|
|
1171
|
+
assert.equal(failed.ok, false);
|
|
1172
|
+
assert.deepEqual(failed.items, []);
|
|
1173
|
+
assert.match(failed.error, /hq down/);
|
|
1174
|
+
const threw = await listMineResult({ base: BASE, token: TOKEN, fetchImpl: () => { throw new Error("ECONNREFUSED"); } });
|
|
1175
|
+
assert.equal(threw.ok, false);
|
|
1176
|
+
assert.deepEqual(threw.items, []);
|
|
1177
|
+
assert.deepEqual(await listMine({ base: BASE, token: TOKEN, fetchImpl: () => { throw new Error("ECONNREFUSED"); } }), [], "listMine stays the fail-open [] its other callers expect");
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
test("listMine: GET /v1/board.mine; parses array and {items:[]}; [] on error / transport failure (fail-open)", async () => {
|
|
1181
|
+
const f = fakeFetch(() => ({ body: [{ itemId: "i1", title: "Deck" }] }));
|
|
1182
|
+
assert.deepEqual(await listMine({ base: BASE, token: TOKEN, fetchImpl: f }), [{ itemId: "i1", title: "Deck" }]);
|
|
1183
|
+
assert.match(f.calls[0].url, /\/v1\/board\.mine$/);
|
|
1184
|
+
assert.equal(f.calls[0].init.method, "GET");
|
|
1185
|
+
assert.deepEqual(await listMine({ base: BASE, token: TOKEN, fetchImpl: fakeFetch(() => ({ body: { items: [{ itemId: "i2" }] } })) }), [{ itemId: "i2" }]);
|
|
1186
|
+
assert.deepEqual(await listMine({ base: BASE, token: TOKEN, fetchImpl: fakeFetch(() => ({ ok: false, status: 404, body: { error: { code: "NOT_FOUND" } } })) }), [], "an hq without board.mine yet → []");
|
|
1187
|
+
assert.deepEqual(await listMine({ base: BASE, token: TOKEN, fetchImpl: async () => { throw new Error("boom"); } }), []);
|
|
1188
|
+
assert.deepEqual(await listMine({ base: "", token: TOKEN }), [], "no base → []");
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
test("trackWork: title/why/stage (incl. accepted|done) pass through to board.track verbatim", async () => {
|
|
1192
|
+
const f = fakeFetch(() => ({ body: { ok: true, result: { tracked: true, taskId: "t1" } } }));
|
|
1193
|
+
const r = await trackWork(
|
|
1194
|
+
{ service: "cohort", channelId: "c1", messageId: "m1", stage: "accepted", title: "Build the deck", why: "asked in DM" },
|
|
1195
|
+
{ base: BASE, token: TOKEN, fetchImpl: f, idempotencyKey: "k1" },
|
|
1196
|
+
);
|
|
1197
|
+
assert.equal(r.ok, true);
|
|
1198
|
+
const body = JSON.parse(f.calls[0].init.body);
|
|
1199
|
+
assert.equal(body.stage, "accepted");
|
|
1200
|
+
assert.equal(body.title, "Build the deck");
|
|
1201
|
+
assert.equal(body.why, "asked in DM");
|
|
1202
|
+
assert.equal(body.channelId, "c1");
|
|
1203
|
+
const f2 = fakeFetch(() => ({ body: { ok: true, result: { tracked: true } } }));
|
|
1204
|
+
await trackWork({ service: "cohort", channelId: "c1", messageId: "m1", stage: "done" }, { base: BASE, token: TOKEN, fetchImpl: f2 });
|
|
1205
|
+
assert.equal(JSON.parse(f2.calls[0].init.body).stage, "done");
|
|
1206
|
+
});
|
|
@@ -406,7 +406,9 @@ function yes(surface, reason, extra = {}) {
|
|
|
406
406
|
* @param {Candidate} cand
|
|
407
407
|
* @param {string} me the agent's member id
|
|
408
408
|
* @param {import("./facts.mjs").Facts} facts
|
|
409
|
-
* @param {{enabled?: Record<string,boolean
|
|
409
|
+
* @param {{enabled?: Record<string,boolean>, meAliases?: string[]}} [opts]
|
|
410
|
+
* `meAliases` — additional identity strings for this seat (slug, member id,
|
|
411
|
+
* agent email). Used ONLY to widen own-echo suppression; never to widen reach.
|
|
410
412
|
* @returns {{directed:boolean, surface:string|null, reason:string, why:string}}
|
|
411
413
|
*/
|
|
412
414
|
export function resolveDirected(cand, me, facts = {}, opts = {}) {
|
|
@@ -414,10 +416,24 @@ export function resolveDirected(cand, me, facts = {}, opts = {}) {
|
|
|
414
416
|
const enabled = opts.enabled || null;
|
|
415
417
|
const on = (s) => (enabled ? !!enabled[s] : true);
|
|
416
418
|
const meId = me ? String(me) : "";
|
|
419
|
+
// Every identity string that means "this seat". `me` SHOULD be the org member
|
|
420
|
+
// id (a cuid), because that is what hq stamps on `actor` and `directTo`. But a
|
|
421
|
+
// real machine can just as easily hand us the SLUG ("A028") — COHORT_AGENT_ID
|
|
422
|
+
// holds a slug, and the daemon's responder-election path needs it that way.
|
|
423
|
+
// When the slug arrives here, `actor === meId` compares across two namespaces,
|
|
424
|
+
// never matches, and the own-echo drop below silently stops existing. Echo
|
|
425
|
+
// suppression must not depend on which alias the caller happened to pass, so
|
|
426
|
+
// match ANY known alias. Observed in production 2026-08-26: A028 re-ingested
|
|
427
|
+
// its own morning brief and its own holding note and began answering itself.
|
|
428
|
+
const meAliases = new Set(
|
|
429
|
+
[meId, ...(Array.isArray(opts.meAliases) ? opts.meAliases : [])]
|
|
430
|
+
.map((v) => (v == null ? "" : String(v).trim()))
|
|
431
|
+
.filter(Boolean),
|
|
432
|
+
);
|
|
417
433
|
|
|
418
434
|
// My OWN echo is never inbound. This is the single most important drop: without
|
|
419
435
|
// it an agent re-ingests its own replies and holds a conversation with itself.
|
|
420
|
-
if (
|
|
436
|
+
if (cand.actor && meAliases.has(String(cand.actor))) return no("own_echo");
|
|
421
437
|
|
|
422
438
|
// A payload that named me outright needs no join at all.
|
|
423
439
|
if (meId && cand.directTo.map(String).includes(meId)) {
|
|
@@ -145,6 +145,64 @@ test("NEGATIVE: my own echo is never inbound", () => {
|
|
|
145
145
|
assert.equal(resolveDirected(c, ME, f).reason, "own_echo");
|
|
146
146
|
});
|
|
147
147
|
|
|
148
|
+
// --- own-echo across id namespaces (regression, production 2026-08-26) -------
|
|
149
|
+
//
|
|
150
|
+
// `me` SHOULD be the org member cuid — that is what hq stamps on `actor`. But
|
|
151
|
+
// COHORT_AGENT_ID holds a SLUG and the daemon's responder-election path needs it
|
|
152
|
+
// that way, so a slug reaches this resolver on a real machine. Strict equality
|
|
153
|
+
// on one id then compares across two namespaces: it does not throw, it answers
|
|
154
|
+
// "no" to every identity question, and the own-echo drop silently stops
|
|
155
|
+
// existing. That is how a seat re-ingested its own morning brief and its own
|
|
156
|
+
// holding note and began answering itself.
|
|
157
|
+
//
|
|
158
|
+
// These tests exist because nothing turns red when this lapses. The failure has
|
|
159
|
+
// no runtime signal at all — only an assertion.
|
|
160
|
+
|
|
161
|
+
const ME_SLUG = "A028";
|
|
162
|
+
|
|
163
|
+
test("REGRESSION: a SLUG in the me position still suppresses my own echo", () => {
|
|
164
|
+
const f = withChannel(facts(), "C-dm", "DM");
|
|
165
|
+
const c = classifyEvent(
|
|
166
|
+
ev({ family: "messaging", kind: "send", actor: ME, entity_id: "m1", payload: { actor: ME, channelId: "C-dm", channelKind: "DM" } }),
|
|
167
|
+
);
|
|
168
|
+
// hq stamped the cuid; the caller handed us the slug. Pre-fix this returned
|
|
169
|
+
// `dm` — the agent's own message delivered back to it as fresh inbound.
|
|
170
|
+
const v = resolveDirected(c, ME_SLUG, f, { meAliases: [ME_SLUG, ME] });
|
|
171
|
+
assert.equal(v.directed, false);
|
|
172
|
+
assert.equal(v.reason, "own_echo");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("REGRESSION: a SLUG-shaped actor is suppressed when me is the cuid", () => {
|
|
176
|
+
const f = withChannel(facts(), "C-dm", "DM");
|
|
177
|
+
const c = classifyEvent(
|
|
178
|
+
ev({ family: "messaging", kind: "send", actor: ME_SLUG, entity_id: "m1", payload: { actor: ME_SLUG, channelId: "C-dm", channelKind: "DM" } }),
|
|
179
|
+
);
|
|
180
|
+
// Symmetric: suppression must not depend on WHICH namespace either side used.
|
|
181
|
+
assert.equal(resolveDirected(c, ME, f, { meAliases: [ME, ME_SLUG] }).reason, "own_echo");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("meAliases widen own-echo ONLY — never reach", () => {
|
|
185
|
+
const f = facts(); // C-theirs is in nobody's roster but its members'
|
|
186
|
+
const c = classifyEvent(
|
|
187
|
+
ev({ family: "messaging", kind: "send", entity_id: "m1", payload: { actor: THEM, channelId: "C-theirs", channelKind: "DM" } }),
|
|
188
|
+
);
|
|
189
|
+
const v = resolveDirected(c, ME_SLUG, f, { meAliases: [ME_SLUG, ME] });
|
|
190
|
+
assert.equal(v.directed, false, "an alias list must never open someone else's room");
|
|
191
|
+
assert.equal(v.reason, "channel_not_visible");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("a blank/garbage alias never swallows real inbound", () => {
|
|
195
|
+
const f = withChannel(facts(), "C-dm", "DM");
|
|
196
|
+
withMessage(f, "C-dm", { id: "m1", authorId: THEM, body: "ping" });
|
|
197
|
+
const c = classifyEvent(
|
|
198
|
+
ev({ family: "messaging", kind: "send", entity_id: "m1", payload: { actor: THEM, channelId: "C-dm", channelKind: "DM", mentionCount: 0, threaded: false } }),
|
|
199
|
+
);
|
|
200
|
+
// Empty strings and nullish entries are dropped, so `has(actor)` cannot match
|
|
201
|
+
// on emptiness and turn every message into my own echo.
|
|
202
|
+
const v = resolveDirected(c, ME, f, { meAliases: [null, "", " ", undefined] });
|
|
203
|
+
assert.deepEqual({ d: v.directed, r: v.reason }, { d: true, r: "dm" });
|
|
204
|
+
});
|
|
205
|
+
|
|
148
206
|
// ---------------------------------------------------------------------------
|
|
149
207
|
// messaging — DM / mention / thread / ambient
|
|
150
208
|
// ---------------------------------------------------------------------------
|
|
@@ -87,6 +87,8 @@ const DEFAULT_EVENT_LIMIT = 200;
|
|
|
87
87
|
* @param {number} [o.limit]
|
|
88
88
|
* @param {Function} [o.fetchImpl]
|
|
89
89
|
* @param {string[]} [o.myNames] display names for prose-mention matching
|
|
90
|
+
* @param {string[]} [o.meAliases] other identity strings for this seat
|
|
91
|
+
* (slug, member id) — widens OWN-ECHO suppression only, never reach
|
|
90
92
|
* @param {string[]|Record<string,boolean>} [o.surfaces] surface enable override
|
|
91
93
|
* @param {object} [o.limits] overrides for {@link DEFAULT_LIMITS}
|
|
92
94
|
* @param {Function} [o.log] (level, message, meta?) — daemon logger
|
|
@@ -125,6 +127,11 @@ export async function pullWideInbound(o = {}) {
|
|
|
125
127
|
return empty;
|
|
126
128
|
}
|
|
127
129
|
|
|
130
|
+
// Extra identity strings for own-echo suppression only (slug AND member id).
|
|
131
|
+
// The caller cannot always know which namespace `agentId` is in; passing both
|
|
132
|
+
// means a slug-valued COHORT_AGENT_ID can never silently disable the drop.
|
|
133
|
+
const meAliases = Array.isArray(o.meAliases) ? o.meAliases.filter(Boolean) : [];
|
|
134
|
+
|
|
128
135
|
const { enabled, unknown } = resolveEnabledSurfaces(
|
|
129
136
|
o.surfaces !== undefined ? o.surfaces : parseSurfaceEnv(process.env.COHORT_INBOUND_SURFACES),
|
|
130
137
|
);
|
|
@@ -185,7 +192,7 @@ export async function pullWideInbound(o = {}) {
|
|
|
185
192
|
// ── 3. Verdicts.
|
|
186
193
|
const directed = [];
|
|
187
194
|
for (const cand of candidates) {
|
|
188
|
-
const verdict = resolveDirected(cand, me, facts, { enabled });
|
|
195
|
+
const verdict = resolveDirected(cand, me, facts, { enabled, meAliases });
|
|
189
196
|
if (!verdict.directed) {
|
|
190
197
|
stats.dropped[verdict.reason] = (stats.dropped[verdict.reason] || 0) + 1;
|
|
191
198
|
continue;
|
|
@@ -208,6 +208,28 @@ test("NEGATIVE: nothing from someone else's private room, my own echo, or ambien
|
|
|
208
208
|
assert.ok(stats.dropped.channel_not_visible >= 1);
|
|
209
209
|
});
|
|
210
210
|
|
|
211
|
+
test("REGRESSION: a slug-valued agentId still drops my own echo, via meAliases", async () => {
|
|
212
|
+
// The production shape on 2026-08-26: COHORT_AGENT_ID is a SLUG, hq stamps
|
|
213
|
+
// `actor` with the member cuid. `agentId` alone then matches nothing and the
|
|
214
|
+
// own-echo drop silently stops existing — no throw, no log, just the seat
|
|
215
|
+
// answering itself. The daemon hands BOTH namespaces down as `meAliases`, so
|
|
216
|
+
// the drop survives regardless of which one `agentId` resolved to.
|
|
217
|
+
const io = fakeIo(routes());
|
|
218
|
+
const { events, stats } = await pullWideInbound({
|
|
219
|
+
cfg: CFG,
|
|
220
|
+
agentId: "A028",
|
|
221
|
+
meAliases: ["A028", ME],
|
|
222
|
+
io,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
assert.ok(stats.dropped.own_echo >= 1, "own_echo must still fire when me is the slug");
|
|
226
|
+
assert.equal(
|
|
227
|
+
events.some((e) => e.message_id === "m-mine"),
|
|
228
|
+
false,
|
|
229
|
+
"my own message must never come back, whichever id namespace the caller passed",
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
211
233
|
test("email is OFF by default and is not even hydrated", async () => {
|
|
212
234
|
const io = fakeIo(routes());
|
|
213
235
|
const { events } = await pullWideInbound({ cfg: CFG, agentId: ME, io });
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mesh-directives.test.mjs — the two front-door directives (design §3.4 /
|
|
3
|
+
* audit F10): hq's `remediateFleetSeat` queues `restart` and nothing honoured
|
|
4
|
+
* it. Now:
|
|
5
|
+
*
|
|
6
|
+
* restart → the daemon flushes and exits NON-ZERO so launchd's
|
|
7
|
+
* KeepAlive {SuccessfulExit:false} relaunches it (a clean
|
|
8
|
+
* exit 0 would leave it down). Injected as requestRestart.
|
|
9
|
+
* session.restart → touch state/session/restart-requested; the session's
|
|
10
|
+
* feed sees it and the session restarts itself at an idle
|
|
11
|
+
* moment. The daemon is untouched.
|
|
12
|
+
*
|
|
13
|
+
* Driven through the same fake client + explicit beatOnce as mesh.test.mjs.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { test } from "node:test";
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
|
|
22
|
+
import { connectOrgMesh, _internals } from "./mesh.mjs";
|
|
23
|
+
|
|
24
|
+
const BASE = "https://org.cohortapp.com";
|
|
25
|
+
function tmpRepo() {
|
|
26
|
+
const root = mkdtempSync(join(tmpdir(), "org-mesh-directives-"));
|
|
27
|
+
mkdirSync(join(root, "config"), { recursive: true });
|
|
28
|
+
writeFileSync(join(root, "config", "agent.json"), JSON.stringify({ firstName: "Alex", fullName: "Alex Chen", repoSlug: "alex-ai" }));
|
|
29
|
+
return root;
|
|
30
|
+
}
|
|
31
|
+
function cfgEnabled() { return { org: { cohort: { enabled: true, base: BASE, token: "tok" } } }; }
|
|
32
|
+
function fakeClient(beatFrames) {
|
|
33
|
+
const c = {
|
|
34
|
+
isEnabled: (cfg) => !!(cfg && cfg.org && cfg.org.cohort && cfg.org.cohort.enabled),
|
|
35
|
+
configFromAgent: () => ({ base: BASE, token: "tok", orgId: "", enabled: true, trustedSignersPem: [] }),
|
|
36
|
+
_beatFrames: beatFrames.slice(),
|
|
37
|
+
register() { return Promise.resolve({ ok: true, result: { status: 200 } }); },
|
|
38
|
+
presenceBeat() { return Promise.resolve(c._beatFrames.length ? c._beatFrames.shift() : { ok: true, result: { directives: [] } }); },
|
|
39
|
+
fetchDirectory() { return Promise.resolve({ ok: true, result: { agents: [] } }); },
|
|
40
|
+
call() { return Promise.resolve({ ok: true, result: {} }); },
|
|
41
|
+
};
|
|
42
|
+
return c;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test("KNOWN_DIRECTIVES includes restart and session.restart", () => {
|
|
46
|
+
assert.ok(_internals.KNOWN_DIRECTIVES.has("restart"));
|
|
47
|
+
assert.ok(_internals.KNOWN_DIRECTIVES.has("session.restart"));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('"restart" directive stops the beat loop and asks the daemon to restart (exit non-zero); never writes .emergency-stop', async () => {
|
|
51
|
+
const root = tmpRepo();
|
|
52
|
+
const client = fakeClient([{ ok: true, result: { directives: ["restart"] } }]);
|
|
53
|
+
let stops = 0;
|
|
54
|
+
let restarts = 0;
|
|
55
|
+
const handle = await connectOrgMesh({
|
|
56
|
+
agentRoot: root, cfg: cfgEnabled(), client,
|
|
57
|
+
setInterval: () => ({ unref() {} }),
|
|
58
|
+
requestStop: () => { stops += 1; },
|
|
59
|
+
requestRestart: () => { restarts += 1; },
|
|
60
|
+
});
|
|
61
|
+
const directives = await _internals.beatOnce(handle._ctx);
|
|
62
|
+
assert.deepEqual(directives, ["restart"]);
|
|
63
|
+
assert.equal(restarts, 1, "requestRestart() was invoked");
|
|
64
|
+
assert.equal(stops, 0, "a restart is not a halt");
|
|
65
|
+
assert.equal(existsSync(join(root, ".emergency-stop")), false, "a restart must never trip the kill switch");
|
|
66
|
+
assert.equal(handle._ctx.stopped, true, "the beat loop stopped: the process is going down to come back");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('"session.restart" directive touches state/session/restart-requested and leaves the daemon running', async () => {
|
|
70
|
+
const root = tmpRepo();
|
|
71
|
+
const client = fakeClient([{ ok: true, result: { directives: ["session.restart"] } }]);
|
|
72
|
+
let stops = 0;
|
|
73
|
+
let restarts = 0;
|
|
74
|
+
const T0 = Date.parse("2026-09-08T10:00:00.000Z");
|
|
75
|
+
const handle = await connectOrgMesh({
|
|
76
|
+
agentRoot: root, cfg: cfgEnabled(), client,
|
|
77
|
+
setInterval: () => ({ unref() {} }),
|
|
78
|
+
now: () => T0,
|
|
79
|
+
requestStop: () => { stops += 1; },
|
|
80
|
+
requestRestart: () => { restarts += 1; },
|
|
81
|
+
});
|
|
82
|
+
const directives = await _internals.beatOnce(handle._ctx);
|
|
83
|
+
assert.deepEqual(directives, ["session.restart"]);
|
|
84
|
+
const flag = join(root, "state", "session", "restart-requested");
|
|
85
|
+
assert.ok(existsSync(flag), "restart-requested flag written");
|
|
86
|
+
assert.match(readFileSync(flag, "utf8"), /2026-09-08T10:00:00/, "flag carries the request time");
|
|
87
|
+
assert.equal(stops, 0);
|
|
88
|
+
assert.equal(restarts, 0, "the daemon itself is not restarted");
|
|
89
|
+
assert.equal(handle._ctx.stopped, false, "the beat loop keeps running");
|
|
90
|
+
// Idempotent: a second directive overwrites the flag, never throws.
|
|
91
|
+
await _internals.handleDirective("session.restart", handle._ctx);
|
|
92
|
+
assert.ok(existsSync(flag));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("the default restart signal sets a non-zero exit code before the graceful stop, so launchd relaunches", () => {
|
|
96
|
+
// defaultRequestRestart is the production path; we only check its contract
|
|
97
|
+
// on process.exitCode without sending ourselves a signal.
|
|
98
|
+
const prevExitCode = process.exitCode;
|
|
99
|
+
const prevKill = process.kill;
|
|
100
|
+
let killed = null;
|
|
101
|
+
process.kill = (pid, sig) => { killed = { pid, sig }; return true; };
|
|
102
|
+
try {
|
|
103
|
+
_internals.defaultRequestRestart();
|
|
104
|
+
assert.equal(process.exitCode, 1, "exit code 1 → KeepAlive {SuccessfulExit:false} brings the daemon back");
|
|
105
|
+
assert.deepEqual(killed, { pid: process.pid, sig: "SIGTERM" }, "graceful stop via the daemon's own SIGTERM handlers (flush)");
|
|
106
|
+
} finally {
|
|
107
|
+
process.kill = prevKill;
|
|
108
|
+
process.exitCode = prevExitCode;
|
|
109
|
+
}
|
|
110
|
+
});
|
package/lib/org/mesh.mjs
CHANGED
|
@@ -22,6 +22,13 @@
|
|
|
22
22
|
* (lib/org/policy.fetchAndApply against the PINNED root).
|
|
23
23
|
* - "resync" → re-fetch the directory (client.fetchDirectory) into the
|
|
24
24
|
* local peers cache (state/org/peers/<id>.json).
|
|
25
|
+
* - "restart" → (audit F10) the daemon flushes and exits NON-ZERO so
|
|
26
|
+
* launchd's KeepAlive {SuccessfulExit:false} relaunches
|
|
27
|
+
* it. Never writes .emergency-stop — a restart is not a
|
|
28
|
+
* halt. Injected as requestRestart.
|
|
29
|
+
* - "session.restart" → touch state/session/restart-requested; the main
|
|
30
|
+
* session's feed sees it and the session restarts itself
|
|
31
|
+
* at an idle moment (design §3.10). The daemon stays up.
|
|
25
32
|
* 3. stop() — clears the interval so the daemon can shut the mesh down cleanly.
|
|
26
33
|
*
|
|
27
34
|
* FAIL-OPEN by design (mirrors lib/org/client): if the org is disabled
|
|
@@ -42,6 +49,7 @@
|
|
|
42
49
|
|
|
43
50
|
import { join, resolve } from "node:path";
|
|
44
51
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
52
|
+
import { writeJsonAtomic } from "../fs-atomic.mjs";
|
|
45
53
|
|
|
46
54
|
import * as defaultClient from "./client.mjs";
|
|
47
55
|
import * as policy from "./policy.mjs";
|
|
@@ -70,8 +78,13 @@ const KNOWN_DIRECTIVES = new Set([
|
|
|
70
78
|
"policy_stale",
|
|
71
79
|
"resync",
|
|
72
80
|
"toolset_changed",
|
|
81
|
+
"restart",
|
|
82
|
+
"session.restart",
|
|
73
83
|
]);
|
|
74
84
|
|
|
85
|
+
/** Where the session runtime looks for a restart request (relative to the agent root). */
|
|
86
|
+
export const SESSION_RESTART_FLAG_RELATIVE = "state/session/restart-requested";
|
|
87
|
+
|
|
75
88
|
function logInfo(msg) { try { console.log(`[org-mesh] ${msg}`); } catch { /* never throw from logging */ } }
|
|
76
89
|
function logWarn(msg) { try { console.warn(`[org-mesh] ${msg}`); } catch { /* never throw from logging */ } }
|
|
77
90
|
|
|
@@ -84,6 +97,38 @@ function defaultRequestStop() {
|
|
|
84
97
|
try { process.kill(process.pid, "SIGTERM"); } catch { /* fail-open */ }
|
|
85
98
|
}
|
|
86
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Default restart signal (the "restart" directive, audit F10). The daemon
|
|
102
|
+
* plist's KeepAlive is {SuccessfulExit:false}: launchd relaunches ONLY after a
|
|
103
|
+
* non-zero exit, and a clean exit 0 leaves the seat down. So: set exitCode 1
|
|
104
|
+
* first, then go through the daemon's own SIGTERM handlers (they flush
|
|
105
|
+
* active-session state + the health dashboard and exit honouring exitCode).
|
|
106
|
+
* If we cannot signal ourselves, exit 1 directly — a restart must not turn
|
|
107
|
+
* into a hang.
|
|
108
|
+
*/
|
|
109
|
+
function defaultRequestRestart() {
|
|
110
|
+
process.exitCode = 1;
|
|
111
|
+
try { process.kill(process.pid, "SIGTERM"); }
|
|
112
|
+
catch { try { process.exit(1); } catch { /* fail-open */ } }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Ask the main session to restart itself ("session.restart"): write the
|
|
117
|
+
* restart-requested flag the session's feed watches. Idempotent overwrite
|
|
118
|
+
* (the newest request time wins) and fail-open. Never throws.
|
|
119
|
+
*/
|
|
120
|
+
function requestSessionRestart(agentRoot, now) {
|
|
121
|
+
const at = new Date((typeof now === "function" ? now() : Date.now())).toISOString();
|
|
122
|
+
try {
|
|
123
|
+
const path = join(agentRoot, SESSION_RESTART_FLAG_RELATIVE);
|
|
124
|
+
mkdirSync(join(agentRoot, "state", "session"), { recursive: true });
|
|
125
|
+
writeFileSync(path, `${at}\n`);
|
|
126
|
+
logInfo(`session.restart directive — flagged ${SESSION_RESTART_FLAG_RELATIVE} (${at})`);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
logWarn(`session.restart directive — failed to write the restart flag (${err && err.message})`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
87
132
|
/**
|
|
88
133
|
* Trip the local kill switch in response to a "halt" directive.
|
|
89
134
|
* 1. Write `.emergency-stop` (the same flag emergency-stop.sh drops) so a
|
|
@@ -187,7 +232,7 @@ async function resyncDirectory(o) {
|
|
|
187
232
|
const id = String(p.id).toLowerCase().replace(/[^a-z0-9._-]/gi, "_");
|
|
188
233
|
if (!id) continue;
|
|
189
234
|
try {
|
|
190
|
-
|
|
235
|
+
writeJsonAtomic(join(peersDir, `${id}.json`), { ...p, source: "synced" });
|
|
191
236
|
written += 1;
|
|
192
237
|
} catch { /* skip this peer, keep going */ }
|
|
193
238
|
}
|
|
@@ -279,6 +324,16 @@ async function handleDirective(directive, ctx) {
|
|
|
279
324
|
// Force an immediate refresh + disk write, ahead of the 30s poll.
|
|
280
325
|
await refreshIntegrationTools(ctx);
|
|
281
326
|
return;
|
|
327
|
+
case "restart":
|
|
328
|
+
// Stop beating first: this process is going down to come back, and a
|
|
329
|
+
// beat in flight during teardown would just be noise.
|
|
330
|
+
logWarn("restart directive received — stopping the beat loop and restarting the daemon (exit 1 → launchd relaunch)");
|
|
331
|
+
ctx.stop();
|
|
332
|
+
try { ctx.requestRestart(); } catch { /* fail-open */ }
|
|
333
|
+
return;
|
|
334
|
+
case "session.restart":
|
|
335
|
+
requestSessionRestart(ctx.agentRoot, ctx.now);
|
|
336
|
+
return;
|
|
282
337
|
default:
|
|
283
338
|
return; // forward-compatible: ignore unknown directives
|
|
284
339
|
}
|
|
@@ -389,6 +444,7 @@ async function beatOnce(ctx) {
|
|
|
389
444
|
* @param {Function} [o.clearInterval] injected interval clearer
|
|
390
445
|
* @param {object} [o.client] injected org client (defaults to lib/org/client)
|
|
391
446
|
* @param {Function}[o.requestStop] injected stop signal (defaults to SIGTERM self)
|
|
447
|
+
* @param {Function}[o.requestRestart] injected restart signal (defaults to exitCode 1 + SIGTERM self)
|
|
392
448
|
* @param {number} [o.beatMs] beat interval (defaults to DEFAULT_BEAT_MS)
|
|
393
449
|
* @param {Function}[o.collectStatus] injected status collector (defaults to lib/telemetry/collect)
|
|
394
450
|
* @returns {Promise<{stop:Function, isEnabled:boolean}>}
|
|
@@ -409,6 +465,7 @@ export async function connectOrgMesh(o = {}) {
|
|
|
409
465
|
const clearIntervalFn = typeof o.clearInterval === "function" ? o.clearInterval : clearInterval;
|
|
410
466
|
const beatMs = Number.isFinite(o.beatMs) && o.beatMs > 0 ? o.beatMs : DEFAULT_BEAT_MS;
|
|
411
467
|
const requestStop = typeof o.requestStop === "function" ? o.requestStop : defaultRequestStop;
|
|
468
|
+
const requestRestart = typeof o.requestRestart === "function" ? o.requestRestart : defaultRequestRestart;
|
|
412
469
|
|
|
413
470
|
let conn = {};
|
|
414
471
|
try { conn = client.configFromAgent(cfg) || {}; } catch { conn = {}; }
|
|
@@ -420,6 +477,7 @@ export async function connectOrgMesh(o = {}) {
|
|
|
420
477
|
fetchImpl: o.fetchImpl,
|
|
421
478
|
now,
|
|
422
479
|
requestStop,
|
|
480
|
+
requestRestart,
|
|
423
481
|
base: conn.base,
|
|
424
482
|
token: conn.token,
|
|
425
483
|
orgId: conn.orgId,
|
|
@@ -491,6 +549,8 @@ export const _internals = {
|
|
|
491
549
|
handleDirective,
|
|
492
550
|
tripKillSwitch,
|
|
493
551
|
flagRepairNeeded,
|
|
552
|
+
defaultRequestRestart,
|
|
553
|
+
requestSessionRestart,
|
|
494
554
|
resyncDirectory,
|
|
495
555
|
refreshPolicy,
|
|
496
556
|
refreshIntegrationTools,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
8bce96163171fc5b9f23ea03889cd9220113af09e14f87d704c635f33f9aa76e
|
package/lib/org/protocol.mjs
CHANGED
|
@@ -1045,6 +1045,49 @@ export const METHODS = Object.freeze({
|
|
|
1045
1045
|
"architecture.status": { family: "architecture", scope: "org.read", sideEffecting: false },
|
|
1046
1046
|
"architecture.accept": { family: "architecture", scope: "org.write", sideEffecting: true, idempotent: true },
|
|
1047
1047
|
"architecture.approve": { family: "architecture", scope: "org.write", sideEffecting: true, idempotent: true },
|
|
1048
|
+
// --- architecture, the LIVE half (2026-08): the COMMITTED company's board at
|
|
1049
|
+
// hq's /org/architecture, as opposed to the three above, which review a
|
|
1050
|
+
// Genesis DRAFT. Same family because it is the same surface outliving the
|
|
1051
|
+
// run — the reason the family was split from `genesis` in the first place.
|
|
1052
|
+
//
|
|
1053
|
+
// THREE METHODS, AND THE SHAPE OF THE THREE IS THE AUTHORITY MODEL.
|
|
1054
|
+
// hq's own rule (src/server/architecture/proposals.ts) is that a change is
|
|
1055
|
+
// gated on a reviewed record when the CHANGE has a cascade, not when the
|
|
1056
|
+
// ACTOR is a machine: axis acts and capability removal need a proposal
|
|
1057
|
+
// somebody with the floor approved; the mechanical 90% goes direct. What
|
|
1058
|
+
// an agent gets on this wire is therefore the ASKING (`propose`) and
|
|
1059
|
+
// enough of the board to ask accurately (`board`), plus the queue that
|
|
1060
|
+
// tells it what became of the ask (`proposals`). Approval is NOT here: it
|
|
1061
|
+
// is a session act in hq, and the module that owns it says why — "an agent
|
|
1062
|
+
// cannot reach an approval at all, not because a check says no, but
|
|
1063
|
+
// because there is no door. A door that does not exist has to be BUILT,
|
|
1064
|
+
// and building it is a review a person will see."
|
|
1065
|
+
//
|
|
1066
|
+
// `architecture.board` EXISTS BECAUSE THE IDS ON SCREEN ADDRESS NOTHING.
|
|
1067
|
+
// hq mints a board's entity ids BY POSITION (`P1`/`S3`/`C12`, `D1`/`T1`)
|
|
1068
|
+
// because its id schema bounds an id at 2-4 chars while a cuid is 25, and
|
|
1069
|
+
// every write resolves through a derived-id -> row-id map the browser
|
|
1070
|
+
// holds. An agent has no browser. Without this read the only ids it could
|
|
1071
|
+
// put in a proposal are guessed ones, so the read is not a convenience —
|
|
1072
|
+
// it is the addressing layer `propose` is unusable without.
|
|
1073
|
+
//
|
|
1074
|
+
// SCOPES REUSE `org.read` / `org.write` — the same argument the resource
|
|
1075
|
+
// family makes below, and here it is also the reason the family is
|
|
1076
|
+
// reachable AT ALL on the day it lands: both are in DEFAULT_AGENT_SCOPES,
|
|
1077
|
+
// `org.read` is in hq's VIEWER tier, so a paired key can read the board
|
|
1078
|
+
// today and an EDITOR key can propose. NOTE the asymmetry that follows,
|
|
1079
|
+
// deliberately: `org.write` is NOT in hq's HUMAN_DEFAULT_SCOPES, so
|
|
1080
|
+
// `architecture.propose` is an AGENT-plane verb and the human proposes
|
|
1081
|
+
// from the web app's own server action. That is the same split the
|
|
1082
|
+
// resource family notes, and it is the right way round here — the human
|
|
1083
|
+
// already has the surface; the agent is the one that had no door.
|
|
1084
|
+
//
|
|
1085
|
+
// `propose` is NOT idempotent, and the omission is the declaration: a
|
|
1086
|
+
// second call with the same params is a second, honestly distinct ask, and
|
|
1087
|
+
// caching one under a replay key would silently swallow it.
|
|
1088
|
+
"architecture.board": { family: "architecture", scope: "org.read", sideEffecting: false },
|
|
1089
|
+
"architecture.propose": { family: "architecture", scope: "org.write", sideEffecting: true },
|
|
1090
|
+
"architecture.proposals": { family: "architecture", scope: "org.read", sideEffecting: false },
|
|
1048
1091
|
// --- resource (2026-08): THE VENTURE-DELIVERABLE CATALOGUE. `OrgResource` is
|
|
1049
1092
|
// the org-scoped index of a venture's own artifacts — one row per
|
|
1050
1093
|
// deliverable, keyed `(orgId, slug)`, provisioned by the portal
|
|
@@ -1163,6 +1206,11 @@ export const READS = Object.freeze({
|
|
|
1163
1206
|
"hierarchy": "org.read",
|
|
1164
1207
|
"board.ready": "board.read",
|
|
1165
1208
|
"board.context": "board.read",
|
|
1209
|
+
// the seat's OWN work across every board (2026-09, front-door session §3.5):
|
|
1210
|
+
// every item where the caller is assignee or reviewer. `board.ready` is the
|
|
1211
|
+
// unassigned frontier and cannot answer "what is mine?"; the daemon caches
|
|
1212
|
+
// this for the main session's primer and the session works it proactively.
|
|
1213
|
+
"board.mine": "board.read",
|
|
1166
1214
|
"ops": "org.read",
|
|
1167
1215
|
// phase 3 reads
|
|
1168
1216
|
"approval.wait": "approval.request", // bounded long-poll for an approval outcome
|
|
@@ -1213,6 +1261,10 @@ export const DIRECTIVES = Object.freeze([
|
|
|
1213
1261
|
"pause_schedules", // stop INITIATING (Loop B + Loop C); keep answering when addressed
|
|
1214
1262
|
"plan_stale", // the seat's mandate moved — refetch `mandate.get` and recompile
|
|
1215
1263
|
"revoke_scope", // a scope was withdrawn; drop it locally and re-probe capabilities
|
|
1264
|
+
// --- front-door session (2026-09) ---
|
|
1265
|
+
"restart", // a human pressed "Restart" on the fleet surface: exit the main
|
|
1266
|
+
// session cleanly so the supervisor relaunches it on current code.
|
|
1267
|
+
// The beat delivers it once; alerts close only on a later clean beat.
|
|
1216
1268
|
]);
|
|
1217
1269
|
|
|
1218
1270
|
// ---------------------------------------------------------------------------
|
|
@@ -205,7 +205,18 @@ test("protocol: frozen family + method counts (additive evolution guard)", () =>
|
|
|
205
205
|
// to `files.*`: a card carries a fileId POINTER and the drive keeps its own
|
|
206
206
|
// seat-scoped read surface → 483. Then +1: messaging.electResponder (the
|
|
207
207
|
// fleet's server-side respond-election) → 484.
|
|
208
|
-
|
|
208
|
+
// The 2026-08 architecture live-board pass adds 3, closing the half of the
|
|
209
|
+
// `architecture` family that was never on the wire: the three methods above
|
|
210
|
+
// review a Genesis DRAFT, while hq's /org/architecture is a CRUD surface over
|
|
211
|
+
// the COMMITTED company and no agent could see it, let alone ask it to change.
|
|
212
|
+
// architecture.board (the read that hands back LIVE ROW IDS beside the
|
|
213
|
+
// positional `P1`/`S3` labels the board renders — an agent has no browser to
|
|
214
|
+
// hold the derived-id -> row-id map, so without it every id it could propose
|
|
215
|
+
// on would be a guess), architecture.propose (the agent's end of hq's
|
|
216
|
+
// "agents propose, a human with the floor approves" rule) and
|
|
217
|
+
// architecture.proposals (the queue that says what became of the ask). NOT
|
|
218
|
+
// approve/reject: those are session acts in hq by design → 487.
|
|
219
|
+
assert.equal(Object.keys(METHODS).length, 487, "method count");
|
|
209
220
|
});
|
|
210
221
|
|
|
211
222
|
test("protocol SP3: messaging + calling families/methods/scopes", async () => {
|
package/lib/org/registry.mjs
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
|
|
17
17
|
"use strict";
|
|
18
18
|
|
|
19
|
-
import { existsSync, readFileSync,
|
|
19
|
+
import { existsSync, readFileSync, mkdirSync, readdirSync } from "node:fs";
|
|
20
|
+
import { writeJsonAtomic } from "../fs-atomic.mjs";
|
|
20
21
|
import { join } from "node:path";
|
|
21
22
|
|
|
22
23
|
const ORG_REL = "state/org";
|
|
@@ -151,7 +152,7 @@ function orgDir(agentRoot) { return join(agentRoot, ORG_REL); }
|
|
|
151
152
|
export function writeSelfEntry(agentRoot, entry) {
|
|
152
153
|
try {
|
|
153
154
|
mkdirSync(orgDir(agentRoot), { recursive: true });
|
|
154
|
-
|
|
155
|
+
writeJsonAtomic(join(orgDir(agentRoot), "self.json"), entry);
|
|
155
156
|
return true;
|
|
156
157
|
} catch { return false; }
|
|
157
158
|
}
|