@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.
Files changed (172) hide show
  1. package/.env.example +37 -22
  2. package/README.md +2 -0
  3. package/bin/maestro.mjs +113 -39
  4. package/bin/maestro.test.mjs +175 -5
  5. package/docs/guides/front-door-session.md +264 -0
  6. package/docs/guides/mac-mini.md +100 -28
  7. package/docs/guides/org-onboarding.md +1 -1
  8. package/docs/guides/setup-wizard.md +9 -5
  9. package/docs/runbooks/cohort-cutover.md +11 -1
  10. package/docs/runbooks/mac-mini-bootstrap.md +38 -63
  11. package/lib/cadence-bus-requeue.test.mjs +83 -0
  12. package/lib/cadence-bus.mjs +43 -7
  13. package/lib/channels/inbox-item.mjs +59 -2
  14. package/lib/cli/board.mjs +285 -0
  15. package/lib/cli/board.test.mjs +227 -0
  16. package/lib/cli/doctor-checks.mjs +441 -0
  17. package/lib/cli/doctor-checks.test.mjs +336 -0
  18. package/lib/cli/global-setup-extras.mjs +410 -0
  19. package/lib/cli/global-setup-extras.test.mjs +367 -0
  20. package/lib/cli/inbox.mjs +304 -0
  21. package/lib/cli/inbox.test.mjs +230 -0
  22. package/lib/cli/session-ack.mjs +63 -0
  23. package/lib/cli/session-ack.test.mjs +63 -0
  24. package/lib/cli/session.mjs +750 -0
  25. package/lib/cli/session.test.mjs +602 -0
  26. package/lib/collective/global-config.mjs +204 -6
  27. package/lib/collective/global-config.test.mjs +140 -0
  28. package/lib/collective/global-skills.mjs +145 -0
  29. package/lib/collective/global-skills.test.mjs +126 -0
  30. package/lib/collective/presence.mjs +4 -3
  31. package/lib/comms/send-gate.mjs +115 -0
  32. package/lib/comms/send-gate.test.mjs +113 -0
  33. package/lib/feature-init.mjs +2 -2
  34. package/lib/identity/persona.mjs +29 -0
  35. package/lib/identity/persona.test.mjs +26 -1
  36. package/lib/mcp/server.test.mjs +9 -4
  37. package/lib/model-router/spawn.test.mjs +21 -0
  38. package/lib/org/board-mine-cache.mjs +99 -0
  39. package/lib/org/board-mine-cache.test.mjs +53 -0
  40. package/lib/org/board.mjs +11 -0
  41. package/lib/org/board.test.mjs +11 -1
  42. package/lib/org/client.mjs +36 -0
  43. package/lib/org/client.test.mjs +46 -0
  44. package/lib/org/inbound/directedness.mjs +18 -2
  45. package/lib/org/inbound/directedness.test.mjs +58 -0
  46. package/lib/org/inbound/index.mjs +8 -1
  47. package/lib/org/inbound/index.test.mjs +22 -0
  48. package/lib/org/mesh-directives.test.mjs +110 -0
  49. package/lib/org/mesh.mjs +61 -1
  50. package/lib/org/protocol.checksum +1 -1
  51. package/lib/org/protocol.mjs +52 -0
  52. package/lib/org/protocol.test.mjs +12 -1
  53. package/lib/org/registry.mjs +3 -2
  54. package/lib/org/tool-surface.mjs +120 -0
  55. package/lib/org/tool-surface.test.mjs +118 -5
  56. package/lib/security/external-content.mjs +1 -1
  57. package/lib/security/external-content.test.mjs +17 -0
  58. package/lib/session/config.mjs +137 -0
  59. package/lib/session/config.test.mjs +92 -0
  60. package/lib/session/feed-core.mjs +229 -0
  61. package/lib/session/feed-core.test.mjs +198 -0
  62. package/lib/session/first-run.mjs +126 -0
  63. package/lib/session/first-run.test.mjs +121 -0
  64. package/lib/session/frontdoor.mjs +266 -0
  65. package/lib/session/frontdoor.test.mjs +205 -0
  66. package/lib/session/handoffs.mjs +295 -0
  67. package/lib/session/handoffs.test.mjs +183 -0
  68. package/lib/session/identity.mjs +220 -0
  69. package/lib/session/identity.test.mjs +180 -0
  70. package/lib/session/inbox-claims.mjs +434 -0
  71. package/lib/session/inbox-claims.test.mjs +286 -0
  72. package/lib/session/launch-args.mjs +161 -0
  73. package/lib/session/launch-args.test.mjs +157 -0
  74. package/lib/session/liveness.mjs +174 -0
  75. package/lib/session/liveness.test.mjs +100 -0
  76. package/lib/session/status-summary.mjs +172 -0
  77. package/lib/session/status-summary.test.mjs +118 -0
  78. package/lib/session-permissions.mjs +39 -3
  79. package/lib/session-permissions.test.mjs +20 -0
  80. package/lib/setup/claude-probe.mjs +161 -24
  81. package/lib/setup/claude-probe.test.mjs +187 -0
  82. package/lib/setup/sections/learning.mjs +2 -1
  83. package/lib/setup/sections/model.mjs +104 -24
  84. package/lib/setup/sections/model.test.mjs +240 -0
  85. package/lib/setup/sections/org.mjs +27 -2
  86. package/lib/setup/sections/org.test.mjs +35 -2
  87. package/lib/setup/sections/verify.mjs +5 -0
  88. package/lib/setup/state.mjs +30 -10
  89. package/lib/setup/state.test.mjs +24 -1
  90. package/lib/singleton.js +11 -3
  91. package/lib/singleton.test.mjs +16 -0
  92. package/lib/subagents/lock.mjs +1 -1
  93. package/lib/telemetry/collect.mjs +270 -6
  94. package/lib/telemetry/collect.test.mjs +196 -1
  95. package/lib/upgrade/global-refresh.mjs +108 -0
  96. package/lib/upgrade/global-refresh.test.mjs +65 -0
  97. package/lib/upgrade/launchd-reconcile.mjs +327 -0
  98. package/lib/upgrade/launchd-reconcile.test.mjs +272 -0
  99. package/lib/upgrade/post-steps.mjs +151 -0
  100. package/lib/upgrade/post-steps.test.mjs +200 -0
  101. package/lib/upgrade/verify.mjs +215 -0
  102. package/lib/upgrade/verify.test.mjs +164 -0
  103. package/lib/voice/outbound.mjs +3 -2
  104. package/lib/voice/post-call-brief.mjs +2 -1
  105. package/lib/voice/session-rotation.mjs +6 -1
  106. package/lib/voice/session-rotation.test.mjs +114 -0
  107. package/package.json +3 -3
  108. package/plugins/maestro-skills/plugin.json +21 -1
  109. package/plugins/maestro-skills/skills/board-work.md +63 -0
  110. package/plugins/maestro-skills/skills/inbound-triage.md +80 -0
  111. package/plugins/maestro-skills/skills/main-session.md +102 -0
  112. package/plugins/maestro-skills/skills/peer-sessions.md +65 -0
  113. package/plugins/maestro-skills/skills/persona-discipline.md +75 -0
  114. package/scaffold/CLAUDE.md +34 -0
  115. package/scripts/ci/check-durable-write-seam.mjs +147 -0
  116. package/scripts/ci/check-durable-write-seam.test.mjs +90 -0
  117. package/scripts/ci/check.mjs +3 -0
  118. package/scripts/collective/hook-runner.mjs +39 -4
  119. package/scripts/collective/hook-runner.test.mjs +85 -2
  120. package/scripts/daemon/agent-daemon-board-mine.test.mjs +96 -0
  121. package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
  122. package/scripts/daemon/agent-daemon.mjs +141 -10
  123. package/scripts/daemon/agent-daemon.test.mjs +73 -0
  124. package/scripts/daemon/assurance-e2e.test.mjs +141 -6
  125. package/scripts/daemon/assurance.mjs +461 -37
  126. package/scripts/daemon/assurance.test.mjs +408 -43
  127. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +334 -0
  128. package/scripts/daemon/cadence-consumer.mjs +254 -78
  129. package/scripts/daemon/cadence-handlers.mjs +53 -0
  130. package/scripts/daemon/classifier.mjs +1 -1
  131. package/scripts/daemon/dispatcher-resume.test.mjs +166 -0
  132. package/scripts/daemon/dispatcher.mjs +127 -19
  133. package/scripts/daemon/health.mjs +12 -1
  134. package/scripts/daemon/inbox-deferral-session.test.mjs +49 -0
  135. package/scripts/daemon/inbox-deferral.mjs +6 -0
  136. package/scripts/daemon/lib/self-echo.mjs +201 -0
  137. package/scripts/daemon/lib/self-echo.test.mjs +153 -0
  138. package/scripts/daemon/maestro-daemon.mjs +3 -0
  139. package/scripts/daemon/prompt-builder.mjs +9 -1
  140. package/scripts/daemon/prompt-builder.test.mjs +22 -0
  141. package/scripts/daemon/responder.mjs +61 -41
  142. package/scripts/daemon/sdk-version.mjs +51 -0
  143. package/scripts/daemon/sdk-version.test.mjs +31 -0
  144. package/scripts/hooks/pre-send-audit.sh +97 -4
  145. package/scripts/hooks/pre-send-audit.test.mjs +140 -1
  146. package/scripts/local-triggers/autoupdate.sh +243 -19
  147. package/scripts/local-triggers/autoupdate.test.mjs +488 -0
  148. package/scripts/local-triggers/generate-plists.sh +24 -1
  149. package/scripts/local-triggers/generate-plists.test.mjs +49 -11
  150. package/scripts/org/send-orgmail.first-contact.test.mjs +102 -0
  151. package/scripts/org/send-orgmail.mjs +27 -3
  152. package/scripts/poller/inbox-privilege-injection.test.mjs +167 -0
  153. package/scripts/poller/slack-poller.mjs +13 -1
  154. package/scripts/poller/utils.mjs +46 -1
  155. package/scripts/poller-launchd/install.sh +19 -11
  156. package/scripts/poller-launchd/install.test.mjs +243 -0
  157. package/scripts/poller-launchd/launchd-poller-wrapper.sh +92 -0
  158. package/scripts/poller-launchd/migrate.sh +66 -0
  159. package/scripts/poller-launchd/poller.plist.template +4 -2
  160. package/scripts/session/feed.mjs +237 -0
  161. package/scripts/session/feed.test.mjs +196 -0
  162. package/scripts/session/supervisor-sh.test.mjs +218 -0
  163. package/scripts/session/supervisor.mjs +328 -0
  164. package/scripts/session/supervisor.sh +141 -0
  165. package/scripts/session/supervisor.test.mjs +482 -0
  166. package/scripts/setup/configure-macos.sh +250 -55
  167. package/scripts/setup/configure-macos.test.mjs +306 -0
  168. package/scripts/setup/init-agent.sh +112 -7
  169. package/scripts/setup/init-agent.test.mjs +220 -1
  170. package/scripts/watchdog/memory-watchdog.sh +37 -1
  171. package/scripts/watchdog/memory-watchdog.test.mjs +64 -0
  172. package/scripts/setup/boot-claude-session.sh +0 -94
