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
@@ -0,0 +1,504 @@
1
+ /**
2
+ * remote-dispatch.mjs — dispatch a wave subagent to a REMOTE host over the
3
+ * `offload` CLI, and measure the result at the produced patch file rather than
4
+ * from the model's own prose (#1160, repo-side half b).
5
+ *
6
+ * Relationship to `foreign-dispatch.mjs`: same contract, different channel.
7
+ * That module sends a task to a foreign MODEL on this machine; this one sends
8
+ * a task to Claude on ANOTHER machine. Both share the parts that must not
9
+ * diverge and are therefore IMPORTED, never copied: the `never_foreign` role
10
+ * lock ({@link isNeverForeignRole}), the run-id path-segment validator
11
+ * ({@link isSafeRunId}), the SIGTERM→SIGKILL child runner ({@link runChild}),
12
+ * and the two budget constants.
13
+ *
14
+ * Three properties are load-bearing here and each has a test:
15
+ *
16
+ * 1. **The prompt travels on STDIN, never in argv.** `offload`'s own help
17
+ * states "prompts travel by file (mode 600), never argv"; argv is visible
18
+ * to every process on the host via `ps`.
19
+ * 2. **The patch never lands in the repo the coordinator commits from.**
20
+ * `--patch` is validated to resolve under `os.tmpdir()` AND outside
21
+ * `repoRoot`, so a remote result cannot be swept into a local commit by
22
+ * an unrelated `git add`.
23
+ * 3. **The patch is READ, never applied.** Applying it is the coordinator's
24
+ * own step, after review — this module returns counts and paths only.
25
+ *
26
+ * The remote side enforces its own timeout; the wall-clock kill here is the
27
+ * backstop for a channel that stops answering (`timeoutSec + 60`).
28
+ */
29
+
30
+ import { execFileSync, spawn as nodeSpawn } from 'node:child_process';
31
+ import fs from 'node:fs';
32
+ import os from 'node:os';
33
+ import path from 'node:path';
34
+
35
+ import { emitEvent, sessionAttribution } from '../events.mjs';
36
+ import { isPathInside } from '../path-utils.mjs';
37
+ import {
38
+ DEFAULT_KILL_GRACE_MS,
39
+ DEFAULT_TIMEOUT_SEC,
40
+ isNeverForeignRole,
41
+ isSafeRunId,
42
+ runChild,
43
+ } from './foreign-dispatch.mjs';
44
+
45
+ export { DEFAULT_KILL_GRACE_MS, DEFAULT_TIMEOUT_SEC };
46
+
47
+ /** The `offload` binary. Resolved on PATH; a missing binary surfaces as ENOENT
48
+ * and is classified as a CHANNEL failure, never as a model failure. */
49
+ export const OFFLOAD_BIN = 'offload';
50
+
51
+ /** Local grace added to the remote budget before the wall-clock kill fires, in
52
+ * seconds. The remote side has its own `--timeout`; killing locally at the same
53
+ * instant would race it and report `timeout` for runs the remote was about to
54
+ * report as `empty-diff` or `remote-command-failed`.
55
+ *
56
+ * Named ceiling (BV-004): 60 s covers the sync-back of a patch after the remote
57
+ * command itself has finished. Revisit if a measured healthy run is ever killed
58
+ * locally while the remote reported success — raise this, never drop the kill. */
59
+ export const LOCAL_TIMEOUT_GRACE_SEC = 60;
60
+
61
+ /** Longest run-id / host accepted as an argv value and path segment. */
62
+ export const MAX_ID_LEN = 64;
63
+
64
+ /**
65
+ * `offload` exit code → failure reason. Measured against `offload --help`
66
+ * (2026-09-02). Kept as data so a new code shows up as `undefined` (mapped to
67
+ * `remote-command-failed` below) rather than being silently read as success.
68
+ * @type {Readonly<Record<number, string>>}
69
+ */
70
+ export const OFFLOAD_EXIT_REASONS = Object.freeze({
71
+ 1: 'usage-config',
72
+ 2: 'host-unreachable',
73
+ 3: 'remote-command-failed',
74
+ 4: 'sync-failed',
75
+ 5: 'timeout',
76
+ 6: 'empty-diff',
77
+ 7: 'rate-limited',
78
+ 8: 'write-lock-busy',
79
+ });
80
+
81
+ /** Event name — the only ledger record a remote dispatch produces. A Bash-spawned
82
+ * child fires no `SubagentStop` hook, so nothing else in the chain sees it. */
83
+ export const REMOTE_DISPATCH_EVENT = 'orchestrator.remote_dispatch.completed';
84
+
85
+ /** Chars of merged child output retained per stream on the envelope. Enough for
86
+ * exit-7's reset time and a stack tail; short enough that the envelope stays
87
+ * loggable. Never emitted in telemetry. */
88
+ const TAIL_CHARS = 2000;
89
+
90
+ /** Refuse to parse a patch larger than this; `patchBytes` is still reported.
91
+ * Named ceiling (BV-004): 64 MiB is far past any review-sized diff. Revisit if
92
+ * a legitimate remote patch is ever measured above it. */
93
+ const MAX_PATCH_PARSE_BYTES = 64 * 1024 * 1024;
94
+
95
+ const tail = (s) => (s.length > TAIL_CHARS ? s.slice(-TAIL_CHARS) : s);
96
+
97
+ /** @param {unknown} v @returns {boolean} safe as an argv value and path segment. */
98
+ function isSafeId(v) {
99
+ const s = String(v ?? '');
100
+ return s.length > 0 && s.length <= MAX_ID_LEN && isSafeRunId(s);
101
+ }
102
+
103
+ /** Longest `repo` accepted as an argv operand. */
104
+ const MAX_REPO_LEN = 128;
105
+
106
+ /**
107
+ * A repo argument safe as the first positional of `offload claude`.
108
+ *
109
+ * Same character allowlist as `SAFE_PATH_RE` in `scripts/lib/config/remote-hosts.mjs`
110
+ * (a `repo-path` declared there is exactly what arrives here), plus two rules the
111
+ * charset alone cannot express: the first character may never be `-`, or the CLI
112
+ * reads the operand as an OPTION and silently consumes the token after it; and
113
+ * `..` may never appear, so a repo argument cannot climb out of the remote's
114
+ * project root.
115
+ */
116
+ const SAFE_REPO_RE = /^[A-Za-z0-9._~/][A-Za-z0-9._~/-]{0,127}$/;
117
+
118
+ /** @param {unknown} v @returns {boolean} */
119
+ function isSafeRepo(v) {
120
+ const s = String(v ?? '');
121
+ return s.length > 0 && s.length <= MAX_REPO_LEN && SAFE_REPO_RE.test(s) && !s.includes('..');
122
+ }
123
+
124
+ /**
125
+ * A model name safe as the argv value of `--model`. Deliberately NARROWER than
126
+ * {@link SAFE_REPO_RE} — a model is a bare name (`sonnet`, `claude-opus-4.5`),
127
+ * never a path — and anchored against a leading `-` for the same option-token
128
+ * reason.
129
+ */
130
+ const SAFE_MODEL_RE = /^[A-Za-z0-9._][A-Za-z0-9._-]{0,63}$/;
131
+
132
+ /** @param {unknown} v @returns {boolean} */
133
+ function isSafeModel(v) {
134
+ return SAFE_MODEL_RE.test(String(v ?? ''));
135
+ }
136
+
137
+ /**
138
+ * Directories a remote patch may live in. Both spellings of the temp directory
139
+ * are accepted because macOS resolves `os.tmpdir()` through a symlink
140
+ * (`/var/folders/...` ↔ `/private/var/folders/...`) and a caller that passed a
141
+ * realpath'd directory is not doing anything unsafe.
142
+ * @returns {string[]}
143
+ */
144
+ function tmpRoots() {
145
+ const t = path.resolve(os.tmpdir());
146
+ const roots = [t];
147
+ try {
148
+ const real = fs.realpathSync(t);
149
+ if (real !== t) roots.push(real);
150
+ } catch {
151
+ /* tmpdir unreadable — the lexical root still applies */
152
+ }
153
+ return roots;
154
+ }
155
+
156
+ /**
157
+ * Paths touched by a unified diff. `+++ b/<path>` covers modified files; a
158
+ * created file has `+++ /dev/null`-free `+++ b/…` too, but a DELETED file's
159
+ * `+++` is `/dev/null`, so `diff --git a/x b/x` is read as well — the same
160
+ * blindness `foreign-dispatch.mjs` documents for `git diff` alone.
161
+ * @param {string} patch
162
+ * @returns {string[]} sorted, de-duplicated
163
+ */
164
+ export function parsePatchFiles(patch) {
165
+ /** @type {Set<string>} */
166
+ const files = new Set();
167
+ for (const line of String(patch || '').split('\n')) {
168
+ if (line.startsWith('+++ ')) {
169
+ const p = line.slice(4).trim().split('\t')[0];
170
+ if (p && p !== '/dev/null') files.add(p.replace(/^b\//, ''));
171
+ continue;
172
+ }
173
+ if (line.startsWith('diff --git ')) {
174
+ const m = /^diff --git a\/(.+?) b\/(.+)$/.exec(line.trim());
175
+ if (m) files.add(m[2]);
176
+ }
177
+ }
178
+ return [...files].sort();
179
+ }
180
+
181
+ /**
182
+ * Dispatch one wave task to a remote host via `offload claude`.
183
+ *
184
+ * @param {object} task
185
+ * @param {string} task.host — `offload` host alias (`-H`).
186
+ * @param {string} task.repo — repository argument handed to `offload claude`.
187
+ * @param {string} task.prompt — written to the child's STDIN, never to argv.
188
+ * @param {string} task.role — checked against the `never_foreign` lock FIRST.
189
+ * @param {string} task.runId — becomes `--job`; a path segment, hence validated.
190
+ * @param {number} [task.timeoutSec] — remote budget; the local kill adds 60 s.
191
+ * @param {string} [task.model] — `--model`; omitted from argv when absent.
192
+ * @param {string} [task.patchPath] — must resolve under `os.tmpdir()` and
193
+ * outside `repoRoot`. Defaults to `<tmpdir>/offload-<runId>.patch`.
194
+ * @param {object} [deps]
195
+ * @param {Function} [deps.spawnFn] — `child_process.spawn` seam.
196
+ * @param {Function} [deps.now] — clock seam.
197
+ * @param {Function} [deps.emitFn] — event-emitter seam.
198
+ * @param {number} [deps.killGraceMs] — SIGTERM→SIGKILL grace.
199
+ * @param {number} [deps.localGraceSec] — seconds added to the remote budget before
200
+ * the LOCAL wall-clock kill; a seam so the escalation is testable without a
201
+ * real 15-minute wait.
202
+ * @param {string} [deps.repoRoot] — the tree the coordinator commits from.
203
+ * @returns {Promise<object>} result envelope; `ok` is false unless the child
204
+ * exited 0 AND left a non-empty patch.
205
+ */
206
+ export async function dispatchRemote(
207
+ { host, repo, prompt, role, runId, timeoutSec = DEFAULT_TIMEOUT_SEC, model, patchPath },
208
+ {
209
+ spawnFn = nodeSpawn,
210
+ now = Date.now,
211
+ emitFn = emitEvent,
212
+ killGraceMs = DEFAULT_KILL_GRACE_MS,
213
+ localGraceSec = LOCAL_TIMEOUT_GRACE_SEC,
214
+ repoRoot,
215
+ } = {},
216
+ ) {
217
+ /** Refusal envelope + its telemetry record. A refused dispatch must leave a
218
+ * ledger record rather than a silence, and `exit_code: null` + `patch_files: 0`
219
+ * keep "refused" distinguishable from "attempted and measured empty". */
220
+ const refuse = async (reason) => {
221
+ try {
222
+ await emitFn(
223
+ REMOTE_DISPATCH_EVENT,
224
+ {
225
+ ...sessionAttribution(repoRoot),
226
+ host,
227
+ role,
228
+ run_id: runId,
229
+ ok: false,
230
+ reason,
231
+ exit_code: null,
232
+ duration_ms: 0,
233
+ patch_files: 0,
234
+ patch_bytes: 0,
235
+ },
236
+ { repoRoot },
237
+ );
238
+ } catch {
239
+ /* telemetry must never fail a refusal */
240
+ }
241
+ return {
242
+ ok: false,
243
+ reason,
244
+ role,
245
+ host,
246
+ repo,
247
+ runId,
248
+ exitCode: null,
249
+ timedOut: false,
250
+ durationMs: 0,
251
+ patchPath: null,
252
+ patchFiles: [],
253
+ patchBytes: 0,
254
+ stdoutTail: '',
255
+ stderrTail: '',
256
+ };
257
+ };
258
+
259
+ // Hard role lock FIRST — before any spawn, any file, any side effect.
260
+ if (isNeverForeignRole(role)) return refuse('never-foreign-role');
261
+ if (!isSafeId(runId)) return refuse('unsafe-run-id');
262
+ if (!isSafeId(host)) return refuse('unsafe-host');
263
+ // `repo` is the first positional and `model` the value of `--model`: both
264
+ // reach argv, and both were previously stringified straight into it. A value
265
+ // starting with `-` is read by the CLI as an option, which shifts every
266
+ // operand after it — the same injection class the two checks above prevent.
267
+ if (!isSafeRepo(repo)) return refuse('unsafe-repo');
268
+ // The truthiness test mirrors the `...(model ? ['--model', …] : [])` argv line
269
+ // below on purpose: a falsy model is OMITTED from argv, so it is absent, not
270
+ // unsafe. Validating a shape that never reaches argv would refuse callers for
271
+ // a value the CLI never sees.
272
+ if (model && !isSafeModel(model)) return refuse('unsafe-model');
273
+
274
+ const patchTarget = path.resolve(
275
+ String(patchPath || path.join(os.tmpdir(), `offload-${runId}.patch`)),
276
+ );
277
+ const underTmp = tmpRoots().some((r) => patchTarget === r || isPathInside(patchTarget, r));
278
+ const rootResolved = repoRoot ? path.resolve(String(repoRoot)) : null;
279
+ const insideRepo =
280
+ rootResolved !== null &&
281
+ (patchTarget === rootResolved || isPathInside(patchTarget, rootResolved));
282
+ // A patch inside the coordinator's tree is not a delivery, it is a
283
+ // contamination: the next `git add` sweeps a remote model's output into a
284
+ // local commit with nobody having reviewed it.
285
+ if (!underTmp || insideRepo) return refuse('unsafe-patch-path');
286
+
287
+ const startedAt = now();
288
+ const args = [
289
+ 'claude',
290
+ String(repo),
291
+ '-H',
292
+ String(host),
293
+ '--job',
294
+ String(runId),
295
+ '--write',
296
+ '--patch',
297
+ patchTarget,
298
+ ...(model ? ['--model', String(model)] : []),
299
+ '--timeout',
300
+ String(timeoutSec),
301
+ ];
302
+
303
+ let stdout = '';
304
+ let stderr = '';
305
+ // Wrapping the spawn seam is how the prompt reaches STDIN and how the two
306
+ // streams stay SEPARATE — runChild merges them by design, but exit 7's reset
307
+ // time arrives on stderr and the operator needs it distinguishable.
308
+ const spawnWithStdin = (cmd, argv, options) => {
309
+ const child = spawnFn(cmd, argv, options);
310
+ child.stdout?.on('data', (c) => {
311
+ stdout += c.toString();
312
+ });
313
+ child.stderr?.on('data', (c) => {
314
+ stderr += c.toString();
315
+ });
316
+ try {
317
+ child.stdin?.write(String(prompt ?? ''));
318
+ child.stdin?.end();
319
+ } catch {
320
+ /* a child that died before its stdin opened is reported by exit/error */
321
+ }
322
+ return child;
323
+ };
324
+
325
+ const { exitCode, timerFired, errorCode } = await runChild(
326
+ OFFLOAD_BIN,
327
+ args,
328
+ { stdio: ['pipe', 'pipe', 'pipe'] },
329
+ {
330
+ spawnFn: spawnWithStdin,
331
+ timeoutMs: (Number(timeoutSec) + Number(localGraceSec)) * 1000,
332
+ killGraceMs,
333
+ },
334
+ );
335
+
336
+ // A missing patch file and a 0-byte one are the SAME observable state:
337
+ // nothing came back. Never an error class of its own.
338
+ let patchBytes = 0;
339
+ try {
340
+ patchBytes = fs.statSync(patchTarget).size;
341
+ } catch {
342
+ /* no patch file — patchBytes stays 0 */
343
+ }
344
+ /** @type {string[]} */
345
+ let patchFiles = [];
346
+ if (patchBytes > 0 && patchBytes <= MAX_PATCH_PARSE_BYTES) {
347
+ try {
348
+ patchFiles = parsePatchFiles(fs.readFileSync(patchTarget, 'utf8'));
349
+ } catch {
350
+ // Unreadable body, measured size: report the bytes, claim no paths.
351
+ patchFiles = [];
352
+ }
353
+ }
354
+
355
+ let reason;
356
+ if (errorCode === 'ENOENT') reason = 'channel-unavailable';
357
+ else if (timerFired) reason = 'timeout';
358
+ else if (exitCode !== 0) reason = OFFLOAD_EXIT_REASONS[exitCode] || 'remote-command-failed';
359
+ else if (patchBytes === 0) reason = 'empty-diff';
360
+ const ok = reason === undefined;
361
+
362
+ const durationMs = now() - startedAt;
363
+
364
+ try {
365
+ await emitFn(
366
+ REMOTE_DISPATCH_EVENT,
367
+ {
368
+ ...sessionAttribution(repoRoot),
369
+ host,
370
+ role,
371
+ run_id: runId,
372
+ ok,
373
+ // Present only when the dispatch failed, so absence means success and
374
+ // every failure class is groupable without string-matching a message.
375
+ ...(reason ? { reason } : {}),
376
+ exit_code: exitCode,
377
+ duration_ms: durationMs,
378
+ patch_files: patchFiles.length,
379
+ patch_bytes: patchBytes,
380
+ // Deliberately ABSENT: the prompt, the patch body, and `patch_path` —
381
+ // this record also travels over the optional Clank webhook with no
382
+ // redaction, and a tmp path names the run id and the operator's host.
383
+ },
384
+ { repoRoot },
385
+ );
386
+ } catch {
387
+ /* telemetry must never fail a dispatch */
388
+ }
389
+
390
+ return {
391
+ ok,
392
+ ...(reason ? { reason } : {}),
393
+ role,
394
+ host,
395
+ repo,
396
+ runId,
397
+ exitCode,
398
+ timedOut: timerFired,
399
+ durationMs,
400
+ patchPath: patchTarget,
401
+ patchFiles,
402
+ patchBytes,
403
+ stdoutTail: tail(stdout),
404
+ stderrTail: tail(stderr),
405
+ };
406
+ }
407
+
408
+ /**
409
+ * Parse one `offload doctor --brief` line.
410
+ *
411
+ * Real line, measured 2026-09-02 against host `m5` (the leading hostname is
412
+ * SCRUBBED to the synthetic `Ferdinands-…` convention of
413
+ * `scripts/lib/host-identity.mjs`; every other segment is verbatim):
414
+ * `Ferdinands-Macbook-2 ready=yes · load 14.23 · mem free 96% · headless: slots
415
+ * none, keychain-route ok · 9 jobs · claude procs 12`
416
+ *
417
+ * Every metric is `null` when its segment is absent — never a fabricated `0`,
418
+ * which would read as "measured, idle" for a host that answered nothing.
419
+ * @param {string} raw
420
+ * @returns {{ready: boolean, raw: string, load: number|null, memFreePct: number|null,
421
+ * jobs: number|null, claudeProcs: number|null}}
422
+ */
423
+ export function parseDoctorLine(raw) {
424
+ const text = String(raw ?? '');
425
+ const num = (re) => {
426
+ const m = re.exec(text);
427
+ return m ? Number(m[1]) : null;
428
+ };
429
+ return {
430
+ // `ready` is the ONLY decision field: anything that is not a literal
431
+ // `ready=yes` is not-ready, including an unparseable line.
432
+ ready: /\bready=yes\b/i.test(text),
433
+ raw: text,
434
+ load: num(/\bload\s+([0-9]+(?:\.[0-9]+)?)/i),
435
+ memFreePct: num(/\bmem free\s+([0-9]+(?:\.[0-9]+)?)\s*%/i),
436
+ jobs: num(/\b([0-9]+)\s+jobs?\b/i),
437
+ claudeProcs: num(/\bclaude procs\s+([0-9]+)/i),
438
+ };
439
+ }
440
+
441
+ /**
442
+ * Host readiness check — `offload doctor -H <host> --brief`. Read-only.
443
+ *
444
+ * @param {object} opts
445
+ * @param {string} opts.host
446
+ * @param {Function} [opts.execFn] — `child_process.execFileSync` seam.
447
+ * @param {number} [opts.timeoutMs]
448
+ * @returns {{ready: boolean, raw: string, load: number|null, memFreePct: number|null,
449
+ * jobs: number|null, claudeProcs: number|null}}
450
+ */
451
+ export function remoteDoctor({ host, execFn = execFileSync, timeoutMs = 90_000 }) {
452
+ if (!isSafeId(host)) return { ...parseDoctorLine(''), raw: '' };
453
+ let raw;
454
+ try {
455
+ raw = String(
456
+ execFn(OFFLOAD_BIN, ['doctor', '-H', String(host), '--brief'], {
457
+ encoding: 'utf8',
458
+ timeout: timeoutMs,
459
+ }),
460
+ ).trim();
461
+ } catch (err) {
462
+ // A non-zero exit still carries diagnostic text on stdout/stderr; keep it
463
+ // so the operator sees WHY, but never let it produce metrics.
464
+ const out = err && (err.stdout || err.stderr);
465
+ return {
466
+ ready: false,
467
+ raw: out ? String(out).trim() : String((err && err.message) || ''),
468
+ load: null,
469
+ memFreePct: null,
470
+ jobs: null,
471
+ claudeProcs: null,
472
+ };
473
+ }
474
+ return parseDoctorLine(raw);
475
+ }
476
+
477
+ /**
478
+ * THE adapter between {@link remoteDoctor} and the wave-resource gate's
479
+ * `probeFn` seam (`scripts/lib/wave-resource-gate.mjs` → `applyOffloadDecision`).
480
+ *
481
+ * The gate's witness contract is `async (alias: string) => boolean`;
482
+ * `remoteDoctor` is SYNC, takes an options object and returns a metrics record.
483
+ * Passing `remoteDoctor` itself as `probeFn` therefore yields `undefined.ready`
484
+ * on an object it never received an alias for, and no host is ever ready — the
485
+ * documented witness could not produce an `offload` decision at all. This
486
+ * function is the one shape both sides agree on; use it, never `remoteDoctor`
487
+ * directly.
488
+ *
489
+ * Never throws: a probe that raises is a host that did not answer, i.e. NOT
490
+ * ready. The gate must fail toward local, never toward a host it cannot vouch
491
+ * for.
492
+ *
493
+ * @param {string} alias — declared host alias (`remote-hosts[].alias`).
494
+ * @param {object} [opts]
495
+ * @param {Function} [opts.execFn] — `child_process.execFileSync` seam.
496
+ * @returns {Promise<boolean>} true only on a literal `ready=yes` line.
497
+ */
498
+ export async function remoteReadyProbe(alias, { execFn } = {}) {
499
+ try {
500
+ return remoteDoctor({ host: alias, ...(execFn ? { execFn } : {}) }).ready === true;
501
+ } catch {
502
+ return false;
503
+ }
504
+ }
@@ -8,6 +8,25 @@
8
8
  */
9
9
 
10
10
  import { probe, evaluate } from './resource-probe.mjs';
11
+ import { isNeverForeignRole } from './wave-executor/foreign-dispatch.mjs';
12
+
13
+ /**
14
+ * Wave roles that may run on a declared remote host, mapped to the
15
+ * `agent-mapping` role a host must list in its `roles-allowed` (#1160).
16
+ *
17
+ * Two enums meet here: the WAVE role ("Quality", "Impl-Core") on the left, the
18
+ * agent-mapping role ("test", "ui", "perf") on the right. Anything absent from
19
+ * this map is local-only — the default is "do not offload", so a wave role added
20
+ * later never becomes offloadable by accident.
21
+ *
22
+ * @type {Readonly<Record<string, string>>}
23
+ */
24
+ export const OFFLOADABLE_WAVE_ROLES = Object.freeze({
25
+ quality: 'test',
26
+ test: 'test',
27
+ ui: 'ui',
28
+ perf: 'perf',
29
+ });
11
30
 
12
31
  // ---------------------------------------------------------------------------
13
32
  // Public API
@@ -87,11 +106,101 @@ async function extractMeasurements(opts) {
87
106
  *
88
107
  * @param {{ramFreeGb: number, ramAvailableGb?: number|null, cpuLoadPct: number, cpuLoad5mPct?: number|null, concurrentSessions: number}} measurements
89
108
  * @param {object} opts - Same opts shape as evaluateWaveResourceGate
90
- * @returns {{decision: string, agents: number, reasons: string[], measurements: object}}
109
+ * @returns {Promise<{decision: string, agents: number, reasons: string[], measurements: object, host?: string}>}
91
110
  */
92
- function applyDecisionRules(measurements, opts) {
111
+ async function applyDecisionRules(measurements, opts) {
93
112
  const result = computeResourceDecision(measurements, opts);
94
- return applyHeavyRepoCap(result, opts);
113
+ // Order is load-bearing: the heavy-repo cap is a STATIC property of the repo
114
+ // and only ever lowers; offload is a PLACEMENT answer to live pressure. Running
115
+ // offload last means a capped wave that gets offloaded still respects HR-004,
116
+ // and never the reverse.
117
+ return applyOffloadDecision(applyHeavyRepoCap(result, opts), opts);
118
+ }
119
+
120
+ /**
121
+ * #1160 offload placement. When the resource rules want to SHRINK the wave
122
+ * (`reduce`) or take it away entirely (`coordinator-direct`), and the repo
123
+ * declares a remote host that accepts this wave role, route the wave to that
124
+ * host at its full planned agent count instead of shrinking it.
125
+ *
126
+ * The gate does NOT probe the network — a placement decision must stay a pure
127
+ * function of its inputs. The caller supplies a readiness WITNESS:
128
+ * - `opts.remoteReady` — `{ [alias]: boolean }`, e.g. built from the
129
+ * SessionStart `Offload m5: ready=yes` banner or `remoteDoctor()`.
130
+ * - `opts.probeFn` — `async (alias) => boolean`, consulted only for hosts the
131
+ * `remoteReady` map does not already answer for. Default `null`. A probeFn
132
+ * that REJECTS is read as not-ready and its message is appended to
133
+ * `reasons` — the gate never loses the local decision to a failed witness.
134
+ * With neither supplied NO host is ready and the decision stays `reduce` /
135
+ * `coordinator-direct` — the gate fails toward local, never toward a host it
136
+ * cannot vouch for.
137
+ *
138
+ * @param {{decision: string, agents: number, reasons: string[], measurements: object}} result
139
+ * @param {object} opts - Same opts shape as evaluateWaveResourceGate
140
+ * @returns {Promise<{decision: string, agents: number, reasons: string[], measurements: object, host?: string}>}
141
+ */
142
+ async function applyOffloadDecision(result, opts) {
143
+ if (result.decision !== 'reduce' && result.decision !== 'coordinator-direct') return result;
144
+
145
+ const { config, plannedAgents, waveRole, remoteReady, probeFn = null } = opts;
146
+ const hosts = config?.['remote-hosts'];
147
+ if (!Array.isArray(hosts) || hosts.length === 0) return result;
148
+
149
+ const role = String(waveRole ?? '').trim().toLowerCase();
150
+ if (isNeverForeignRole(role)) return result;
151
+ const mappedRole = OFFLOADABLE_WAVE_ROLES[role];
152
+ if (mappedRole === undefined) return result;
153
+
154
+ const ready = remoteReady && typeof remoteReady === 'object' ? remoteReady : {};
155
+ // A witness that THROWS is a host that did not answer — never a reason to lose
156
+ // the local decision the resource rules already computed. Before this catch, a
157
+ // rejecting probeFn (ssh down, `offload` missing) propagated all the way out of
158
+ // evaluateWaveResourceGate and rejected the whole gate call.
159
+ /** @type {string[]} */
160
+ const probeFailures = [];
161
+ // First fit in DECLARATION order — the operator's order is the preference order.
162
+ let host;
163
+ for (const h of hosts) {
164
+ if (!Array.isArray(h?.['roles-allowed']) || !h['roles-allowed'].includes(mappedRole)) continue;
165
+ let isReady = ready[h.alias] === true;
166
+ if (!isReady && ready[h.alias] === undefined && typeof probeFn === 'function') {
167
+ try {
168
+ isReady = (await probeFn(h.alias)) === true;
169
+ } catch (err) {
170
+ isReady = false;
171
+ probeFailures.push(
172
+ `offload probe for '${h.alias}' failed (${(err && err.message) || String(err)}) — staying local`,
173
+ );
174
+ }
175
+ }
176
+ if (isReady) {
177
+ host = h;
178
+ break;
179
+ }
180
+ }
181
+ if (host === undefined) {
182
+ return probeFailures.length === 0
183
+ ? result
184
+ : { ...result, reasons: [...result.reasons, ...probeFailures] };
185
+ }
186
+
187
+ // The wave runs at its planned size again — but never above the HR-004 static
188
+ // ceiling, which is a property of the REPO and holds wherever the wave runs.
189
+ // Restoring plannedAgents unconditionally here would let a heavy repo exceed
190
+ // its own cap by way of a remote host.
191
+ const cap = config?.['heavy-repo'] === true ? resolveApwCap(config['agents-per-wave']) : null;
192
+ const agents = cap === null ? plannedAgents : Math.min(plannedAgents, cap);
193
+
194
+ return {
195
+ decision: 'offload',
196
+ agents,
197
+ host: host.alias,
198
+ reasons: [
199
+ ...result.reasons,
200
+ `offload: ${role} routed to host '${host.alias}' instead of reducing to ${result.agents}`,
201
+ ],
202
+ measurements: result.measurements,
203
+ };
95
204
  }
96
205
 
97
206
  /**
@@ -243,7 +352,15 @@ function computeResourceDecision(measurements, opts) {
243
352
  * @param {string} opts.waveRole - e.g. "Impl-Core", "Quality"
244
353
  * @param {object} [opts.probeOverride] - {ramFreeGb, cpuLoadPct, cpuLoad5mPct?, concurrentSessions}
245
354
  * for testing; when omitted, calls resource-probe
246
- * @returns {Promise<{decision: "proceed"|"reduce"|"coordinator-direct", agents: number, reasons: string[], measurements: object}>}
355
+ * @param {Record<string, boolean>} [opts.remoteReady] - #1160 readiness witness per
356
+ * declared host alias. The gate never probes the network itself; without a witness
357
+ * no host counts as ready and the decision stays local.
358
+ * @param {(alias: string) => Promise<boolean>} [opts.probeFn] - optional async witness,
359
+ * consulted only for aliases absent from `remoteReady`. Default null. Use
360
+ * `remoteReadyProbe` from scripts/lib/wave-executor/remote-dispatch.mjs; a
361
+ * rejection counts as not-ready and is reported in `reasons`, never thrown.
362
+ * @returns {Promise<{decision: "proceed"|"reduce"|"coordinator-direct"|"offload", agents: number, reasons: string[], measurements: object, host?: string}>}
363
+ * `host` is present only on an `offload` decision — the declared alias the wave runs on.
247
364
  */
248
365
  export async function evaluateWaveResourceGate(opts) {
249
366
  const { config, plannedAgents } = opts;
@@ -280,11 +397,11 @@ export async function evaluateWaveResourceGate(opts) {
280
397
 
281
398
  /**
282
399
  * Format a gate result into a short multi-line coordinator progress string.
283
- * @param {{decision: string, agents: number, reasons: string[], measurements: object}} result
400
+ * @param {{decision: string, agents: number, reasons: string[], measurements: object, host?: string}} result
284
401
  * @returns {string}
285
402
  */
286
403
  export function formatGateReport(result) {
287
- const { decision, agents, reasons, measurements } = result;
404
+ const { decision, agents, reasons, measurements, host } = result;
288
405
  const lines = reasons.map((r) => ` - ${r}`);
289
406
  const m = measurements;
290
407
  // Prefer the macOS available-RAM figure in the banner when present (#667):
@@ -297,6 +414,9 @@ export function formatGateReport(result) {
297
414
  Object.keys(m).length > 0
298
415
  ? ` (${ramStr}, CPU ${m.cpuLoadPct ?? '?'}%, sessions ${m.concurrentSessions ?? '?'})`
299
416
  : '';
300
- lines.push(`Decision: ${decision} agents: ${agents}${measStr}`);
417
+ // An `offload` decision without its host in the banner is unreadable: the agent
418
+ // count did not shrink, so the line would look identical to `proceed` (HR-106).
419
+ const agentsStr = decision === 'offload' && host ? `${agents} @ ${host}` : `${agents}`;
420
+ lines.push(`Decision: ${decision} — agents: ${agentsStr}${measStr}`);
301
421
  return lines.join('\n');
302
422
  }