@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,245 @@
1
+ #!/usr/bin/env node
2
+ // gate-lint.mjs : audit whether a ledger is worth passing.
3
+ // Zero dependencies. Node 16+.
4
+ //
5
+ // The checker and the Stop hook decide whether gates were met. Neither asks
6
+ // whether the gates were worth meeting. A gate reading "the entire feature
7
+ // works perfectly" with `CHECK: echo ok` and `EXPECT: ok` passes the checker,
8
+ // the parent re-verification and the hook, because the oracle is real, runs,
9
+ // and returns what it promised. Authoring is the one step in the enforcement
10
+ // hierarchy that is still pure prose discipline, and this lints it.
11
+ //
12
+ // This never executes a CHECK. It reads the ledger and judges its oracles.
13
+ //
14
+ // node gate-lint.mjs [options] <ledger.md ...>
15
+ // --strict treat warnings as failures
16
+ // --json machine-readable findings
17
+ //
18
+ // exit codes: 0 no strict failures, 1 strict findings, 2 usage or parse error.
19
+ //
20
+ // Usable as a gate, so a ledger can require its own quality:
21
+ // CHECK: node scripts/gate-lint.mjs GATES.md
22
+ // EXPECT: LINT OK
23
+
24
+ import { parseGates, readStableRegularFile } from "./lib/gates.mjs";
25
+
26
+ const HELP = `usage: gate-lint.mjs [--strict] [--json] <ledger.md ...>
27
+
28
+ Audit gate quality, not gate completion. Report lexical signs of fixed-output
29
+ oracles, weak expectations, manual measurements, and titles that name an
30
+ activity instead of an outcome. Never executes a CHECK.
31
+
32
+ exit codes: 0 no strict failures, 1 strict findings, 2 usage or parse error.`;
33
+
34
+ const KNOWN_OPTIONS = new Set(["--strict", "--json", "--help", "-h"]);
35
+ const MAX_GATE_LEDGER_BYTES = 8 * 1024 * 1024;
36
+ const MAX_REPORTED_FINDINGS = 64;
37
+ const MAX_REPORT_BYTES = 256 * 1024;
38
+ const TRUNCATION_MARKER = "...[truncated]";
39
+
40
+ // Findings and CLI diagnostics can contain repository-controlled filenames,
41
+ // gate text, parser messages, or argv. Escape terminal controls at those data
42
+ // boundaries instead of rewriting complete output, so help text, structural
43
+ // newlines, and JSON indentation retain their intended formatting.
44
+ const TERMINAL_CONTROL = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/;
45
+ function terminalSafe(value, maxBytes = 1024) {
46
+ const pieces = [];
47
+ const sizes = [];
48
+ let bytes = 0;
49
+ let truncated = false;
50
+ for (const character of String(value)) {
51
+ let piece = character;
52
+ if (TERMINAL_CONTROL.test(character)) {
53
+ const code = character.codePointAt(0);
54
+ const prefix = code <= 0xff ? "\\x" : "\\u";
55
+ const width = code <= 0xff ? 2 : 4;
56
+ piece = prefix + code.toString(16).padStart(width, "0");
57
+ }
58
+ const size = Buffer.byteLength(piece, "utf8");
59
+ if (bytes + size > maxBytes) { truncated = true; break; }
60
+ pieces.push(piece);
61
+ sizes.push(size);
62
+ bytes += size;
63
+ }
64
+ if (!truncated) return pieces.join("");
65
+ const markerBytes = Buffer.byteLength(TRUNCATION_MARKER, "utf8");
66
+ while (pieces.length && bytes + markerBytes > maxBytes) {
67
+ pieces.pop();
68
+ bytes -= sizes.pop();
69
+ }
70
+ return pieces.join("") + TRUNCATION_MARKER;
71
+ }
72
+
73
+ const args = process.argv.slice(2);
74
+ if (!args.length) {
75
+ console.error(HELP);
76
+ process.exit(2);
77
+ }
78
+ // `--` makes every following token a filename, including literal files named
79
+ // `--help` and `-h`. Only scan the option prefix for the help flags.
80
+ const positionalIndex = args.indexOf("--");
81
+ const optionPrefix = positionalIndex === -1 ? args : args.slice(0, positionalIndex);
82
+ if (optionPrefix.includes("--help") || optionPrefix.includes("-h")) {
83
+ console.log(HELP);
84
+ process.exit(0);
85
+ }
86
+ let strict = false;
87
+ let asJson = false;
88
+ let positional = false;
89
+ const files = [];
90
+ for (const arg of args) {
91
+ if (!positional && arg === "--") { positional = true; continue; }
92
+ if (!positional && KNOWN_OPTIONS.has(arg)) {
93
+ if (arg === "--strict") strict = true;
94
+ else if (arg === "--json") asJson = true;
95
+ continue;
96
+ }
97
+ if (!positional && arg.startsWith("-")) {
98
+ console.error("gate-lint: unknown option " + terminalSafe(arg, 512));
99
+ console.error("run gate-lint.mjs --help for usage");
100
+ process.exit(2);
101
+ }
102
+ files.push(arg);
103
+ }
104
+ if (!files.length) {
105
+ console.error("gate-lint: name at least one ledger file");
106
+ process.exit(2);
107
+ }
108
+
109
+ // This is deliberately advisory and whole-command only. Shell text beginning
110
+ // with `echo` can still chain a real verifier, and argv containing EXPECT says
111
+ // nothing about what the called program prints or whether it exits zero.
112
+ const FIXED_OUTPUT_COMMAND = /^\s*(?:(?:echo|printf)(?:\s+[^&|;]*)?|true|:|exit\s+0)\s*$/i;
113
+ // Tokens that appear in failure output as readily as in success output.
114
+ const WEAK_EXPECT = new Set([
115
+ "ok", "okay", "done", "pass", "passed", "success", "successful", "succeeded",
116
+ "complete", "completed", "finished", "yes", "true", "0", "good", "fine", "working",
117
+ ]);
118
+ // Openings that name an activity rather than an outcome a stranger could judge.
119
+ const ACTIVITY_START = /^(work(ing)? on|improve|enhance|handle|support|ensure|make sure|try|attempt|look (at|into)|investigate|consider|review|refactor|clean ?up|polish|update|tidy|address|deal with|add support)\b/i;
120
+ const findings = [];
121
+ let errorCount = 0;
122
+ let warningCount = 0;
123
+ let findingCount = 0;
124
+ const add = (file, level, gate, rule, message) => {
125
+ findingCount++;
126
+ if (level === "error") errorCount++;
127
+ else if (level === "warn") warningCount++;
128
+ if (findings.length >= MAX_REPORTED_FINDINGS) return;
129
+ findings.push({
130
+ file: terminalSafe(file, 512),
131
+ level: terminalSafe(level, 16),
132
+ gate: gate ? terminalSafe(gate, 128) : null,
133
+ rule: terminalSafe(rule, 64),
134
+ message: terminalSafe(message, 1024),
135
+ });
136
+ };
137
+
138
+ let parseFailed = false;
139
+
140
+ for (const file of files) {
141
+ let text;
142
+ try {
143
+ // Explicit lint targets may intentionally live outside the current
144
+ // working directory, so constrain file kind and size without a root.
145
+ text = readStableRegularFile(file, {
146
+ maxBytes: MAX_GATE_LEDGER_BYTES,
147
+ label: "gate ledger",
148
+ });
149
+ } catch (error) {
150
+ console.error("gate-lint: cannot read " + terminalSafe(file, 512) + ": " + terminalSafe(error.message, 1024));
151
+ process.exit(2);
152
+ }
153
+
154
+ const doc = parseGates(text);
155
+ if (doc.errors.length) {
156
+ // A ledger the shared parser rejects cannot be judged on quality.
157
+ parseFailed = true;
158
+ for (const error of doc.errors) add(file, "error", null, "parse", error);
159
+ continue;
160
+ }
161
+
162
+ const live = doc.gates.filter((gate) => !doc.abandoned.has(gate.id));
163
+ const runnable = live.filter((gate) => gate.check);
164
+
165
+ for (const gate of live) {
166
+ const { id, title, check, expect } = gate;
167
+
168
+ if (check && FIXED_OUTPUT_COMMAND.test(check)) {
169
+ add(file, "warn", id, "tautological-check",
170
+ 'CHECK looks like a fixed-output command: "' + check + '"; use an oracle that observes the named outcome');
171
+ }
172
+
173
+ if (expect && WEAK_EXPECT.has(expect.trim().toLowerCase())) {
174
+ add(file, "warn", id, "weak-expect",
175
+ 'EXPECT "' + expect + '" also appears in failure output; match a line only success can print');
176
+ }
177
+
178
+ if (gate.expectation && gate.expectation.kind === "regex" && gate.expectation.pathLike) {
179
+ add(file, "warn", id, "path-read-as-regex",
180
+ 'EXPECT "' + expect + '" looks like a literal path but is read as a regular expression, so its dots are wildcards');
181
+ }
182
+
183
+ if (!check) {
184
+ add(file, "warn", id, "manual-gate",
185
+ "no CHECK, so this outcome is judged by hand and its evidence is only as good as the reader");
186
+ if (/\d/.test(title)) {
187
+ add(file, "warn", id, "unmeasured-number",
188
+ 'title states a number that nothing measures: "' + title + '"');
189
+ }
190
+ }
191
+
192
+ if (ACTIVITY_START.test(title)) {
193
+ add(file, "warn", id, "activity-not-outcome",
194
+ 'names an activity, not an outcome a stranger could judge: "' + title + '"');
195
+ }
196
+ }
197
+
198
+ if (live.length && runnable.length / live.length < 0.5) {
199
+ add(file, "warn", null, "mostly-manual",
200
+ runnable.length + "/" + live.length + " gates are runnable; a mostly manual ledger is prose with checkboxes");
201
+ }
202
+ }
203
+
204
+ const failed = errorCount > 0 || (strict && warningCount > 0);
205
+
206
+ if (asJson) {
207
+ const report = {
208
+ ok: !failed,
209
+ errors: errorCount,
210
+ warnings: warningCount,
211
+ findings: [...findings],
212
+ truncated: findingCount > findings.length,
213
+ omittedFindings: findingCount - findings.length,
214
+ };
215
+ let output = JSON.stringify(report, null, 2);
216
+ while (Buffer.byteLength(output, "utf8") > MAX_REPORT_BYTES && report.findings.length) {
217
+ report.findings.pop();
218
+ report.truncated = true;
219
+ report.omittedFindings = findingCount - report.findings.length;
220
+ output = JSON.stringify(report, null, 2);
221
+ }
222
+ console.log(output);
223
+ } else {
224
+ let lastFile = null;
225
+ for (const finding of findings) {
226
+ if (finding.file !== lastFile) {
227
+ console.log(finding.file);
228
+ lastFile = finding.file;
229
+ }
230
+ const label = finding.level === "error" ? "ERROR" : "WARN ";
231
+ const who = finding.gate ? finding.gate + ": " : "";
232
+ console.log(" " + label + " " + who + finding.message + " [" + finding.rule + "]");
233
+ }
234
+ const omitted = findingCount - findings.length;
235
+ if (omitted) console.log("... [report truncated: " + omitted + " finding(s) omitted]");
236
+ if (!failed) {
237
+ console.log(warningCount ? "LINT OK (" + warningCount + " warning(s))" : "LINT OK");
238
+ } else {
239
+ console.log("LINT FINDINGS: " + errorCount + " error(s), " + warningCount + " warning(s)");
240
+ }
241
+ }
242
+
243
+ // Let Node drain stdout before exiting. A forced process.exit() can truncate a
244
+ // large report when stdout is an asynchronous POSIX pipe.
245
+ process.exitCode = parseFailed ? 2 : failed ? 1 : 0;
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ // Keep a stable process-group leader alive until CHECK stdio closes.
3
+ // Zero dependencies. Node 16+.
4
+
5
+ import { spawn } from "node:child_process";
6
+
7
+ const [shell, command] = process.argv.slice(2);
8
+ if (!shell || command === undefined) {
9
+ console.error("unlazy-check-supervisor: expected resolved shell and CHECK command");
10
+ process.exit(2);
11
+ }
12
+
13
+ let child;
14
+ let spawnError = null;
15
+ try {
16
+ child = spawn(command, {
17
+ cwd: process.cwd(),
18
+ shell,
19
+ windowsHide: true,
20
+ env: process.env,
21
+ stdio: ["ignore", "pipe", "pipe"],
22
+ });
23
+ } catch (error) {
24
+ console.error("unlazy-check-supervisor: could not start CHECK: " + error.message);
25
+ process.exit(127);
26
+ }
27
+
28
+ // Pipe instead of inheriting descriptors directly. Node's `close` event then
29
+ // waits for descendants that inherited the shell's stdout or stderr, keeping
30
+ // this detached supervisor alive as the original process-group identity.
31
+ child.stdout.pipe(process.stdout, { end: false });
32
+ child.stderr.pipe(process.stderr, { end: false });
33
+ child.once("error", (error) => { spawnError = error; });
34
+ child.once("close", (code, signal) => {
35
+ if (spawnError) {
36
+ console.error("unlazy-check-supervisor: CHECK spawn failed: " + spawnError.message);
37
+ process.exitCode = 127;
38
+ return;
39
+ }
40
+ if (Number.isInteger(code)) {
41
+ process.exitCode = code;
42
+ return;
43
+ }
44
+ console.error("unlazy-check-supervisor: CHECK ended by " + (signal || "unknown signal"));
45
+ process.exitCode = 1;
46
+ });
@@ -0,0 +1,293 @@
1
+ // Atomic host-dispatch wave state. Zero dependencies. Node 16+.
2
+
3
+ import { join, resolve } from "node:path";
4
+ import {
5
+ appendStatus, readStableRegularFile, scopeRoot, validateScopeId, withFileLock, writeAtomic,
6
+ } from "./gates.mjs";
7
+
8
+ const SCHEMA = 1;
9
+ const MAX_STATE_BYTES = 8 * 1024 * 1024;
10
+ const STATES = new Set(["open", "sealed", "complete", "abandoned"]);
11
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/;
12
+ const CONTROL_GLOBAL = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/g;
13
+
14
+ const record = (value = {}) => Object.assign(Object.create(null), value);
15
+ const emptyState = () => ({ schema: SCHEMA, waves: record() });
16
+ const count = (record) => Object.keys(record).length;
17
+
18
+ function fail(message) { throw new Error(message); }
19
+ const safeDiagnostic = (value) => String(value).replace(CONTROL_GLOBAL, " ").replace(/\s+/g, " ").trim().slice(0, 500);
20
+
21
+ function validId(value, label) {
22
+ if (typeof value !== "string") fail(label + " must be a string");
23
+ const error = validateScopeId(value, label);
24
+ if (error) fail(error);
25
+ return value;
26
+ }
27
+
28
+ function validHandle(value) {
29
+ if (typeof value !== "string") fail("handle must be a string");
30
+ const handle = value.trim();
31
+ if (!handle || handle.length > 256 || CONTROL.test(handle)) {
32
+ fail("handle must be printable, nonblank, and at most 256 characters");
33
+ }
34
+ return handle;
35
+ }
36
+
37
+ function validReason(value) {
38
+ if (typeof value !== "string") fail("reason must be a string");
39
+ const reason = value.trim();
40
+ if (!reason || reason.length > 500 || CONTROL.test(reason)) {
41
+ fail("reason must be printable, nonblank, and at most 500 characters");
42
+ }
43
+ return reason;
44
+ }
45
+
46
+ function validTime(value, label) {
47
+ if (typeof value !== "string" || !value || Number.isNaN(Date.parse(value))) {
48
+ fail(label + " must be an ISO timestamp");
49
+ }
50
+ return Date.parse(value);
51
+ }
52
+
53
+ const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
54
+
55
+ function validateState(state) {
56
+ if (!state || typeof state !== "object" || Array.isArray(state) || state.schema !== SCHEMA ||
57
+ !state.waves || typeof state.waves !== "object" || Array.isArray(state.waves)) {
58
+ fail("expected schema 1 with a waves object");
59
+ }
60
+ state.waves = record(state.waves);
61
+
62
+ for (const [waveId, wave] of Object.entries(state.waves)) {
63
+ validId(waveId, "wave");
64
+ if (!wave || typeof wave !== "object" || Array.isArray(wave) || !STATES.has(wave.state) ||
65
+ !Array.isArray(wave.leaves) || !wave.leaves.length ||
66
+ !wave.started || typeof wave.started !== "object" || Array.isArray(wave.started) ||
67
+ !wave.returned || typeof wave.returned !== "object" || Array.isArray(wave.returned)) {
68
+ fail("wave " + waveId + " has an invalid shape");
69
+ }
70
+ wave.started = record(wave.started);
71
+ wave.returned = record(wave.returned);
72
+ const openedAt = validTime(wave.openedAt, "wave " + waveId + " openedAt");
73
+ let abandonedAt = null;
74
+ if (wave.state === "abandoned") {
75
+ abandonedAt = validTime(wave.abandonedAt, "wave " + waveId + " abandonedAt");
76
+ wave.reason = validReason(wave.reason);
77
+ } else if (hasOwn(wave, "abandonedAt") || hasOwn(wave, "reason")) {
78
+ fail(wave.state + " wave " + waveId + " contains abandonment metadata");
79
+ }
80
+ const leaves = new Set();
81
+ for (const leaf of wave.leaves) {
82
+ const id = validId(leaf, "leaf");
83
+ if (leaves.has(id)) fail("wave " + waveId + " has duplicate leaf " + id);
84
+ leaves.add(id);
85
+ }
86
+
87
+ const handles = new Set();
88
+ const startTimes = record();
89
+ let latestStart = openedAt;
90
+ for (const [leaf, start] of Object.entries(wave.started)) {
91
+ if (!leaves.has(leaf)) fail("wave " + waveId + " started unknown leaf " + leaf);
92
+ if (!start || typeof start !== "object" || Array.isArray(start)) fail("wave " + waveId + " has invalid start for " + leaf);
93
+ const handle = validHandle(start.handle);
94
+ if (handles.has(handle)) fail("wave " + waveId + " reuses handle " + handle);
95
+ handles.add(handle);
96
+ const at = validTime(start.at, "wave " + waveId + " start time for " + leaf);
97
+ if (at < openedAt) fail("wave " + waveId + " starts " + leaf + " before it opened");
98
+ startTimes[leaf] = at;
99
+ latestStart = Math.max(latestStart, at);
100
+ }
101
+
102
+ const returnTimes = [];
103
+ for (const [leaf, returned] of Object.entries(wave.returned)) {
104
+ if (!leaves.has(leaf) || !wave.started[leaf]) fail("wave " + waveId + " returned unstarted leaf " + leaf);
105
+ if (!returned || typeof returned !== "object" || Array.isArray(returned)) fail("wave " + waveId + " has invalid return for " + leaf);
106
+ const at = validTime(returned.at, "wave " + waveId + " return time for " + leaf);
107
+ if (at < startTimes[leaf]) fail("wave " + waveId + " returns " + leaf + " before it started");
108
+ returnTimes.push(at);
109
+ }
110
+
111
+ const allStarted = count(wave.started) === wave.leaves.length;
112
+ const allReturned = count(wave.returned) === wave.leaves.length;
113
+ if (wave.state === "open" && count(wave.returned)) fail("open wave " + waveId + " contains returns");
114
+ if (wave.state !== "open" && wave.state !== "abandoned" && !allStarted) {
115
+ fail(wave.state + " wave " + waveId + " is missing starts");
116
+ }
117
+ if (wave.state === "complete" && !allReturned) fail("complete wave " + waveId + " is missing returns");
118
+ if (wave.state === "sealed" && allReturned) fail("sealed wave " + waveId + " should be complete");
119
+
120
+ const needsSeal = wave.state === "sealed" || wave.state === "complete" ||
121
+ (wave.state === "abandoned" && hasOwn(wave, "sealedAt"));
122
+ let sealedAt = null;
123
+ if (needsSeal) {
124
+ sealedAt = validTime(wave.sealedAt, "wave " + waveId + " sealedAt");
125
+ if (!allStarted) fail(wave.state + " wave " + waveId + " is missing starts after sealing");
126
+ if (sealedAt < latestStart) fail("wave " + waveId + " was sealed before its final start");
127
+ } else if (hasOwn(wave, "sealedAt")) {
128
+ fail(wave.state + " wave " + waveId + " contains seal metadata");
129
+ }
130
+
131
+ if (returnTimes.length) {
132
+ if (sealedAt === null) fail(wave.state + " wave " + waveId + " contains returns without being sealed");
133
+ if (returnTimes.some((at) => at < sealedAt)) fail("wave " + waveId + " contains a return before sealing");
134
+ }
135
+
136
+ if (wave.state === "complete") {
137
+ const completedAt = validTime(wave.completedAt, "wave " + waveId + " completedAt");
138
+ if (completedAt < Math.max(sealedAt, ...returnTimes)) {
139
+ fail("wave " + waveId + " completed before its final return");
140
+ }
141
+ } else if (hasOwn(wave, "completedAt")) {
142
+ fail(wave.state + " wave " + waveId + " contains completion metadata");
143
+ }
144
+
145
+ if (wave.state === "abandoned") {
146
+ if (allReturned) fail("abandoned wave " + waveId + " already has every return and must be complete");
147
+ if (abandonedAt < Math.max(openedAt, latestStart, sealedAt || openedAt, ...returnTimes)) {
148
+ fail("wave " + waveId + " was abandoned before its latest transition");
149
+ }
150
+ }
151
+ }
152
+ return state;
153
+ }
154
+
155
+ function readState(root, path) {
156
+ try {
157
+ const text = readStableRegularFile(path, {
158
+ root: resolve(root),
159
+ maxBytes: MAX_STATE_BYTES,
160
+ label: "dispatch state",
161
+ });
162
+ return validateState(JSON.parse(text));
163
+ } catch (error) {
164
+ if (error && error.code === "ENOENT") return emptyState();
165
+ if (String(error.message).startsWith("invalid dispatch state:")) throw error;
166
+ throw new Error("invalid dispatch state: " + error.message);
167
+ }
168
+ }
169
+
170
+ export function dispatchStatePath(root, scope) {
171
+ return join(scopeRoot(resolve(root), validId(scope, "scope")), "dispatch.json");
172
+ }
173
+
174
+ export function getDispatchWave(root, scope, waveId) {
175
+ const wave = validId(waveId, "wave");
176
+ const state = readState(root, dispatchStatePath(root, scope));
177
+ if (!state.waves[wave]) fail("unknown wave " + wave);
178
+ return state.waves[wave];
179
+ }
180
+
181
+ export function dispatchIssues(root, scope) {
182
+ return dispatchStatus(root, scope).blocking;
183
+ }
184
+
185
+ export function dispatchStatus(root, scope) {
186
+ if (!scope) return { blocking: [], abandoned: [], resolved: [], errors: [] };
187
+ let state;
188
+ try { state = readState(root, dispatchStatePath(root, scope)); }
189
+ catch (error) {
190
+ const message = safeDiagnostic(error.message);
191
+ return {
192
+ blocking: ["dispatch:PARSE invalid dispatch state"],
193
+ abandoned: [],
194
+ resolved: ["dispatch:PARSE=invalid"],
195
+ errors: ["invalid dispatch state for scope " + scope + ": " + message],
196
+ };
197
+ }
198
+ const result = { blocking: [], abandoned: [], resolved: [], errors: [] };
199
+ for (const [id, wave] of Object.entries(state.waves).sort(([left], [right]) => left.localeCompare(right))) {
200
+ const started = count(wave.started);
201
+ const returned = count(wave.returned);
202
+ result.resolved.push("dispatch:" + id + "=" + wave.state + ";started=" + started + "/" +
203
+ wave.leaves.length + ";returned=" + returned + "/" + wave.leaves.length);
204
+ if (wave.state === "complete") continue;
205
+ if (wave.state === "abandoned") {
206
+ result.abandoned.push("dispatch:" + id);
207
+ } else if (wave.state === "open") {
208
+ result.blocking.push("dispatch:" + id + " open (" + started + "/" + wave.leaves.length + " started)");
209
+ } else {
210
+ result.blocking.push("dispatch:" + id + " sealed (" + returned + "/" + wave.leaves.length + " returned)");
211
+ }
212
+ }
213
+ return result;
214
+ }
215
+
216
+ export async function updateDispatch(root, spec) {
217
+ const scope = validId(spec.scope, "scope");
218
+ const waveId = validId(spec.wave, "wave");
219
+ const path = dispatchStatePath(root, scope);
220
+ let event = "";
221
+
222
+ const wave = await withFileLock(root, path, () => {
223
+ const state = readState(root, path);
224
+ const now = spec.now || new Date().toISOString();
225
+ validTime(now, "timestamp");
226
+
227
+ if (spec.action === "open") {
228
+ if (state.waves[waveId]) fail("wave " + waveId + " already exists");
229
+ const leaves = (spec.leaves || []).map((leaf) => validId(leaf, "leaf"));
230
+ if (!leaves.length) fail("open requires at least one --leaf");
231
+ const seen = new Set();
232
+ for (const leaf of leaves) {
233
+ if (seen.has(leaf)) fail("duplicate leaf " + leaf);
234
+ seen.add(leaf);
235
+ }
236
+ state.waves[waveId] = { leaves, state: "open", openedAt: now, started: record(), returned: record() };
237
+ event = "dispatch " + waveId + " opened: " + leaves.join(", ");
238
+ } else {
239
+ const current = state.waves[waveId];
240
+ if (!current) fail("unknown wave " + waveId);
241
+ if (spec.action === "abandon") {
242
+ if (current.state === "complete" || current.state === "abandoned") {
243
+ fail("wave " + waveId + " is " + current.state + "; abandon requires an open or sealed wave");
244
+ }
245
+ current.state = "abandoned";
246
+ current.reason = validReason(spec.reason);
247
+ current.abandonedAt = now;
248
+ event = "dispatch " + waveId + " abandoned: " + current.reason;
249
+ } else if (spec.action === "start") {
250
+ const leaf = validId(spec.leaf, "leaf");
251
+ const handle = validHandle(spec.handle);
252
+ if (current.state !== "open") fail("wave " + waveId + " is " + current.state + "; start requires an open wave");
253
+ if (!current.leaves.includes(leaf)) fail("unknown leaf " + leaf + " in wave " + waveId);
254
+ if (current.started[leaf]) fail("leaf " + leaf + " already started");
255
+ const owner = Object.entries(current.started).find(([, start]) => start.handle === handle);
256
+ if (owner) fail("handle is already assigned to " + owner[0]);
257
+ current.started[leaf] = { handle, at: now };
258
+ event = "dispatch " + waveId + " started " + leaf + " as " + handle;
259
+ } else if (spec.action === "seal") {
260
+ if (current.state !== "open") fail("wave " + waveId + " is " + current.state + "; seal requires an open wave");
261
+ const missing = current.leaves.filter((leaf) => !current.started[leaf]);
262
+ if (missing.length) fail("cannot seal " + waveId + ": missing starts for " + missing.join(", "));
263
+ current.state = "sealed";
264
+ current.sealedAt = now;
265
+ event = "dispatch " + waveId + " sealed";
266
+ } else if (spec.action === "return") {
267
+ const leaf = validId(spec.leaf, "leaf");
268
+ if (current.state !== "sealed") fail("return requires a sealed wave; " + waveId + " is " + current.state);
269
+ if (!current.leaves.includes(leaf)) fail("unknown leaf " + leaf + " in wave " + waveId);
270
+ if (current.returned[leaf]) fail("leaf " + leaf + " already returned");
271
+ current.returned[leaf] = { at: now };
272
+ if (count(current.returned) === current.leaves.length) {
273
+ current.state = "complete";
274
+ current.completedAt = now;
275
+ }
276
+ event = "dispatch " + waveId + " returned " + leaf;
277
+ } else fail("unknown dispatch action " + spec.action);
278
+ }
279
+
280
+ validateState(state);
281
+ writeAtomic(path, JSON.stringify(state, null, 2) + "\n", { root });
282
+ return state.waves[waveId];
283
+ });
284
+
285
+ let logWarning = "";
286
+ try {
287
+ await appendStatus(root, scope, new Date().toISOString() + " " + event);
288
+ } catch (error) {
289
+ logWarning = "state transition committed; audit status append was skipped: " +
290
+ safeDiagnostic(error && error.message ? error.message : error);
291
+ }
292
+ return { wave, logWarning };
293
+ }