@cohortapp/agent-sdk 2.11.15 → 2.13.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 (280) hide show
  1. package/.env.example +37 -22
  2. package/README.md +2 -0
  3. package/bin/maestro.mjs +117 -39
  4. package/bin/maestro.test.mjs +175 -5
  5. package/docs/guides/front-door-session.md +313 -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/design.mjs +185 -0
  17. package/lib/cli/design.test.mjs +270 -0
  18. package/lib/cli/doctor-checks.mjs +441 -0
  19. package/lib/cli/doctor-checks.test.mjs +336 -0
  20. package/lib/cli/global-setup-extras.mjs +454 -0
  21. package/lib/cli/global-setup-extras.test.mjs +462 -0
  22. package/lib/cli/inbox.mjs +304 -0
  23. package/lib/cli/inbox.test.mjs +230 -0
  24. package/lib/cli/session-ack.mjs +63 -0
  25. package/lib/cli/session-ack.test.mjs +63 -0
  26. package/lib/cli/session.mjs +760 -0
  27. package/lib/cli/session.test.mjs +613 -0
  28. package/lib/collective/global-config.mjs +209 -6
  29. package/lib/collective/global-config.test.mjs +145 -0
  30. package/lib/collective/global-skills.mjs +145 -0
  31. package/lib/collective/global-skills.test.mjs +126 -0
  32. package/lib/collective/presence.mjs +4 -3
  33. package/lib/collective/vendor-skills.mjs +305 -0
  34. package/lib/collective/vendor-skills.test.mjs +306 -0
  35. package/lib/comms/send-gate.mjs +115 -0
  36. package/lib/comms/send-gate.test.mjs +113 -0
  37. package/lib/design/design-md.mjs +793 -0
  38. package/lib/design/design-md.test.mjs +318 -0
  39. package/lib/design/fixtures/DESIGN.golden.md +238 -0
  40. package/lib/design/fixtures/PRODUCT.golden.md +67 -0
  41. package/lib/design/fixtures/foundation.json +133 -0
  42. package/lib/design/refresh-gate.mjs +154 -0
  43. package/lib/design/refresh-gate.test.mjs +144 -0
  44. package/lib/design/write.mjs +275 -0
  45. package/lib/design/write.test.mjs +241 -0
  46. package/lib/feature-init.mjs +2 -2
  47. package/lib/mcp/server.test.mjs +9 -4
  48. package/lib/model-router/spawn.test.mjs +21 -0
  49. package/lib/org/board-mine-cache.mjs +99 -0
  50. package/lib/org/board-mine-cache.test.mjs +53 -0
  51. package/lib/org/board.mjs +11 -0
  52. package/lib/org/board.test.mjs +11 -1
  53. package/lib/org/client.mjs +36 -0
  54. package/lib/org/client.test.mjs +46 -0
  55. package/lib/org/inbound/directedness.mjs +18 -2
  56. package/lib/org/inbound/directedness.test.mjs +58 -0
  57. package/lib/org/inbound/index.mjs +8 -1
  58. package/lib/org/inbound/index.test.mjs +22 -0
  59. package/lib/org/mesh-directives.test.mjs +110 -0
  60. package/lib/org/mesh.mjs +61 -1
  61. package/lib/org/protocol.checksum +1 -1
  62. package/lib/org/protocol.mjs +52 -0
  63. package/lib/org/protocol.test.mjs +12 -1
  64. package/lib/org/registry.mjs +3 -2
  65. package/lib/org/tool-surface.mjs +120 -0
  66. package/lib/org/tool-surface.test.mjs +118 -5
  67. package/lib/prompts/parallelism.mjs +79 -0
  68. package/lib/prompts/parallelism.test.mjs +177 -0
  69. package/lib/security/external-content.mjs +1 -1
  70. package/lib/security/external-content.test.mjs +17 -0
  71. package/lib/session/config.mjs +137 -0
  72. package/lib/session/config.test.mjs +92 -0
  73. package/lib/session/feed-core.mjs +229 -0
  74. package/lib/session/feed-core.test.mjs +198 -0
  75. package/lib/session/first-run.mjs +126 -0
  76. package/lib/session/first-run.test.mjs +121 -0
  77. package/lib/session/frontdoor.mjs +266 -0
  78. package/lib/session/frontdoor.test.mjs +205 -0
  79. package/lib/session/handoffs.mjs +295 -0
  80. package/lib/session/handoffs.test.mjs +183 -0
  81. package/lib/session/identity.mjs +220 -0
  82. package/lib/session/identity.test.mjs +180 -0
  83. package/lib/session/inbox-claims.mjs +434 -0
  84. package/lib/session/inbox-claims.test.mjs +286 -0
  85. package/lib/session/launch-args.mjs +161 -0
  86. package/lib/session/launch-args.test.mjs +157 -0
  87. package/lib/session/liveness.mjs +174 -0
  88. package/lib/session/liveness.test.mjs +100 -0
  89. package/lib/session/status-summary.mjs +172 -0
  90. package/lib/session/status-summary.test.mjs +118 -0
  91. package/lib/session-permissions.mjs +39 -3
  92. package/lib/session-permissions.test.mjs +20 -0
  93. package/lib/setup/claude-probe.mjs +161 -24
  94. package/lib/setup/claude-probe.test.mjs +187 -0
  95. package/lib/setup/sections/learning.mjs +2 -1
  96. package/lib/setup/sections/model.mjs +104 -24
  97. package/lib/setup/sections/model.test.mjs +240 -0
  98. package/lib/setup/sections/org.mjs +27 -2
  99. package/lib/setup/sections/org.test.mjs +35 -2
  100. package/lib/setup/sections/verify.mjs +5 -0
  101. package/lib/setup/state.mjs +30 -10
  102. package/lib/setup/state.test.mjs +24 -1
  103. package/lib/singleton.js +11 -3
  104. package/lib/singleton.test.mjs +16 -0
  105. package/lib/subagents/lock.mjs +1 -1
  106. package/lib/telemetry/collect.mjs +270 -6
  107. package/lib/telemetry/collect.test.mjs +196 -1
  108. package/lib/upgrade/global-refresh.mjs +108 -0
  109. package/lib/upgrade/global-refresh.test.mjs +65 -0
  110. package/lib/upgrade/launchd-reconcile.mjs +327 -0
  111. package/lib/upgrade/launchd-reconcile.test.mjs +272 -0
  112. package/lib/upgrade/post-steps.mjs +151 -0
  113. package/lib/upgrade/post-steps.test.mjs +200 -0
  114. package/lib/upgrade/verify.mjs +215 -0
  115. package/lib/upgrade/verify.test.mjs +164 -0
  116. package/lib/voice/outbound.mjs +3 -2
  117. package/lib/voice/post-call-brief.mjs +2 -1
  118. package/lib/voice/session-rotation.mjs +6 -1
  119. package/lib/voice/session-rotation.test.mjs +114 -0
  120. package/package.json +3 -3
  121. package/plugins/maestro-skills/plugin.json +25 -1
  122. package/plugins/maestro-skills/skills/board-work.md +63 -0
  123. package/plugins/maestro-skills/skills/cohort-design.md +153 -0
  124. package/plugins/maestro-skills/skills/inbound-triage.md +80 -0
  125. package/plugins/maestro-skills/skills/main-session.md +102 -0
  126. package/plugins/maestro-skills/skills/peer-sessions.md +65 -0
  127. package/plugins/maestro-skills/skills/persona-discipline.md +75 -0
  128. package/plugins/maestro-skills/vendor/emilkowalski/LICENSE +21 -0
  129. package/plugins/maestro-skills/vendor/emilkowalski/UPSTREAM.json +70 -0
  130. package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/RECIPES.md +324 -0
  131. package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/SKILL.md +199 -0
  132. package/plugins/maestro-skills/vendor/emilkowalski/skills/animation-vocabulary/SKILL.md +173 -0
  133. package/plugins/maestro-skills/vendor/emilkowalski/skills/apple-design/SKILL.md +282 -0
  134. package/plugins/maestro-skills/vendor/emilkowalski/skills/emil-design-eng/SKILL.md +674 -0
  135. package/plugins/maestro-skills/vendor/emilkowalski/skills/find-animation-opportunities/SKILL.md +132 -0
  136. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/AUDIT.md +115 -0
  137. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/PLAN-TEMPLATE.md +73 -0
  138. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/SKILL.md +101 -0
  139. package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/PICKER.md +197 -0
  140. package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/SKILL.md +90 -0
  141. package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/SKILL.md +112 -0
  142. package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/STANDARDS.md +187 -0
  143. package/plugins/maestro-skills/vendor/impeccable/LICENSE +191 -0
  144. package/plugins/maestro-skills/vendor/impeccable/NOTICE.md +11 -0
  145. package/plugins/maestro-skills/vendor/impeccable/SKILL.md +86 -0
  146. package/plugins/maestro-skills/vendor/impeccable/UPSTREAM.json +201 -0
  147. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-asset-producer.md +42 -0
  148. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-documenter.md +29 -0
  149. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-finish-reviewer.md +43 -0
  150. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-manual-edit-applier.md +97 -0
  151. package/plugins/maestro-skills/vendor/impeccable/reference/adapt.md +312 -0
  152. package/plugins/maestro-skills/vendor/impeccable/reference/adapt.native.md +58 -0
  153. package/plugins/maestro-skills/vendor/impeccable/reference/android.md +46 -0
  154. package/plugins/maestro-skills/vendor/impeccable/reference/animate.md +89 -0
  155. package/plugins/maestro-skills/vendor/impeccable/reference/audit.md +136 -0
  156. package/plugins/maestro-skills/vendor/impeccable/reference/audit.native.md +139 -0
  157. package/plugins/maestro-skills/vendor/impeccable/reference/bolder.md +33 -0
  158. package/plugins/maestro-skills/vendor/impeccable/reference/clarify.md +94 -0
  159. package/plugins/maestro-skills/vendor/impeccable/reference/colorize.md +86 -0
  160. package/plugins/maestro-skills/vendor/impeccable/reference/craft-floor.md +44 -0
  161. package/plugins/maestro-skills/vendor/impeccable/reference/craft.md +5 -0
  162. package/plugins/maestro-skills/vendor/impeccable/reference/critique.md +806 -0
  163. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/asset-producer.md +37 -0
  164. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/documenter.md +24 -0
  165. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/finish-reviewer.md +38 -0
  166. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/manual-edit-applier.md +92 -0
  167. package/plugins/maestro-skills/vendor/impeccable/reference/delight.md +70 -0
  168. package/plugins/maestro-skills/vendor/impeccable/reference/distill.md +111 -0
  169. package/plugins/maestro-skills/vendor/impeccable/reference/doctor.md +54 -0
  170. package/plugins/maestro-skills/vendor/impeccable/reference/document.md +416 -0
  171. package/plugins/maestro-skills/vendor/impeccable/reference/extract.md +69 -0
  172. package/plugins/maestro-skills/vendor/impeccable/reference/harden.md +336 -0
  173. package/plugins/maestro-skills/vendor/impeccable/reference/hooks.md +111 -0
  174. package/plugins/maestro-skills/vendor/impeccable/reference/init.md +131 -0
  175. package/plugins/maestro-skills/vendor/impeccable/reference/ios.md +51 -0
  176. package/plugins/maestro-skills/vendor/impeccable/reference/layout.md +84 -0
  177. package/plugins/maestro-skills/vendor/impeccable/reference/live-setup.md +104 -0
  178. package/plugins/maestro-skills/vendor/impeccable/reference/live.md +325 -0
  179. package/plugins/maestro-skills/vendor/impeccable/reference/new-work.md +147 -0
  180. package/plugins/maestro-skills/vendor/impeccable/reference/onboard.md +234 -0
  181. package/plugins/maestro-skills/vendor/impeccable/reference/operate.md +61 -0
  182. package/plugins/maestro-skills/vendor/impeccable/reference/optimize.md +258 -0
  183. package/plugins/maestro-skills/vendor/impeccable/reference/overdrive.md +127 -0
  184. package/plugins/maestro-skills/vendor/impeccable/reference/polish.md +105 -0
  185. package/plugins/maestro-skills/vendor/impeccable/reference/quieter.md +99 -0
  186. package/plugins/maestro-skills/vendor/impeccable/reference/routing.md +24 -0
  187. package/plugins/maestro-skills/vendor/impeccable/reference/shape.md +59 -0
  188. package/plugins/maestro-skills/vendor/impeccable/reference/typeset.md +80 -0
  189. package/plugins/maestro-skills/vendor/impeccable/reference/visualize.md +46 -0
  190. package/plugins/maestro-skills/vendor/taste-skill/LICENSE +21 -0
  191. package/plugins/maestro-skills/vendor/taste-skill/UPSTREAM.json +37 -0
  192. package/plugins/maestro-skills/vendor/taste-skill/skills/minimalist-skill/SKILL.md +85 -0
  193. package/plugins/maestro-skills/vendor/taste-skill/skills/redesign-skill/SKILL.md +178 -0
  194. package/plugins/maestro-skills/vendor/taste-skill/skills/soft-skill/SKILL.md +98 -0
  195. package/plugins/maestro-skills/vendor/taste-skill/skills/taste-skill/SKILL.md +1206 -0
  196. package/plugins/maestro-skills/vendor/unlazy/LICENSE +21 -0
  197. package/plugins/maestro-skills/vendor/unlazy/SECURITY.md +72 -0
  198. package/plugins/maestro-skills/vendor/unlazy/SKILL.md +104 -0
  199. package/plugins/maestro-skills/vendor/unlazy/UPSTREAM.json +94 -0
  200. package/plugins/maestro-skills/vendor/unlazy/references/dispatch.md +82 -0
  201. package/plugins/maestro-skills/vendor/unlazy/references/gates.md +149 -0
  202. package/plugins/maestro-skills/vendor/unlazy/references/method.md +49 -0
  203. package/plugins/maestro-skills/vendor/unlazy/references/orchestration.md +107 -0
  204. package/plugins/maestro-skills/vendor/unlazy/references/parallel.md +133 -0
  205. package/plugins/maestro-skills/vendor/unlazy/references/token-economy.md +48 -0
  206. package/plugins/maestro-skills/vendor/unlazy/scripts/dispatch-check.mjs +139 -0
  207. package/plugins/maestro-skills/vendor/unlazy/scripts/gate-check.mjs +960 -0
  208. package/plugins/maestro-skills/vendor/unlazy/scripts/gate-lint.mjs +245 -0
  209. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/check-supervisor.mjs +46 -0
  210. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/dispatch.mjs +293 -0
  211. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/gates.mjs +953 -0
  212. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/process-tree.mjs +161 -0
  213. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/regex-worker.mjs +9 -0
  214. package/plugins/maestro-skills/vendor/unlazy/templates/PLAN.md +116 -0
  215. package/plugins/maestro-skills/vendor/unlazy/templates/gates-leaf.md +51 -0
  216. package/plugins/maestro-skills/vendor/unlazy/templates/gates-node.md +51 -0
  217. package/scaffold/CLAUDE.md +24 -0
  218. package/scripts/ci/check-durable-write-seam.mjs +147 -0
  219. package/scripts/ci/check-durable-write-seam.test.mjs +90 -0
  220. package/scripts/ci/check-skill-packs.mjs +388 -0
  221. package/scripts/ci/check-skill-packs.test.mjs +495 -0
  222. package/scripts/ci/check.mjs +6 -0
  223. package/scripts/collective/hook-runner.mjs +39 -4
  224. package/scripts/collective/hook-runner.test.mjs +85 -2
  225. package/scripts/daemon/agent-daemon-board-mine.test.mjs +96 -0
  226. package/scripts/daemon/agent-daemon-design.test.mjs +238 -0
  227. package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
  228. package/scripts/daemon/agent-daemon.mjs +249 -10
  229. package/scripts/daemon/agent-daemon.test.mjs +73 -0
  230. package/scripts/daemon/assurance-e2e.test.mjs +141 -6
  231. package/scripts/daemon/assurance.mjs +461 -37
  232. package/scripts/daemon/assurance.test.mjs +408 -43
  233. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +393 -0
  234. package/scripts/daemon/cadence-consumer.mjs +289 -89
  235. package/scripts/daemon/cadence-handlers.mjs +53 -0
  236. package/scripts/daemon/classifier.mjs +1 -1
  237. package/scripts/daemon/dispatcher-resume.test.mjs +166 -0
  238. package/scripts/daemon/dispatcher.mjs +127 -19
  239. package/scripts/daemon/health.mjs +12 -1
  240. package/scripts/daemon/inbox-deferral-session.test.mjs +49 -0
  241. package/scripts/daemon/inbox-deferral.mjs +6 -0
  242. package/scripts/daemon/lib/self-echo.mjs +201 -0
  243. package/scripts/daemon/lib/self-echo.test.mjs +153 -0
  244. package/scripts/daemon/maestro-daemon.mjs +3 -0
  245. package/scripts/daemon/prompt-builder.mjs +19 -3
  246. package/scripts/daemon/responder.mjs +51 -40
  247. package/scripts/daemon/sdk-version.mjs +51 -0
  248. package/scripts/daemon/sdk-version.test.mjs +31 -0
  249. package/scripts/hooks/pre-send-audit.sh +97 -4
  250. package/scripts/hooks/pre-send-audit.test.mjs +140 -1
  251. package/scripts/local-triggers/autoupdate.sh +243 -19
  252. package/scripts/local-triggers/autoupdate.test.mjs +518 -0
  253. package/scripts/local-triggers/generate-plists.sh +24 -1
  254. package/scripts/local-triggers/generate-plists.test.mjs +49 -11
  255. package/scripts/org/send-orgmail.first-contact.test.mjs +102 -0
  256. package/scripts/org/send-orgmail.mjs +27 -3
  257. package/scripts/poller/inbox-privilege-injection.test.mjs +167 -0
  258. package/scripts/poller/slack-poller.mjs +13 -1
  259. package/scripts/poller/utils.mjs +46 -1
  260. package/scripts/poller-launchd/install.sh +19 -11
  261. package/scripts/poller-launchd/install.test.mjs +243 -0
  262. package/scripts/poller-launchd/launchd-poller-wrapper.sh +92 -0
  263. package/scripts/poller-launchd/migrate.sh +66 -0
  264. package/scripts/poller-launchd/poller.plist.template +4 -2
  265. package/scripts/session/feed.mjs +237 -0
  266. package/scripts/session/feed.test.mjs +196 -0
  267. package/scripts/session/supervisor-sh.test.mjs +218 -0
  268. package/scripts/session/supervisor.mjs +328 -0
  269. package/scripts/session/supervisor.sh +141 -0
  270. package/scripts/session/supervisor.test.mjs +482 -0
  271. package/scripts/setup/configure-macos.sh +250 -55
  272. package/scripts/setup/configure-macos.test.mjs +306 -0
  273. package/scripts/setup/init-agent.sh +112 -7
  274. package/scripts/setup/init-agent.test.mjs +220 -1
  275. package/scripts/vendor/skill-packs.mjs +354 -0
  276. package/scripts/vendor/sync-skill-packs.mjs +242 -0
  277. package/scripts/vendor/sync-skill-packs.test.mjs +103 -0
  278. package/scripts/watchdog/memory-watchdog.sh +37 -1
  279. package/scripts/watchdog/memory-watchdog.test.mjs +64 -0
  280. package/scripts/setup/boot-claude-session.sh +0 -94
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
  }
