session-orchestrator 3.22.0 → 3.24.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 (316) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/commands/autopilot-multi.md +14 -0
  5. package/.cursor/commands/autopilot.md +14 -0
  6. package/.cursor/commands/bootstrap.md +14 -0
  7. package/.cursor/commands/brainstorm.md +14 -0
  8. package/.cursor/commands/close.md +13 -0
  9. package/.cursor/commands/contract-version-bump.md +14 -0
  10. package/.cursor/commands/debug.md +14 -0
  11. package/.cursor/commands/discovery.md +14 -0
  12. package/.cursor/commands/dispatcher.md +14 -0
  13. package/.cursor/commands/eli5.md +14 -0
  14. package/.cursor/commands/eval.md +14 -0
  15. package/.cursor/commands/evolve.md +14 -0
  16. package/.cursor/commands/go.md +14 -0
  17. package/.cursor/commands/grill.md +14 -0
  18. package/.cursor/commands/harness-audit.md +13 -0
  19. package/.cursor/commands/journey-audit.md +14 -0
  20. package/.cursor/commands/memory-cleanup.md +14 -0
  21. package/.cursor/commands/persona-panel.md +14 -0
  22. package/.cursor/commands/plan.md +14 -0
  23. package/.cursor/commands/portfolio.md +14 -0
  24. package/.cursor/commands/reconcile.md +14 -0
  25. package/.cursor/commands/release.md +14 -0
  26. package/.cursor/commands/repo-audit.md +13 -0
  27. package/.cursor/commands/session.md +14 -0
  28. package/.cursor/commands/spinout.md +14 -0
  29. package/.cursor/commands/sunset-review.md +14 -0
  30. package/.cursor/commands/templates-ack.md +14 -0
  31. package/.cursor/commands/test.md +14 -0
  32. package/.cursor/hooks.json +60 -0
  33. package/.cursor/rules/000-session-orchestrator.mdc +8 -0
  34. package/.cursor/rules/010-session-workflow.mdc +9 -1
  35. package/.cursor/rules/020-quality-gates.mdc +1 -1
  36. package/.cursor/rules/030-wave-execution.mdc +1 -1
  37. package/.cursor/rules/050-plan.mdc +2 -2
  38. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  39. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  40. package/.cursor/skills/architecture/SKILL.md +13 -0
  41. package/.cursor/skills/autopilot/SKILL.md +12 -0
  42. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  43. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  44. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  45. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  46. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  47. package/.cursor/skills/daily/SKILL.md +12 -0
  48. package/.cursor/skills/debug/SKILL.md +13 -0
  49. package/.cursor/skills/discovery/SKILL.md +13 -0
  50. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  51. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  52. package/.cursor/skills/domain-model/SKILL.md +13 -0
  53. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  54. package/.cursor/skills/eli5/SKILL.md +13 -0
  55. package/.cursor/skills/eval/SKILL.md +12 -0
  56. package/.cursor/skills/evolve/SKILL.md +13 -0
  57. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  58. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  60. package/.cursor/skills/grill/SKILL.md +13 -0
  61. package/.cursor/skills/hook-development/SKILL.md +13 -0
  62. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  63. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  64. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  65. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  66. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  67. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  68. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  69. package/.cursor/skills/plan/SKILL.md +13 -0
  70. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  71. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  72. package/.cursor/skills/reconcile/SKILL.md +12 -0
  73. package/.cursor/skills/remote-offload/SKILL.md +13 -0
  74. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  75. package/.cursor/skills/session-end/SKILL.md +13 -0
  76. package/.cursor/skills/session-plan/SKILL.md +13 -0
  77. package/.cursor/skills/session-start/SKILL.md +13 -0
  78. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  79. package/.cursor/skills/spinout/SKILL.md +12 -0
  80. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  81. package/.cursor/skills/test-runner/SKILL.md +13 -0
  82. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  83. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  84. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  85. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  86. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  87. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  88. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  89. package/.mcp.json +4 -1
  90. package/CHANGELOG.md +446 -0
  91. package/README.md +22 -17
  92. package/agents/AGENTS.md +23 -4
  93. package/agents/code-implementer.md +2 -1
  94. package/agents/db-specialist.md +2 -2
  95. package/agents/docs-writer.md +3 -1
  96. package/agents/eval-judge.md +1 -1
  97. package/agents/session-reviewer.md +7 -1
  98. package/agents/test-writer.md +2 -1
  99. package/agents/ui-developer.md +2 -1
  100. package/commands/bootstrap.md +2 -2
  101. package/commands/close.md +3 -1
  102. package/commands/go.md +1 -1
  103. package/commands/journey-audit.md +43 -0
  104. package/docs/USER-GUIDE.md +2 -2
  105. package/docs/ci-setup.md +194 -25
  106. package/docs/codex-setup.md +64 -0
  107. package/docs/components.md +7 -7
  108. package/docs/cursor-setup.md +26 -47
  109. package/docs/events-schema.md +120 -10
  110. package/docs/github-mirror-protection.md +197 -0
  111. package/docs/pi-setup.md +2 -0
  112. package/docs/rule-authoring.md +3 -1
  113. package/docs/scope-collision-guard.md +49 -2
  114. package/docs/session-config-reference.md +89 -9
  115. package/docs/session-config-template.md +38 -7
  116. package/docs/telemetry/telemetry-claims.md +11 -10
  117. package/docs/telemetry.md +52 -1
  118. package/hooks/_lib/atomic-json.mjs +111 -0
  119. package/hooks/_lib/lock-bootstrap.mjs +8 -4
  120. package/hooks/_lib/subagent-paths.mjs +143 -0
  121. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  122. package/hooks/cwd-change-restore.mjs +9 -29
  123. package/hooks/enforce-scope.mjs +93 -0
  124. package/hooks/hooks-codex.json +1 -1
  125. package/hooks/hooks-cursor.json +201 -20
  126. package/hooks/hooks-pi.json +1 -1
  127. package/hooks/hooks.json +2 -2
  128. package/hooks/on-session-end.mjs +486 -19
  129. package/hooks/on-session-start.mjs +263 -12
  130. package/hooks/on-stop.mjs +392 -24
  131. package/hooks/post-bash-write-verify.mjs +104 -4
  132. package/hooks/post-subagent-discovery-validator.mjs +182 -21
  133. package/hooks/post-tool-batch-wave-signal.mjs +165 -42
  134. package/hooks/post-tool-failure-corrective-context.mjs +9 -32
  135. package/hooks/pre-bash-issue-budget.mjs +117 -4
  136. package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
  137. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  138. package/hooks/pre-bash-staging-fence.mjs +4 -0
  139. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  140. package/hooks/skill-invocation-telemetry.mjs +21 -10
  141. package/hooks/subagent-telemetry.mjs +11 -26
  142. package/monitors/monitors.json +6 -0
  143. package/package.json +1 -1
  144. package/pi/prompts/journey-audit.md +12 -0
  145. package/rules/_index.md +9 -1
  146. package/rules/always-on/ask-via-tool.md +62 -0
  147. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  148. package/rules/always-on/build-value.md +47 -0
  149. package/rules/always-on/cross-session-messaging.md +59 -0
  150. package/rules/always-on/loop-and-monitor.md +221 -0
  151. package/rules/always-on/parallel-sessions.md +142 -12
  152. package/rules/always-on/receiving-review.md +108 -0
  153. package/rules/always-on/test-value.md +40 -0
  154. package/rules/always-on/verification-before-completion.md +77 -0
  155. package/scripts/archive-closed-prds.mjs +258 -18
  156. package/scripts/autopilot.mjs +31 -12
  157. package/scripts/backfill-abandoned-sessions.mjs +80 -11
  158. package/scripts/backfill-evidence-digest.mjs +376 -0
  159. package/scripts/cursor-install.mjs +89 -48
  160. package/scripts/emit-event.mjs +10 -2
  161. package/scripts/export-hw-learnings.mjs +143 -2
  162. package/scripts/express-path.mjs +299 -0
  163. package/scripts/generate-cursor-adapter.mjs +253 -0
  164. package/scripts/github-protection-audit.mjs +358 -0
  165. package/scripts/lib/auq/parse.mjs +5 -29
  166. package/scripts/lib/auto-dialectic.mjs +68 -0
  167. package/scripts/lib/autopilot/worktree-pipeline.mjs +318 -18
  168. package/scripts/lib/build-live-signals.mjs +49 -27
  169. package/scripts/lib/ci-status-banner.mjs +158 -11
  170. package/scripts/lib/cold-start-detector.mjs +23 -14
  171. package/scripts/lib/command-blocker.mjs +70 -0
  172. package/scripts/lib/config/block-header.mjs +55 -0
  173. package/scripts/lib/config/discovery-validator.mjs +7 -2
  174. package/scripts/lib/config/health-endpoints.mjs +383 -0
  175. package/scripts/lib/config/reconcile.mjs +79 -4
  176. package/scripts/lib/config/remote-hosts.mjs +233 -0
  177. package/scripts/lib/config/section-extractor.mjs +235 -36
  178. package/scripts/lib/config-schema.mjs +9 -1
  179. package/scripts/lib/config.mjs +87 -8
  180. package/scripts/lib/convergence-monitor.mjs +13 -2
  181. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  182. package/scripts/lib/dispatcher/cli.mjs +2 -2
  183. package/scripts/lib/dispatcher/enumerate.mjs +2 -17
  184. package/scripts/lib/events-schema.mjs +48 -0
  185. package/scripts/lib/events.mjs +238 -5
  186. package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
  187. package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
  188. package/scripts/lib/express-path.mjs +327 -0
  189. package/scripts/lib/file-lock.mjs +22 -4
  190. package/scripts/lib/gates/gate-full.mjs +81 -8
  191. package/scripts/lib/gates/gate-helpers.mjs +76 -15
  192. package/scripts/lib/git-config-drift.mjs +134 -5
  193. package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
  194. package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
  195. package/scripts/lib/host-identity.mjs +247 -2
  196. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  197. package/scripts/lib/issue-budget.mjs +229 -30
  198. package/scripts/lib/learnings/io.mjs +55 -10
  199. package/scripts/lib/learnings/schema.mjs +95 -28
  200. package/scripts/lib/lock-reaper.mjs +7 -1
  201. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  202. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  203. package/scripts/lib/memory-banner.mjs +25 -10
  204. package/scripts/lib/memory-paths.mjs +15 -6
  205. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  206. package/scripts/lib/peer-discovery.mjs +20 -2
  207. package/scripts/lib/platform.mjs +72 -9
  208. package/scripts/lib/plugin-root.mjs +143 -19
  209. package/scripts/lib/project-hygiene.mjs +43 -3
  210. package/scripts/lib/quality-gate.mjs +271 -13
  211. package/scripts/lib/reconcile/emitter.mjs +87 -19
  212. package/scripts/lib/reconcile/engine.mjs +517 -18
  213. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  214. package/scripts/lib/reconcile/renderer.mjs +148 -3
  215. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  216. package/scripts/lib/reconcile/writer.mjs +415 -84
  217. package/scripts/lib/rule-loader.mjs +37 -2
  218. package/scripts/lib/rules-sync.mjs +51 -8
  219. package/scripts/lib/scope-gate.mjs +126 -0
  220. package/scripts/lib/session-close-backfill.mjs +427 -37
  221. package/scripts/lib/session-discovery.mjs +69 -5
  222. package/scripts/lib/session-end/phase-skip.mjs +38 -5
  223. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  224. package/scripts/lib/session-id.mjs +30 -14
  225. package/scripts/lib/session-identity/own-session.mjs +220 -0
  226. package/scripts/lib/session-lock.mjs +85 -30
  227. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  228. package/scripts/lib/session-schema/validator.mjs +40 -0
  229. package/scripts/lib/session-start-probes.mjs +608 -0
  230. package/scripts/lib/session-transition.mjs +277 -0
  231. package/scripts/lib/sessions-canonical.mjs +446 -0
  232. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  233. package/scripts/lib/spiral-carryover.mjs +90 -9
  234. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  235. package/scripts/lib/state-md/mission-status.mjs +350 -52
  236. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  237. package/scripts/lib/state-md.mjs +12 -2
  238. package/scripts/lib/telemetry/schema.mjs +74 -8
  239. package/scripts/lib/telemetry/sync.mjs +91 -16
  240. package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
  241. package/scripts/lib/validate/check-agents.mjs +66 -0
  242. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  243. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  244. package/scripts/lib/validate/check-doc-cli-commands.mjs +25 -65
  245. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
  246. package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
  247. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  248. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  249. package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
  250. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  251. package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
  252. package/scripts/lib/validate/check-unwired-features.mjs +333 -32
  253. package/scripts/lib/validate/check-validator-registration.mjs +248 -0
  254. package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
  255. package/scripts/lib/validate/markdown-fences.mjs +196 -0
  256. package/scripts/lib/validate/repo-files.mjs +275 -0
  257. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  258. package/scripts/lib/vault-mirror/process.mjs +99 -43
  259. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  260. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  261. package/scripts/lib/vault-status/board-lock.mjs +185 -0
  262. package/scripts/lib/vault-status/board-writer.mjs +381 -141
  263. package/scripts/lib/vault-status/narrative-mirror.mjs +190 -27
  264. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  265. package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
  266. package/scripts/lib/wave-resource-gate.mjs +127 -7
  267. package/scripts/lib/wave-transcript-tail.mjs +889 -0
  268. package/scripts/materialize-wave-scope.mjs +228 -15
  269. package/scripts/mcp-server.sh +11 -2
  270. package/scripts/memory-propose.mjs +132 -8
  271. package/scripts/parse-config.mjs +65 -0
  272. package/scripts/promote-vault-strict.mjs +4 -15
  273. package/scripts/site-numbers.mjs +36 -4
  274. package/scripts/token-audit.sh +9 -2
  275. package/scripts/validate-plugin.mjs +29 -0
  276. package/scripts/validate-wave-scope.mjs +67 -0
  277. package/scripts/vault-consolidate.mjs +3 -11
  278. package/scripts/vault-integration-watcher.mjs +2 -4
  279. package/scripts/vault-mirror.mjs +305 -51
  280. package/skills/_shared/monitor-patterns.md +31 -5
  281. package/skills/_shared/parallel-aware-auq.md +31 -2
  282. package/skills/_shared/parallel-aware-preamble.md +19 -4
  283. package/skills/_shared/platform-tools.md +11 -5
  284. package/skills/_shared/state-ownership.md +29 -2
  285. package/skills/autopilot/SKILL.md +5 -1
  286. package/skills/bootstrap/SKILL.md +3 -3
  287. package/skills/bootstrap/_shared-template.md +18 -10
  288. package/skills/bootstrap/deep-template.md +10 -6
  289. package/skills/bootstrap/fast-template.md +15 -8
  290. package/skills/bootstrap/standard-template.md +10 -6
  291. package/skills/claude-md-drift-check/checker.mjs +39 -11
  292. package/skills/contract-version-bump/SKILL.md +1 -1
  293. package/skills/dispatcher/SKILL.md +1 -1
  294. package/skills/ecosystem-health/SKILL.md +4 -1
  295. package/skills/ecosystem-health/wizard.md +5 -0
  296. package/skills/evolve/SKILL.md +38 -1
  297. package/skills/journey-audit/SKILL.md +270 -0
  298. package/skills/peekaboo-driver/SKILL.md +15 -3
  299. package/skills/persona-panel/SKILL.md +1 -1
  300. package/skills/reconcile/SKILL.md +46 -3
  301. package/skills/remote-offload/SKILL.md +89 -0
  302. package/skills/session-end/SKILL.md +17 -4
  303. package/skills/session-end/metrics-collection.md +7 -4
  304. package/skills/session-end/phase-3-6-tail.md +20 -9
  305. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  306. package/skills/session-plan/SKILL.md +6 -1
  307. package/skills/session-plan/wave-template.md +1 -0
  308. package/skills/session-start/SKILL.md +54 -17
  309. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  310. package/skills/session-start/phase-8-5-express-path.md +77 -12
  311. package/skills/vault-sync/validator.mjs +31 -0
  312. package/skills/wave-executor/SKILL.md +5 -3
  313. package/skills/wave-executor/circuit-breaker.md +34 -9
  314. package/skills/wave-executor/wave-loop.md +143 -22
  315. package/templates/_shared/journey-manifest.md +110 -0
  316. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -14,6 +14,10 @@
