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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/commands/autopilot-multi.md +14 -0
- package/.cursor/commands/autopilot.md +14 -0
- package/.cursor/commands/bootstrap.md +14 -0
- package/.cursor/commands/brainstorm.md +14 -0
- package/.cursor/commands/close.md +13 -0
- package/.cursor/commands/contract-version-bump.md +14 -0
- package/.cursor/commands/debug.md +14 -0
- package/.cursor/commands/discovery.md +14 -0
- package/.cursor/commands/dispatcher.md +14 -0
- package/.cursor/commands/eli5.md +14 -0
- package/.cursor/commands/eval.md +14 -0
- package/.cursor/commands/evolve.md +14 -0
- package/.cursor/commands/go.md +14 -0
- package/.cursor/commands/grill.md +14 -0
- package/.cursor/commands/harness-audit.md +13 -0
- package/.cursor/commands/journey-audit.md +14 -0
- package/.cursor/commands/memory-cleanup.md +14 -0
- package/.cursor/commands/persona-panel.md +14 -0
- package/.cursor/commands/plan.md +14 -0
- package/.cursor/commands/portfolio.md +14 -0
- package/.cursor/commands/reconcile.md +14 -0
- package/.cursor/commands/release.md +14 -0
- package/.cursor/commands/repo-audit.md +13 -0
- package/.cursor/commands/session.md +14 -0
- package/.cursor/commands/spinout.md +14 -0
- package/.cursor/commands/sunset-review.md +14 -0
- package/.cursor/commands/templates-ack.md +14 -0
- package/.cursor/commands/test.md +14 -0
- package/.cursor/hooks.json +60 -0
- package/.cursor/rules/000-session-orchestrator.mdc +8 -0
- package/.cursor/rules/010-session-workflow.mdc +9 -1
- package/.cursor/rules/020-quality-gates.mdc +1 -1
- package/.cursor/rules/030-wave-execution.mdc +1 -1
- package/.cursor/rules/050-plan.mdc +2 -2
- package/.cursor/rules/070-gitlab-ops.mdc +73 -57
- package/.cursor/rules/080-ecosystem-health.mdc +7 -7
- package/.cursor/skills/architecture/SKILL.md +13 -0
- package/.cursor/skills/autopilot/SKILL.md +12 -0
- package/.cursor/skills/bootstrap/SKILL.md +12 -0
- package/.cursor/skills/brainstorm/SKILL.md +13 -0
- package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
- package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
- package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
- package/.cursor/skills/daily/SKILL.md +12 -0
- package/.cursor/skills/debug/SKILL.md +13 -0
- package/.cursor/skills/discovery/SKILL.md +13 -0
- package/.cursor/skills/dispatcher/SKILL.md +13 -0
- package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/domain-model/SKILL.md +13 -0
- package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
- package/.cursor/skills/eli5/SKILL.md +13 -0
- package/.cursor/skills/eval/SKILL.md +12 -0
- package/.cursor/skills/evolve/SKILL.md +13 -0
- package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
- package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
- package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
- package/.cursor/skills/grill/SKILL.md +13 -0
- package/.cursor/skills/hook-development/SKILL.md +13 -0
- package/.cursor/skills/journey-audit/SKILL.md +13 -0
- package/.cursor/skills/mcp-builder/SKILL.md +13 -0
- package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
- package/.cursor/skills/mode-selector/SKILL.md +13 -0
- package/.cursor/skills/npm-publish/SKILL.md +12 -0
- package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
- package/.cursor/skills/persona-panel/SKILL.md +12 -0
- package/.cursor/skills/plan/SKILL.md +13 -0
- package/.cursor/skills/playwright-driver/SKILL.md +13 -0
- package/.cursor/skills/quality-gates/SKILL.md +13 -0
- package/.cursor/skills/reconcile/SKILL.md +12 -0
- package/.cursor/skills/repo-audit/SKILL.md +13 -0
- package/.cursor/skills/session-end/SKILL.md +13 -0
- package/.cursor/skills/session-plan/SKILL.md +13 -0
- package/.cursor/skills/session-start/SKILL.md +13 -0
- package/.cursor/skills/skill-creator/SKILL.md +13 -0
- package/.cursor/skills/spinout/SKILL.md +12 -0
- package/.cursor/skills/sunset-review/SKILL.md +13 -0
- package/.cursor/skills/test-runner/SKILL.md +13 -0
- package/.cursor/skills/tmux-layout/SKILL.md +13 -0
- package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
- package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/vault-mirror/SKILL.md +13 -0
- package/.cursor/skills/vault-sync/SKILL.md +13 -0
- package/.cursor/skills/wave-executor/SKILL.md +13 -0
- package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
- package/.mcp.json +4 -1
- package/CHANGELOG.md +168 -0
- package/README.md +18 -15
- package/agents/AGENTS.md +23 -4
- package/agents/code-implementer.md +2 -1
- package/agents/db-specialist.md +2 -1
- package/agents/docs-writer.md +3 -1
- package/agents/eval-judge.md +1 -1
- package/agents/session-reviewer.md +7 -1
- package/agents/test-writer.md +2 -1
- package/agents/ui-developer.md +2 -1
- package/commands/bootstrap.md +2 -2
- package/commands/close.md +3 -1
- package/commands/go.md +1 -1
- package/commands/journey-audit.md +43 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/ci-setup.md +14 -0
- package/docs/codex-setup.md +64 -0
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +26 -47
- package/docs/events-schema.md +76 -4
- package/docs/github-mirror-protection.md +197 -0
- package/docs/pi-setup.md +2 -0
- package/docs/rule-authoring.md +3 -1
- package/docs/scope-collision-guard.md +49 -2
- package/docs/session-config-reference.md +26 -4
- package/docs/session-config-template.md +4 -3
- package/docs/telemetry.md +22 -0
- package/hooks/_lib/lock-bootstrap.mjs +8 -4
- package/hooks/_lib/vcs-create-matcher.mjs +397 -38
- package/hooks/enforce-scope.mjs +64 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +201 -20
- package/hooks/hooks-pi.json +1 -1
- package/hooks/hooks.json +2 -2
- package/hooks/on-session-end.mjs +211 -10
- package/hooks/on-session-start.mjs +214 -11
- package/hooks/on-stop.mjs +48 -9
- package/hooks/post-subagent-discovery-validator.mjs +34 -3
- package/hooks/post-tool-batch-wave-signal.mjs +11 -2
- package/hooks/pre-bash-issue-budget.mjs +117 -4
- package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
- package/hooks/pre-bash-staging-fence.mjs +4 -0
- package/hooks/pre-task-scope-disjoint.mjs +368 -35
- package/hooks/skill-invocation-telemetry.mjs +21 -10
- package/monitors/monitors.json +6 -0
- package/package.json +1 -1
- package/pi/prompts/journey-audit.md +12 -0
- package/rules/_index.md +9 -1
- package/rules/always-on/ask-via-tool.md +62 -0
- package/rules/always-on/bash-harness-pitfalls.md +168 -0
- package/rules/always-on/build-value.md +47 -0
- package/rules/always-on/cross-session-messaging.md +59 -0
- package/rules/always-on/loop-and-monitor.md +221 -0
- package/rules/always-on/parallel-sessions.md +142 -12
- package/rules/always-on/receiving-review.md +108 -0
- package/rules/always-on/test-value.md +40 -0
- package/rules/always-on/verification-before-completion.md +77 -0
- package/scripts/archive-closed-prds.mjs +258 -18
- package/scripts/autopilot.mjs +5 -0
- package/scripts/backfill-evidence-digest.mjs +376 -0
- package/scripts/cursor-install.mjs +89 -48
- package/scripts/export-hw-learnings.mjs +143 -2
- package/scripts/express-path.mjs +299 -0
- package/scripts/generate-cursor-adapter.mjs +253 -0
- package/scripts/github-protection-audit.mjs +358 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
- package/scripts/lib/build-live-signals.mjs +24 -5
- package/scripts/lib/ci-status-banner.mjs +158 -11
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config-schema.mjs +9 -1
- package/scripts/lib/config.mjs +57 -6
- package/scripts/lib/convergence-monitor.mjs +13 -2
- package/scripts/lib/cursor-hook-bridge.mjs +443 -0
- package/scripts/lib/dispatcher/cli.mjs +2 -2
- package/scripts/lib/express-path.mjs +327 -0
- package/scripts/lib/file-lock.mjs +22 -4
- package/scripts/lib/gates/gate-full.mjs +81 -8
- package/scripts/lib/gates/gate-helpers.mjs +76 -15
- package/scripts/lib/git-config-drift.mjs +134 -5
- package/scripts/lib/host-identity.mjs +247 -2
- package/scripts/lib/instruction-budget-guard.mjs +31 -1
- package/scripts/lib/issue-budget.mjs +229 -30
- package/scripts/lib/learnings/io.mjs +55 -10
- package/scripts/lib/learnings/schema.mjs +95 -28
- package/scripts/lib/lock-reaper.mjs +7 -1
- package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
- package/scripts/lib/locks/state-md-lock.mjs +8 -1
- package/scripts/lib/memory-banner.mjs +5 -2
- package/scripts/lib/memory-paths.mjs +15 -6
- package/scripts/lib/mode-selector/scoring.mjs +53 -6
- package/scripts/lib/platform.mjs +72 -9
- package/scripts/lib/plugin-root.mjs +143 -19
- package/scripts/lib/project-hygiene.mjs +43 -3
- package/scripts/lib/quality-gate.mjs +271 -13
- package/scripts/lib/reconcile/emitter.mjs +87 -19
- package/scripts/lib/reconcile/engine.mjs +281 -13
- package/scripts/lib/reconcile/idempotency.mjs +102 -1
- package/scripts/lib/reconcile/renderer.mjs +148 -3
- package/scripts/lib/reconcile/sanitize.mjs +40 -17
- package/scripts/lib/reconcile/writer.mjs +415 -84
- package/scripts/lib/rule-loader.mjs +37 -2
- package/scripts/lib/rules-sync.mjs +51 -8
- package/scripts/lib/scope-gate.mjs +90 -0
- package/scripts/lib/session-close-backfill.mjs +369 -28
- package/scripts/lib/session-discovery.mjs +13 -3
- package/scripts/lib/session-end/phase-skip.mjs +37 -4
- package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
- package/scripts/lib/session-id.mjs +30 -14
- package/scripts/lib/session-identity/own-session.mjs +159 -0
- package/scripts/lib/session-lock.mjs +85 -30
- package/scripts/lib/session-schema/normalizer.mjs +70 -3
- package/scripts/lib/session-schema/validator.mjs +40 -0
- package/scripts/lib/session-start-probes.mjs +608 -0
- package/scripts/lib/session-transition.mjs +277 -0
- package/scripts/lib/sessions-staleness-banner.mjs +124 -57
- package/scripts/lib/spiral-carryover.mjs +90 -9
- package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
- package/scripts/lib/state-md/mission-status.mjs +350 -52
- package/scripts/lib/state-md/yaml-parser.mjs +145 -16
- package/scripts/lib/state-md.mjs +12 -2
- package/scripts/lib/telemetry/sync.mjs +46 -8
- package/scripts/lib/validate/check-agents.mjs +66 -0
- package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
- package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
- package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
- package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
- package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
- package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
- package/scripts/lib/validate/check-unwired-features.mjs +340 -32
- package/scripts/lib/validate/repo-files.mjs +275 -0
- package/scripts/lib/validate-vendored-rules.mjs +229 -7
- package/scripts/lib/vault-mirror/process.mjs +99 -43
- package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
- package/scripts/lib/vault-staleness-banner.mjs +76 -6
- package/scripts/lib/vault-status/board-writer.mjs +211 -10
- package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-transcript-tail.mjs +869 -0
- package/scripts/materialize-wave-scope.mjs +209 -12
- package/scripts/mcp-server.sh +11 -2
- package/scripts/parse-config.mjs +65 -0
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +3 -0
- package/scripts/validate-wave-scope.mjs +67 -0
- package/scripts/vault-mirror.mjs +203 -34
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +1 -1
- package/skills/_shared/parallel-aware-preamble.md +4 -2
- package/skills/_shared/platform-tools.md +11 -5
- package/skills/_shared/state-ownership.md +29 -2
- package/skills/autopilot/SKILL.md +5 -1
- package/skills/bootstrap/SKILL.md +3 -3
- package/skills/bootstrap/_shared-template.md +18 -10
- package/skills/bootstrap/deep-template.md +10 -6
- package/skills/bootstrap/fast-template.md +15 -8
- package/skills/bootstrap/standard-template.md +10 -6
- package/skills/claude-md-drift-check/checker.mjs +39 -11
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/journey-audit/SKILL.md +269 -0
- package/skills/peekaboo-driver/SKILL.md +15 -3
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +41 -1
- package/skills/session-end/SKILL.md +17 -4
- package/skills/session-end/metrics-collection.md +7 -4
- package/skills/session-end/phase-3-6-tail.md +11 -3
- package/skills/session-end/phase-3-7a-recommendations.md +16 -2
- package/skills/session-plan/SKILL.md +6 -1
- package/skills/session-plan/wave-template.md +1 -0
- package/skills/session-start/SKILL.md +30 -16
- package/skills/session-start/phase-7-5-mode-selector.md +15 -3
- package/skills/session-start/phase-8-5-express-path.md +77 -12
- package/skills/vault-sync/validator.mjs +31 -0
- package/skills/wave-executor/SKILL.md +4 -2
- package/skills/wave-executor/circuit-breaker.md +34 -9
- package/skills/wave-executor/wave-loop.md +102 -19
- package/templates/_shared/journey-manifest.md +110 -0
- 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
|
+
}
|