@@ -14,7 +14,7 @@ import { tmpdir } from "node:os";
14
14
  import { join } from "node:path";
15
15
 
16
16
  import {
17
- createItem, claimItem, completeItem, decompose, assign, link, ready,
17
+ createItem, claimItem, completeItem, decompose, assign, link, ready, mine,
18
18
  buildProjection, writeProjection, writeReadyProjection,
19
19
  } from "./board.mjs";
20
20
  import { okFrame, errFrame, BOARD_STATUS } from "./protocol.mjs";
@@ -38,6 +38,7 @@ function fakeClient(map = {}) {
38
38
  boardAssign: make("boardAssign"),
39
39
  boardLink: make("boardLink"),
40
40
  listReady: make("listReady"),
41
+ listMine: make("listMine"),
41
42
  };
42
43
  }
43
44
 
@@ -175,3 +176,12 @@ test("writeReadyProjection: down server (empty ready) writes an empty projection
175
176
  assert.equal(doc.count, 0);
176
177
  } finally { rmSync(root, { recursive: true, force: true }); }
177
178
  });
179
+
180
+ test("mine: routes to client.listMine, forwarding conn opts (fail-open [] handled by the client)", async () => {
181
+ const client = fakeClient({ listMine: () => [{ itemId: "m1" }] });
182
+ assert.deepEqual(await mine({ client, ...CONN }), [{ itemId: "m1" }]);
183
+ // listMine takes ONE argument (the conn opts), like listReady.
184
+ const c = client.calls.find((x) => x.name === "listMine");
185
+ assert.equal(c.params.base, CONN.base);
186
+ assert.equal(c.params.token, CONN.token);
187
+ });
@@ -694,6 +694,42 @@ export async function listReady(o = {}) {
694
694
  return [];
695
695
  }