14
14
  * events-rotation.mjs — _parseEventsRotation
15
15
  * vault-integration.mjs — _parseVaultIntegration + _parseResourceThresholds
16
16
  *
17
+ * One parser lives OUTSIDE that directory: `_parseExpressPath` in
18
+ * scripts/lib/express-path.mjs, alongside the express-path decision it configures.
19
+ * See that module's header for the reason and the revisit trigger.
20
+ *
17
21
  * Originally ported from parse-config.sh (v2) plus its helper libs
18
22
  * config-yaml-parser.sh and config-json-coercion.sh. Windows + CRLF safe.
19
23
  *
@@ -57,6 +61,7 @@ import { _parseAutoDream } from './config/auto-dream.mjs';
57
61
  import { _parseStateMdLock } from './config/state-md-lock.mjs';
58
62
  import { _parseHandoverGate } from './config/handover-gate.mjs';
59
63
  import { _parseIssueBudget } from './config/issue-budget.mjs';
64
+ import { _parseHealthEndpoints, _parseEcosystemHealthBlockEnabled } from './config/health-endpoints.mjs';
60
65
  import { _parseBrokenWindow } from './config/broken-window.mjs';
61
66
  import { _parseSlopcheck } from './config/slopcheck.mjs';
62
67
  import { _parseDiscoveryValidator } from './config/discovery-validator.mjs';