@@ -96,7 +96,15 @@ import {
96
96
  read,
97
97
  approvalWait,
98
98
  fetchSelfProfile,
99
+ listMine,
99
100
  } from "./client.mjs";
101
+ // The work ladder hq's board.track walks (accepted → working → blocked/review
102
+ // → done/failed) — one vocabulary, owned by the ledger client.
103
+ import { WORK_STAGES } from "./work-ledger.mjs";
104
+ // Front-door session status (2026-09): the main session's heartbeat, its peer
105
+ // sessions, open cadence handoffs and the cached board-mine count, read from
106
+ // local state so the tool answers with no token and no network.
107
+ import { readSessionStatus, statusLine } from "../session/status-summary.mjs";
100
108
  // The hq vocabularies the model must pick from (an out-of-vocabulary enum value
101
109
  // is a BAD_REQUEST). Sourced from the wire contract so the tool table and the
102
110
  // normaliser can never disagree about what hq accepts.
@@ -809,6 +817,86 @@ export const ORG_TOOLS = Object.freeze([
809
817
  access: "write",
810
818
  binding: { kind: "rpc", method: "board.track" },
811
819
  },
820
+ // ── front-door session (2026-09): my work across boards, the inbound→board
821
+ // seam, and the main session's liveness ─────────────────────────────────
822
+ {
823
+ name: "board_mine",
824
+ title: "My work across all boards",
825
+ description:
826
+ "Every board item assigned to you or awaiting your review, across every board and " +
827
+ "workstream (GET /v1/board.mine) — the list `board_ready` cannot show, because that feed " +
828
+ "is unassigned work only. Each item: {itemId, title, boardId, boardName, channelId, " +
829
+ "workstreamId, col, stage, priority, dueAt, updatedAt, url}. Use it when idle to pick the " +
830
+ "highest-priority item you can move, then `board_claim` it; use it to answer \"what is on " +
831
+ "your plate?\". An org server that does not serve this read yet returns an empty list, " +
832
+ "not an error.",
833
+ input_schema: S({}),
834
+ access: "read",
835
+ binding: { kind: "local" },
836
+ },
837
+ {
838
+ name: "board_track",
839
+ title: "File or advance an ask on its board",
840
+ description:
841
+ "Record where an ask stands on the board that owns it (board.track) — the server derives " +
842
+ "the board from the message identity (a DM's conversation board or the space's board), " +
843
+ "dedupes on (channelId, messageId) and ladders the column, so a repeated call is free. " +
844
+ "This is the sanctioned way to put an inbound ask onto a board: when a request needs more " +
845
+ "than one reply's worth of work, acknowledge it in-channel in the SAME turn and call this " +
846
+ "with stage 'accepted' (title = what you took on, why = the one-line reason it is more " +
847
+ "than a reply). Advance with 'working', 'blocked' (say what you need; `notify` whoever " +
848
+ "can unstick it), 'review' (finished, wants a human's eyes) and close with 'done' or " +
849
+ "'failed'. `work_track` is the mid-work subset of this verb.",
850
+ input_schema: S(
851
+ {
852
+ channelId: str("The channel the ask arrived on."),
853
+ messageId: str("The id of the message that asked. Identifies the board row."),
854
+ stage: {
855
+ type: "string",
856
+ enum: WORK_STAGES,
857
+ description:
858
+ "accepted = taken on (opens the row); working = started; blocked = stuck and needs " +
859
+ "someone; review = finished, needs a human's eyes; done / failed = closed.",
860
+ },
861
+ title: str("Row title when the row is opened (optional; the server defaults it from the ask)."),
862
+ why: str("One line on why this became board work rather than a reply (optional)."),
863
+ note: str("What changed, in a sentence or two, for the person waiting (optional)."),
864
+ priority: {
865
+ type: "string",
866
+ enum: TASK_PRIORITIES,
867
+ description: "Priority P0 (highest) .. P4, read only when the row is created (optional).",
868
+ },
869
+ notify: {
870
+ type: "array",
871
+ items: { type: "string" },
872
+ description: "Member ids to @-tag on this step — only people who must act or know.",
873
+ },
874
+ attachments: {
875
+ type: "array",
876
+ description: "Files this step produced: [{name, mimeType?, sizeBytes?, dataUrl}].",
877
+ items: { type: "object" },
878
+ },
879
+ },
880
+ ["channelId", "messageId", "stage"],
881
+ ),
882
+ access: "write",
883
+ binding: { kind: "rpc", method: "board.track" },
884
+ },
885
+ {
886
+ name: "session_status",
887
+ title: "Main session status",
888
+ description:
889
+ "Whether this agent's main (front-door) session is live on this machine, its name, the " +
890
+ "peer sessions it has spawned, how many cadence handoffs are waiting for it, and how many " +
891
+ "board items the daemon last cached for it — read from local state, offline, no token " +
892
+ "needed. `live:false` with state 'stale' means the session exists but stopped beating; " +
893
+ "'absent' means it is not running (the daemon is then answering inbound itself). Use it " +
894
+ "before addressing the main session with SendMessage, or to answer \"is the front door " +
895
+ "up?\". Never quote session names to a human — they are internal.",
896
+ input_schema: S({}),
897
+ access: "read",
898
+ binding: { kind: "local" },
899
+ },
812
900
  // ── decisions / approvals ────────────────────────────────────────────────
813
901
  {
814
902
  name: "decision_list",
@@ -2977,6 +3065,9 @@ export function orgToolDef(name) {
2977
3065
  * the first-class tools.
2978
3066
  * @type {Set<string>}
2979
3067
  */
3068
+ /** board_track input fields forwarded to hq (the schema's optional keys). */
3069
+ export const BOARD_TRACK_PASSTHROUGH = Object.freeze(["title", "why", "note", "priority", "notify", "attachments"]);
3070
+
2980
3071
  export const OUTBOUND_METHODS = Object.freeze(
2981
3072
  new Set(
2982
3073
  ORG_TOOLS.filter((t) => t.outbound && t.binding && t.binding.kind === "rpc").map(
@@ -3263,6 +3354,10 @@ async function executeOrgToolInner(name, input = {}, o = {}) {
3263
3354
 
3264
3355
  // Offline-capable local tools first.
3265
3356
  if (name === "org_describe") return describeProtocol(input);
3357
+ if (name === "session_status") {
3358
+ const status = readSessionStatus(cfg.agentRoot, { now: Date.now() });
3359
+ return okFrame({ ...status, agentRoot: cfg.agentRoot, summary: statusLine(status) });
3360
+ }
3266
3361
 
3267
3362
  // Every network tool needs a token; per §1.4 the server stays up and each
3268
3363
  // call returns a clear error frame instead.
@@ -3421,6 +3516,31 @@ async function executeOrgToolInner(name, input = {}, o = {}) {
3421
3516
  return readToFrame(await read(withQuery("events", q), callOpts));
3422
3517
  }
3423
3518
 
3519
+ case "board_mine": {
3520
+ // listMine is fail-open by contract: an hq that does not serve the read
3521
+ // yet (404), a transport error or a malformed body all come back as [].
3522
+ const items = await listMine(callOpts);
3523
+ return okFrame({ items, count: items.length });
3524
+ }
3525
+
3526
+ case "board_track": {
3527
+ const i = input || {};
3528
+ const channelId = i.channelId ? String(i.channelId) : "";
3529
+ const messageId = i.messageId ? String(i.messageId) : "";
3530
+ const stage = i.stage ? String(i.stage) : "";
3531
+ if (!channelId || !messageId) return errFrame("BAD_REQUEST", "board_track: channelId and messageId are required");
3532
+ if (!WORK_STAGES.includes(stage)) {
3533
+ return errFrame("BAD_REQUEST", `board_track: stage must be one of ${WORK_STAGES.join("|")} (got ${stage || "(none)"})`);
3534
+ }
3535
+ // `service` is pinned (this seam is Cohort-only) and only the schema's
3536
+ // passthrough fields travel: model input never reaches hq verbatim.
3537
+ const params = { service: "cohort", channelId, messageId, stage };
3538
+ for (const k of BOARD_TRACK_PASSTHROUGH) if (i[k] !== undefined) params[k] = i[k];
3539
+ // Same ask + same stage = the same call (mirrors lib/org/work-ledger):
3540
+ // a retry after a timeout must not double-post the tag.
3541
+ return call("board.track", params, { ...callOpts, idempotencyKey: `board.track:${channelId}:${messageId}:${stage}` });
3542
+ }
3543
+
3424
3544
  case "decision_list": {
3425
3545
  const q = input && input.status ? { status: input.status } : null;
3426
3546
  return readToFrame(await read(withQuery("decision.list", q), callOpts));
@@ -9,6 +9,9 @@
9
9
  "use strict";
10
10
 
11
11
  import { test, before, after } from "node:test";
12
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
12
15
  import assert from "node:assert/strict";
13
16
 
14
17
  import {
@@ -117,7 +120,14 @@ test("table: curated 54 + 5 email + 5 artifact + 77 desk tools, snake_case names
117
120
  // declaration (lib/org/resource-tools.mjs) rather than transcribed from hq's
118
121
  // desk, so it cannot acquire the 239-vs-69 drift the hand-copied desks
119
122
  // already carry; resource-tools.test.mjs is the parity that holds it there.
120
- assert.equal(ORG_TOOLS.length, 141, "54 curated + 5 email + 5 artifact + 77 desk");
123
+ // The FRONT-DOOR delta (+3 curated → 57, table 141 → 144; design spec
124
+ // 2026-09-08 §3.5/§3.7): board_mine (the agent's own items across every
125
+ // board — board.ready is unassigned-only, so "my tasks" was unreadable from
126
+ // any plane), board_track (the sanctioned inbound→board seam with the
127
+ // accepted/done ends of the ladder and title/why passthrough) and
128
+ // session_status (the main session's liveness + peers from local state,
129
+ // offline-capable).
130
+ assert.equal(ORG_TOOLS.length, 144, "57 curated + 5 email + 5 artifact + 77 desk");
121
131
  assert.equal(ORG_TOOLS.filter((t) => t.desk).length, 77, "exactly seventy-seven desk tools");
122
132
  assert.equal(ORG_TOOLS.filter((t) => t.email).length, 5, "exactly five email tools");
123
133
  assert.equal(ORG_TOOLS.filter((t) => t.artifact).length, 5, "exactly five artifact tools");
@@ -168,19 +178,19 @@ test("OUTBOUND_METHODS is DERIVED from outbound:true tools (messaging/email/mail
168
178
  test("email gating: family present in the vendored protocol → tools active; override excludes", () => {
169
179
  // The email family landed in the vendored protocol (sync-protocol phase 1).
170
180
  assert.equal(emailFamilyAvailable(), true, "vendored protocol carries email.send");
171
- assert.equal(getOrgTools().length, 141);
181
+ assert.equal(getOrgTools().length, 144);
172
182
  const without = getOrgTools({ emailAvailable: false });
173
- assert.equal(without.length, 136);
183
+ assert.equal(without.length, 139);
174
184
  assert.ok(!without.some((t) => t.email), "email tools excluded when family absent");
175
185
  });
176
186
 
177
187
  test("artifact gating: family present → tools active; override excludes (email precedent)", () => {
178
188
  assert.equal(artifactFamilyAvailable(), true, "vendored protocol carries artifact.act");
179
189
  const without = getOrgTools({ artifactAvailable: false });
180
- assert.equal(without.length, 136);
190
+ assert.equal(without.length, 139);
181
191
  assert.ok(!without.some((t) => t.artifact), "artifact tools excluded when family absent");
182
192
  const neither = getOrgTools({ emailAvailable: false, artifactAvailable: false, desksAvailable: false });
183
- assert.equal(neither.length, 54, "all additive families off → the 54 always-on tools");
193
+ assert.equal(neither.length, 57, "all additive families off → the 57 always-on tools");
184
194
  });
185
195
 
186
196
  test("isOrgTool / orgToolDef cover the full table; unknown names rejected", () => {
@@ -1153,3 +1163,106 @@ test("transport failure on a new tool fails open into an INTERNAL frame, never a
1153
1163
  assert.equal(frame.ok, false);
1154
1164
  assert.equal(frame.error.code, "INTERNAL");
1155
1165
  });
1166
+
1167
+ // ── WP-M4: front-door tools (board_mine / board_track / session_status) ────
1168
+
1169
+ test("board_mine: read access, GET /v1/board.mine, items normalised, [] when hq lacks the read (fail-open)", async () => {
1170
+ const def = orgToolDef("board_mine");
1171
+ assert.ok(def, "board_mine curated");
1172
+ assert.equal(def.access, "read");
1173
+ assert.equal(def.binding.kind, "local", "local so an hq without board.mine yet degrades to [] instead of a bare 404");
1174
+ const fetchImpl = fakeFetch({ items: [{ itemId: "i1", title: "Deck", col: "doing" }] });
1175
+ const frame = await executeOrgTool("board_mine", {}, { orgConfig: CFG, agentRoot: "/tmp/none", fetchImpl });
1176
+ assert.equal(frame.ok, true);
1177
+ assert.deepEqual(frame.result.items, [{ itemId: "i1", title: "Deck", col: "doing" }]);
1178
+ assert.equal(frame.result.count, 1);
1179
+ assert.match(fetchImpl.calls[0].url, /\/v1\/board\.mine$/);
1180
+ assert.equal(fetchImpl.calls[0].init.method, "GET");
1181
+ const gone = fakeFetch({ error: { code: "NOT_FOUND", message: "no such read" } }, { status: 404, ok: false });
1182
+ const f2 = await executeOrgTool("board_mine", {}, { orgConfig: CFG, agentRoot: "/tmp/none", fetchImpl: gone });
1183
+ assert.equal(f2.ok, true, "fail-open: an older hq is an empty list, not an error the model must interpret");
1184
+ assert.deepEqual(f2.result.items, []);
1185
+ // No token → UNAUTHORIZED like every other network tool.
1186
+ const noTok = await executeOrgTool("board_mine", {}, { orgConfig: { org: { cohort: { enabled: true, base: "https://org.example" } } }, agentRoot: "/tmp/none", fetchImpl });
1187
+ assert.equal(noTok.ok, false);
1188
+ assert.equal(noTok.error.code, "UNAUTHORIZED");
1189
+ });
1190
+
1191
+ test("board_track: write access on board.track; title/why/stage pass through; whole ladder incl. accepted|done; bad stage rejected offline", async () => {
1192
+ const def = orgToolDef("board_track");
1193
+ assert.ok(def, "board_track curated");
1194
+ assert.equal(def.access, "write");
1195
+ assert.equal(def.binding.method, "board.track");
1196
+ assert.deepEqual(def.input_schema.properties.stage.enum, ["accepted", "working", "blocked", "review", "done", "failed"]);
1197
+ assert.ok(def.input_schema.properties.title, "title passthrough");
1198
+ assert.ok(def.input_schema.properties.why, "why passthrough");
1199
+ const fetchImpl = fakeFetch({ ok: true, result: { tracked: true, taskId: "t1", col: "todo", created: true } });
1200
+ const frame = await executeOrgTool(
1201
+ "board_track",
1202
+ { channelId: "c1", messageId: "m1", stage: "accepted", title: "Build the deck", why: "asked in DM", note: "on it" },
1203
+ { orgConfig: CFG, agentRoot: "/tmp/none", fetchImpl },
1204
+ );
1205
+ assert.equal(frame.ok, true);
1206
+ assert.equal(frame.result.taskId, "t1");
1207
+ assert.match(fetchImpl.calls[0].url, /\/v1\/board\.track$/);
1208
+ const body = JSON.parse(fetchImpl.calls[0].init.body);
1209
+ assert.equal(body.stage, "accepted");
1210
+ assert.equal(body.title, "Build the deck");
1211
+ assert.equal(body.why, "asked in DM");
1212
+ assert.equal(body.service, "cohort", "service defaults to cohort");
1213
+ assert.equal(body.channelId, "c1");
1214
+ assert.equal(body.messageId, "m1");
1215
+ assert.equal(fetchImpl.calls[0].init.headers["x-idempotency-key"], "board.track:c1:m1:accepted", "stable per (ask, stage)");
1216
+ const bad = fakeFetch();
1217
+ const rej = await executeOrgTool("board_track", { channelId: "c1", messageId: "m1", stage: "later" }, { orgConfig: CFG, agentRoot: "/tmp/none", fetchImpl: bad });
1218
+ assert.equal(rej.ok, false);
1219
+ assert.equal(rej.error.code, "BAD_REQUEST");
1220
+ assert.equal(bad.calls.length, 0, "validated before any network");
1221
+ });
1222
+
1223
+ test("session_status: offline local read of state/session + state/org/board-mine; works with no token", async () => {
1224
+ const def = orgToolDef("session_status");
1225
+ assert.ok(def, "session_status curated");
1226
+ assert.equal(def.access, "read");
1227
+ assert.equal(def.binding.kind, "local");
1228
+ const root = mkdtempSync(join(tmpdir(), "ts-session-"));
1229
+ try {
1230
+ mkdirSync(join(root, "state", "session", "handoffs"), { recursive: true });
1231
+ mkdirSync(join(root, "state", "org"), { recursive: true });
1232
+ writeFileSync(join(root, "state", "session", "heartbeat.json"), JSON.stringify({ pid: 1, name: "alex-main", sessionId: "s1", ts: Date.now() }));
1233
+ writeFileSync(join(root, "state", "session", "peers.json"), JSON.stringify([{ name: "alex-deck", purpose: "board deck" }]));
1234
+ writeFileSync(join(root, "state", "session", "handoffs", "t1.json"), "{}");
1235
+ writeFileSync(join(root, "state", "org", "board-mine.json"), JSON.stringify({ items: [{ itemId: "i1" }, { itemId: "i2" }], fetchedAt: new Date().toISOString() }));
1236
+ const fetchImpl = fakeFetch();
1237
+ const frame = await executeOrgTool("session_status", {}, { orgConfig: { org: { cohort: { enabled: true, base: "https://org.example" } } }, agentRoot: root, fetchImpl });
1238
+ assert.equal(frame.ok, true, "no token is fine — this never touches the network");
1239
+ assert.equal(fetchImpl.calls.length, 0);
1240
+ assert.equal(frame.result.live, true);
1241
+ assert.equal(frame.result.name, "alex-main");
1242
+ assert.equal(frame.result.peers[0].name, "alex-deck");
1243
+ assert.equal(frame.result.handoffsOpen, 1);
1244
+ assert.equal(frame.result.boardMineCount, 2);
1245
+ assert.equal(typeof frame.result.summary, "string");
1246
+ // An empty root is "absent", not an error.
1247
+ const empty = await executeOrgTool("session_status", {}, { orgConfig: CFG, agentRoot: join(root, "nope"), fetchImpl });
1248
+ assert.equal(empty.ok, true);
1249
+ assert.equal(empty.result.state, "absent");
1250
+ } finally { rmSync(root, { recursive: true, force: true }); }
1251
+ });
1252
+
1253
+ test("board_track: the model cannot override service or smuggle extra keys — only the schema's passthrough fields reach hq", async () => {
1254
+ const fetchImpl = fakeFetch({ ok: true, result: { tracked: true, taskId: "t2", col: "doing" } });
1255
+ const frame = await executeOrgTool(
1256
+ "board_track",
1257
+ { channelId: "c1", messageId: "m1", stage: "working", service: "slack", junk: "x", note: "halfway", priority: "P1", notify: ["u1"] },
1258
+ { orgConfig: CFG, agentRoot: "/tmp/none", fetchImpl },
1259
+ );
1260
+ assert.equal(frame.ok, true);
1261
+ const body = JSON.parse(fetchImpl.calls[0].init.body);
1262
+ assert.equal(body.service, "cohort", "service is pinned, not model-supplied");
1263
+ assert.equal(body.junk, undefined, "unknown keys are dropped");
1264
+ assert.equal(body.note, "halfway");
1265
+ assert.equal(body.priority, "P1");
1266
+ assert.deepEqual(body.notify, ["u1"]);
1267
+ assert.deepEqual(Object.keys(body).sort(), ["channelId", "messageId", "note", "notify", "priority", "service", "stage"]);
1268
+ });
@@ -0,0 +1,79 @@
1
+ /**
2
+ * lib/prompts/parallelism.mjs — the fleet-wide parallelism directive (WP-M7).
3
+ *
4
+ * THE GAP this closes (owner directive 2026-09-09): a session handed a plan with
5
+ * six independent tasks ran them one at a time, because nothing in its prompt
6
+ * ever said it was allowed not to. The directive below is the standing
7
+ * permission — plus the ONE safety rule that makes parallel dispatch safe on a
8
+ * shared checkout (no two agents editing the same file).
9
+ *
10
+ * It is a CONSTANT, not a template: the same bytes reach `maestro session spawn`
11
+ * (peer sessions) and `scripts/daemon/prompt-builder.mjs` (escalate/guarded
12
+ * sub-sessions), so the wording can be audited in one place and a test can pin
13
+ * it byte-for-byte.
14
+ *
15
+ * {@link withParallelism} is IDEMPOTENT. Two prompt seams compose (a daemon
16
+ * prompt handed to `session spawn`), and a directive repeated twice reads as
17
+ * emphasis to a model, which is exactly how "maximum safe parallelism" turns
18
+ * into "ignore the file-scope rule" — so a prompt that already carries the text
19
+ * is returned unchanged.
20
+ *
21
+ * Pure: no clock, no env, no I/O. Node builtins only. ESM.
22
+ *
23
+ * @module lib/prompts/parallelism
24
+ */
25
+
26
+ "use strict";
27
+
28
+ /**
29
+ * The directive, verbatim (design spec 2026-09-08 §WP-M7 mechanic 5). Do not
30
+ * reword without changing the spec — `parallelism.test.mjs` pins these bytes.
31
+ * @type {string}
32
+ */
33
+ export const PARALLELISM_DIRECTIVE = `You are allowed to run multiple sub-agents concurrently.
34
+
35
+ When the plan contains independent tasks, dispatch them together in a single parallel batch rather than waiting for each task to finish before starting the next.
36
+
37
+ Only run tasks sequentially when one task genuinely depends on the output of another.
38
+
39
+ Before dispatching any batch, check the planned file scope for each task and ensure that no two agents are assigned to edit the same file. If tasks would overlap on a file, sequence those tasks or redefine their scope to eliminate the conflict.
40
+
41
+ Default to maximum safe parallelism.`;
42
+
43
+ /**
44
+ * Prepend {@link PARALLELISM_DIRECTIVE} to a prompt, exactly once.
45
+ *
46
+ * A prompt that already contains the directive anywhere in its body is returned
47
+ * unchanged (idempotent — see the module docstring). A non-string or empty
48
+ * prompt yields the directive alone, so a caller that lost its prompt still
49
+ * sends something coherent rather than `undefined`.
50
+ *
51
+ * @param {string} prompt the prompt to lead
52
+ * @returns {string}
53
+ */
54
+ export function withParallelism(prompt) {
55
+ const body = typeof prompt === "string" ? prompt : "";
56
+ if (body.includes(PARALLELISM_DIRECTIVE)) return body;
57
+ if (!body.trim()) return PARALLELISM_DIRECTIVE;
58
+ return `${PARALLELISM_DIRECTIVE}\n\n${body}`;
59
+ }
60
+
61
+ /**
62
+ * How many times the directive appears in a string. The seam a test (or a
63
+ * caller composing two prompts) uses to assert "exactly once".
64
+ * @param {string} text
65
+ * @returns {number}
66
+ */
67
+ export function countParallelism(text) {
68
+ const body = typeof text === "string" ? text : "";
69
+ if (!body) return 0;
70
+ let n = 0;
71
+ let i = body.indexOf(PARALLELISM_DIRECTIVE);
72
+ while (i !== -1) {
73
+ n += 1;
74
+ i = body.indexOf(PARALLELISM_DIRECTIVE, i + PARALLELISM_DIRECTIVE.length);
75
+ }
76
+ return n;
77
+ }
78
+
79
+ export default { PARALLELISM_DIRECTIVE, withParallelism, countParallelism };