@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,161 @@
1
+ // Best-effort process-tree cleanup shared by the gate runner and tests.
2
+ // Zero dependencies. Node 16+.
3
+
4
+ import { spawnSync as nodeSpawnSync } from "node:child_process";
5
+ import { win32 } from "node:path";
6
+
7
+ export const WINDOWS_TASKKILL_TIMEOUT_MS = 1000;
8
+
9
+ export function windowsTaskkillPath(env = process.env) {
10
+ const normalizeRoot = (value) => String(value || "").replace(/\//g, "\\").replace(/\\+$/, "");
11
+ const systemRoot = normalizeRoot(env.SystemRoot);
12
+ const windir = normalizeRoot(env.WINDIR);
13
+ const systemDrive = String(env.SystemDrive || "").replace(/[\\/]+$/, "").toUpperCase();
14
+ const driveRoot = /^[A-Za-z]:\\Windows$/i;
15
+ // Require the three standard Windows launcher values to identify the same
16
+ // drive-root directory. A single arbitrary absolute variable must not select
17
+ // an executable, and disagreement fails closed to the ChildProcess handle.
18
+ if (driveRoot.test(systemRoot) && driveRoot.test(windir) &&
19
+ systemRoot.toLowerCase() === windir.toLowerCase() &&
20
+ systemDrive === systemRoot.slice(0, 2).toUpperCase()) {
21
+ return win32.join(systemRoot, "System32", "taskkill.exe");
22
+ }
23
+ // A bare executable name consults cwd/PATH, which are controlled by the
24
+ // CHECK environment. If neither trusted system root exists, skip the helper
25
+ // and use the already-held ChildProcess handle instead.
26
+ return null;
27
+ }
28
+
29
+ function syncFailure(result) {
30
+ if (!result) return "returned no result";
31
+ if (result.error) return result.error.code || result.error.message || "spawn error";
32
+ if (result.signal) return "signal " + result.signal;
33
+ if (result.status !== 0) return "exit " + String(result.status);
34
+ return null;
35
+ }
36
+
37
+ const childExited = (child) => child.exitCode !== null && child.exitCode !== undefined ||
38
+ child.signalCode !== null && child.signalCode !== undefined;
39
+
40
+ export function terminateProcessTree(child, options = {}) {
41
+ const platform = options.platform || process.platform;
42
+ const spawnSyncImpl = options.spawnSyncImpl || nodeSpawnSync;
43
+ const killGroup = options.killGroup || process.kill;
44
+ const pid = child && child.pid;
45
+ if (!Number.isInteger(pid) || pid <= 0) {
46
+ return { ok: false, fallback: false, diagnostic: "child PID is unavailable" };
47
+ }
48
+
49
+ if (platform !== "win32") {
50
+ // The gate runner launches a detached Node supervisor as the group leader
51
+ // and keeps it alive until the shell and inherited stdout/stderr close. If
52
+ // Node has already observed that supervisor exit, the numeric PGID no
53
+ // longer carries identity and may have been reused; never signal it.
54
+ if (childExited(child)) {
55
+ return { ok: true, fallback: false, diagnostic: "process supervisor already exited" };
56
+ }
57
+ try {
58
+ killGroup(-pid, "SIGKILL");
59
+ return { ok: true, fallback: false, diagnostic: null };
60
+ } catch (error) {
61
+ if (childExited(child)) {
62
+ return {
63
+ ok: true,
64
+ fallback: true,
65
+ diagnostic: "process-group kill failed (" + (error.code || error.message) + "); supervisor already exited",
66
+ };
67
+ }
68
+ try {
69
+ const requested = child.kill("SIGKILL");
70
+ if (requested === false) {
71
+ return {
72
+ ok: false,
73
+ fallback: true,
74
+ diagnostic: "process-group kill failed (" + (error.code || error.message) +
75
+ "); child fallback returned false",
76
+ };
77
+ }
78
+ return {
79
+ ok: true,
80
+ fallback: true,
81
+ diagnostic: "process-group kill failed (" + (error.code || error.message) + "); child fallback requested",
82
+ };
83
+ } catch (fallbackError) {
84
+ return {
85
+ ok: false,
86
+ fallback: true,
87
+ diagnostic: "process-group kill failed (" + (error.code || error.message) +
88
+ "); child fallback failed (" + (fallbackError.code || fallbackError.message) + ")",
89
+ };
90
+ }
91
+ }
92
+ }
93
+
94
+ // taskkill addresses the stored leader PID, unlike a POSIX process-group
95
+ // request. Do not target it after Node reports exit because the PID may have
96
+ // been reused while an inherited pipe remains open.
97
+ if (childExited(child)) {
98
+ return { ok: true, fallback: false, diagnostic: "child already exited" };
99
+ }
100
+
101
+ const command = windowsTaskkillPath(options.env || process.env);
102
+ const requestedTimeout = Number(options.taskkillTimeoutMs);
103
+ const taskkillTimeoutMs = Number.isFinite(requestedTimeout) && requestedTimeout > 0
104
+ ? Math.floor(requestedTimeout)
105
+ : WINDOWS_TASKKILL_TIMEOUT_MS;
106
+ let result;
107
+ let failure;
108
+ if (!command) {
109
+ failure = "trusted system taskkill path unavailable";
110
+ } else {
111
+ try {
112
+ result = spawnSyncImpl(command, ["/pid", String(pid), "/f", "/t"], {
113
+ stdio: "ignore",
114
+ windowsHide: true,
115
+ // Cleanup runs inside the gate timeout path. A broken helper must not
116
+ // replace a bounded CHECK with an unbounded synchronous wait.
117
+ timeout: taskkillTimeoutMs,
118
+ killSignal: "SIGKILL",
119
+ });
120
+ failure = syncFailure(result);
121
+ } catch (error) {
122
+ failure = error.code || error.message || "spawn threw";
123
+ }
124
+ }
125
+ if (!failure) return { ok: true, fallback: false, diagnostic: null, command };
126
+
127
+ if (childExited(child)) {
128
+ return {
129
+ ok: true,
130
+ fallback: true,
131
+ command,
132
+ diagnostic: "taskkill failed (" + failure + "); child already exited",
133
+ };
134
+ }
135
+
136
+ try {
137
+ const requested = child.kill("SIGKILL");
138
+ if (requested === false) {
139
+ return {
140
+ ok: false,
141
+ fallback: true,
142
+ command,
143
+ diagnostic: "taskkill failed (" + failure + "); child fallback returned false",
144
+ };
145
+ }
146
+ return {
147
+ ok: true,
148
+ fallback: true,
149
+ command,
150
+ diagnostic: "taskkill failed (" + failure + "); child fallback requested",
151
+ };
152
+ } catch (error) {
153
+ return {
154
+ ok: false,
155
+ fallback: true,
156
+ command,
157
+ diagnostic: "taskkill failed (" + failure + "); child fallback failed (" +
158
+ (error.code || error.message) + ")",
159
+ };
160
+ }
161
+ }
@@ -0,0 +1,9 @@
1
+ import { parentPort } from "node:worker_threads";
2
+
3
+ parentPort.once("message", ({ source, flags, output }) => {
4
+ try {
5
+ parentPort.postMessage({ matched: new RegExp(source, flags).test(output) });
6
+ } catch (error) {
7
+ parentPort.postMessage({ error: error.message });
8
+ }
9
+ });
@@ -0,0 +1,116 @@
1
+ # Plan: <task>
2
+
3
+ Scope: <validated pipeline id; store this file at .unlazy/<scope>/PLAN.md>
4
+ Depth: tree <N>
5
+ Mode: orchestrated
6
+
7
+ ## Contract
8
+
9
+ Decide before fan-out:
10
+
11
+ - Interfaces: <signatures, schemas, formats, integration points>
12
+ - Ownership: <one complete set of repository-relative paths per leaf; no absolute paths, traversal, or concurrent overlap>
13
+ - Dependencies: <leaf ids that must be VERIFIED first>
14
+ - Host launch mode: <Codex native subagents | Claude background Agents | Claude Dynamic Workflow | sequential fallback>
15
+ - Wave policy: <which independent READY leaves launch together and the maximum host concurrency>
16
+ - Toolchain: <runtime versions, shell, working-directory rules, test commands>
17
+ - Conventions: <naming, errors, compatibility, formatting>
18
+ - Manual review: <owner and evidence standard for consequential manual gates>
19
+
20
+ ## Current contract inventory
21
+
22
+ Contract revision: 1. Before fan-out, reread the original request and current amendments. Record every independently omittable required outcome and every constraint that changes acceptance; do not copy credentials, private text, or unrelated context.
23
+
24
+ | ID | Required outcome or constraint | Owner | Observing gate or manual review | Disposition | Revision |
25
+ |---|---|---|---|---|---|
26
+ | C1 | <concise paraphrase> | <leaf/node> | <qualified gate/reviewer> | ACTIVE | 1 |
27
+
28
+ Use stable ids. On an amendment, increment the revision and reconcile every affected row before dispatch or completion credit. `ACTIVE` is complete only with a current owner and observation. `ABANDONED`, `DEFERRED`, and `OWNER_DECISION` are honest non-completion; only explicit user authority may use `REMOVED_BY_USER`.
29
+
30
+ ## State vocabulary
31
+
32
+ Leaf state is exactly one of:
33
+
34
+ - WAITING: at least one id in Needs is not VERIFIED
35
+ - READY: dependencies are VERIFIED and ownership can be claimed
36
+ - IN-FLIGHT: dispatched, not yet parent-verified
37
+ - VERIFIED: parent --reverify passed and manual gates were reviewed
38
+ - ABANDONED: a required gate has a visible handoff
39
+
40
+ Branch state is exactly one of OPEN, VERIFIED, or ABANDONED. Derive root and
41
+ branch state from their ledgers; do not copy it into the topology tree.
42
+
43
+ ## Tree
44
+
45
+ Use this tree only for parent-child topology and ledger paths. Use `leaf-` paths
46
+ for work leaves and `node-` paths for branch integration. Keep leaf operational
47
+ fields only in the dispatch table below; do not repeat `Owns`, `Needs`, `Tier`,
48
+ `Planned wave`, or `State` here and do not create a separate schedule.
49
+
50
+ - 1 <task> .............. GATES.md
51
+ - 1.1 <branch> ........ gates/node-1.1.md
52
+ - 1.1.1 <leaf> ...... gates/leaf-1.1.1.md
53
+ - 1.1.2 <leaf> ...... gates/leaf-1.1.2.md
54
+ - 1.2 <branch> ........ gates/node-1.2.md
55
+ - 1.2.1 <leaf> ...... gates/leaf-1.2.1.md
56
+ - 1.2.2 <leaf> ...... gates/leaf-1.2.2.md
57
+
58
+ ## Leaf dispatch table
59
+
60
+ This is the single authoritative PLAN table for leaf operation. It is
61
+ authoritative for `Needs`, `Tier`, `Planned wave`, and `State`; `Owns` remains
62
+ visible here as the derived planning mirror described below. Keep exactly one
63
+ row per tree leaf and do not duplicate these fields in the tree or a schedule.
64
+
65
+ | Leaf | Owns | Needs | Tier | Planned wave | State |
66
+ |---|---|---|---|---|---|
67
+ | 1.1.1 | src/<a>/**, tests/<a>/** | - | mechanical | 1 | READY |
68
+ | 1.1.2 | src/<b>/**, tests/<b>/** | - | judgment | 1 | READY |
69
+ | 1.2.1 | src/<c>/**, tests/<c>/** | - | mechanical | 1 | READY |
70
+ | 1.2.2 | src/<d>/**, tests/<d>/** | 1.2.1 | judgment | 2 | WAITING |
71
+
72
+ `Owns` is a derived planning mirror of the complete glob set in the leaf ledger.
73
+ The ledger's `OWNS:` header is the command-time authority read by `--claim`.
74
+ Normalize both into sets and require equality before marking the row `READY` and
75
+ again before each claim. On disagreement, fail closed, correct and log the plan
76
+ or ledger, and recheck; never guess ownership. A successful claim does not prove
77
+ the mirror agreed with the ledger.
78
+
79
+ `Tier` is planner metadata for execution leaves. Use `judgment` when a leaf's own
80
+ artifact needs design or review, and `mechanical` only when its pattern and gates
81
+ are fixed. Map a tier through documented host-specific model or reasoning
82
+ controls only when the host exposes them. Otherwise retain the tier as a briefing
83
+ and review requirement without claiming a model choice. Driver and branch duties,
84
+ including planning, dispatch, parent verification, integration, and final audit,
85
+ remain judgment responsibilities outside this leaf field. Tier never weakens a
86
+ leaf's gates, parent re-verification, or integration standard.
87
+
88
+ `Planned wave` is the earliest intended launch group under the current contract.
89
+ Use a positive integer, put every dependency in an earlier planned wave, and let
90
+ the wave policy cap concurrency. It is a plan, not a barrier: rolling dispatch
91
+ may start a later planned wave as soon as that row's dependencies are verified,
92
+ without waiting for unrelated work. Actual starts belong in `dispatch.json` and
93
+ `status.log`; do not add a second schedule to this file.
94
+
95
+ Change `Needs`, `Owns`, `Tier`, or `Planned wave` only through a recorded plan
96
+ amendment before that row launches; do not erase a dependency when it becomes
97
+ satisfied. Update `State` in this table as work progresses. After parent
98
+ re-verification and manual review, mark a leaf `VERIFIED`, release that exact
99
+ leaf lease, and record the release. Do not promote a dependent until that exact
100
+ release is recorded. For an abandoned leaf, record the handoff and confirm its
101
+ worker has settled before releasing only that leaf; never call it
102
+ parent-verified. Release the whole scope only after every leaf is settled and
103
+ final scope verification has run. If final verification reports a handoff,
104
+ record it before release and never describe the scope as complete.
105
+
106
+ ## Status log
107
+
108
+ Append events to `.unlazy/<scope>/status.log`; do not copy the event history into this file:
109
+
110
+ ```text
111
+ node <skill-dir>/scripts/gate-check.mjs --scope <scope> --log "leaf-1.1.1 dispatched"
112
+ node <skill-dir>/scripts/gate-check.mjs --scope <scope> --log "leaf-1.1.1 verified"
113
+ node <skill-dir>/scripts/gate-check.mjs --scope <scope> --log "leaf-1.1.1 lease released"
114
+ ```
115
+
116
+ Record contract amendments, plan changes, dispatch, parent verification, abandonment, branch integration, and lease release. Apply logged plan amendments and live State updates only in the dispatch table; keep the log append-only. Before root completion, reread the current request and review every current inventory row against its owner and observing gate or manual review.
@@ -0,0 +1,51 @@
1
+ # Gates: <leaf or task name>
2
+
3
+ OWNS: <repository-relative globs this leaf may write, for example src/api/**, tests/api/**>
4
+
5
+ Scope: <one sentence describing the complete deliverable>
6
+
7
+ - [ ] G1: <observable outcome measured directly from the artifact>
8
+ CHECK: node scripts/verify-outcome.mjs
9
+ EXPECT: outcome verification passed
10
+ EVIDENCE: pending
11
+
12
+ - [ ] G2: <integration outcome in a subproject>
13
+ CHECK: node scripts/verify-integration.mjs
14
+ EXPECT: integration verification passed
15
+ CWD: packages/example
16
+ EVIDENCE: pending
17
+
18
+ - [ ] G3: <manual outcome that no command can decide>
19
+ EVIDENCE: pending
20
+
21
+ <!--
22
+ Replace every placeholder before running the checker.
23
+
24
+ Strict format:
25
+ - Use a unique explicit id for every gate.
26
+ - Indent CHECK, EXPECT, CWD, and EVIDENCE.
27
+ - Give a runnable gate both CHECK and EXPECT; give a manual gate neither.
28
+ - Success requires process exit 0 and EXPECT.
29
+ - Make EXPECT a success-only marker produced after every assertion passes.
30
+ - For an absence or negative assertion, test the same checker against a known
31
+ positive fixture and record that control in the gate's manual review.
32
+ - Measure supplied figures from source. Do not copy a supplied number into
33
+ EXPECT as its own proof.
34
+ - Use repository-owned Node scripts for portable examples. Declare any
35
+ non-default shell or external tool requirement explicitly.
36
+ - Scoped and legacy discovery anchor checks at the repository root. When this
37
+ ledger is named explicitly from `.unlazy/`, pass an explicit repository
38
+ `--root` and `--cwd` so repository-relative commands keep the same base.
39
+ - Record exact manual evidence and review consequential manual gates by risk.
40
+ - OWNS paths must be repository-relative, complete, and disjoint from every
41
+ concurrently dispatched leaf. Claims coordinate writers; they do not sandbox.
42
+
43
+ If a gate becomes genuinely impossible, keep the gate and add:
44
+
45
+ ```text
46
+ ABANDON: G<n> <non-empty reason and handoff>
47
+ ```
48
+
49
+ Surface every abandonment as a non-successful handoff in the final report; an
50
+ abandoned leaf is not complete. See references/gates.md.
51
+ -->
@@ -0,0 +1,51 @@
1
+ # Gates: <branch name> integration
2
+
3
+ Scope: integrate children <explicit child ids> into one verified result
4
+
5
+ - [ ] N1: every named direct child is reverified from its exact ledger
6
+ CHECK: node <skill-dir>/scripts/gate-check.mjs --root . --cwd . --reverify --jobs 1 .unlazy/<scope>/gates/leaf-<a>.md .unlazy/<scope>/gates/node-<b>.md
7
+ EXPECT: ALL MET
8
+ EVIDENCE: pending
9
+
10
+ - [ ] N2: child interfaces match the contract in PLAN.md
11
+ CHECK: node scripts/verify-interfaces.mjs
12
+ EXPECT: interface verification passed
13
+ EVIDENCE: pending
14
+
15
+ - [ ] N3: cross-child behavior works end to end
16
+ CHECK: node scripts/verify-integration.mjs
17
+ EXPECT: integration verification passed
18
+ EVIDENCE: pending
19
+
20
+ - [ ] N4: affected sibling behavior has not regressed
21
+ CHECK: node scripts/verify-regressions.mjs
22
+ EXPECT: regression verification passed
23
+ EVIDENCE: pending
24
+
25
+ - [ ] N5: every direct leaf child's ownership lease was released after parent verification
26
+ EVIDENCE: pending
27
+
28
+ - [ ] N6: consequential manual outcomes from the children were reviewed at branch level
29
+ EVIDENCE: pending
30
+
31
+ <!--
32
+ Replace every placeholder before running the checker.
33
+
34
+ N1 must name every direct child ledger explicitly, whether that child is a leaf
35
+ or another branch, and use --reverify, not --status. Status validates the stored
36
+ definition binding without executing or inspecting current artifacts. Keep --jobs 1 unless the child checks are independent and
37
+ deterministic parallel execution is intentional.
38
+ If a child reports an abandonment, N1 exits 1 with `HANDOFF REQUIRED`. Mark the
39
+ branch ABANDONED and surface the handoff; never rewrite that result as completion.
40
+
41
+ Branch paths use node-<id>.md. Leaf paths use leaf-<id>.md. Branch completion
42
+ requires integration evidence; a set of locally complete leaves is not enough.
43
+
44
+ For N5, run this once for each direct leaf child after verification and record
45
+ the outputs as manual evidence. Branch children do not own leaf leases:
46
+
47
+ node <skill-dir>/scripts/gate-check.mjs --scope <scope> --leaf leaf-<id> --release
48
+
49
+ Drop N5 only when no child claimed ownership. See references/orchestration.md
50
+ and references/parallel.md.
51
+ -->
@@ -351,6 +351,30 @@ This agent is powered by the `@cohortapp/agent-sdk` framework. Update framework:
351
351
  - All configs must use environment variables for secrets
352
352
  - All actions must be auditable via logs/
353
353
 
354
+ ### Writing code that survives unattended operation
355
+
356
+ You run unattended. Nobody is watching when something goes wrong, and your
357
+ daemon, pollers and cadence consumers share one state directory. Code you cannot
358
+ reason about in isolation is code whose failure surfaces days later, in a log.
359
+ So:
360
+
361
+ - **Decisions are pure functions; I/O happens around them.** Work out *what*
362
+ should happen from the arguments you were given, then read and write at the
363
+ edges. A rule you can call without a filesystem is a rule you can test.
364
+ - **`now` is an argument** wherever it changes the answer:
365
+ `fn(args, now = new Date())`. A schedule that reads the wall clock internally
366
+ cannot be checked until the moment it fires.
367
+ - **Expected failure is a returned value,** `{ok:false, error}` — not a throw.
368
+ Throw only when something is genuinely broken.
369
+ - **Every `catch {}` gets a comment** saying why continuing is correct. A silent
370
+ swallow is a bug you will meet later without a clue where it came from.
371
+ - **Durable JSON is written atomically** — via maestro's `lib/fs-atomic.mjs`
372
+ (`writeJsonAtomic`) where available. A bare write can leave a half-written
373
+ file for a concurrent reader to parse.
374
+ - **Prefer adding a file to a registry over editing a dispatcher.**
375
+ - Full doctrine, if maestro is checked out locally:
376
+ `~/maestro/docs/engineering/functional-architecture.md`.
377
+
354
378
  ## Three Operating Modes
355
379
 
356
380
  ### Mode 1: Reactive — Respond to Events
@@ -0,0 +1,147 @@
1
+ /**
2
+ * check-durable-write-seam.mjs — the durable-write effect boundary, enforced.
3
+ *
4
+ * ── WHAT THIS DEFENDS ────────────────────────────────────────────────────────
5
+ * `lib/fs-atomic.mjs` is the ONE home for the write-temp → fsync → rename
6
+ * primitive. Its own docstring names the hazard: a bare `writeFileSync` can
7
+ * leave a zero-length or half-written file if the process dies mid-write, and a
8
+ * reader then parses corruption. Because maestro runs daemons, pollers and
9
+ * cadence consumers concurrently on the same state directory, a torn write is
10
+ * not a theoretical failure here — it is the observable one.
11
+ *
12
+ * ── WHAT THIS RULE CAN SEE ───────────────────────────────────────────────────
13
+ * A `writeFileSync(` call, in a non-test file under `lib/`, whose argument list
14
+ * contains `JSON.stringify` — i.e. a write of DURABLE STRUCTURED STATE. That is
15
+ * the shape worth defending: JSON is what gets read back and parsed, so JSON is
16
+ * what a torn write corrupts.
17
+ *
18
+ * ── WHAT IT DELIBERATELY DOES NOT SEE, and why each is correct ───────────────
19
+ * · `writeFileSync(tmp, …)` — a write to a temp sibling that a `renameSync`
20
+ * then publishes. That IS the atomic pattern, hand-rolled. It is safe. Some
21
+ * 30-odd files in `lib/` do exactly this; failing them would make this guard
22
+ * noise, and a guard that cries wolf gets suppressed rather than obeyed.
23
+ * · `deps.writeFileSync || writeFileSync` — the explicit-dependency form
24
+ * (`capability/probe.mjs`, `mandate/cache.mjs`, `plan/emit.mjs`,
25
+ * `capability/inventory.mjs`). Injecting the effect is BETTER than routing
26
+ * it through a shared helper, not worse. Never flag it.
27
+ * · Non-JSON writes — audio buffers, YAML config, `.md` briefs, `.env` stubs,
28
+ * marker files like `.emergency-stop`. Torn-write risk is real but the blast
29
+ * radius is a re-run, not a parse error in a consumer.
30
+ *
31
+ * So this is the cheap first net over one specific, high-value shape. It is not
32
+ * a proof that `lib/` has no unsafe write.
33
+ *
34
+ * ── WHY A REGISTER AND NOT A ZERO ────────────────────────────────────────────
35
+ * One legitimate spelling survives: acquiring a lock via `O_EXCL`
36
+ * (`{ flag: "wx" }`). A lock MUST be created exclusively and in place — publish
37
+ * it by rename and two processes can both believe they hold it, which is the
38
+ * exact bug the lock exists to prevent. So the check asserts SET EQUALITY
39
+ * against an explicit register with a reason per entry: a NEW site fails, and a
40
+ * site that goes away ALSO fails (its register entry is now stale). The register
41
+ * cannot become a place stale names hide. This is the idiom
42
+ * `eslint.config.mjs`/`authz-drift.test.ts` use in the Cohort repo.
43
+ *
44
+ * Set equality also makes this guard self-canarying: the register is non-empty,
45
+ * so a regex broken by a refactor yields zero matches and fails LOUDLY as three
46
+ * stale entries, rather than passing vacuously.
47
+ *
48
+ * Pure, dependency-light: Node builtins only. ESM.
49
+ *
50
+ * Usage: `node scripts/ci/check-durable-write-seam.mjs` (exit 0 ok / 1 found)
51
+ * @module scripts/ci/check-durable-write-seam
52
+ */
53
+
54
+ "use strict";
55
+
56
+ import { readFileSync, readdirSync, statSync } from "node:fs";
57
+ import path from "node:path";
58
+ import { fileURLToPath } from "node:url";
59
+
60
+ const REPO_ROOT = path.resolve(fileURLToPath(new URL(".", import.meta.url)), "..", "..");
61
+
62
+ /**
63
+ * The sanctioned direct writers of durable JSON, and why each one must be.
64
+ * `site` is `<repo-relative path>:<line>`; a reason is mandatory.
65
+ * @type {Record<string, string>}
66
+ */
67
+ export const SANCTIONED_DIRECT_WRITES = {
68
+ "lib/cadence-bus.mjs:315":
69
+ "acquireScheduleLock: O_EXCL create. A lock published by rename is not a lock — two processes could both succeed.",
70
+ "lib/cadence-bus.mjs:323":
71
+ "acquireScheduleLock: reclaim of a lock already proven stale by mtime; must land in place under the same name.",
72
+ "lib/cadence-bus.mjs:328":
73
+ "acquireScheduleLock: second O_EXCL attempt after the stale holder vanished.",
74
+ };
75
+
76
+ /** Argument spellings that are already safe and must never be flagged. */
77
+ const TEMP_TARGET_RE = /writeFileSync\(\s*(tmp|temp|tmpPath|tmpFile|fd)\b/;
78
+ const INJECTED_RE = /deps\.writeFileSync/;
79
+
80
+ /** @param {string} dir @param {string[]} [out] @returns {string[]} */
81
+ function walk(dir, out = []) {
82
+ for (const entry of readdirSync(dir)) {
83
+ if (entry === "node_modules") continue;
84
+ const p = path.join(dir, entry);
85
+ if (statSync(p).isDirectory()) walk(p, out);
86
+ else if (/\.(mjs|js)$/.test(entry) && !/\.test\.(mjs|js)$/.test(entry)) out.push(p);
87
+ }
88
+ return out;
89
+ }
90
+
91
+ /**
92
+ * Every direct durable-JSON write under `lib/`, as `path:line` → source line.
93
+ * @param {object} [opts] @param {string} [opts.cwd]
94
+ * @returns {Array<{ site: string, line: string }>}
95
+ */
96
+ export function findDirectDurableWrites(opts = {}) {
97
+ const cwd = opts.cwd || REPO_ROOT;
98
+ const libDir = path.join(cwd, "lib");
99
+ const hits = [];
100
+ for (const file of walk(libDir)) {
101
+ const rel = path.relative(cwd, file);
102
+ const lines = readFileSync(file, "utf8").split("\n");
103
+ lines.forEach((line, i) => {
104
+ if (!line.includes("writeFileSync(")) return;
105
+ if (!line.includes("JSON.stringify")) return;
106
+ if (TEMP_TARGET_RE.test(line)) return;
107
+ if (INJECTED_RE.test(line)) return;
108
+ hits.push({ site: `${rel}:${i + 1}`, line: line.trim() });
109
+ });
110
+ }
111
+ return hits;
112
+ }
113
+
114
+ /** @param {string} [cwd=REPO_ROOT] @returns {Promise<number>} */
115
+ export async function run(cwd = REPO_ROOT) {
116
+ let hits;
117
+ try {
118
+ hits = findDirectDurableWrites({ cwd });
119
+ } catch (err) {
120
+ console.error(`check-durable-write-seam: ERROR — ${err && err.message ? err.message : err}`);
121
+ return 2;
122
+ }
123
+ const found = new Set(hits.map((h) => h.site));
124
+ const registered = new Set(Object.keys(SANCTIONED_DIRECT_WRITES));
125
+
126
+ const unregistered = hits.filter((h) => !registered.has(h.site));
127
+ const stale = [...registered].filter((s) => !found.has(s));
128
+
129
+ if (unregistered.length === 0 && stale.length === 0) {
130
+ console.log(`check-durable-write-seam: OK (${registered.size} sanctioned direct writes, all accounted for)`);
131
+ return 0;
132
+ }
133
+ console.error("check-durable-write-seam: FAIL — the durable-write seam has drifted:");
134
+ for (const h of unregistered) {
135
+ console.error(` NEW ${h.site}`);
136
+ console.error(` ${h.line}`);
137
+ console.error(" Use writeJsonAtomic() from lib/fs-atomic.mjs, or add a reasoned entry to SANCTIONED_DIRECT_WRITES.");
138
+ }
139
+ for (const s of stale) {
140
+ console.error(` STALE ${s} no longer matches — delete its SANCTIONED_DIRECT_WRITES entry (reason: ${SANCTIONED_DIRECT_WRITES[s]})`);
141
+ }
142
+ return 1;
143
+ }
144
+
145
+ if (import.meta.url === `file://${process.argv[1]}`) {
146
+ run().then((c) => process.exit(c)).catch((e) => { console.error("check-durable-write-seam: ERROR", e && e.message ? e.message : e); process.exit(2); });
147
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Tests for check-durable-write-seam.mjs — the durable-write effect boundary.
3
+ *
4
+ * Two things are worth testing and they are not the same thing:
5
+ * 1. The MATCHER's discrimination — that it fires on a direct durable-JSON
6
+ * write and stays silent on the three shapes that are already safe
7
+ * (temp-then-rename, injected `deps.writeFileSync`, non-JSON payloads).
8
+ * A guard that flags safe code gets suppressed, so this is the property
9
+ * that decides whether the guard survives contact with contributors.
10
+ * 2. The REGISTER's honesty against the real tree — that every sanctioned
11
+ * site still exists at the line it claims. This is what stops the register
12
+ * becoming a place stale names hide.
13
+ *
14
+ * @module scripts/ci/check-durable-write-seam.test
15
+ */
16
+
17
+ import { describe, it } from "node:test";
18
+ import assert from "node:assert/strict";
19
+ import { readFileSync } from "node:fs";
20
+ import path from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+ import {
23
+ findDirectDurableWrites,
24
+ SANCTIONED_DIRECT_WRITES,
25
+ } from "./check-durable-write-seam.mjs";
26
+
27
+ const REPO_ROOT = path.resolve(fileURLToPath(new URL(".", import.meta.url)), "..", "..");
28
+
29
+ /** The matcher, lifted verbatim from the guard so a line can be tested alone. */
30
+ function flags(line) {
31
+ if (!line.includes("writeFileSync(")) return false;
32
+ if (!line.includes("JSON.stringify")) return false;
33
+ if (/writeFileSync\(\s*(tmp|temp|tmpPath|tmpFile|fd)\b/.test(line)) return false;
34
+ if (/deps\.writeFileSync/.test(line)) return false;
35
+ return true;
36
+ }
37
+
38
+ describe("check-durable-write-seam: what the matcher sees", () => {
39
+ it("flags a direct write of durable JSON to a final path", () => {
40
+ assert.equal(flags('writeFileSync(join(dir, "self.json"), JSON.stringify(entry, null, 2));'), true);
41
+ });
42
+
43
+ it("does NOT flag temp-then-rename — that IS the atomic pattern, hand-rolled", () => {
44
+ assert.equal(flags('writeFileSync(tmp, JSON.stringify(state, null, 2) + "\\n");'), false);
45
+ assert.equal(flags("writeFileSync(tmpPath, JSON.stringify(o));"), false);
46
+ });
47
+
48
+ it("does NOT flag the injected-dependency form — that is better than a shared helper, not worse", () => {
49
+ assert.equal(flags("const wr = deps.writeFileSync || writeFileSync; wr(p, JSON.stringify(o));"), false);
50
+ });
51
+
52
+ it("does NOT flag non-JSON payloads — audio, YAML, markdown, marker files", () => {
53
+ assert.equal(flags("writeFileSync(join(d, f), audio);"), false);
54
+ assert.equal(flags("writeFileSync(p, yaml.dump(doc));"), false);
55
+ });
56
+ });
57
+
58
+ describe("check-durable-write-seam: the register is honest about the real tree", () => {
59
+ const hits = findDirectDurableWrites();
60
+ const found = new Set(hits.map((h) => h.site));
61
+
62
+ it("finds at least one site — a matcher that matches nothing passes vacuously", () => {
63
+ assert.ok(hits.length > 0, "matcher found nothing; it is probably broken");
64
+ });
65
+
66
+ it("every sanctioned site still exists at the line it claims", () => {
67
+ for (const site of Object.keys(SANCTIONED_DIRECT_WRITES)) {
68
+ assert.ok(found.has(site), `stale register entry: ${site}`);
69
+ }
70
+ });
71
+
72
+ it("no unregistered direct durable write exists under lib/", () => {
73
+ const unregistered = [...found].filter((s) => !(s in SANCTIONED_DIRECT_WRITES)).sort();
74
+ assert.deepEqual(unregistered, []);
75
+ });
76
+
77
+ it("every sanctioned entry carries a non-empty reason", () => {
78
+ for (const [site, reason] of Object.entries(SANCTIONED_DIRECT_WRITES)) {
79
+ assert.ok(reason && reason.length > 20, `register entry ${site} needs a real reason`);
80
+ }
81
+ });
82
+
83
+ it("the sanctioned sites are all O_EXCL lock acquisition — the one shape rename would break", () => {
84
+ for (const site of Object.keys(SANCTIONED_DIRECT_WRITES)) {
85
+ const [rel, lineNo] = site.split(":");
86
+ const line = readFileSync(path.join(REPO_ROOT, rel), "utf8").split("\n")[Number(lineNo) - 1];
87
+ assert.match(line, /scheduleLock/, `${site} is not a lock write; it should not be exempt`);
88
+ }
89
+ });
90
+ });