@@ -67,11 +72,17 @@ import { _parseEval } from './config/eval.mjs';
67
72
  import { _parseMemory } from './config/memory.mjs';
68
73
  import { _parseReconcile } from './config/reconcile.mjs';
69
74
  import { _parseCustomPhases } from './config/custom-phases.mjs';
75
+ import { _parseRemoteHosts } from './config/remote-hosts.mjs';
70
76
  import { _parseEvolve, _parseEvolveDecay } from './config/evolve.mjs';
71
77
  import { _parseSkillEvolution } from './config/skill-evolution.mjs';
72
78
  import { _parseDispatcherAutonomy, resolveDispatcherAutonomy } from './config/dispatcher-autonomy.mjs';
73
79
  import { loadHostPaths, resolveHostPath } from './config/host-paths.mjs';
74
80
  import { resolveNamedBaseline } from './named-baseline-resolver.mjs';
81
+ // express-path lives one level UP from config/ (see its module header for why):
82
+ // the parser is a sibling of config/state-md-lock.mjs in every respect except
83
+ // its directory. Only the pure parser is imported here — `evaluateExpressPath`
84
+ // and its telemetry stay out of this module's import graph by construction.
85
+ import { _parseExpressPath } from './express-path.mjs';
75
86
 
76
87
  // Re-export the two functions that external callers import directly from this module.
