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