@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
@@ -0,0 +1,275 @@
1
+ /**
2
+ * lib/design/write.mjs — the effect edge of the design sync (WP-M7 mechanic 4).
3
+ *
4
+ * `design-md.mjs` decides WHAT the files say and `refresh-gate.mjs` decides
5
+ * WHEN; this module is the only place that touches the disk, so the CLI
6
+ * (`maestro design sync`) and the daemon's poller write the same bytes with the
7
+ * same modes and the same idempotence rule.
8
+ *
9
+ * IDEMPOTENCE IS BY BYTES, not by timestamp. A re-sync of an unchanged
10
+ * foundation must not touch DESIGN.md — the file is read by editors and skills,
11
+ * and a pointless mtime bump is how a watcher storms and how a git tree looks
12
+ * dirty for no reason. So each file is read back and compared before writing.
13
+ * That also means `generatedAt` must NOT be part of the rendered bytes when
14
+ * idempotence matters: the caller passes the SAME `generatedAt` it recorded on
15
+ * the previous sync, or none. (`sync.mjs` callers pass the stored value.)
16
+ *
17
+ * EVERY write here goes through `lib/fs-atomic.mjs` — the JSON snapshot and
18
+ * both markdown files. There are TWO writers of the same two absolute paths (a
19
+ * hand-run `maestro design sync` and the daemon's 15-minute poll), and a design
20
+ * skill reads `$AGENT_ROOT/DESIGN.md` at arbitrary moments; an in-place
21
+ * `writeFileSync` would let it read a half-written frontmatter block. The mode
22
+ * is applied with an explicit `chmod` after the rename as well as on the temp
23
+ * file, because Node honours a `mode` option only when it CREATES a file — a
24
+ * DESIGN.md some other tool left at 0600 would otherwise stay 0600 forever.
25
+ *
26
+ * Node builtins only. ESM.
27
+ *
28
+ * @module lib/design/write
29
+ */
30
+
31
+ "use strict";
32
+
33
+ import { chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
34
+ import { dirname, join, resolve } from "node:path";
35
+ import { writeFileAtomic, writeJsonAtomic } from "../fs-atomic.mjs";
36
+ import { renderDesignBundle } from "./design-md.mjs";
37
+ import { DESIGN_STATE_REL, foundationVersion, foundationVersionLabel } from "./refresh-gate.mjs";
38
+
39
+ /** The mode the generated docs are written with (readable by any tool on the seat). */
40
+ export const DESIGN_FILE_MODE = 0o644;
41
+
42
+ /**
43
+ * The workspace/brand name for the rendered heading, from the loaded org config.
44
+ *
45
+ * SHARED ON PURPOSE. `maestro design sync` and the daemon's poller both write
46
+ * DESIGN.md, and the heading is part of the rendered bytes — two definitions of
47
+ * "what is this workspace called" would make a hand-run sync and an automatic
48
+ * one disagree, and the idempotence rule above would then rewrite the file on
49
+ * every alternation. One function, both callers.
50
+ *
51
+ * The foundation's own `positioning` is prose, not a name, so it is never used
52
+ * here. Nothing is invented: an org config without a name reads "Workspace".
53
+ *
54
+ * @param {object} cfg the loaded org config (`lib/org/client.mjs#loadOrgConfig`)
55
+ * @returns {string}
56
+ */
57
+ export function workspaceName(cfg) {
58
+ const org = cfg && typeof cfg === "object" ? cfg.org : null;
59
+ const cohort = org && typeof org === "object" ? org.cohort : null;
60
+ const name = cohort && typeof cohort === "object" ? cohort.orgName || cohort.workspace : null;
61
+ return typeof name === "string" && name.trim() ? name.trim() : "Workspace";
62
+ }
63
+
64
+ /**
65
+ * Write the rendered bundle, skipping any file whose bytes are already correct.
66
+ *
67
+ * @param {object} o
68
+ * @param {Array<{rel:string, content:string}>} o.files rendered by `renderDesignBundle`
69
+ * @param {string} o.outDir where the two markdown files go
70
+ * @param {object} [io] injectable I/O for tests: {readFileImpl, writeFileImpl, existsImpl, mkdirImpl}
71
+ * @returns {{written:string[], unchanged:string[]}}
72
+ */
73
+ export function writeDesignFiles({ files, outDir }, io = {}) {
74
+ const readImpl = io.readFileImpl || ((p) => readFileSync(p, "utf8"));
75
+ const writeImpl = io.writeFileImpl || ((p, data, mode) => {
76
+ writeFileAtomic(p, data, { mode });
77
+ // The rename published a file created with `mode`, but only because it was
78
+ // new. chmod the published name too, so a pre-existing file that some other
79
+ // tool left at a tighter mode is corrected rather than inherited.
80
+ try { chmodSync(p, mode); } catch { /* best effort: the bytes matter more than the bits */ }
81
+ });
82
+ const existsImpl = io.existsImpl || existsSync;
83
+ const mkdirImpl = io.mkdirImpl || ((p) => mkdirSync(p, { recursive: true }));
84
+ const written = [];
85
+ const unchanged = [];
86
+ if (!existsImpl(outDir)) mkdirImpl(outDir);
87
+ for (const file of Array.isArray(files) ? files : []) {
88
+ if (!file || typeof file.rel !== "string" || typeof file.content !== "string") continue;
89
+ const target = join(outDir, file.rel);
90
+ let current = null;
91
+ try {
92
+ current = existsImpl(target) ? readImpl(target) : null;
93
+ } catch {
94
+ current = null; // unreadable is the same as absent: rewrite it
95
+ }
96
+ if (current === file.content) {
97
+ unchanged.push(file.rel);
98
+ continue;
99
+ }
100
+ writeImpl(target, file.content, DESIGN_FILE_MODE);
101
+ written.push(file.rel);
102
+ }
103
+ return { written, unchanged };
104
+ }
105
+
106
+ /**
107
+ * Persist the foundation snapshot the gate reads back next tick.
108
+ * `{fetchedAt, version, versionLabel, foundation}` — durable JSON, atomic.
109
+ *
110
+ * @param {string} agentRoot
111
+ * @param {{fetchedAt:string, version:string|null, versionLabel?:string, foundation:object}} doc
112
+ * @param {object} [io] {writeJsonImpl, mkdirImpl}
113
+ * @returns {string} the path written
114
+ */
115
+ export function writeFoundationState(agentRoot, doc, io = {}) {
116
+ const path = join(String(agentRoot || ""), DESIGN_STATE_REL);
117
+ const mkdirImpl = io.mkdirImpl || ((p) => mkdirSync(p, { recursive: true }));
118
+ mkdirImpl(dirname(path));
119
+ (io.writeJsonImpl || writeJsonAtomic)(path, doc);
120
+ return path;
121
+ }
122
+
123
+ /**
124
+ * Read back the last synced snapshot. Returns null when absent or corrupt — a
125
+ * corrupt snapshot is treated as "never synced", which re-syncs rather than
126
+ * wedging.
127
+ * @param {string} agentRoot
128
+ * @param {object} [io] {readFileImpl}
129
+ * @returns {{fetchedAt?:string, version?:string|null, versionLabel?:string, foundation?:object}|null}
130
+ */
131
+ export function readFoundationState(agentRoot, io = {}) {
132
+ const path = join(String(agentRoot || ""), DESIGN_STATE_REL);
133
+ try {
134
+ const raw = (io.readFileImpl || ((p) => readFileSync(p, "utf8")))(path);
135
+ const doc = JSON.parse(raw);
136
+ return doc && typeof doc === "object" && !Array.isArray(doc) ? doc : null;
137
+ } catch {
138
+ return null; // absent or corrupt → re-sync
139
+ }
140
+ }
141
+
142
+ /**
143
+ * The per-output-directory render stamps from a state snapshot, as a mutable
144
+ * map of resolved path → ISO string.
145
+ *
146
+ * MIGRATION. Snapshots written before the map existed carry one bare
147
+ * `renderedAt` string, which described the agent dir (the only directory the
148
+ * daemon ever writes). Reading it as that entry means an upgraded seat finds
149
+ * its DESIGN.md already matching and rewrites nothing.
150
+ *
151
+ * @param {object|null} prior
152
+ * @param {string} agentRoot
153
+ * @returns {Record<string,string>}
154
+ */
155
+ export function priorRenderStamps(prior, agentRoot) {
156
+ const out = {};
157
+ const p = prior && typeof prior === "object" ? prior : {};
158
+ if (p.rendered && typeof p.rendered === "object" && !Array.isArray(p.rendered)) {
159
+ for (const [k, v] of Object.entries(p.rendered)) {
160
+ if (typeof k === "string" && typeof v === "string" && v) out[k] = v;
161
+ }
162
+ }
163
+ const legacy = typeof p.renderedAt === "string" && p.renderedAt ? p.renderedAt : "";
164
+ const rootKey = resolve(String(agentRoot || ""));
165
+ if (legacy && !out[rootKey]) out[rootKey] = legacy;
166
+ return out;
167
+ }
168
+
169
+ /**
170
+ * Do all rendered files already match what is on disk? The idempotence probe.
171
+ * @param {Array<{rel:string, content:string}>} files
172
+ * @param {string} outDir
173
+ * @param {object} [io]
174
+ * @returns {boolean}
175
+ */
176
+ export function filesMatch(files, outDir, io = {}) {
177
+ const readImpl = io.readFileImpl || ((p) => readFileSync(p, "utf8"));
178
+ const existsImpl = io.existsImpl || existsSync;
179
+ for (const file of Array.isArray(files) ? files : []) {
180
+ const target = join(outDir, file.rel);
181
+ let current = null;
182
+ try {
183
+ current = existsImpl(target) ? readImpl(target) : null;
184
+ } catch {
185
+ return false; // unreadable → rewrite
186
+ }
187
+ if (current !== file.content) return false;
188
+ }
189
+ return true;
190
+ }
191
+
192
+ /**
193
+ * Render a fetched foundation and land it on the seat: DESIGN.md + PRODUCT.md in
194
+ * `outDir`, the snapshot in `state/design/foundation.json`. The ONE code path
195
+ * both `maestro design sync` and the daemon's poller use, so a hand-run sync and
196
+ * an automatic one produce identical bytes.
197
+ *
198
+ * THE `generatedAt` DANCE. A timestamp inside the rendered bytes would make
199
+ * every sync a rewrite, defeating the idempotence the whole design turns on. So
200
+ * the files are first rendered carrying the PREVIOUS sync's stamp: if they then
201
+ * match the disk byte-for-byte, nothing changed and nothing is written. Only
202
+ * when something genuinely differs are they re-rendered with `now` — so the
203
+ * stamp means "when this content was minted", which is the honest reading, and
204
+ * an unchanged foundation touches no file.
205
+ *
206
+ * THE STAMP IS PER OUTPUT DIRECTORY (`rendered`, keyed by resolved path). One
207
+ * shared stamp is a permanent rewrite loop the moment a second directory
208
+ * exists: `maestro design sync --out docs/brand` stamps the state with its own
209
+ * `now`, the daemon's next poll of the agent dir renders with that stamp, does
210
+ * not match ITS files, rewrites them and re-stamps — and each lane then
211
+ * invalidates the other's files forever, with a foundation that never changed.
212
+ * A legacy state file carrying a bare `renderedAt` string is read as the agent
213
+ * dir's entry, so an existing seat migrates without one spurious rewrite.
214
+ *
215
+ * @param {object} o
216
+ * @param {string} o.agentRoot the agent dir (where state/ lives)
217
+ * @param {string} [o.outDir] where the docs go (default: agentRoot)
218
+ * @param {object} o.result the `branding.getFoundation` result frame's `result`
219
+ * @param {number} o.now epoch ms (injected)
220
+ * @param {string} [o.name] workspace/brand name for the heading
221
+ * @param {string} [o.source] provenance line
222
+ * @param {object} [io] injectable I/O (see writeDesignFiles / writeFoundationState)
223
+ * @returns {{written:string[], unchanged:string[], version:string|null, statePath:string, outDir:string}}
224
+ */
225
+ export function syncDesign(o, io = {}) {
226
+ const agentRoot = String(o.agentRoot || "");
227
+ const outDir = String(o.outDir || agentRoot);
228
+ const now = Number.isFinite(o.now) ? o.now : 0;
229
+ const nowIso = new Date(now).toISOString();
230
+ const result = o.result && typeof o.result === "object" ? o.result : {};
231
+ const foundation = result.foundation && typeof result.foundation === "object" ? result.foundation : {};
232
+ const version = foundationVersion(result);
233
+ const versionLabel = foundationVersionLabel(result);
234
+ const prior = readFoundationState(agentRoot, io);
235
+ const rendered = priorRenderStamps(prior, agentRoot);
236
+ const key = resolve(outDir);
237
+ const priorStamp = rendered[key] || nowIso;
238
+
239
+ const base = { name: o.name, source: o.source, versionId: version || "", versionLabel };
240
+ let renderedAt = priorStamp;
241
+ let files = renderDesignBundle(foundation, { ...base, generatedAt: renderedAt });
242
+ let out = { written: [], unchanged: files.map((f) => f.rel) };
243
+ if (!filesMatch(files, outDir, io)) {
244
+ renderedAt = nowIso;
245
+ files = renderDesignBundle(foundation, { ...base, generatedAt: renderedAt });
246
+ out = writeDesignFiles({ files, outDir }, io);
247
+ }
248
+ rendered[key] = renderedAt;
249
+ const statePath = writeFoundationState(
250
+ agentRoot,
251
+ {
252
+ fetchedAt: nowIso,
253
+ // The agent dir's own stamp stays at the top level: it is what a reader
254
+ // (and an older SDK) means by "when was DESIGN.md minted".
255
+ renderedAt: rendered[resolve(agentRoot)] || renderedAt,
256
+ rendered,
257
+ version: version || null,
258
+ versionLabel,
259
+ foundation,
260
+ },
261
+ io,
262
+ );
263
+ return { ...out, version: version || null, statePath, outDir };
264
+ }
265
+
266
+ export default {
267
+ workspaceName,
268
+ priorRenderStamps,
269
+ writeDesignFiles,
270
+ writeFoundationState,
271
+ readFoundationState,
272
+ filesMatch,
273
+ syncDesign,
274
+ DESIGN_FILE_MODE,
275
+ };
@@ -0,0 +1,241 @@
1
+ /**
2
+ * write.test.mjs — the effect edge of the design sync (WP-M7 mechanic 4).
3
+ *
4
+ * Real files in a temp dir, not stubs: the claims worth holding here are about
5
+ * the FILESYSTEM — that an unchanged foundation touches no mtime, and that the
6
+ * two markdown files land at 0644 so any tool on the seat can read them.
7
+ */
8
+
9
+ import { test } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { chmodSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, writeFileSync, statSync, utimesSync, existsSync, rmSync } from "node:fs";
12
+ import { join, resolve } from "node:path";
13
+ import { tmpdir } from "node:os";
14
+
15
+ import { syncDesign, writeDesignFiles, writeFoundationState, readFoundationState, filesMatch, workspaceName, DESIGN_FILE_MODE } from "./write.mjs";
16
+ import { renderDesignBundle } from "./design-md.mjs";
17
+ import { DESIGN_STATE_REL } from "./refresh-gate.mjs";
18
+
19
+ const NOW = Date.parse("2026-09-08T12:00:00Z");
20
+ const FIXTURE = JSON.parse(readFileSync(new URL("./fixtures/foundation.json", import.meta.url), "utf8"));
21
+
22
+ /** A `branding.getFoundation` result frame's `result`. */
23
+ const result = (over = {}) => ({
24
+ foundation: FIXTURE,
25
+ version: { id: "bv_01HQZ", seq: 7, label: "V2.7" },
26
+ headVersionId: "bv_01HQZ",
27
+ isHead: true,
28
+ unversioned: false,
29
+ history: [],
30
+ ...over,
31
+ });
32
+
33
+ function tmpRoot(t) {
34
+ const dir = mkdtempSync(join(tmpdir(), "maestro-design-write-"));
35
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
36
+ return dir;
37
+ }
38
+
39
+ test("a first sync writes both files plus the state snapshot", (t) => {
40
+ const root = tmpRoot(t);
41
+ const out = syncDesign({ agentRoot: root, result: result(), now: NOW, name: "Northwind" });
42
+
43
+ assert.deepEqual(out.written, ["DESIGN.md", "PRODUCT.md"]);
44
+ assert.deepEqual(out.unchanged, []);
45
+ assert.equal(out.version, "bv_01HQZ");
46
+ assert.equal(out.outDir, root);
47
+
48
+ const design = readFileSync(join(root, "DESIGN.md"), "utf8");
49
+ assert.match(design, /^---\nname: "Northwind"\n/);
50
+ assert.match(design, /\{colors\.primary\}/);
51
+ assert.match(readFileSync(join(root, "PRODUCT.md"), "utf8"), /# Northwind — product voice/);
52
+
53
+ const state = JSON.parse(readFileSync(join(root, DESIGN_STATE_REL), "utf8"));
54
+ assert.equal(state.version, "bv_01HQZ");
55
+ assert.equal(state.versionLabel, "V2.7");
56
+ assert.equal(state.fetchedAt, "2026-09-08T12:00:00.000Z");
57
+ assert.deepEqual(state.foundation, FIXTURE, "the raw foundation is kept verbatim for the next gate read");
58
+ });
59
+
60
+ test("both markdown files are 0644", (t) => {
61
+ const root = tmpRoot(t);
62
+ syncDesign({ agentRoot: root, result: result(), now: NOW, name: "Northwind" });
63
+ for (const rel of ["DESIGN.md", "PRODUCT.md"]) {
64
+ assert.equal(statSync(join(root, rel)).mode & 0o777, DESIGN_FILE_MODE, rel);
65
+ }
66
+ });
67
+
68
+ test("re-syncing an unchanged foundation rewrites NOTHING — mtimes are untouched", (t) => {
69
+ const root = tmpRoot(t);
70
+ syncDesign({ agentRoot: root, result: result(), now: NOW, name: "Northwind" });
71
+
72
+ // Backdate both files so any rewrite is unmistakable.
73
+ const old = new Date(NOW - 86_400_000);
74
+ for (const rel of ["DESIGN.md", "PRODUCT.md"]) utimesSync(join(root, rel), old, old);
75
+ const before = ["DESIGN.md", "PRODUCT.md"].map((rel) => statSync(join(root, rel)).mtimeMs);
76
+
77
+ // A LATER clock, deliberately: the timestamp inside the file must not be what
78
+ // makes a sync a rewrite, or the interval poll would churn the tree forever.
79
+ const again = syncDesign({ agentRoot: root, result: result(), now: NOW + 20 * 60_000, name: "Northwind" });
80
+ assert.deepEqual(again.written, [], "nothing written");
81
+ assert.deepEqual(again.unchanged, ["DESIGN.md", "PRODUCT.md"]);
82
+
83
+ const after = ["DESIGN.md", "PRODUCT.md"].map((rel) => statSync(join(root, rel)).mtimeMs);
84
+ assert.deepEqual(after, before, "the files were not touched at all");
85
+
86
+ const design = readFileSync(join(root, "DESIGN.md"), "utf8");
87
+ assert.match(design, /\*\*Synced:\*\* 2026-09-08T12:00:00\.000Z/, "the stamp still says when the CONTENT was minted");
88
+
89
+ // The snapshot is still refreshed, so the gate knows the seat did check.
90
+ const state = JSON.parse(readFileSync(join(root, DESIGN_STATE_REL), "utf8"));
91
+ assert.equal(state.fetchedAt, "2026-09-08T12:20:00.000Z");
92
+ assert.equal(state.renderedAt, "2026-09-08T12:00:00.000Z");
93
+ });
94
+
95
+ test("a changed foundation rewrites the affected file and re-stamps it", (t) => {
96
+ const root = tmpRoot(t);
97
+ syncDesign({ agentRoot: root, result: result(), now: NOW, name: "Northwind" });
98
+
99
+ const moved = JSON.parse(JSON.stringify(FIXTURE));
100
+ moved.colors[2].value = "#111111";
101
+ const out = syncDesign({
102
+ agentRoot: root,
103
+ result: result({ foundation: moved, headVersionId: "bv_02", version: { id: "bv_02", seq: 8, label: "V2.8" } }),
104
+ now: NOW + 20 * 60_000,
105
+ name: "Northwind",
106
+ });
107
+
108
+ assert.ok(out.written.includes("DESIGN.md"));
109
+ assert.equal(out.version, "bv_02");
110
+ const design = readFileSync(join(root, "DESIGN.md"), "utf8");
111
+ assert.match(design, /primary-moss: "#111111"/);
112
+ assert.match(design, /\*\*Synced:\*\* 2026-09-08T12:20:00\.000Z/);
113
+ assert.match(design, /\*\*Version:\*\* V2\.8/);
114
+ });
115
+
116
+ test("--out sends the docs elsewhere; the snapshot always stays under the agent root", (t) => {
117
+ const root = tmpRoot(t);
118
+ const outDir = join(root, "docs", "brand");
119
+ const out = syncDesign({ agentRoot: root, outDir, result: result(), now: NOW, name: "Northwind" });
120
+
121
+ assert.equal(out.outDir, outDir);
122
+ assert.ok(existsSync(join(outDir, "DESIGN.md")));
123
+ assert.ok(!existsSync(join(root, "DESIGN.md")));
124
+ assert.ok(existsSync(join(root, DESIGN_STATE_REL)));
125
+ });
126
+
127
+ test("writeDesignFiles skips a file whose bytes already match and rewrites an unreadable one", (t) => {
128
+ const root = tmpRoot(t);
129
+ const files = renderDesignBundle(FIXTURE, { name: "Northwind" });
130
+ assert.deepEqual(writeDesignFiles({ files, outDir: root }).written, ["DESIGN.md", "PRODUCT.md"]);
131
+ assert.deepEqual(writeDesignFiles({ files, outDir: root }), { written: [], unchanged: ["DESIGN.md", "PRODUCT.md"] });
132
+
133
+ writeFileSync(join(root, "DESIGN.md"), "someone edited this by hand\n");
134
+ assert.deepEqual(writeDesignFiles({ files, outDir: root }).written, ["DESIGN.md"], "a hand edit is overwritten — the file is generated");
135
+
136
+ const boom = writeDesignFiles({ files, outDir: root }, { readFileImpl: () => { throw new Error("EIO"); } });
137
+ assert.deepEqual(boom.written, ["DESIGN.md", "PRODUCT.md"], "unreadable is treated as absent");
138
+ });
139
+
140
+ test("writeDesignFiles ignores malformed entries rather than throwing", (t) => {
141
+ const root = tmpRoot(t);
142
+ const out = writeDesignFiles({ files: [null, { rel: "A.md" }, { content: "x" }, { rel: "ok.md", content: "y" }], outDir: root });
143
+ assert.deepEqual(out.written, ["ok.md"]);
144
+ assert.deepEqual(writeDesignFiles({ files: "not a list", outDir: root }), { written: [], unchanged: [] });
145
+ });
146
+
147
+ test("readFoundationState reads back what writeFoundationState wrote; corrupt reads as never-synced", (t) => {
148
+ const root = tmpRoot(t);
149
+ assert.equal(readFoundationState(root), null, "absent → null");
150
+
151
+ const path = writeFoundationState(root, { fetchedAt: "2026-09-08T12:00:00.000Z", version: "bv_1", foundation: { colors: [] } });
152
+ assert.equal(path, join(root, DESIGN_STATE_REL));
153
+ assert.equal(readFoundationState(root).version, "bv_1");
154
+
155
+ writeFileSync(path, "{ not json");
156
+ assert.equal(readFoundationState(root), null, "corrupt → null → re-sync, never a wedge");
157
+
158
+ writeFileSync(path, JSON.stringify([1, 2]));
159
+ assert.equal(readFoundationState(root), null, "an array is not a snapshot");
160
+ });
161
+
162
+ test("filesMatch is the idempotence probe the CLI's claim rests on", (t) => {
163
+ const root = tmpRoot(t);
164
+ const files = renderDesignBundle(FIXTURE, { name: "Northwind" });
165
+ assert.equal(filesMatch(files, root), false, "nothing on disk yet");
166
+ writeDesignFiles({ files, outDir: root });
167
+ assert.equal(filesMatch(files, root), true);
168
+ writeFileSync(join(root, "PRODUCT.md"), "drift\n");
169
+ assert.equal(filesMatch(files, root), false);
170
+ });
171
+
172
+ test("workspaceName reads the org config and invents nothing", () => {
173
+ assert.equal(workspaceName({ org: { cohort: { orgName: "Northwind" } } }), "Northwind");
174
+ assert.equal(workspaceName({ org: { cohort: { workspace: "adaptic-hq" } } }), "adaptic-hq");
175
+ assert.equal(workspaceName({ org: { cohort: { orgName: " " } } }), "Workspace");
176
+ assert.equal(workspaceName({}), "Workspace");
177
+ assert.equal(workspaceName(null), "Workspace");
178
+ });
179
+
180
+ test("a state directory that does not exist yet is created", (t) => {
181
+ const root = join(tmpRoot(t), "not", "made", "yet");
182
+ mkdirSync(root, { recursive: true });
183
+ const out = syncDesign({ agentRoot: root, result: result(), now: NOW, name: "Northwind" });
184
+ assert.ok(existsSync(out.statePath));
185
+ });
186
+
187
+ // ── two output directories, one state file ──────────────────────────────────
188
+
189
+ test("the daemon's agent dir and a --out directory do not invalidate each other, ever", (t) => {
190
+ // The scenario: an operator runs `maestro design sync --out docs/brand`
191
+ // (advertised in the guide) on a seat whose daemon polls the agent dir every
192
+ // 15 minutes. With one shared render stamp, each lane re-stamped the state
193
+ // and the other lane then found its own files "stale" — both files rewritten
194
+ // on every tick, forever, with a foundation that never changed.
195
+ const root = tmpRoot(t);
196
+ const brand = join(root, "docs", "brand");
197
+ let now = NOW;
198
+ const daemon = () => syncDesign({ agentRoot: root, result: result(), now: (now += 900_000), name: "Northwind" });
199
+ const cli = () => syncDesign({ agentRoot: root, outDir: brand, result: result(), now: (now += 60_000), name: "Northwind" });
200
+
201
+ assert.deepEqual(daemon().written, ["DESIGN.md", "PRODUCT.md"], "first write of the agent dir");
202
+ assert.deepEqual(cli().written, ["DESIGN.md", "PRODUCT.md"], "first write of docs/brand");
203
+
204
+ for (let i = 0; i < 6; i++) {
205
+ assert.deepEqual(daemon().written, [], `daemon poll ${i + 1} rewrote nothing`);
206
+ assert.deepEqual(cli().written, [], `--out run ${i + 1} rewrote nothing`);
207
+ }
208
+
209
+ const state = readFoundationState(root);
210
+ assert.equal(typeof state.rendered, "object");
211
+ assert.equal(Object.keys(state.rendered).length, 2, "one stamp per output directory");
212
+ assert.equal(state.renderedAt, state.rendered[resolve(root)], "the top-level stamp still describes the agent dir");
213
+ });
214
+
215
+ test("a legacy state file with a bare renderedAt migrates without a spurious rewrite", (t) => {
216
+ const root = tmpRoot(t);
217
+ syncDesign({ agentRoot: root, result: result(), now: NOW, name: "Northwind" });
218
+ // Rewrite the snapshot in the pre-map shape, as an older SDK left it.
219
+ const doc = readFoundationState(root);
220
+ const legacy = { ...doc };
221
+ delete legacy.rendered;
222
+ writeFoundationState(root, legacy);
223
+ assert.equal(readFoundationState(root).rendered, undefined);
224
+
225
+ const out = syncDesign({ agentRoot: root, result: result(), now: NOW + 3_600_000, name: "Northwind" });
226
+ assert.deepEqual(out.written, [], "the old stamp is read as the agent dir's, so the bytes still match");
227
+ assert.equal(readFoundationState(root).rendered[resolve(root)], legacy.renderedAt);
228
+ });
229
+
230
+ test("both markdown files are published atomically and forced to 0644 even over a tighter file", (t) => {
231
+ const root = tmpRoot(t);
232
+ syncDesign({ agentRoot: root, result: result(), now: NOW, name: "Northwind" });
233
+ // Some other tool tightens the mode and truncates the content.
234
+ chmodSync(join(root, "DESIGN.md"), 0o600);
235
+ writeFileSync(join(root, "DESIGN.md"), "half a file");
236
+ syncDesign({ agentRoot: root, result: result(), now: NOW + 1000, name: "Northwind" });
237
+ assert.equal(statSync(join(root, "DESIGN.md")).mode & 0o777, DESIGN_FILE_MODE, "chmod runs on a rewrite, not only on create");
238
+
239
+ // No temp file is left behind by the atomic publish.
240
+ assert.deepEqual(readdirSync(root).filter((f) => f.includes(".tmp.")), []);
241
+ });
@@ -39,10 +39,10 @@ import {
39
39
  existsSync,
40
40
  mkdirSync,
41
41
  readFileSync,
42
- writeFileSync,
43
42
  } from "node:fs";
44
43
  import { join, resolve, dirname } from "node:path";
45
44
  import { spawnSync } from "node:child_process";
45
+ import { writeJsonAtomic } from "./fs-atomic.mjs";
46
46
 
47
47
  export const FEATURE_REGISTRY_RELATIVE = "framework-features.json";
48
48
  export const AGENT_STATE_RELATIVE = ".maestro/features.json";
@@ -116,7 +116,7 @@ export function loadAgentState(agentRoot) {
116
116
  export function saveAgentState(agentRoot, state) {
117
117
  const statePath = getStatePath(agentRoot);
118
118
  mkdirSync(dirname(statePath), { recursive: true });
119
- writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n");
119
+ writeJsonAtomic(statePath, state);
120
120
  }
121
121
 
122
122
  // ---------------------------------------------------------------------------
@@ -166,19 +166,24 @@ test("tools/list: full active surface (email tools present — family is vendore
166
166
  // other desk block, these eight are GENERATED from the vendored protocol
167
167
  // declaration (lib/org/resource-tools.mjs), so this transport gets them, and
168
168
  // every future resource method, without a second hand-written table.
169
- assert.equal(tools.length, 141, "email + artifact + desk families vendored → 141 tools");
169
+ // 141 → 144: the front-door session (2026-09). board_mine / board_track /
170
+ // session_status — "my tasks across every board" was unreadable from any
171
+ // plane (board.ready is unassigned-only), the inbound→board seam had no
172
+ // accepted/done ends, and nothing could tell a session whether the main
173
+ // session was alive.
174
+ assert.equal(tools.length, 144, "email + artifact + desk + front-door families vendored → 144 tools");
170
175
  const names = tools.map((t) => t.name);
171
- for (const expected of ["org_whoami", "org_describe", "org_rpc", "org_read", "messaging_send", "task_assign", "board_ready", "email_send", "email_inbox", "artifact_create", "artifact_act", "artifact_catalog", "email_mailboxes", "email_draft_send", "files_list", "calendar_find_a_time", "crm_list_deals", "books_reports", "meetings_recap_file", "resource_list", "resource_attach_file"]) {
176
+ for (const expected of ["org_whoami", "org_describe", "org_rpc", "org_read", "messaging_send", "task_assign", "board_ready", "board_mine", "board_track", "session_status", "email_send", "email_inbox", "artifact_create", "artifact_act", "artifact_catalog", "email_mailboxes", "email_draft_send", "files_list", "calendar_find_a_time", "crm_list_deals", "books_reports", "meetings_recap_file", "resource_list", "resource_attach_file"]) {
172
177
  assert.ok(names.includes(expected), `${expected} listed`);
173
178
  }
174
179
  assert.ok(tools.every((t) => t.inputSchema && t.inputSchema.type === "object"));
175
180
  h.server.stop();
176
181
  });
177
182
 
178
- test("tools/list honours the email gate (emailAvailable:false → 136 tools)", async () => {
183
+ test("tools/list honours the email gate (emailAvailable:false → 139 tools)", async () => {
179
184
  const h = harness({ emailAvailable: false });
180
185
  const r = await h.request("tools/list", {});
181
- assert.equal(r.result.tools.length, 136, "the 5 own-mailbox email tools drop out");
186
+ assert.equal(r.result.tools.length, 139, "the 5 own-mailbox email tools drop out");
182
187
  const names = new Set(r.result.tools.map((t) => t.name));
183
188
  // The five own-mailbox tools (email:true) are gated out …
184
189
  for (const gated of ["email_send", "email_inbox", "email_message", "email_thread", "email_mark_read"]) {
@@ -423,3 +423,24 @@ test("DeepSeek session retarget env is built when DeepSeek is the cheap candidat
423
423
  // Foreign keys scrubbed.
424
424
  assert.equal(seenEnv.MOONSHOT_API_KEY, undefined);
425
425
  });
426
+
427
+ test("buildChildEnv KEEPS CLAUDE_CODE_OAUTH_TOKEN — the subscription token is not a foreign credential (§3.1)", () => {
428
+ // The scrub regex ends in `_AUTH_TOKEN$`; `_OAUTH_TOKEN` must not match it,
429
+ // otherwise every daemon spawn on the oauth-token path would silently fall
430
+ // back to an expired keychain login. Pinned here so a "tidy" of the regex
431
+ // cannot re-open F1.
432
+ const base = {
433
+ PATH: "/usr/bin",
434
+ CLAUDE_CODE_OAUTH_TOKEN: "sk-ant-oat01-example",
435
+ MAESTRO_PREFER_SUBSCRIPTION_AUTH: "1",
436
+ SOME_AUTH_TOKEN: "tok",
437
+ };
438
+ const stock = buildChildEnv(base, {});
439
+ assert.equal(stock.CLAUDE_CODE_OAUTH_TOKEN, "sk-ant-oat01-example");
440
+ assert.equal(stock.MAESTRO_PREFER_SUBSCRIPTION_AUTH, "1");
441
+ assert.equal(stock.SOME_AUTH_TOKEN, undefined, "a real *_AUTH_TOKEN is still scrubbed");
442
+ // A retarget (third-party session) keeps it too: the token is harmless to a
443
+ // foreign base URL and the ANTHROPIC_API_KEY="" discipline is what fences it.
444
+ const retarget = buildChildEnv(base, { ANTHROPIC_BASE_URL: "https://api.moonshot.ai/anthropic", ANTHROPIC_API_KEY: "" });
445
+ assert.equal(retarget.CLAUDE_CODE_OAUTH_TOKEN, "sk-ant-oat01-example");
446
+ });