77
88
  export { _coerceEnum, _coerceCollisionRisk } from './config/coercers.mjs';
@@ -189,7 +200,14 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
189
200
 
190
201
  // Boolean fields
191
202
  const persistence = _coerceBoolean(kv, 'persistence', true);
192
- const ecosystemHealth = _coerceBoolean(kv, 'ecosystem-health', false);
203
+ // ecosystem-health has TWO spellings and only the scalar one reaches `kv`:
204
+ // the wizard writes a valueless `ecosystem-health:` header opening a block,
205
+ // which carries no `key: value` pair and is therefore invisible to the KV
206
+ // map (#1174). Scalar wins when present; otherwise the block form decides.
207
+ const ecosystemHealth =
208
+ _getVal(kv, 'ecosystem-health', undefined) !== undefined
209
+ ? _coerceBoolean(kv, 'ecosystem-health', false)
210
+ : (_parseEcosystemHealthBlockEnabled(mdContent) ?? false);
193
211
  const discoveryOnClose = _coerceBoolean(kv, 'discovery-on-close', false);
194
212
  const reasoningOutput = _coerceBoolean(kv, 'reasoning-output', false);
195
213
  const groundingCheck = _coerceBoolean(kv, 'grounding-check', true);
@@ -205,7 +223,6 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
205
223
  const ssotFiles = _coerceList(kv, 'ssot-files', undefined);
206
224
  const discoveryProbes = _coerceList(kv, 'discovery-probes', '[all]');
207
225
  const discoveryExcludePaths = _coerceList(kv, 'discovery-exclude-paths', '[]');
