session-orchestrator 3.22.0 → 3.23.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 (268) 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/repo-audit/SKILL.md +13 -0
  74. package/.cursor/skills/session-end/SKILL.md +13 -0
  75. package/.cursor/skills/session-plan/SKILL.md +13 -0
  76. package/.cursor/skills/session-start/SKILL.md +13 -0
  77. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  78. package/.cursor/skills/spinout/SKILL.md +12 -0
  79. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  80. package/.cursor/skills/test-runner/SKILL.md +13 -0
  81. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  82. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  83. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  84. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  85. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  86. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  87. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  88. package/.mcp.json +4 -1
  89. package/CHANGELOG.md +168 -0
  90. package/README.md +18 -15
  91. package/agents/AGENTS.md +23 -4
  92. package/agents/code-implementer.md +2 -1
  93. package/agents/db-specialist.md +2 -1
  94. package/agents/docs-writer.md +3 -1
  95. package/agents/eval-judge.md +1 -1
  96. package/agents/session-reviewer.md +7 -1
  97. package/agents/test-writer.md +2 -1
  98. package/agents/ui-developer.md +2 -1
  99. package/commands/bootstrap.md +2 -2
  100. package/commands/close.md +3 -1
  101. package/commands/go.md +1 -1
  102. package/commands/journey-audit.md +43 -0
  103. package/docs/USER-GUIDE.md +2 -2
  104. package/docs/ci-setup.md +14 -0
  105. package/docs/codex-setup.md +64 -0
  106. package/docs/components.md +6 -6
  107. package/docs/cursor-setup.md +26 -47
  108. package/docs/events-schema.md +76 -4
  109. package/docs/github-mirror-protection.md +197 -0
  110. package/docs/pi-setup.md +2 -0
  111. package/docs/rule-authoring.md +3 -1
  112. package/docs/scope-collision-guard.md +49 -2
  113. package/docs/session-config-reference.md +26 -4
  114. package/docs/session-config-template.md +4 -3
  115. package/docs/telemetry.md +22 -0
  116. package/hooks/_lib/lock-bootstrap.mjs +8 -4
  117. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  118. package/hooks/enforce-scope.mjs +64 -0
  119. package/hooks/hooks-codex.json +1 -1
  120. package/hooks/hooks-cursor.json +201 -20
  121. package/hooks/hooks-pi.json +1 -1
  122. package/hooks/hooks.json +2 -2
  123. package/hooks/on-session-end.mjs +211 -10
  124. package/hooks/on-session-start.mjs +214 -11
  125. package/hooks/on-stop.mjs +48 -9
  126. package/hooks/post-subagent-discovery-validator.mjs +34 -3
  127. package/hooks/post-tool-batch-wave-signal.mjs +11 -2
  128. package/hooks/pre-bash-issue-budget.mjs +117 -4
  129. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  130. package/hooks/pre-bash-staging-fence.mjs +4 -0
  131. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  132. package/hooks/skill-invocation-telemetry.mjs +21 -10
  133. package/monitors/monitors.json +6 -0
  134. package/package.json +1 -1
  135. package/pi/prompts/journey-audit.md +12 -0
  136. package/rules/_index.md +9 -1
  137. package/rules/always-on/ask-via-tool.md +62 -0
  138. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  139. package/rules/always-on/build-value.md +47 -0
  140. package/rules/always-on/cross-session-messaging.md +59 -0
  141. package/rules/always-on/loop-and-monitor.md +221 -0
  142. package/rules/always-on/parallel-sessions.md +142 -12
  143. package/rules/always-on/receiving-review.md +108 -0
  144. package/rules/always-on/test-value.md +40 -0
  145. package/rules/always-on/verification-before-completion.md +77 -0
  146. package/scripts/archive-closed-prds.mjs +258 -18
  147. package/scripts/autopilot.mjs +5 -0
  148. package/scripts/backfill-evidence-digest.mjs +376 -0
  149. package/scripts/cursor-install.mjs +89 -48
  150. package/scripts/export-hw-learnings.mjs +143 -2
  151. package/scripts/express-path.mjs +299 -0
  152. package/scripts/generate-cursor-adapter.mjs +253 -0
  153. package/scripts/github-protection-audit.mjs +358 -0
  154. package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
  155. package/scripts/lib/build-live-signals.mjs +24 -5
  156. package/scripts/lib/ci-status-banner.mjs +158 -11
  157. package/scripts/lib/command-blocker.mjs +70 -0
  158. package/scripts/lib/config/reconcile.mjs +79 -4
  159. package/scripts/lib/config/section-extractor.mjs +235 -36
  160. package/scripts/lib/config-schema.mjs +9 -1
  161. package/scripts/lib/config.mjs +57 -6
  162. package/scripts/lib/convergence-monitor.mjs +13 -2
  163. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  164. package/scripts/lib/dispatcher/cli.mjs +2 -2
  165. package/scripts/lib/express-path.mjs +327 -0
  166. package/scripts/lib/file-lock.mjs +22 -4
  167. package/scripts/lib/gates/gate-full.mjs +81 -8
  168. package/scripts/lib/gates/gate-helpers.mjs +76 -15
  169. package/scripts/lib/git-config-drift.mjs +134 -5
  170. package/scripts/lib/host-identity.mjs +247 -2
  171. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  172. package/scripts/lib/issue-budget.mjs +229 -30
  173. package/scripts/lib/learnings/io.mjs +55 -10
  174. package/scripts/lib/learnings/schema.mjs +95 -28
  175. package/scripts/lib/lock-reaper.mjs +7 -1
  176. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  177. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  178. package/scripts/lib/memory-banner.mjs +5 -2
  179. package/scripts/lib/memory-paths.mjs +15 -6
  180. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  181. package/scripts/lib/platform.mjs +72 -9
  182. package/scripts/lib/plugin-root.mjs +143 -19
  183. package/scripts/lib/project-hygiene.mjs +43 -3
  184. package/scripts/lib/quality-gate.mjs +271 -13
  185. package/scripts/lib/reconcile/emitter.mjs +87 -19
  186. package/scripts/lib/reconcile/engine.mjs +281 -13
  187. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  188. package/scripts/lib/reconcile/renderer.mjs +148 -3
  189. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  190. package/scripts/lib/reconcile/writer.mjs +415 -84
  191. package/scripts/lib/rule-loader.mjs +37 -2
  192. package/scripts/lib/rules-sync.mjs +51 -8
  193. package/scripts/lib/scope-gate.mjs +90 -0
  194. package/scripts/lib/session-close-backfill.mjs +369 -28
  195. package/scripts/lib/session-discovery.mjs +13 -3
  196. package/scripts/lib/session-end/phase-skip.mjs +37 -4
  197. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  198. package/scripts/lib/session-id.mjs +30 -14
  199. package/scripts/lib/session-identity/own-session.mjs +159 -0
  200. package/scripts/lib/session-lock.mjs +85 -30
  201. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  202. package/scripts/lib/session-schema/validator.mjs +40 -0
  203. package/scripts/lib/session-start-probes.mjs +608 -0
  204. package/scripts/lib/session-transition.mjs +277 -0
  205. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  206. package/scripts/lib/spiral-carryover.mjs +90 -9
  207. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  208. package/scripts/lib/state-md/mission-status.mjs +350 -52
  209. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  210. package/scripts/lib/state-md.mjs +12 -2
  211. package/scripts/lib/telemetry/sync.mjs +46 -8
  212. package/scripts/lib/validate/check-agents.mjs +66 -0
  213. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  214. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  215. package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
  216. package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
  217. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  218. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  219. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  220. package/scripts/lib/validate/check-unwired-features.mjs +340 -32
  221. package/scripts/lib/validate/repo-files.mjs +275 -0
  222. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  223. package/scripts/lib/vault-mirror/process.mjs +99 -43
  224. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  225. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  226. package/scripts/lib/vault-status/board-writer.mjs +211 -10
  227. package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
  228. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  229. package/scripts/lib/wave-transcript-tail.mjs +869 -0
  230. package/scripts/materialize-wave-scope.mjs +209 -12
  231. package/scripts/mcp-server.sh +11 -2
  232. package/scripts/parse-config.mjs +65 -0
  233. package/scripts/token-audit.sh +9 -2
  234. package/scripts/validate-plugin.mjs +3 -0
  235. package/scripts/validate-wave-scope.mjs +67 -0
  236. package/scripts/vault-mirror.mjs +203 -34
  237. package/skills/_shared/monitor-patterns.md +31 -5
  238. package/skills/_shared/parallel-aware-auq.md +1 -1
  239. package/skills/_shared/parallel-aware-preamble.md +4 -2
  240. package/skills/_shared/platform-tools.md +11 -5
  241. package/skills/_shared/state-ownership.md +29 -2
  242. package/skills/autopilot/SKILL.md +5 -1
  243. package/skills/bootstrap/SKILL.md +3 -3
  244. package/skills/bootstrap/_shared-template.md +18 -10
  245. package/skills/bootstrap/deep-template.md +10 -6
  246. package/skills/bootstrap/fast-template.md +15 -8
  247. package/skills/bootstrap/standard-template.md +10 -6
  248. package/skills/claude-md-drift-check/checker.mjs +39 -11
  249. package/skills/dispatcher/SKILL.md +1 -1
  250. package/skills/journey-audit/SKILL.md +269 -0
  251. package/skills/peekaboo-driver/SKILL.md +15 -3
  252. package/skills/persona-panel/SKILL.md +1 -1
  253. package/skills/reconcile/SKILL.md +41 -1
  254. package/skills/session-end/SKILL.md +17 -4
  255. package/skills/session-end/metrics-collection.md +7 -4
  256. package/skills/session-end/phase-3-6-tail.md +11 -3
  257. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  258. package/skills/session-plan/SKILL.md +6 -1
  259. package/skills/session-plan/wave-template.md +1 -0
  260. package/skills/session-start/SKILL.md +30 -16
  261. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  262. package/skills/session-start/phase-8-5-express-path.md +77 -12
  263. package/skills/vault-sync/validator.mjs +31 -0
  264. package/skills/wave-executor/SKILL.md +4 -2
  265. package/skills/wave-executor/circuit-breaker.md +34 -9
  266. package/skills/wave-executor/wave-loop.md +102 -19
  267. package/templates/_shared/journey-manifest.md +110 -0
  268. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -0,0 +1,832 @@