696
696
 
697
+ /**
698
+ * List every board item assigned to (or under review by) THIS agent across all
699
+ * boards and workstreams (GET /v1/board.mine — hq 2026-09, front-door session
700
+ * §3.5). Returns the items array, or [] on any failure (fail-open): an older hq
701
+ * without the read answers 404 and this returns [], same as listReady.
702
+ * Item shape: {itemId, title, boardId, boardName, channelId, workstreamId, col,
703
+ * stage, priority, dueAt, updatedAt, url}.
704
+ * @param {object} o - { base, token, orgId?, fetchImpl? }
705
+ * @returns {Promise<object[]>}
706
+ */
707
+ export async function listMine(o = {}) {
708
+ return (await listMineResult(o)).items;
709
+ }
710
+
711
+ /**
712
+ * `listMine` with the failure kept visible: `{ok:false, items:[]}` when hq
713
+ * could not be read, `{ok:true, items}` otherwise (an empty board is
714
+ * `ok:true`). The daemon's board-mine cache needs the difference so one
715
+ * unreachable tick does not overwrite the last known good answer with [].
716
+ * Never throws.
717
+ * @param {object} o - { base, token, orgId, fetchImpl? }
718
+ * @returns {Promise<{ok:boolean, items:object[], error?:string}>}
719
+ */
720
+ export async function listMineResult(o = {}) {
721
+ try {
722
+ const r = await read("board.mine", o);
723
+ if (!r.ok) return { ok: false, items: [], error: (r.error && r.error.message) || `http ${r.status || 0}` };
724
+ if (!r.payload) return { ok: true, items: [] };
725
+ if (Array.isArray(r.payload)) return { ok: true, items: r.payload };
726
+ if (Array.isArray(r.payload.items)) return { ok: true, items: r.payload.items };
727
+ return { ok: true, items: [] };
728
+ } catch (err) {
729
+ return { ok: false, items: [], error: String((err && err.message) || err) }; // read() never throws; belt-and-braces for a throwing fetchImpl
730
+ }
731
+ }
732
+
697
733
  /**
698
734
  * Fetch the event slice (GET /v1/events?cursor=N&limit=500). Returns the events
699
735
  * array (or [] on failure). The caller pipes these to verify.verifyEvents.
@@ -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>}} [opts]
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 (meId && cand.actor && cand.actor === meId) return no("own_echo");
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
- writeFileSync(join(peersDir, `${id}.json`), JSON.stringify({ ...p, source: "synced" }, null, 2));
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
- b2cbf124c99800a0ad1b19d7aea6a88c3c72f07b53838eb44ef6f82d888fda43
1
+ 8bce96163171fc5b9f23ea03889cd9220113af09e14f87d704c635f33f9aa76e
@@ -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
- assert.equal(Object.keys(METHODS).length, 484, "method count");
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 () => {
@@ -16,7 +16,8 @@
16
16
 
17
17
  "use strict";
18
18
 
19
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs";
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
- writeFileSync(join(orgDir(agentRoot), "self.json"), JSON.stringify(entry, null, 2));
155
+ writeJsonAtomic(join(orgDir(agentRoot), "self.json"), entry);
155
156
  return true;
156
157
  } catch { return false; }
157
158
  }