208
- const healthEndpoints = _coerceList(kv, 'health-endpoints', undefined);
209
226
  const worktreeExclude = _coerceList(
210
227
  kv,
211
228
  'worktree-exclude',
@@ -221,29 +238,77 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
221
238
  // follow-up (see docs/session-config-reference.md). This wires the parser + value only.
222
239
  const worktreeCleanup = _coerceEnum(kv, 'worktree-cleanup', 'default', ['default', 'aggressive']);
223
240
 
241
+ // remote-hosts: opt-in ssh-reachable hosts heavy roles may be offloaded to (#1160).
242
+ // Parsed HERE, before agent-mapping, because the `ssh:<alias>` channel below
243
+ // validates its target against the declared aliases. Defaults to [].
244
+ const remoteHosts = _parseRemoteHosts(mdContent);
245
+
224
246
  // Object fields
225
247
  const agentMapping = _coerceObject(kv, 'agent-mapping');
226
248
  if (agentMapping !== null) {
227
249
  // Validate role keys against canonical list from skills/_shared/config-reading.md
228
250
  const ALLOWED_ROLES = ['impl', 'test', 'db', 'ui', 'security', 'compliance', 'docs', 'perf'];
251
+ // Dispatch channels a mapping value may name via a `<channel>:<target>`
252
+ // prefix (#1150). Without a colon the value is a plain agent name and is
253
+ // dispatched natively, exactly as before.
254
+ // impl: code-implementer → native Agent dispatch (unchanged)
255
+ // impl: cursor:composer-2.5 → foreign model over the Cursor channel
256
+ // test: ssh:m5 → same Claude, another host (#1160)
257
+ // An unknown prefix is a typo or a channel this build cannot route, and
258
+ // must fail loudly: silently accepting it would dispatch to an agent that
259
+ // does not exist, which surfaces only as an empty wave much later.
260
+ const KNOWN_CHANNELS = ['cursor', 'session-orchestrator', 'ssh'];
261
+ // Accumulate every defect, then report once. A config with two bad entries
262
+ // must name BOTH — throwing on the first one makes the operator re-run the
263
+ // parse per defect, and each re-run hides the ones behind it.
229
264
  const invalidKeys = [];
265
+ const valueProblems = [];
230
266
  for (const [k, v] of Object.entries(agentMapping)) {
231
267
  if (!ALLOWED_ROLES.includes(k)) {
232
268
  invalidKeys.push(k);
233
269
  continue;
234
270
  }
235
271
  if (typeof v !== 'string' || v === '') {
236
- throw new Error(
237
- `config.mjs: agent-mapping role '${k}' has invalid value '${v}' (expected non-empty string)`
238
- );
272
+ valueProblems.push(`role '${k}' has invalid value '${v}' (expected non-empty string)`);
273
+ continue;
274
+ }
275
+ const colonIdx = v.indexOf(':');
276
+ if (colonIdx !== -1) {
277
+ const channel = v.slice(0, colonIdx).trim();
278
+ const target = v.slice(colonIdx + 1).trim();
279
+ if (!KNOWN_CHANNELS.includes(channel)) {
280
+ valueProblems.push(
281
+ `role '${k}' names unknown channel '${channel}' in value '${v}' ` +
282
+ `(known channels: ${KNOWN_CHANNELS.join(', ')}; a value without ':' is a plain agent name)`
283
+ );
284
+ } else if (target === '') {
285
+ valueProblems.push(
286
+ `role '${k}' has channel '${channel}' but no target in value '${v}' ` +
287
+ `(expected '${channel}:<model-or-agent>')`
288
+ );
289
+ } else if (channel === 'ssh' && !remoteHosts.some((h) => h.alias === target)) {
290
+ // An ssh target is a host ALIAS, and the only place aliases are declared
291
+ // is the remote-hosts: block. An undeclared alias would reach argv as
292
+ // `-H <target>` and fail at connect time, one wave too late.
293
+ const declared = remoteHosts.map((h) => h.alias).join(', ') || 'none';
294
+ valueProblems.push(
295
+ `role '${k}' names ssh host '${target}' which is not declared in remote-hosts: ` +
296
+ `(declared: ${declared})`
297
+ );
298
+ }
239
299
  }
240
300
  }
301
+ const problems = [];
241
302
  if (invalidKeys.length > 0) {
242
- throw new Error(
243
- `config.mjs: agent-mapping contains invalid role key(s): ${invalidKeys.join(', ')} ` +
244
- `(allowed: ${ALLOWED_ROLES.join(', ')})`
303
+ problems.push(
304
+ `invalid role key(s): ${invalidKeys.join(', ')} (allowed: ${ALLOWED_ROLES.join(', ')})`
245
305
  );
246
306
  }
307
+ problems.push(...valueProblems);
308
+ if (problems.length > 0) {
309
+ // Still fail loud — only the reporting shape changed, never the verdict.
310
+ throw new Error(`config.mjs: agent-mapping ${problems.join('; ')}`);
311
+ }
247
312
  }
248
313
  const enforcementGates = _coerceBoolObject(kv, 'enforcement-gates');
249
314
 
@@ -286,6 +351,14 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
286
351
  // events-rotation: parsed from full content (standalone top-level block)
287
352
  const eventsRotation = _parseEventsRotation(mdContent);
288
353
 
354
+ // express-path: parsed from full content (standalone top-level block, #214/#1119).
355
+ // Documented in docs/session-config-template.md:585 and
356
+ // docs/session-config-reference.md:1519 since #214, but never in the key schema —
357
+ // measured 2026-08-23, a probe carrying `express-path:\n enabled: false` produced
358
+ // 88 keys and none of them was `express-path`. The key was dropped even when it
359
+ // stood in the file, so the documented default was prose rather than a value.
360
+ const expressPath = _parseExpressPath(mdContent);
361
+
289
362
  // vault-mirror.quality: parsed from full content (PRD F1.2 / issue #504)
290
363
  const vaultMirror = _parseVaultMirrorQuality(mdContent);
291
364
 
@@ -392,6 +465,10 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
392
465
  const evolveDecay = _parseEvolveDecay(mdContent);
393
466
 
394
467
  // persona-gate-wave: opt-in mid-wave persona-panel hook (#458). Returns null when absent.
468
+ // health-endpoints: content-scoped (NOT _coerceList, which bails to null on
469
+ // any '{' and cannot see the wizard's nested block form at all) — #1174.
470
+ const healthEndpoints = _parseHealthEndpoints(mdContent);
471
+
395
472
  const personaGateWave = _parsePersonaGateWave(mdContent);
396
473
  if (personaGateWave !== null && personaGateWave.enabled === true && personaGateWave.mode === 'off') {
397
474
  process.stderr.write(
@@ -482,11 +559,13 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
482
559
  'context-coverage': contextCoverage,
483
560
  'worktree-orphans': worktreeOrphans,
484
561
  'events-rotation': eventsRotation,
562
+ 'express-path': expressPath,
485
563
  'test': testConfig,
486
564
  'gitlab-portfolio': gitlabPortfolio,
487
565
  'wave-reviewers': waveReviewers,
488
566
  'persona-gate-wave': personaGateWave,
489
567
  'custom-phases': customPhases,
568
+ 'remote-hosts': remoteHosts,
490
569
  'evolve.extra-sources': evolveExtraSources,
491
570
  'evolve.decay': evolveDecay,
492
571
  };
@@ -312,12 +312,23 @@ function evaluateSignals(state, alreadyEmitted, latestWave) {
312
312
  }
313
313
 
314
314
  /**
315
+ * Poll delay.
316
+ *
317
+ * The timer is deliberately NOT `unref()`d: it is the only handle this process
318
+ * holds, so an unref'd timer drains the event loop and node exits 0 the instant
319
+ * the first tick is scheduled — a monitor that supervises nothing while looking
320
+ * like a clean shutdown (it has already emitted `tail.started` and writes
321
+ * nothing to stderr). Measured 2026-08-25 on the unref'd variant:
322
+ * `node scripts/lib/convergence-monitor.mjs --tail --interval=1` returned
323
+ * exit 0 after 47 ms instead of running until SIGTERM. This is the mechanism
324
+ * behind #980 (convergence-monitor never fires) — the same defect A1 measured
325
+ * in `scripts/lib/wave-transcript-tail.mjs`, which this sleep() was copied to.
326
+ *
315
327
  * @param {number} ms
316
328
  */
317
329
  function sleep(ms) {
318
330
  return new Promise((resolve) => {
319
- const t = setTimeout(resolve, ms);
320
- t.unref?.();
331
+ setTimeout(resolve, ms);
321
332
  });
322
333
  }
323
334
 
@@ -0,0 +1,443 @@
1
+ /**
2
+ * cursor-hook-bridge.mjs — map Cursor IDE hook events onto existing handlers.
3
+ *
4
+ * Cursor emits native events (`beforeShellExecution`, `preToolUse`, …) with its
5
+ * own stdin JSON and reads `{ permission, user_message, agent_message }` back.
6
+ * Existing Session Orchestrator hooks speak the Claude/Codex contract:
7
+ * `{ hook_event_name, tool_name, tool_input, ... }` plus an exit-0
8
+ * `hookSpecificOutput.permissionDecision` envelope (#906).
9
+ *
10
+ * This bridge is the Cursor counterpart of `pi-hook-bridge.mjs`. It closes the
11
+ * #919 silent-no-op: without it, `enforce-commands.mjs` sees no `tool_name ===
12
+ * "Bash"` on a Cursor payload and writes 0 bytes / exit 0, so the command runs.
13
+ *
14
+ * Usage (`.cursor/hooks.json`):
15
+ * sh hooks/run-node.sh scripts/lib/cursor-hook-bridge.mjs --event beforeShellExecution
16
+ */
17
+
18
+ import { spawnSync } from 'node:child_process';
19
+ import { existsSync, readFileSync } from 'node:fs';
20
+ import path from 'node:path';
21
+ import { pathToFileURL } from 'node:url';
22
+
23
+ import { matcherMatches, readHookDecision } from './pi-hook-bridge.mjs';
24
+ import { resolvePluginRoot } from './plugin-root.mjs';
25
+
26
+ export const CURSOR_TO_CANONICAL_EVENT = Object.freeze({
27
+ sessionStart: 'SessionStart',
28
+ sessionEnd: 'SessionEnd',
29
+ beforeShellExecution: 'PreToolUse',
30
+ afterShellExecution: 'PostToolUse',
31
+ preToolUse: 'PreToolUse',
32
+ postToolUse: 'PostToolUse',
33
+ afterFileEdit: 'PostToolUse',
34
+ postToolUseFailure: 'PostToolUseFailure',
35
+ stop: 'Stop',
36
+ subagentStart: 'SubagentStart',
37
+ subagentStop: 'SubagentStop',
38
+ });
39
+
40
+ export const CURSOR_BLOCKING_EVENTS = Object.freeze([
41
+ 'beforeShellExecution',
42
+ 'preToolUse',
43
+ 'beforeMCPExecution',
44
+ 'subagentStart',
45
+ ]);
46
+
47
+ const TOOL_NAME_MAP = Object.freeze({
48
+ shell: 'Bash',
49
+ bash: 'Bash',
50
+ write: 'Write',
51
+ tabwrite: 'Write',
52
+ strreplace: 'Edit',
53
+ edit: 'Edit',
54
+ multiedit: 'MultiEdit',
55
+ read: 'Read',
56
+ tabread: 'Read',
57
+ delete: 'Write',
58
+ task: 'Agent',
59
+ });
60
+
61
+ /**
62
+ * @param {unknown} value
63
+ * @returns {Record<string, unknown>}
64
+ */
65
+ function objectOrEmpty(value) {
66
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
67
+ }
68
+
69
+ /**
70
+ * @param {string|undefined} toolName
71
+ * @returns {string|undefined}
72
+ */
73
+ export function mapCursorToolName(toolName) {
74
+ if (typeof toolName !== 'string' || toolName.length === 0) return undefined;
75
+ const key = toolName.toLowerCase();
76
+ return TOOL_NAME_MAP[key] ?? toolName;
77
+ }
78
+
79
+ /**
80
+ * @param {unknown} value
81
+ * @returns {string|undefined}
82
+ */
83
+ function firstString(...values) {
84
+ for (const value of values) {
85
+ if (typeof value === 'string' && value.length > 0) return value;
86
+ }
87
+ return undefined;
88
+ }
89
+
90
+ /**
91
+ * @param {string} cursorEventName
92
+ * @param {Record<string, unknown>} event
93
+ * @returns {string|undefined}
94
+ */
95
+ function inferCursorToolName(cursorEventName, event) {
96
+ if (cursorEventName === 'beforeShellExecution' || cursorEventName === 'afterShellExecution') {
97
+ return 'Bash';
98
+ }
99
+ if (cursorEventName === 'afterFileEdit') {
100
+ return mapCursorToolName(typeof event.tool_type === 'string' ? event.tool_type : 'Write');
101
+ }
102
+ const raw = firstString(
103
+ typeof event.tool_name === 'string' ? event.tool_name : undefined,
104
+ typeof event.toolName === 'string' ? event.toolName : undefined,
105
+ typeof event.tool_type === 'string' ? event.tool_type : undefined,
106
+ typeof event.toolType === 'string' ? event.toolType : undefined,
107
+ );
108
+ return mapCursorToolName(raw);
109
+ }
110
+
111
+ /**
112
+ * @param {Record<string, unknown>} event
113
+ * @returns {Record<string, unknown>}
114
+ */
115
+ function collectToolInput(event) {
116
+ const nested = objectOrEmpty(event.tool_input ?? event.arguments ?? event.args ?? event.input);
117
+ const next = { ...nested };
118
+ const command = firstString(
119
+ typeof event.command === 'string' ? event.command : undefined,
120
+ typeof next.command === 'string' ? next.command : undefined,
121
+ );
122
+ if (command) next.command = command;
123
+
124
+ const filePath = firstString(
125
+ typeof next.file_path === 'string' ? next.file_path : undefined,
126
+ typeof event.file_path === 'string' ? event.file_path : undefined,
127
+ typeof event.filePath === 'string' ? event.filePath : undefined,
128
+ typeof event.path === 'string' ? event.path : undefined,
129
+ typeof next.path === 'string' ? next.path : undefined,
130
+ );
131
+ if (filePath) {
132
+ next.file_path = filePath;
133
+ if (typeof next.path !== 'string') next.path = filePath;
134
+ }
135
+
136
+ if (typeof next.old_string !== 'string' && typeof next.oldText === 'string') {
137
+ next.old_string = next.oldText;
138
+ }
139
+ if (typeof next.new_string !== 'string' && typeof next.newText === 'string') {
140
+ next.new_string = next.newText;
141
+ }
142
+
143
+ return next;
144
+ }
145
+
146
+ /**
147
+ * Build the hook stdin payload expected by existing session-orchestrator hooks.
148
+ *
149
+ * @param {string} cursorEventName
150
+ * @param {Record<string, unknown>} [event]
151
+ * @param {Record<string, unknown>} [ctx]
152
+ * @returns {Record<string, unknown>}
153
+ */
154
+ export function normalizeCursorHookPayload(cursorEventName, event = {}, ctx = {}) {
155
+ const eventObject = objectOrEmpty(event);
156
+ const ctxObject = objectOrEmpty(ctx);
157
+ const toolName = inferCursorToolName(cursorEventName, eventObject);
158
+ const toolInput = collectToolInput(eventObject);
159
+ const cwd = firstString(
160
+ typeof ctxObject.cwd === 'string' ? ctxObject.cwd : undefined,
161
+ typeof eventObject.cwd === 'string' ? eventObject.cwd : undefined,
162
+ process.cwd(),
163
+ );
164
+
165
+ return {
166
+ hook_event_name: CURSOR_TO_CANONICAL_EVENT[cursorEventName] ?? cursorEventName,
167
+ cursor_event_name: cursorEventName,
168
+ session_id: eventObject.session_id ?? eventObject.sessionId ?? ctxObject.sessionId,
169
+ cwd,
170
+ reason: eventObject.reason,
171
+ tool_name: toolName,
172
+ tool_input: toolInput,
173
+ tool_call_id: eventObject.tool_call_id ?? eventObject.toolCallId,
174
+ tool_response: eventObject.tool_response ?? eventObject.result ?? eventObject.output,
175
+ is_error: eventObject.is_error ?? eventObject.isError,
176
+ timestamp: eventObject.timestamp ?? new Date().toISOString(),
177
+ };
178
+ }
179
+
180
+ /**
181
+ * @param {string} pluginRoot
182
+ * @returns {Record<string, unknown>}
183
+ */
184
+ export function loadCursorHookManifest(pluginRoot = resolvePluginRoot()) {
185
+ const manifestPath = path.join(pluginRoot, 'hooks', 'hooks-cursor.json');
186
+ if (!existsSync(manifestPath)) {
187
+ throw new Error(`Cursor hook manifest not found at ${manifestPath}`);
188
+ }
189
+ return JSON.parse(readFileSync(manifestPath, 'utf8'));
190
+ }
191
+
192
+ /**
193
+ * @param {string} cursorEventName
194
+ * @param {Record<string, unknown>} event
195
+ * @param {string|undefined} canonicalToolName
196
+ * @returns {string[]}
197
+ */
198
+ function matcherTargets(cursorEventName, event, canonicalToolName) {
199
+ const rawToolName = firstString(
200
+ typeof event.tool_name === 'string' ? event.tool_name : undefined,
201
+ typeof event.toolName === 'string' ? event.toolName : undefined,
202
+ typeof event.tool_type === 'string' ? event.tool_type : undefined,
203
+ );
204
+ return [
205
+ cursorEventName,
206
+ typeof event.reason === 'string' ? event.reason : '',
207
+ rawToolName ?? '',
208
+ canonicalToolName ?? '',
209
+ cursorEventName === 'beforeShellExecution' || cursorEventName === 'afterShellExecution' ? 'bash' : '',
210
+ cursorEventName === 'beforeShellExecution' || cursorEventName === 'afterShellExecution' ? 'shell' : '',
211
+ ].map((v) => v.toLowerCase());
212
+ }
213
+
214
+ /**
215
+ * @param {Record<string, unknown>} manifest
216
+ * @param {string} cursorEventName
217
+ * @param {Record<string, unknown>} [event]
218
+ * @returns {Array<Record<string, unknown>>}
219
+ */
220
+ export function selectCursorHooks(manifest, cursorEventName, event = {}) {
221
+ const hooksByEvent = objectOrEmpty(manifest.hooks);
222
+ const entry = hooksByEvent[cursorEventName];
223
+ const eventObject = objectOrEmpty(event);
224
+ const canonicalToolName = inferCursorToolName(cursorEventName, eventObject);
225
+ const targets = matcherTargets(cursorEventName, eventObject, canonicalToolName);
226
+ const selected = [];
227
+
228
+ if (entry && !Array.isArray(entry) && typeof objectOrEmpty(entry).script === 'string') {
229
+ const script = objectOrEmpty(entry).script;
230
+ selected.push({
231
+ type: 'command',
232
+ command: `sh "$CURSOR_PLUGIN_ROOT/hooks/run-node.sh" "$CURSOR_PLUGIN_ROOT/${script}"`,
233
+ });
234
+ return selected;
235
+ }
236
+
237
+ const matchers = Array.isArray(entry) ? entry : [];
238
+ for (const matcherConfig of matchers) {
239
+ const config = objectOrEmpty(matcherConfig);
240
+ const matcher = typeof config.matcher === 'string' ? config.matcher : '';
241
+ if (!matcherMatches(matcher, targets)) continue;
242
+ const hooks = Array.isArray(config.hooks) ? config.hooks : [];
243
+ for (const hook of hooks) {
244
+ selected.push(objectOrEmpty(hook));
245
+ }
246
+ }
247
+
248
+ return selected;
249
+ }
250
+
251
+ /**
252
+ * @param {Record<string, unknown>} hook
253
+ * @param {Record<string, unknown>} payload
254
+ * @param {{ pluginRoot: string, cwd?: string, env?: NodeJS.ProcessEnv }} options
255
+ * @returns {{ command: string, status: number|null, stdout: string, stderr: string, blocked: boolean, reason: string|null }}
256
+ */
257
+ export function runCursorHookCommand(hook, payload, options) {
258
+ const command = typeof hook.command === 'string' ? hook.command : '';
259
+ if (!command) {
260
+ return { command, status: 0, stdout: '', stderr: '', blocked: false, reason: null };
261
+ }
262
+
263
+ const timeoutSeconds = Number.isFinite(hook.timeout) ? Number(hook.timeout) : 5;
264
+ const cwd = options.cwd || (typeof payload.cwd === 'string' ? payload.cwd : process.cwd());
265
+ const env = {
266
+ ...process.env,
267
+ ...options.env,
268
+ };
269
+ delete env.CLAUDE_PLUGIN_ROOT;
270
+ delete env.CODEX_PLUGIN_ROOT;
271
+ delete env.PI_PLUGIN_ROOT;
272
+ delete env.CLAUDE_PROJECT_DIR;
273
+ delete env.CODEX_PROJECT_DIR;
274
+ delete env.PI_PROJECT_DIR;
275
+ env.CURSOR_PLUGIN_ROOT = options.pluginRoot;
276
+ env.CURSOR_RULES_DIR = options.pluginRoot;
277
+ env.PLUGIN_ROOT = options.pluginRoot;
278
+ env.SO_PLATFORM = 'cursor';
279
+ if (typeof payload.cwd === 'string') env.CURSOR_PROJECT_DIR = payload.cwd;
280
+
281
+ // `hook.command` is a shell line from the repo-committed hooks.json, so a
282
+ // shell is the intended interpreter — the same commit-gated trust class as
283
+ // the `*-command` keys run by scripts/lib/quality-gate.mjs (see
284
+ // .claude/rules/security.md § Session Config Command Trust). Nothing here
285
+ // interpolates user or payload input into the command string; the payload
286
+ // travels on stdin.
287
+ // nosemgrep: unsafe-shell-spawn
288
+ const result = spawnSync(command, {
289
+ shell: true,
290
+ cwd,
291
+ env,
292
+ input: JSON.stringify(payload) + '\n',
293
+ encoding: 'utf8',
294
+ timeout: timeoutSeconds * 1000,
295
+ });
296
+
297
+ const stdout = result.stdout ?? '';
298
+ const stderr = result.stderr ?? '';
299
+ const decision = readHookDecision(stdout);
300
+ const isPreToolUse = payload.hook_event_name === 'PreToolUse';
301
+ const infraFailure = Boolean(result.error) ||
302
+ result.status === null ||
303
+ (result.status !== 0 && result.status !== 2) ||
304
+ decision.malformed;
305
+ const blocked = result.status === 2 ||
306
+ decision.deny ||
307
+ (isPreToolUse && infraFailure);
308
+
309
+ let reason = decision.reason;
310
+ if (reason === null && blocked) {
311
+ reason = result.error?.message
312
+ || (decision.malformed
313
+ ? `Hook stdout is not a parseable decision envelope (possible truncated output) — failing closed: ${command}`
314
+ : '')
315
+ || stdout.trim()
316
+ || stderr.trim()
317
+ || `Hook command blocked: ${command}`;
318
+ }
319
+
320
+ return {
321
+ command,
322
+ status: result.status,
323
+ stdout,
324
+ stderr,
325
+ blocked,
326
+ reason,
327
+ };
328
+ }
329
+
330
+ /**
331
+ * @param {string} cursorEventName
332
+ * @param {Record<string, unknown>} [event]
333
+ * @param {Record<string, unknown>} [ctx]
334
+ * @param {{ pluginRoot?: string, manifest?: Record<string, unknown>, env?: NodeJS.ProcessEnv }} [options]
335
+ * @returns {Promise<{ block: boolean, reason: string|null, payload: Record<string, unknown>, results: Array<object> }>}
336
+ */
337
+ export async function runCursorHookEvent(cursorEventName, event = {}, ctx = {}, options = {}) {
338
+ const pluginRoot = options.pluginRoot ?? resolvePluginRoot();
339
+ const manifest = options.manifest ?? loadCursorHookManifest(pluginRoot);
340
+ const payload = normalizeCursorHookPayload(cursorEventName, event, ctx);
341
+ const hooks = selectCursorHooks(manifest, cursorEventName, event);
342
+ const results = [];
343
+
344
+ for (const hook of hooks) {
345
+ const result = runCursorHookCommand(hook, payload, {
346
+ pluginRoot,
347
+ cwd: typeof payload.cwd === 'string' ? payload.cwd : process.cwd(),
348
+ env: options.env,
349
+ });
350
+ results.push(result);
351
+ if (result.blocked) {
352
+ return {
353
+ block: true,
354
+ reason: result.reason,
355
+ payload,
356
+ results,
357
+ };
358
+ }
359
+ }
360
+
361
+ return {
362
+ block: false,
363
+ reason: null,
364
+ payload,
365
+ results,
366
+ };
367
+ }
368
+
369
+ /**
370
+ * Map a bridge result onto Cursor's hook stdout contract.
371
+ *
372
+ * @param {string} cursorEventName
373
+ * @param {{ block: boolean, reason: string|null }} result
374
+ * @returns {Record<string, unknown>}
375
+ */
376
+ export function toCursorHookOutput(cursorEventName, result) {
377
+ const blocking = CURSOR_BLOCKING_EVENTS.includes(cursorEventName);
378
+ if (result.block && blocking) {
379
+ const reason = result.reason || 'Blocked by Session Orchestrator';
380
+ return {
381
+ permission: 'deny',
382
+ user_message: reason,
383
+ agent_message: reason,
384
+ };
385
+ }
386
+ if (result.block && (cursorEventName === 'afterFileEdit' || cursorEventName === 'postToolUse')) {
387
+ return {
388
+ additional_context: result.reason || 'Session Orchestrator post-hoc warning',
389
+ };
390
+ }
391
+ if (blocking) {
392
+ return { permission: 'allow' };
393
+ }
394
+ return {};
395
+ }
396
+
397
+ function readStdinSync() {
398
+ try {
399
+ return readFileSync(0, 'utf8');
400
+ } catch {
401
+ return '';
402
+ }
403
+ }
404
+
405
+ async function main() {
406
+ const eventIdx = process.argv.indexOf('--event');
407
+ const eventName = eventIdx >= 0 ? process.argv[eventIdx + 1] : '';
408
+ if (!eventName || typeof CURSOR_TO_CANONICAL_EVENT[eventName] !== 'string') {
409
+ process.stderr.write(`cursor-hook-bridge: missing or unknown --event ${eventName || '(none)'}\n`);
410
+ process.stdout.write(`${JSON.stringify({ permission: 'allow' })}\n`);
411
+ return;
412
+ }
413
+
414
+ const raw = readStdinSync();
415
+ let event;
416
+ try {
417
+ event = raw.trim() ? JSON.parse(raw) : {};
418
+ } catch (err) {
419
+ const blocking = CURSOR_BLOCKING_EVENTS.includes(eventName);
420
+ if (blocking) {
421
+ const reason = `Cursor hook payload is not valid JSON: ${err instanceof Error ? err.message : err}`;
422
+ process.stdout.write(`${JSON.stringify({
423
+ permission: 'deny',
424
+ user_message: reason,
425
+ agent_message: reason,
426
+ })}\n`);
427
+ return;
428
+ }
429
+ process.stdout.write('{}\n');
430
+ return;
431
+ }
432
+
433
+ const result = await runCursorHookEvent(eventName, objectOrEmpty(event), { cwd: process.cwd() });
434
+ process.stdout.write(`${JSON.stringify(toCursorHookOutput(eventName, result))}\n`);
435
+ }
436
+
437
+ const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
438
+ if (isMain) {
439
+ main().catch((err) => {
440
+ process.stderr.write(`cursor-hook-bridge: ${err instanceof Error ? err.message : err}\n`);
441
+ process.stdout.write(`${JSON.stringify({ permission: 'allow' })}\n`);
442
+ });
443
+ }
@@ -99,8 +99,8 @@ export async function runDispatch({ startDir, now, deps } = {}) {
99
99
  * session-lock.mjs — returns its result VERBATIM so callers (the skill prose,
100
100
  * Wave-4 tests) can branch on the exact `{ ok, reason, ... }` contract.
101
101
  *
102
- * On `ok:false` (race lost / busy — reasons: active, stale-pid-alive,
103
- * stale-pid-dead, fs-error, ...) the dispatcher must exclude that repo and
102
+ * On `ok:false` (race lost / busy — reasons: active, stale-heartbeat,
103
+ * fs-error, ...) the dispatcher must exclude that repo and
104
104
  * re-rank the remaining free candidates. On `ok:true` the claim is held and the
105
105
  * coordinator may launch the chosen entry command.
106
106
  *