1
+ /**
2
+ * foreign-dispatch.mjs — dispatch a wave subagent to a FOREIGN model over the
3
+ * Cursor channel (`cursor-agent`), in a detached git worktree, and measure the
4
+ * result at the filesystem rather than from the model's own prose (#1150).
5
+ *
6
+ * Why this module exists rather than a shell wrapper:
7
+ * - `never_foreign` (the hard role lock in account-switch
8
+ * `tools/routing/routing.yaml`) is enforced by NOTHING on the Cursor
9
+ * channel — `dispatch-cursor.sh` does not read it. This adapter is the only
10
+ * enforcement point for a foreign dispatch that goes through here.
11
+ * - `dispatch-cursor.sh` keeps its stream log in a `mktemp` file deleted by a
12
+ * shell trap and surfaces only the last 20 stdout lines. A supervising
13
+ * Claude reviewer needs the whole stream, so we spawn `cursor-agent`
14
+ * directly and keep the log.
15
+ * - A Bash child process fires no `SubagentStop` hook, so wave telemetry
16
+ * cannot see a foreign dispatch at all. The
17
+ * `orchestrator.foreign_dispatch.completed` event emitted here is that
18
+ * replacement signal.
19
+ *
20
+ * Supervision is NOT optional: `routing.yaml` records
21
+ * `foreign_impl_supervision_required: claude-review` because a measured
22
+ * foreign implementation was test-green and semantically wrong. This module
23
+ * returns the diff so a Claude reviewer can read it; it never merges anything.
24
+ *
25
+ * ## Containment: the worktree is NOT a boundary
26
+ *
27
+ * A linked git worktree isolates the working TREE and nothing else. Measured
28
+ * 2026-08-25 in this repo: `git config --get core.hooksPath` →
29
+ * `<repo>/.husky/_`, and `rev-parse --git-common-dir` → `.git` — both resolve
30
+ * to the operator's REAL repository from inside any linked worktree. A foreign
31
+ * model running with write+shell access there can therefore write a git hook
32
+ * that executes on the coordinator's NEXT commit, and every review surface this
33
+ * module offers is blind to it: `result.diff` only covers paths inside the
34
+ * worktree, `git status` ignores a gitignored hooks directory, and the
35
+ * telemetry event counts `changed_files` only. Three defences answer that, all
36
+ * local to this module:
37
+ *
38
+ * 1. `--sandbox enabled` in the argv (see {@link FOREIGN_AGENT_BASE_ARGS}).
39
+ * 2. An ALLOWLISTED child environment, never `process.env` inheritance
40
+ * (see {@link FOREIGN_ENV_ALLOWLIST}).
41
+ * 3. A before/after fingerprint of the effective hooks directory, surfaced as
42
+ * `hookTampering` on the envelope so the reviewer has a mechanical signal
43
+ * rather than an eyeball pass over a diff that structurally cannot show it.
44
+ *
45
+ * None of the three is sufficient alone, and the caller is expected to read
46
+ * `hookTampering` — a `true` there invalidates the run regardless of `ok`.
47
+ */
48
+
49
+ import { execFileSync, spawn as nodeSpawn } from 'node:child_process';
50
+ import crypto from 'node:crypto';
51
+ import fs from 'node:fs';
52
+ import os from 'node:os';
53
+ import path from 'node:path';
54
+
55
+ import { emitEvent, sessionAttribution } from '../events.mjs';
56
+ import { isPathInside } from '../path-utils.mjs';
57
+
58
+ /**
59
+ * Roles that may NEVER run on a foreign model. Copied verbatim from
60
+ * account-switch `tools/routing/routing.yaml` § never_foreign (read 2026-08-25).
61
+ * Keep in sync by hand — the YAML lives in a different repo and is parsed there
62
+ * by awk, not by a shared library.
63
+ * @type {readonly string[]}
64
+ */
65
+ export const NEVER_FOREIGN_ROLES = Object.freeze([
66
+ 'impl-core',
67
+ 'security-review',
68
+ 'migration',
69
+ 'release',
70
+ 'secrets',
71
+ 'incident',
72
+ 'refactor-crosscut',
73
+ ]);
74
+
75
+ /** Default wall-clock budget for one foreign dispatch, in seconds.
76
+ * This is a FLOOR, not a suggestion: measured 2026-08-23, `cursor-grok-4.6-high`
77
+ * ran 2 of 3 hard-test tasks past a 540 s cap (recorded as DNF). Lowering this
78
+ * manufactures timeouts and reads as model failure. */
79
+ export const DEFAULT_TIMEOUT_SEC = 900;
80
+
81
+ /**
82
+ * Grace period between SIGTERM and SIGKILL, in ms.
83
+ *
84
+ * `child.kill('SIGTERM')` is a REQUEST: a child that installs a handler and
85
+ * ignores it never emits `close`, and this module's only resolve paths are
86
+ * `close` and `error` — so without escalation a wedged `cursor-agent` hangs
87
+ * `dispatchForeign` forever and takes the whole wave with it. SIGKILL is not
88
+ * catchable, so the escalation always terminates.
89
+ *
90
+ * Named ceiling (BV-004): 10 s is enough for a cooperative child to flush its
91
+ * stream-json tail and exit. Revisit if a well-behaved `cursor-agent` is ever
92
+ * measured needing longer than that to shut down — raise the constant, never
93
+ * drop the escalation.
94
+ */
95
+ export const DEFAULT_KILL_GRACE_MS = 10_000;
96
+
97
+ /**
98
+ * argv prefix for every foreign dispatch, ahead of `--model`/`--output-format`.
99
+ *
100
+ * `--sandbox enabled` is load-bearing, not decoration. Measured against
101
+ * `cursor-agent --help` (2026-08-25): `--sandbox <mode>` exists with
102
+ * `(choices: "enabled", "disabled")` and is documented as "Explicitly enable or
103
+ * disable sandbox mode (overrides config)". It is a DIFFERENT axis from
104
+ * `--force` ("Force allow commands unless explicitly denied", alias `--yolo`):
105
+ * `--force` governs whether a tool call is auto-approved, `--sandbox` governs
106
+ * what an approved call may reach. The two compose — auto-approved AND confined
107
+ * — which is why `--force` can stay without leaving the run at full yolo.
108
+ *
109
+ * Named ceiling (BV-004): the flag's EXISTENCE is measured; the sandbox's
110
+ * confinement SEMANTICS are not, because verifying them requires spawning the
111
+ * real binary against a live account. Revisit trigger: if a foreign dispatch is
112
+ * ever observed writing outside its worktree despite this flag, the sandbox is
113
+ * not the boundary it is assumed to be here and the `hookTampering` check below
114
+ * becomes the only defence rather than the backstop.
115
+ * @type {readonly string[]}
116
+ */
117
+ export const FOREIGN_AGENT_BASE_ARGS = Object.freeze([
118
+ '--print',
119
+ '--force',
120
+ '--sandbox',
121
+ 'enabled',
122
+ ]);
123
+
124
+ /**
125
+ * The ONLY environment variables handed to the foreign child.
126
+ *
127
+ * An allowlist rather than a denylist by construction: the coordinator's own
128
+ * environment routinely carries `CLANK_EVENT_SECRET`, host PATs and other
129
+ * repo-unrelated credentials, and inheriting `process.env` hands every one of
130
+ * them to a foreign model with shell access. A denylist would need updating
131
+ * every time a new secret enters the environment; this list needs updating only
132
+ * when `cursor-agent` genuinely needs something new.
133
+ *
134
+ * `CURSOR_API_KEY` / `CURSOR_API_ENDPOINT` are the channel's own credentials
135
+ * (documented on `cursor-agent --help`); the rest is the minimum a CLI needs to
136
+ * locate its config, its shell and its temp space.
137
+ * @type {readonly string[]}
138
+ */
139
+ export const FOREIGN_ENV_ALLOWLIST = Object.freeze([
140
+ 'PATH',
141
+ 'HOME',
142
+ 'TMPDIR',
143
+ 'SHELL',
144
+ 'LANG',
145
+ 'LC_ALL',
146
+ 'TERM',
147
+ 'USER',
148
+ 'LOGNAME',
149
+ 'CURSOR_API_KEY',
150
+ 'CURSOR_API_ENDPOINT',
151
+ 'CURSOR_CONFIG_DIR',
152
+ ]);
153
+
154
+ /** Prefix every foreign worktree parent directory carries under the tmp root.
155
+ * {@link removeForeignWorktree} refuses to `--force`-remove anything outside a
156
+ * directory named this way. */
157
+ export const FOREIGN_TMP_PREFIX = 'so-foreign';
158
+
159
+ /** A runId names a directory and a log file. Anything outside this alphabet —
160
+ * or the two relative-path literals the alphabet happens to admit — can escape
161
+ * the parent it is joined to. */
162
+ const SAFE_RUN_ID = /^[A-Za-z0-9._-]+$/;
163
+
164
+ /**
165
+ * @param {unknown} runId
166
+ * @returns {boolean} true when the id is safe to use as a path segment.
167
+ */
168
+ function isSafeRunId(runId) {
169
+ const s = String(runId ?? '');
170
+ if (s === '.' || s === '..') return false; // admitted by SAFE_RUN_ID, still an escape
171
+ return SAFE_RUN_ID.test(s);
172
+ }
173
+
174
+ /**
175
+ * @param {string} role
176
+ * @returns {boolean} true when the role is locked to Claude.
177
+ */
178
+ export function isNeverForeignRole(role) {
179
+ return NEVER_FOREIGN_ROLES.includes(String(role || '').trim());
180
+ }
181
+
182
+ /**
183
+ * Build the allowlisted child environment.
184
+ * @param {Record<string, string|undefined>} [source]
185
+ * @returns {Record<string, string>}
186
+ */
187
+ function buildForeignEnv(source) {
188
+ const src = source || process.env;
189
+ /** @type {Record<string, string>} */
190
+ const env = {};
191
+ for (const key of FOREIGN_ENV_ALLOWLIST) {
192
+ const value = src[key];
193
+ if (typeof value === 'string') env[key] = value;
194
+ }
195
+ return env;
196
+ }
197
+
198
+ /**
199
+ * Recursively collect tool-call names from one parsed stream event.
200
+ *
201
+ * The vocabulary is deliberately NOT pinned to a known list (`readToolCall`,
202
+ * `grepToolCall`, `globToolCall`, `shellToolCall`, `editToolCall`, …): a new
203
+ * `cursor-agent` release adds tool names, and a hard-coded list would silently
204
+ * count them as zero. Anything whose object KEY or string VALUE ends in
205
+ * `ToolCall` counts.
206
+ *
207
+ * Depth is capped at 6 — deep enough for every observed event shape; a runaway
208
+ * nested payload must not turn stream parsing into a hang. Revisit if a future
209
+ * stream format nests tool events deeper than that.
210
+ *
211
+ * @param {unknown} node
212
+ * @param {Record<string, number>} sink
213
+ * @param {number} [depth]
214
+ */
215
+ function collectToolCalls(node, sink, depth = 0) {
216
+ if (depth > 6 || node === null || typeof node !== 'object') return;
217
+ if (Array.isArray(node)) {
218
+ for (const item of node) collectToolCalls(item, sink, depth + 1);
219
+ return;
220
+ }
221
+ for (const [key, value] of Object.entries(node)) {
222
+ if (/ToolCall$/.test(key)) sink[key] = (sink[key] || 0) + 1;
223
+ else if (typeof value === 'string' && /ToolCall$/.test(value)) {
224
+ sink[value] = (sink[value] || 0) + 1;
225
+ }
226
+ collectToolCalls(value, sink, depth + 1);
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Extract assistant text from one parsed stream event, if it carries any.
232
+ * @param {Record<string, unknown>} obj
233
+ * @returns {string|null}
234
+ */
235
+ function extractText(obj) {
236
+ if (typeof obj.result === 'string' && obj.result.trim()) return obj.result;
237
+ const message = obj.message;
238
+ if (message && typeof message === 'object') {
239
+ const content = /** @type {{content?: unknown}} */ (message).content;
240
+ if (Array.isArray(content)) {
241
+ const parts = content
242
+ .filter((c) => c && typeof c === 'object' && typeof (/** @type {{text?: unknown}} */ (c).text) === 'string')
243
+ .map((c) => /** @type {{text: string}} */ (c).text);
244
+ if (parts.length > 0) return parts.join('');
245
+ }
246
+ if (typeof (/** @type {{text?: unknown}} */ (message).text) === 'string') {
247
+ return /** @type {{text: string}} */ (message).text;
248
+ }
249
+ }
250
+ if (typeof obj.text === 'string' && obj.text.trim()) return obj.text;
251
+ return null;
252
+ }
253
+
254
+ /** The NUL character, built rather than written: a literal control byte in
255
+ * authored source is invisible in review, and a regex form trips
256
+ * `no-control-regex`. */
257
+ const NUL_BYTE = String.fromCharCode(0);
258
+
259
+ /**
260
+ * Tolerant parse of a `cursor-agent --output-format stream-json` capture.
261
+ *
262
+ * The capture is stdout AND stderr merged, so it contains lines that are not
263
+ * JSON at all (progress chatter, warnings) and — measured — literal NUL bytes.
264
+ * Both are skipped silently; a parse failure is data, never an exception.
265
+ *
266
+ * @param {string} raw — the merged capture.
267
+ * @returns {{toolCounts: Record<string, number>, toolCallTotal: number,
268
+ * resultText: string, parsedLines: number, skippedLines: number}}
269
+ */
270
+ export function parseCursorStream(raw) {
271
+ /** @type {Record<string, number>} */
272
+ const toolCounts = {};
273
+ let resultText = '';
274
+ let parsedLines = 0;
275
+ let skippedLines = 0;
276
+
277
+ const cleaned = String(raw ?? '').split(NUL_BYTE).join('');
278
+
279
+ for (const line of cleaned.split('\n')) {
280
+ const trimmed = line.trim();
281
+ if (trimmed === '') continue;
282
+ let obj;
283
+ try {
284
+ obj = JSON.parse(trimmed);
285
+ } catch {
286
+ skippedLines += 1;
287
+ continue;
288
+ }
289
+ if (obj === null || typeof obj !== 'object') {
290
+ skippedLines += 1;
291
+ continue;
292
+ }
293
+ parsedLines += 1;
294
+ collectToolCalls(obj, toolCounts);
295
+ const text = extractText(obj);
296
+ if (text) resultText = text;
297
+ }
298
+
299
+ const toolCallTotal = Object.values(toolCounts).reduce((a, b) => a + b, 0);
300
+ return { toolCounts, toolCallTotal, resultText, parsedLines, skippedLines };
301
+ }
302
+
303
+ /**
304
+ * Paths kept out of the measurement: an `npm install` inside the worktree must
305
+ * not drown the model's actual edits.
306
+ *
307
+ * Applied at QUERY time (`git ls-files --exclude=…`), NOT by writing an
308
+ * `info/exclude` file — measured 2026-08-25 in a synthetic repo:
309
+ * - `<per-worktree gitdir>/info/exclude` (what `rev-parse --git-dir` returns
310
+ * from inside a linked worktree) is NOT read by git: `status --porcelain`
311
+ * still printed `?? node_modules/` after writing it.
312
+ * - The file git DOES read is `--git-common-dir/info/exclude`, i.e. the
313
+ * SHARED `.git/info/exclude` of the operator's real repo. Writing there to
314
+ * tidy a throwaway worktree mutates the main working copy (PSA-003), so we
315
+ * do not write an exclude file at all.
316
+ * @type {readonly string[]}
317
+ */
318
+ export const MEASUREMENT_EXCLUDES = Object.freeze(['node_modules']);
319
+
320
+ /**
321
+ * Run the child process, capturing stdout+stderr merged, with a hand-rolled
322
+ * SIGTERM timeout that ESCALATES to SIGKILL. `spawnFn` is the DI seam so tests
323
+ * never touch the real `cursor-agent` binary.
324
+ *
325
+ * The escalation is the difference between a bounded dispatch and a hung wave:
326
+ * this function's only resolve paths are the child's `close` and `error`
327
+ * events, so a child that ignores SIGTERM never lets the promise settle. See
328
+ * {@link DEFAULT_KILL_GRACE_MS}.
329
+ *
330
+ * @returns {Promise<{capture: string, exitCode: number|null, signal: string|null,
331
+ * timerFired: boolean, killSignals: string[], errorCode: string|null}>}
332
+ */
333
+ function runChild(cmd, args, options, { spawnFn, timeoutMs, killGraceMs }) {
334
+ return new Promise((resolve) => {
335
+ /** @type {string[]} */
336
+ const killSignals = [];
337
+ let child;
338
+ try {
339
+ child = spawnFn(cmd, args, options);
340
+ } catch (err) {
341
+ resolve({
342
+ capture: `spawn failed: ${err && err.message}`,
343
+ exitCode: null,
344
+ signal: null,
345
+ timerFired: false,
346
+ killSignals,
347
+ errorCode: (err && err.code) || null,
348
+ });
349
+ return;
350
+ }
351
+
352
+ let capture = '';
353
+ let timerFired = false;
354
+ let settled = false;
355
+ let errorCode = null;
356
+ /** @type {ReturnType<typeof setTimeout>|null} */
357
+ let graceTimer = null;
358
+
359
+ const onChunk = (chunk) => {
360
+ capture += chunk.toString();
361
+ };
362
+ child.stdout?.on('data', onChunk);
363
+ child.stderr?.on('data', onChunk);
364
+
365
+ const send = (signal) => {
366
+ killSignals.push(signal);
367
+ try {
368
+ child.kill(signal);
369
+ } catch {
370
+ /* already gone */
371
+ }
372
+ };
373
+
374
+ const timer = setTimeout(() => {
375
+ timerFired = true;
376
+ send('SIGTERM');
377
+ // SIGTERM is a request; SIGKILL is not. A child that installs a handler
378
+ // and declines to exit would otherwise hang this promise forever.
379
+ graceTimer = setTimeout(() => send('SIGKILL'), killGraceMs);
380
+ graceTimer.unref?.();
381
+ }, timeoutMs);
382
+
383
+ const finish = (exitCode, signal) => {
384
+ if (settled) return;
385
+ settled = true;
386
+ clearTimeout(timer);
387
+ if (graceTimer) clearTimeout(graceTimer);
388
+ resolve({ capture, exitCode, signal, timerFired, killSignals, errorCode });
389
+ };
390
+
391
+ child.on('error', (err) => {
392
+ errorCode = (err && err.code) || null;
393
+ capture += `\nchild error: ${err && err.message}`;
394
+ finish(null, null);
395
+ });
396
+ child.on('close', (code, signal) => finish(code, signal ?? null));
397
+ });
398
+ }
399
+
400
+ /** Read `git` output as text, returning '' on failure (a detached worktree with
401
+ * no commits still answers, but a broken invocation must not throw here). */
402
+ function gitText(args, execFn) {
403
+ try {
404
+ return String(execFn('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }));
405
+ } catch {
406
+ return '';
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Fingerprint the hooks git would actually RUN for `repoRoot`.
412
+ *
413
+ * Covers both the configured `core.hooksPath` (measured in this repo:
414
+ * `<repo>/.husky/_`) and the default `<git-common-dir>/hooks`, plus the config
415
+ * VALUE itself — repointing `core.hooksPath` at an attacker-chosen directory is
416
+ * a tampering shape that leaves every existing hook file byte-identical.
417
+ *
418
+ * Best-effort and fail-OPEN by design: a `null` return means "not measured",
419
+ * never "unchanged". The caller must treat a null on either side as unknown
420
+ * rather than clean — a fingerprint that failed closed would turn any
421
+ * unreadable hooks directory into a false tampering alarm, and
422
+ * `.claude/rules/host-resources.md` § HR-101 is what a chronically-firing
423
+ * warning costs.
424
+ *
425
+ * @param {string} repoRoot
426
+ * @param {Function} execFn
427
+ * @returns {string|null}
428
+ */
429
+ function hooksFingerprint(repoRoot, execFn) {
430
+ try {
431
+ const commonDir = gitText(['-C', repoRoot, 'rev-parse', '--git-common-dir'], execFn).trim();
432
+ if (!commonDir) return null;
433
+ // `git config --get` exits non-zero when unset; gitText turns that into ''.
434
+ const configured = gitText(['-C', repoRoot, 'config', '--get', 'core.hooksPath'], execFn).trim();
435
+ const hooksDir = configured
436
+ ? path.resolve(repoRoot, configured)
437
+ : path.resolve(repoRoot, commonDir, 'hooks');
438
+
439
+ const hash = crypto.createHash('sha256');
440
+ hash.update(`hooksPath:${configured}\n`);
441
+
442
+ let names = [];
443
+ try {
444
+ names = fs
445
+ .readdirSync(hooksDir, { withFileTypes: true })
446
+ .filter((e) => e.isFile())
447
+ .map((e) => e.name)
448
+ .sort();
449
+ } catch {
450
+ // No hooks directory at all is a legitimate, stable state — fingerprint
451
+ // it as such so CREATING one later reads as a change.
452
+ return hash.digest('hex');
453
+ }
454
+
455
+ for (const name of names) {
456
+ hash.update(name);
457
+ hash.update(NUL_BYTE);
458
+ try {
459
+ hash.update(crypto.createHash('sha256').update(fs.readFileSync(path.join(hooksDir, name))).digest('hex'));
460
+ } catch {
461
+ hash.update('unreadable');
462
+ }
463
+ hash.update('\n');
464
+ }
465
+ return hash.digest('hex');
466
+ } catch {
467
+ return null;
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Dispatch one wave subagent to a foreign model over the Cursor channel.
473
+ *
474
+ * The worktree is deliberately NOT removed on the way out — a failed run stays
475
+ * inspectable. Cleanup is the caller's, via {@link removeForeignWorktree}.
476
+ *
477
+ * @param {object} args
478
+ * @param {string} args.model — e.g. `composer-2.5`, `cursor-grok-4.6-high`.
479
+ * @param {string} args.prompt — the full agent prompt (positional argument).
480
+ * @param {string} args.repoRoot — the repo the worktree is created from.
481
+ * @param {string} args.role — routing role; checked against NEVER_FOREIGN_ROLES.
482
+ * @param {number} [args.timeoutSec] — wall-clock budget (default 900; a floor).
483
+ * @param {string} args.runId — unique id; names the worktree and the log file.
484
+ * @param {string} [args.worktreeRoot] — explicit worktree path (default
485
+ * `<tmpdir>/so-foreign/<runId>`). Never under `.claude/worktrees/`, which is
486
+ * read by the repo's own worktree scanners.
487
+ * @param {object} [deps]
488
+ * @param {Function} [deps.spawnFn] — `child_process.spawn` seam.
489
+ * @param {Function} [deps.now] — clock seam (ms).
490
+ * @param {Function} [deps.execFn] — `child_process.execFileSync` seam (git).
491
+ * @param {Function} [deps.emitFn] — event-emitter seam.
492
+ * @param {number} [deps.killGraceMs] — SIGTERM→SIGKILL grace (default 10 s).
493
+ * @param {Record<string, string|undefined>} [deps.envSource] — environment the
494
+ * allowlist is drawn from (default `process.env`).
495
+ * @returns {Promise<object>} result envelope; `ok` is false unless the child
496
+ * exited 0, did not time out, AND left a non-empty diff.
497
+ */
498
+ export async function dispatchForeign(
499
+ { model, prompt, repoRoot, role, timeoutSec = DEFAULT_TIMEOUT_SEC, runId, worktreeRoot },
500
+ {
501
+ spawnFn = nodeSpawn,
502
+ now = Date.now,
503
+ execFn = execFileSync,
504
+ emitFn = emitEvent,
505
+ killGraceMs = DEFAULT_KILL_GRACE_MS,
506
+ envSource,
507
+ } = {}
508
+ ) {
509
+ /** Refusal envelope + its telemetry record. `docs/events-schema.md` promises
510
+ * the completion event is "emitted on refusals too", so a refused dispatch
511
+ * must leave a ledger record rather than a silence — and it carries
512
+ * `exit_code: null` + `changed_files: 0` PRESENT so "refused" stays
513
+ * distinguishable from "attempted and measured". */
514
+ const refuse = async (reason) => {
515
+ try {
516
+ await emitFn(
517
+ 'orchestrator.foreign_dispatch.completed',
518
+ {
519
+ ...sessionAttribution(repoRoot),
520
+ model,
521
+ role,
522
+ ok: false,
523
+ reason,
524
+ exit_code: null,
525
+ timed_out: false,
526
+ duration_s: 0,
527
+ changed_files: 0,
528
+ },
529
+ { repoRoot }
530
+ );
531
+ } catch {
532
+ /* telemetry must never fail a refusal */
533
+ }
534
+ return {
535
+ ok: false,
536
+ reason,
537
+ role,
538
+ model,
539
+ exitCode: null,
540
+ timedOut: false,
541
+ durationSec: 0,
542
+ toolCounts: {},
543
+ diff: '',
544
+ diffStat: '',
545
+ changedFiles: [],
546
+ modifiedFiles: [],
547
+ newFiles: [],
548
+ resultText: '',
549
+ worktreePath: null,
550
+ logPath: null,
551
+ killSignals: [],
552
+ hookTampering: null,
553
+ };
554
+ };
555
+
556
+ // Hard role lock FIRST — before any worktree, any spawn, any side effect.
557
+ // dispatch-cursor.sh does not enforce this; this adapter is the only gate.
558
+ if (isNeverForeignRole(role)) return refuse('never-foreign-role');
559
+
560
+ // runId names a directory and a log file, so it is a path segment before it
561
+ // is an identifier: `../../x` would place the worktree — and the `--force`
562
+ // removal that follows it — anywhere the process can write.
563
+ if (!isSafeRunId(runId)) return refuse('unsafe-run-id');
564
+
565
+ // A worktree INSIDE the repo is not a detached workspace: it lands in the
566
+ // tree the coordinator commits from and is read by this repo's own worktree
567
+ // scanners. Equality is rejected with containment — `git worktree add` onto
568
+ // the repo root itself is the worst case of the same mistake.
569
+ if (worktreeRoot) {
570
+ const wtResolved = path.resolve(String(worktreeRoot));
571
+ const rootResolved = path.resolve(String(repoRoot || ''));
572
+ if (wtResolved === rootResolved || isPathInside(wtResolved, rootResolved)) {
573
+ return refuse('unsafe-worktree-root');
574
+ }
575
+ }
576
+
577
+ const startedAt = now();
578
+ // A FIXED `<tmpdir>/so-foreign/<runId>` is guessable and pre-creatable by any
579
+ // other process on the host; mkdtemp's 0700 random parent is not.
580
+ const parent = worktreeRoot
581
+ ? path.dirname(path.resolve(String(worktreeRoot)))
582
+ : fs.mkdtempSync(path.join(os.tmpdir(), `${FOREIGN_TMP_PREFIX}-`));
583
+ const wt = worktreeRoot ? path.resolve(String(worktreeRoot)) : path.join(parent, String(runId));
584
+ let logPath = path.join(parent, `${runId}.log.jsonl`);
585
+
586
+ fs.mkdirSync(parent, { recursive: true });
587
+
588
+ // Snapshot the hooks git would run BEFORE handing the tree to a foreign
589
+ // model — see the module header: the worktree shares them with the real repo.
590
+ const hooksBefore = hooksFingerprint(repoRoot, execFn);
591
+
592
+ // Detached, never branch-oriented: createWorktree()/enterWorktree() both
593
+ // create or check out a BRANCH, which a throwaway foreign run must not do.
594
+ execFn('git', ['-C', repoRoot, 'worktree', 'add', '--detach', wt, 'HEAD'], { encoding: 'utf8' });
595
+
596
+ const { capture, exitCode, signal, timerFired, killSignals, errorCode } = await runChild(
597
+ 'cursor-agent',
598
+ [
599
+ ...FOREIGN_AGENT_BASE_ARGS,
600
+ '--model',
601
+ String(model),
602
+ '--output-format',
603
+ 'stream-json',
604
+ String(prompt),
605
+ ],
606
+ // NOT `{ ...process.env }`: an inherited environment hands every credential
607
+ // the coordinator holds to a foreign model with shell access.
608
+ { cwd: wt, env: buildForeignEnv(envSource) },
609
+ { spawnFn, timeoutMs: timeoutSec * 1000, killGraceMs }
610
+ );
611
+
612
+ // The log is kept, not trap-deleted: a supervising Claude reviewer needs the
613
+ // whole stream, and `dispatch-cursor.sh` losing it is why this module exists.
614
+ // `wx` so a pre-planted symlink at the log path fails loudly instead of being
615
+ // followed into whatever it points at.
616
+ let logError;
617
+ try {
618
+ fs.writeFileSync(logPath, capture, { encoding: 'utf8', flag: 'wx' });
619
+ } catch (err) {
620
+ // A lost log must not fail the dispatch — but it must not be claimed
621
+ // either, so the path is nulled rather than left pointing at nothing.
622
+ logError = err && err.message ? err.message : String(err);
623
+ logPath = null;
624
+ }
625
+
626
+ // A missing binary is NOT a model failure. Classified as `exit-nonzero` it
627
+ // reads as "the foreign model could not do the task", which sends the
628
+ // coordinator hunting the prompt instead of the channel — and leaves the
629
+ // just-created worktree registered in the operator's REAL repo.
630
+ if (errorCode === 'ENOENT') {
631
+ removeForeignWorktree({ repoRoot, worktreePath: wt }, { execFn, allowAnyPath: true });
632
+ const durationSec = Math.round(((now() - startedAt) / 1000) * 100) / 100;
633
+ try {
634
+ await emitFn(
635
+ 'orchestrator.foreign_dispatch.completed',
636
+ {
637
+ ...sessionAttribution(repoRoot),
638
+ model,
639
+ role,
640
+ ok: false,
641
+ reason: 'channel-unavailable',
642
+ exit_code: null,
643
+ timed_out: false,
644
+ duration_s: durationSec,
645
+ changed_files: 0,
646
+ },
647
+ { repoRoot }
648
+ );
649
+ } catch {
650
+ /* telemetry must never fail a dispatch */
651
+ }
652
+ return {
653
+ ok: false,
654
+ reason: 'channel-unavailable',
655
+ role,
656
+ model,
657
+ exitCode: null,
658
+ timedOut: false,
659
+ durationSec,
660
+ toolCounts: {},
661
+ diff: '',
662
+ diffStat: '',
663
+ changedFiles: [],
664
+ modifiedFiles: [],
665
+ newFiles: [],
666
+ resultText: '',
667
+ worktreePath: null,
668
+ logPath,
669
+ ...(logError ? { logError } : {}),
670
+ killSignals,
671
+ hookTampering: null,
672
+ };
673
+ }
674
+
675
+ const { toolCounts, resultText } = parseCursorStream(capture);
676
+ const timedOut = timerFired || signal === 'SIGTERM' || signal === 'SIGKILL' || exitCode === 143;
677
+
678
+ // Measure at the filesystem, never from the model's prose.
679
+ //
680
+ // `git diff` covers TRACKED files only — measured 2026-08-25, it is blind to
681
+ // a file the model creates (`diff --name-only` printed nothing for a brand-new
682
+ // file that `ls-files --others` listed). Since a foreign impl agent's output
683
+ // is frequently a NEW file, a diff-only measurement would classify a
684
+ // successful run as `empty-diff`. Untracked files are therefore enumerated
685
+ // separately — without `git add`, which is an index write (PSA-007).
686
+ const diffStat = gitText(['-C', wt, 'diff', '--stat'], execFn).trim();
687
+ const nameOnly = gitText(['-C', wt, 'diff', '--name-only'], execFn);
688
+ const diff = gitText(['-C', wt, 'diff'], execFn);
689
+ const untracked = gitText(
690
+ [
691
+ '-C',
692
+ wt,
693
+ 'ls-files',
694
+ '--others',
695
+ '--exclude-standard',
696
+ ...MEASUREMENT_EXCLUDES.map((p) => `--exclude=${p}`),
697
+ ],
698
+ execFn
699
+ );
700
+ const toLines = (s) => s.split('\n').map((l) => l.trim()).filter(Boolean);
701
+ const modifiedFiles = toLines(nameOnly);
702
+ const newFiles = toLines(untracked);
703
+ const changedFiles = [...new Set([...modifiedFiles, ...newFiles])].sort();
704
+
705
+ let reason;
706
+ if (timedOut) reason = 'timeout';
707
+ else if (exitCode !== 0) reason = 'exit-nonzero';
708
+ else if (changedFiles.length === 0) reason = 'empty-diff';
709
+ const ok = reason === undefined;
710
+
711
+ // The diff above covers the worktree only. This covers the one surface a
712
+ // `--force` foreign model could reach OUTSIDE it that also executes later.
713
+ const hooksAfter = hooksFingerprint(repoRoot, execFn);
714
+ const hookTampering = hooksBefore && hooksAfter ? hooksBefore !== hooksAfter : null;
715
+
716
+ const durationSec = Math.round(((now() - startedAt) / 1000) * 100) / 100;
717
+
718
+ // SubagentStop telemetry cannot fire for a Bash child (#1150 / D4 finding 5) —
719
+ // this event is the only ledger record a foreign dispatch produces.
720
+ try {
721
+ await emitFn(
722
+ 'orchestrator.foreign_dispatch.completed',
723
+ {
724
+ ...sessionAttribution(repoRoot),
725
+ model,
726
+ role,
727
+ ok,
728
+ // Present only when the dispatch failed, so absence means success —
729
+ // and every failure class (refusal, channel, timeout, empty-diff) is
730
+ // groupable in the ledger without string-matching a message.
731
+ ...(reason ? { reason } : {}),
732
+ exit_code: exitCode,
733
+ timed_out: timedOut,
734
+ duration_s: durationSec,
735
+ changed_files: changedFiles.length,
736
+ ...(hookTampering === true ? { hook_tampering: true } : {}),
737
+ },
738
+ { repoRoot }
739
+ );
740
+ } catch {
741
+ /* telemetry must never fail a dispatch */
742
+ }
743
+
744
+ return {
745
+ ok,
746
+ ...(reason ? { reason } : {}),
747
+ role,
748
+ model,
749
+ exitCode,
750
+ timedOut,
751
+ durationSec,
752
+ toolCounts,
753
+ diff,
754
+ diffStat,
755
+ changedFiles,
756
+ modifiedFiles,
757
+ newFiles,
758
+ resultText,
759
+ worktreePath: wt,
760
+ logPath,
761
+ ...(logError ? { logError } : {}),
762
+ killSignals,
763
+ // true = the effective hooks directory changed across the dispatch;
764
+ // false = it did not; null = NOT MEASURED (never read as "clean").
765
+ hookTampering,
766
+ };
767
+ }
768
+
769
+ /**
770
+ * True when `p` is a path this module could plausibly have created: strictly
771
+ * inside the OS temp root, under a first-level directory whose name starts with
772
+ * {@link FOREIGN_TMP_PREFIX}.
773
+ *
774
+ * Accepts both parent shapes on purpose — the mkdtemp default
775
+ * (`<tmpdir>/so-foreign-a1b2c3/<runId>`) and an explicitly-passed
776
+ * `<tmpdir>/so-foreign/<runId>`.
777
+ *
778
+ * @param {unknown} p
779
+ * @returns {boolean}
780
+ */
781
+ export function isForeignWorktreePath(p) {
782
+ if (typeof p !== 'string' || p === '' || p.includes(NUL_BYTE)) return false;
783
+ const tmp = os.tmpdir();
784
+ let resolved;
785
+ try {
786
+ resolved = path.resolve(p);
787
+ if (!isPathInside(resolved, tmp)) return false;
788
+ } catch {
789
+ return false;
790
+ }
791
+ const [first] = path.relative(path.resolve(tmp), resolved).split(path.sep);
792
+ return Boolean(first) && first.startsWith(FOREIGN_TMP_PREFIX);
793
+ }
794
+
795
+ /**
796
+ * Remove a foreign worktree once its diff has been reviewed. Separate from
797
+ * {@link dispatchForeign} on purpose: a failed run must stay on disk until the
798
+ * caller has read it.
799
+ *
800
+ * `git worktree remove --force` DELETES the directory including uncommitted
801
+ * work, so the path is checked before git is called, not after: this function's
802
+ * argument travels through a result envelope and a coordinator prompt, and a
803
+ * `worktreePath` that is not one of ours is a request to destroy something
804
+ * nobody here created (PSA-003). A refusal makes NO git call at all.
805
+ *
806
+ * @param {{repoRoot: string, worktreePath: string}} args
807
+ * @param {{execFn?: Function, allowAnyPath?: boolean}} [deps] — `allowAnyPath`
808
+ * is the INTERNAL escape used by {@link dispatchForeign} to clean up a
809
+ * worktree it just created at a caller-supplied `worktreeRoot`; that path was
810
+ * already validated against `repoRoot` there. Callers do not set it.
811
+ * @returns {{ok: boolean, reason?: string, error?: string}}
812
+ */
813
+ export function removeForeignWorktree(
814
+ { repoRoot, worktreePath },
815
+ { execFn = execFileSync, allowAnyPath = false } = {}
816
+ ) {
817
+ if (!allowAnyPath && !isForeignWorktreePath(worktreePath)) {
818
+ return {
819
+ ok: false,
820
+ reason: 'unsafe-worktree-path',
821
+ error: `refusing to force-remove a path outside <tmpdir>/${FOREIGN_TMP_PREFIX}*: ${worktreePath}`,
822
+ };
823
+ }
824
+ try {
825
+ execFn('git', ['-C', repoRoot, 'worktree', 'remove', '--force', worktreePath], {
826
+ encoding: 'utf8',
827
+ });
828
+ return { ok: true };
829
+ } catch (err) {
830
+ return { ok: false, error: err && err.message ? err.message : String(err) };
831
+ }
832
+ }