session-orchestrator 5.0.0 → 5.1.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.
@@ -103,6 +103,7 @@ namespace we own. The validator + regex live in `scripts/lib/events-schema.mjs`
103
103
  | `orchestrator.wave.started` / `.completed` | `hooks/post-tool-batch-wave-signal.mjs` · `hooks/on-session-end.mjs` (`emitFinalWaveCompleted`, `.completed` only) | PostToolBatch — fires live via `.claude/wave-scope.json` `.wave` increase (mechanical fallback, #612); an explicit injected `wave_signal` still takes precedence. **Second `.completed` emitter (#1193):** the batch hook closes wave N-1 only at an N-1→N transition, so the LAST wave of every session never received a completion — measured fleet-wide 2026-09-02 as **296 gaps over 296 wave runs** (1018 started vs 722 completed), exactly one missing final completion per run. SessionEnd now emits it. **Payload of that record:** `wave_number` (the `current-session.json` `last_wave`), `reason: 'session-end'`, `emitted_by: 'on-session-end'`, plus `session_id` / `semantic_session_id` — **omitted when unattested**, never fabricated. **Idempotent** via the `last_wave_completed` high-water mark in `.orchestrator/current-session.json`, written by BOTH emitters and preserved across `/clear`+compact by `on-session-start.mjs`; `last_wave` absent or `0` emits nothing (an Express-Path or coordinator-direct session never batched, and zero waves is the correct reading, not a gap). **Two gates on the SessionEnd emitter, both load-bearing:** (a) OWNERSHIP — `current-session.json` is a single repo-global file describing whichever session most recently ran SessionStart, routinely a different still-live session in a shared working copy, so the emit reuses `resolveSession()`'s `isRecordedSession` predicate; when false it emits nothing AND writes nothing, since writing the marker into a peer's file would silence the peer's own SessionEnd and preserve this very gap on the wrong session. (b) REASON — `reason === 'clear'` **and `reason === 'resume'`** are SKIPPED: the SessionEnd matcher is empty, so `/clear` fires the hook mid-wave while the LOGICAL session continues, and `on-session-start.mjs` preserves `last_wave` / `last_wave_completed` across a resume of the SAME logical session exactly as it does across a clear — so both end the HARNESS session, not the logical one. Closing the live wave on either is premature, and the preserved marker would then suppress the real completion later. Resume is the MORE common of the two (fleet n = 1498 `session.ended`, 2026-09-02: 12 `resume` vs 9 `clear`). Deliberately SessionEnd-only (`on-stop.mjs` is not mirrored) so the closed-vs-abandoned split stays measurable. **Diff-size keys on the BATCH-HOOK emitter only (#980):** `files_changed` (integer) + `files_changed_source: 'worktree-vs-wave-start-sha'` (the only value emitted today; present iff `files_changed` is). Measurement: the DEDUPED union of `git diff --name-only <wave_start_sha>` and `git ls-files --others --exclude-standard`, run in the project dir at the N→N+1 transition, where `wave_start_sha` is the `git rev-parse HEAD` the batch hook persisted into `.orchestrator/current-session.json` when wave N was OPENED (same ownership gate as the wave keys; written as `null` when git is unreadable, so a previous wave's sha can never inflate the next count). Worktree-vs-sha rather than `<sha>..HEAD` because the coordinator commits at session close, not per wave — a commit-only diff reads 0 for every wave of a normal session. **Both keys are OPTIONAL and absent-is-not-zero:** any git failure, a 1.5 s timeout, or a missing `wave_start_sha` omits them, and `scripts/lib/convergence-monitor.mjs` reads an absent key as `null`, so the `shrinking_diff` signal simply does not fire (it never reads a fabricated 0). The `on-session-end.mjs` final-wave `.completed` carries NEITHER key by design — no wave-open transition runs there, so it has no start sha to measure against |
104
104
  | `orchestrator.wave.final_refused` | `hooks/on-session-end.mjs` (`emitFinalWaveCompleted` → `emitFinalRefused`, via `emitEvent(..., {repoRoot})` + `sessionAttribution(repoRoot)`, wrapped in its OWN try/catch — independent of the caller's outer catch — so a telemetry failure on one refusal can never surface as a teardown failure) | the SIBLING event to `orchestrator.wave.completed` above (#1201 Part B / Discovery D8) — fires exactly once per SessionEnd in which the final `.completed` was NOT emitted, one row per refusal. Deliberately a SEPARATE event name rather than `.completed` carrying `emitted:false`: existing consumers of `.completed` treat every row as a finished wave, and overloading it would silently corrupt that count. **Payload:** `reason` (always present, closed enum — verified against `emitFinalWaveCompleted`'s own call sites) `not-recorded` \| `clear` \| `resume` \| `unreadable` \| `session-id-mismatch` \| `no-wave` \| `already-completed` \| `exception`, `emitted_by: 'on-session-end'` (always), plus `session_id` / `semantic_session_id` — omitted when `null`, never fabricated. **Optional, absent-is-not-zero:** `wave_number` — present ONLY on the `already-completed` reason, the sole call site that passes a resolved `last_wave` through to `emitFinalRefused`; every other reason, INCLUDING `no-wave`, omits it (that path never resolved a wave number at all — the function's own JSDoc pins this: "only when `last_wave` was resolved to a positive number before the refusal (currently only `already-completed`)"). Six of the eight reasons were previously SILENT refusal paths with no trace anywhere (`.claude/rules/host-resources.md` § HR-105: "a refusal that writes nothing is unfalsifiable") |
105
105
  | `orchestrator.quality_gate.passed` / `.failed` | `scripts/run-quality-gate.mjs` (the gate CLI — live between waves) · `scripts/lib/quality-gate.mjs` (`emitGateEvent` inside `runQualityGateWithRetry`, reached ONLY under `verification-auto-fix.enabled: true` — default `false`, and `false` in this repo) | CLI: once per gate-CLI run. Library: once per `runQualityGateWithRetry` **call**, never per retry attempt (`attempts` carries that detail). The two paths never nest, so one run passes through exactly one emitter. **Payload (both):** `variant`, `exit_code`, plus `session_id` / `semantic_session_id` when `sessionAttribution()` finds a session lock (both omitted when it does not). CLI `variant` is the `--variant` value (`baseline`\|`incremental`\|`full-gate`\|`per-file`); the library pins `variant: 'auto-fix-loop'`. **CLI only:** `wave_number`. **Library only:** `attempts` (1…`maxRetries+1`) and `gate` (`lint`\|`typecheck`\|`test` — the fail-fast gate of the last attempt; omitted on the passing path). **Optional on both:** `counts: {passed, failed, total}`. **Absent is not zero — for both optional fields.** `counts` is admitted by the ONE shared policy `admitSuiteCounts()` (`scripts/lib/gates/gate-helpers.mjs`, #967 item 2), which returns `null` — never a zero triple — for an unmeasured or inconsistent input (test gate skipped/stubbed, fail-fast on lint or typecheck before the test step, no parseable `<N> passed` marker, or `passed + failed !== total`); both callers spread `...(counts ? { counts } : {})`, so the KEY is missing in those runs. A present `counts.failed: 0` therefore means "measured, zero failures", while an absent `counts` means "not measured" — reading a missing field as `0` mis-analyses the ledger in both directions. Same contract for `wave_number` (CLI, #966 step 1): resolved from the `.{pi,cursor,codex,claude}/wave-scope.json` sidecar, **omitted** — never `0` — when there is no sidecar or its `wave` is non-numeric/non-positive; a human running `npm run quality-gate` from a `git push` has no wave at all, so an invented wave 0 would have to be special-cased by every consumer. Note `total` is `passed + failed` and EXCLUDES skipped/todo (see `extractTestCounts`) |
106
+ | `orchestrator.scope.coordinator_carveout_allowed` | `hooks/enforce-scope.mjs` (coordinator carveout, #245 / #1361) | exactly once per ALLOWED write BY THE COORDINATOR into one of the harness-owned in-repo files the carveout covers — `.claude/STATE.md` and its `.codex/` / `.cursor/` / `.pi/` siblings, plus the exact relative path of the live `wave-scope.json` the hook itself just read. Since #1361 a payload carrying `agent_id` is a dispatched subagent: it gets no carveout and falls through to Gate 7, where a manifest that does not grant the path is a DENY (a subagent permitted to write `wave-scope.json` could rewrite its own file scope and disarm every later gate of the wave; STATE.md is coordinator-owned per `skills/_shared/state-ownership.md`). **Payload:** `hook`, `manifest` (the manifest path), `wave`, `file_path` (the project-RELATIVE, forward-slash-normalized path — the carveout set is in-repo by construction, so no host-local absolute path enters the ledger) and `discriminator` (`'coordinator' \| 'malformed' \| 'absent'` — same enum and same meanings as `orchestrator.scope.memory_dir_allowed`: `'coordinator'` = `agent_type` present without `agent_id`; `'malformed'` = an `agent_id` key present but unusable (number, object, array, blank string), a fail-open that must stay rare; `'absent'` = no `agent_id` key at all, the harness's documented main-thread shape. `'subagent'` never appears here BECAUSE a subagent gets no carveout and so emits no allow) — exactly the five keys the `emitEvent` call passes, with `{ repoRoot: projectRoot }` as options. **Fail-safe:** awaited BEFORE `emitAllow()` (which calls `process.exit()` and would discard a pending append) and wrapped in its own `try {} catch {}`, so a telemetry failure can never flip the decision. Before #1361 this branch was a bare `emitAllow()` with no log and no event, so whether it ever fired was unfalsifiable after the fact (HR-105) | <!-- path-check: example -->
106
107
  | `orchestrator.scope.foreign_session_ignored` | `hooks/enforce-scope.mjs` (Gate 3b, #1123) · `hooks/enforce-commands.mjs` · `hooks/post-bash-write-verify.mjs` (both Gate 3b, #1153 P1) | exactly once per gated tool call while a FOREIGN-session `wave-scope.json` is live: the manifest's `session_id`/`semantic_session_id` provably name another session (legacy `session`/`semantic_session` still read, #1153 P2), so the hook stands down instead of enforcing. **Payload:** `hook`, `manifest` (path), `manifest_session` (string[]), `own_session` (string[]), `wave`; additionally `file_path` from `enforce-scope` (PreToolUse Edit/Write) and `command` from `enforce-commands` (PreToolUse Bash). `post-bash-write-verify` (PostToolUse Bash) carries neither — it reports on the working tree, not on one tool input. Legacy manifests without a session-binding field never emit this — they stay enforced |
107
108
  | `orchestrator.scope.memory_dir_allowed` | `hooks/enforce-scope.mjs` (Gate 5c, #1295) | exactly once per ALLOWED write BY THE COORDINATOR (#1352 — a payload carrying `agent_id` is a dispatched subagent, gets no carve-out and falls through to the normal gates) into THIS repo's harness auto-memory directory `~/.claude/projects/<encodeProjectDir(repoRoot)>/memory/` — the single out-of-repo carveout, evaluated only on the out-of-root branch (Gate 6) and only when a `wave-scope.json` manifest is live, so the in-repo gates are untouched. **Payload:** `hook`, `manifest` (the manifest path), `wave`, `file_path` (the REALPATH-resolved candidate) and, since #1352, `discriminator` (`'coordinator' \| 'malformed' \| 'absent'` — `'coordinator'` = `agent_type` present without `agent_id`; `'malformed'` = an `agent_id` key present but unusable (number, object, array, blank string), a fail-open that must stay rare; `'absent'` = no `agent_id` key at all, the harness's documented main-thread shape. `'subagent'` never appears here BECAUSE a subagent gets no carve-out and so emits no allow) — exactly the five keys the `emitEvent` call passes, with `{ repoRoot: projectRoot }` as options. **Fail-safe:** awaited BEFORE `emitAllow()` (which calls `process.exit()` and would discard a pending append) and wrapped in its own `try {} catch {}`, so a telemetry failure can never flip the decision. This row is the audit trail for the carveout: it is the only place an out-of-repo ALLOW becomes measurable after the fact |
108
109
  | `orchestrator.scope.unbound_manifest` | `scripts/wave-scope-binding.mjs` (#1153 P4) | wave-executor § Scope Manifest, when the binding step resolves to `{}` — `attributionForRecord()` found no `.orchestrator/session.lock`, or the lock's `session_id` did not match this process's own identity, so the manifest about to be written names NOBODY. **Payload:** `wave` (number\|string\|null), `role` (string\|null), `reason` (currently only `no-confirmed-session-attribution`). Exactly one per invocation, and only on the unbound path — a bound binding emits nothing. An unbound manifest is the FAIL-CLOSED direction (it enforces against every session in the checkout) and is therefore otherwise silent; this event is what makes it countable instead of indistinguishable from a coordinator who skipped the step |
@@ -0,0 +1,112 @@
1
+ # Install, Upgrade, Uninstall
2
+
3
+ Everything the README's short install block leaves out: the full requirement
4
+ matrix, the per-harness install commands with their caveats, how an upgrade is
5
+ detected and performed, and exactly what remains in your repository after an
6
+ uninstall.
7
+
8
+ - [Requirements](#requirements)
9
+ - [Install](#install)
10
+ - [Upgrade](#upgrade)
11
+ - [Uninstall](#uninstall)
12
+
13
+ ## Requirements
14
+
15
+ | | |
16
+ |---|---|
17
+ | **Node.js** | **24 or later** (`node --version`) ; `package.json` `engines.node` is `>=24.0.0`. The plugin is ES modules and needs a real Node runtime. [Install Node.js](https://nodejs.org/). |
18
+ | **A coding agent** | Claude Code, Codex CLI, Cursor IDE, or Pi. This is a workflow layer *on top of* one of them, not a replacement. |
19
+ | **Harness version** | Codex CLI **0.144.4 or later** ([codex-setup.md](codex-setup.md)). No minimum is pinned for Claude Code, Cursor, or Pi; if `/plugin` (or the Cursor/Pi installer) runs, the plugin loads. |
20
+ | **OS** | macOS and Linux are tested in CI. Windows is untested and best-effort; shell hooks and the optional Bash/`jq` MCP server need WSL or Git Bash. |
21
+ | **Git** | A git repository. Session-orchestrator reads git state at every session start and commits at close. |
22
+
23
+ ## Install
24
+
25
+ | Platform | Install |
26
+ |---|---|
27
+ | **Claude Code** | `/plugin marketplace add Kanevry/session-orchestrator` then `/plugin install session-orchestrator@kanevry` (run both inside Claude Code). |
28
+ | **Codex CLI** | `git clone https://github.com/Kanevry/session-orchestrator.git ~/Projects/session-orchestrator && cd ~/Projects/session-orchestrator && npm install && node scripts/codex-install.mjs` |
29
+ | **Cursor IDE** | `git clone https://github.com/Kanevry/session-orchestrator.git ~/Projects/session-orchestrator && cd ~/Projects/session-orchestrator && npm install && node scripts/cursor-install.mjs /path/to/your/project` |
30
+ | **Pi** | `pi install npm:session-orchestrator` ; dev fallback: `git clone https://github.com/Kanevry/session-orchestrator.git ~/Projects/session-orchestrator && cd ~/Projects/session-orchestrator && npm install && node scripts/pi-install.mjs /path/to/your/project --settings-only` |
31
+
32
+ ### Claude Code: install the Node dependencies once
33
+
34
+ For Claude Code, also install the package's Node dependencies **once** and
35
+ restart Claude Code. First locate the installed plugin:
36
+
37
+ ```bash
38
+ claude plugin list --json
39
+ ```
40
+
41
+ Find the enabled `session-orchestrator@kanevry` entry, then replace the
42
+ placeholder below with its `installPath` value:
43
+
44
+ ```bash
45
+ cd "/absolute/installPath/from/the/list" && npm install
46
+ ```
47
+
48
+ If that entry is missing or disabled, resolve it through `/plugin` first. Use
49
+ the path reported for that entry; another cached version or a nested dependency
50
+ is not the installed plugin.
51
+
52
+ Setup guides: [Codex](codex-setup.md) · [Cursor IDE](cursor-setup.md) ·
53
+ [Pi](pi-setup.md). Per-IDE notes on which instruction file each harness reads:
54
+ [instruction-file-resolution](../skills/_shared/instruction-file-resolution.md).
55
+
56
+ ## Upgrade
57
+
58
+ ```text
59
+ /plugin update session-orchestrator@kanevry # Claude Code
60
+ ```
61
+
62
+ Restart the harness afterwards, and re-run `npm install` in the plugin directory
63
+ when the release adds dependencies. On Cursor and the Pi clone fallback, upgrade
64
+ with `git pull` in your clone followed by the same install script you originally
65
+ ran. Manage npm-installed Pi packages through Pi's package manager. For Codex,
66
+ follow the [refresh instructions](codex-setup.md#refresh-and-explicit-cache-invalidation)
67
+ for your marketplace source, then reload the skill picker or restart Codex.
68
+
69
+ ### How you learn that you are behind
70
+
71
+ Session-start tells you when the running copy is behind:
72
+ `scripts/lib/plugin-update-banner.mjs` compares the version of the code **that
73
+ is actually loaded** against the published npm version and warns in the
74
+ session-start banner (minor or major; patch-only updates stay silent). It fails
75
+ silent: offline, a non-2xx response, or a malformed answer produces *no
76
+ statement*, never a false "up to date".
77
+
78
+ ### Across a major version
79
+
80
+ **[migration-v5.md](migration-v5.md)** covers the current release: the
81
+ agent-status reader API changes and close-time discovery is enabled by default.
82
+ If upgrading from before v4, also follow **[migration-v4.md](migration-v4.md)**
83
+ for the removed skills, commands and scripts and their replacements.
84
+ [migration-v3.md](migration-v3.md) documents the older v2 → v3 path and the
85
+ shape both guides follow (what changes · prerequisites · per-platform steps ·
86
+ what stays · known issues · rollback).
87
+
88
+ ## Uninstall
89
+
90
+ Remove the plugin through your harness's own plugin manager: `/plugin` in Claude
91
+ Code (marketplace entry `session-orchestrator@kanevry`), `codex plugin remove`
92
+ on Codex CLI ([codex-setup.md](codex-setup.md)), or Pi's package manager for an
93
+ npm-installed Pi package. On Cursor and the Pi clone fallback, delete the files
94
+ the installer wrote into your project.
95
+
96
+ ### What stays behind in your repo
97
+
98
+ None of it is removed by uninstalling, and all of it is plain text you can
99
+ delete by hand:
100
+
101
+ - `.orchestrator/`: `bootstrap.lock`, `metrics/` (your session and learning JSONL records), `policy/`, `steering/`, `runtime/`, `peers/`, `session.lock`
102
+ - `STATE.md` under your harness's state directory (`.claude/STATE.md` on Claude Code; see [Platform support](../README.md#platform-support)) <!-- path-check: example -->
103
+ - The `## Session Config` block you added to your instruction file
104
+ - `.claude/rules/*.md` if you vendored the rule library via `/bootstrap --sync-rules`
105
+
106
+ Deleting `.orchestrator/metrics/` deletes your session history. Telemetry
107
+ requires explicit consent ([telemetry.md](telemetry.md)). The session-start
108
+ update check (`scripts/lib/plugin-update-banner.mjs`) makes an anonymous `GET`
109
+ to the npm registry to compare your installed version against the latest
110
+ release. Successful results are cached for 24 hours per repo; failed checks can
111
+ retry at the next session start. Set `SO_DISABLE_UPDATE_CHECK=1` (or
112
+ `DO_NOT_TRACK=1`) to turn it off.
@@ -690,14 +690,19 @@ special: "follow .claude/rules/parallel-sessions.md"
690
690
 
691
691
  # VCS & infrastructure
692
692
  vcs: gitlab
693
+ gitlab-host: gitlab.example.com # only if remote URL doesn't expose it
693
694
  mirror: github
694
695
  cross-repos: []
696
+ pencil: path/to/design.pen # design-code alignment input
695
697
  ecosystem-health: true
696
698
  health-endpoints: []
697
699
  issue-limit: 50
698
700
  stale-branch-days: 7
699
701
  stale-issue-days: 30
700
702
 
703
+ # Auto-skill dispatch
704
+ auto-skill-dispatch: false # opt-in; phrase-match meta-skill — see skills/using-orchestrator/SKILL.md
705
+
701
706
  # Quality
702
707
  ssot-files: [STATE.md]
703
708
  ssot-freshness-days: 5
@@ -711,6 +716,12 @@ discovery-severity-threshold: low
711
716
  discovery-confidence-threshold: 60
712
717
  discovery-parallelism: 5
713
718
 
719
+ # Issue budget — QUANTITY cap on issue creation (not a quality filter)
720
+ issue-budget:
721
+ max-per-session: 12 # non-exempt issues one session may create (0 = block all)
722
+ mode: strict # strict | warn | off
723
+ overflow: collect-issue # collect-issue | vault-note
724
+
714
725
  # Persistence & safety
715
726
  memory-cleanup-threshold: 5
716
727
  memory-cleanup-soft-limit: 180
@@ -731,6 +742,10 @@ isolation: auto
731
742
  max-turns: auto
732
743
  auto-commit-per-wave: false # opt-in: commit after each wave's Quality-Lite PASS (V3.6 plumbing)
733
744
 
745
+ # Heavy-repo preflight & worktree hygiene (HR-003)
746
+ heavy-repo: false # true clamps the dispatched agent count to agents-per-wave
747
+ worktree-cleanup: default # default | aggressive
748
+
734
749
  # Env-aware
735
750
  resource-awareness: true
736
751
  enable-host-banner: true
@@ -815,6 +830,29 @@ templates-first:
815
830
  enabled: true
816
831
  hosts: [github, gitlab]
817
832
 
833
+ # Wave reviewers — opt-in inter-wave architecture/QA/PRD audit dispatch (#461 / #478)
834
+ wave-reviewers:
835
+ enabled: false # opt-in; absent, false, or an empty reviewers array = no-op
836
+ reviewers: [] # ["architect-reviewer", "qa-strategist", "analyst"]
837
+ mode: warn # warn | strict | off
838
+
839
+ # Persona gate — opt-in persona-panel fan-out after a wave (#458)
840
+ persona-gate-wave:
841
+ enabled: false # opt-in
842
+ after: quality # quality | impl-polish
843
+ threshold: all # all | any | <m>-of-<n>
844
+ personas: [] # persona slugs the panel dispatches
845
+ dispatch-model: claude-opus-4-7
846
+ mode: off # off | warn | strict — enabled: true with mode: off never fires
847
+
848
+ # Agentic test orchestrator (/test) — run profiles and artifact retention
849
+ test:
850
+ enabled: false # opt-in
851
+ default-profile: smoke # profile name looked up in profiles-path
852
+ profiles-path: .orchestrator/policy/test-profiles.json
853
+ mode: warn # warn | strict | off
854
+ retention-days: 30 # days test-run artifacts are kept (0 = no cleanup)
855
+
818
856
  # Verification auto-fix loop (PRD gsd Pattern 4 / #521)
819
857
  verification-auto-fix:
820
858
  enabled: false
package/docs/telemetry.md CHANGED
@@ -21,6 +21,21 @@ it is the locked v1 schema and consent precedence this plugin implements.
21
21
  [Relationship to `telemetry-claims.md`](#relationship-to-telemetry-claimsmd)
22
22
  below.
23
23
 
24
+ ## Your data stays in your repo
25
+
26
+ Session Orchestrator runs locally, requires no account, and writes its records
27
+ as append-only JSONL under `.orchestrator/metrics/` in *your* repository:
28
+ sessions, learnings, events, subagent records. Those files are yours: readable,
29
+ greppable, deletable. The optional anonymous usage telemetry described on the
30
+ rest of this page is **off until you explicitly consent** and is a separate
31
+ channel from those local records — turning telemetry on or off changes nothing
32
+ about what is written locally, and deleting the local records sends nothing
33
+ anywhere.
34
+
35
+ Reported metrics describe *this* repository under its own conditions and will
36
+ not transfer unchanged to yours
37
+ ([telemetry-claims.md](telemetry/telemetry-claims.md)).
38
+
24
39
  ## What we collect
25
40
 
26
41
  When telemetry is enabled and a batch is flushed, the payload is built from
@@ -529,7 +529,48 @@ async function main() {
529
529
  // Coordinator carveout (#245): exact-path allowlist for harness-owned files.
530
530
  // STATE.md and wave-scope.json are written by the coordinator between waves;
531
531
  // per-wave allowedPaths lists should not need to enumerate harness infrastructure.
532
- if (isCoordinatorCarveout(normalizedRel, projectRoot, scopePath)) {
532
+ //
533
+ // CALLER DISCRIMINATION (#1361) — the carve-out is the COORDINATOR's, exactly
534
+ // as Gate 5c's is (#1352), and for a sharper reason: the two files it covers
535
+ // are the harness's own control surfaces. STATE.md is coordinator-owned by
536
+ // contract (`skills/_shared/state-ownership.md`), and `wave-scope.json` IS the
537
+ // manifest every later gate of this wave reads — a subagent permitted to write
538
+ // it can rewrite its own file scope and disarm the guard for the rest of the
539
+ // wave. A `'subagent'` therefore falls through to Gate 7, where a manifest that
540
+ // does not grant the path is a DENY.
541
+ //
542
+ // ALLOW-CENSUS (RCR-007, the precondition for forbidding what is allowed
543
+ // today): 1069 transcripts across 46 sessions of this repo carried 4385
544
+ // subagent writes; 0 targeted a carve-out path (2026-09-13 @ `5501f700`). The
545
+ // fix takes nothing away from any observed legitimate writer.
546
+ //
547
+ // FAIL-OPEN on ambiguity, same as Gate 5c and for the same reason: the other
548
+ // three classifications keep the ALLOW, because fail-closed would deny the
549
+ // coordinator's own `/close` STATE.md writes. The classification is emitted so
550
+ // that fail-open stays COUNTABLE (HR-105) — before #1361 this branch called
551
+ // `emitAllow()` (`process.exit(0)`, no log, no event), so whether it ever fired
552
+ // was unfalsifiable after the fact.
553
+ const carveoutCaller = classifyCaller(input);
554
+ if (carveoutCaller !== 'subagent' && isCoordinatorCarveout(normalizedRel, projectRoot, scopePath)) {
555
+ // One event per decision point, awaited before emitAllow() — emitAllow()
556
+ // calls process.exit(), which would discard a pending append.
557
+ try {
558
+ const { emitEvent } = await import('../scripts/lib/events.mjs');
559
+ await emitEvent(
560
+ 'orchestrator.scope.coordinator_carveout_allowed',
561
+ {
562
+ hook: HOOK_NAME,
563
+ manifest: scopePath,
564
+ wave: scope.wave,
565
+ // Repo-RELATIVE by choice: the carve-out set is in-repo by
566
+ // construction, so the relative form carries the whole decision
567
+ // without putting a host-local absolute path into the ledger.
568
+ file_path: normalizedRel,
569
+ discriminator: carveoutCaller,
570
+ },
571
+ { repoRoot: projectRoot },
572
+ );
573
+ } catch { /* observability is best-effort — never blocks the decision */ }
533
574
  return emitAllow();
534
575
  }
535
576
 
@@ -7,7 +7,7 @@
7
7
  "hooks": [
8
8
  {
9
9
  "type": "command",
10
- "command": "echo '🎯 Session Orchestrator v5.0.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
10
+ "command": "echo '🎯 Session Orchestrator v5.1.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
11
11
  "async": false
12
12
  },
13
13
  {
package/hooks/hooks.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "echo '🎯 Session Orchestrator v5.0.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
9
+ "command": "echo '🎯 Session Orchestrator v5.1.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
10
10
  "async": false
11
11
  },
12
12
  {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "session-orchestrator",
3
- "version": "5.0.0",
4
- "description": "Loop engineering for AI coding agents turn ad-hoc sessions into a repeatable research plan wave-execute close loop with verification gates. Runs on Claude Code, Codex CLI, Cursor, and Pi.",
3
+ "version": "5.1.0",
4
+ "description": "A repeatable Plan, Go, Close workflow for AI coding sessions: /session reads your repo and agrees the scope, /go runs the work in waves with a quality gate between each, /close verifies and commits. Runs on Claude Code, Codex CLI, Cursor and Pi.",
5
5
  "type": "module",
6
6
  "homepage": "https://session-orchestrator.com",
7
7
  "keywords": [
@@ -1663,18 +1663,90 @@ export function redirectRuleMatches(rule, command, opts = {}) {
1663
1663
  const denylist = Array.isArray(rule['target-denylist']) ? rule['target-denylist'] : [];
1664
1664
  if (denylist.length === 0) return false;
1665
1665
  const modes = new Set(Array.isArray(rule.modes) && rule.modes.length > 0 ? rule.modes : ['truncate']);
1666
- const regexes = denylist.map(redirectGlobToRegExp);
1666
+
1667
+ // #1362 — TWO denylist classes, deliberately kept apart:
1668
+ //
1669
+ // REPO-RELATIVE (every pre-#1362 entry): judged against
1670
+ // `repoRelativeRedirectTarget`, which returns `null` for anything outside
1671
+ // the root. Behaviour is byte-identical to before, and that null is what
1672
+ // keeps a relative entry from ever reaching an out-of-repo path.
1673
+ //
1674
+ // HOME-ANCHORED (`~/…`, new): judged against the EXPANDED ABSOLUTE target,
1675
+ // never relativized. Needed because the artefacts worth denying are not all
1676
+ // inside the repo — the harness auto-memory directory
1677
+ // (`~/.claude/projects/<encodeProjectDir(root)>/memory/`) is auto-injected
1678
+ // as trusted project context into every later session yet lives outside
1679
+ // `git diff`, CI review and the secret scanners. Measured 2026-09-13: with
1680
+ // only the repo-relative class, NO glob spelling could match it, because
1681
+ // `relativizeAgainstRoot` discards out-of-repo targets by design.
1682
+ //
1683
+ // The split is one-directional on purpose: a home-anchored entry cannot widen
1684
+ // an existing repo-relative rule, and a repo-relative entry still cannot
1685
+ // escape the root. An unexpandable `~` (no usable `home`) matches NOTHING —
1686
+ // fail-closed for the new class, since an unexpanded `~` names no file.
1687
+ const relRegexes = [];
1688
+ const absRegexes = [];
1689
+ for (const pattern of denylist) {
1690
+ const text = String(pattern);
1691
+ if (text === '~' || text.startsWith('~/')) {
1692
+ const expanded = expandLeadingHome(text, home);
1693
+ if (expanded === text) continue; // no usable home → no match, never a guess
1694
+ absRegexes.push(redirectGlobToRegExp(foldAbsoluteTarget(expanded)));
1695
+ } else {
1696
+ relRegexes.push(redirectGlobToRegExp(text));
1697
+ }
1698
+ }
1699
+ if (relRegexes.length === 0 && absRegexes.length === 0) return false;
1667
1700
 
1668
1701
  for (const entry of extractRedirectTargets(command)) {
1669
1702
  if (entry.unresolved) continue;
1670
1703
  if (!modes.has(entry.mode)) continue;
1671
- const target = repoRelativeRedirectTarget(entry.target, repoRoot, home);
1672
- if (target === null) continue;
1673
- if (regexes.some((re) => re.test(target))) return true;
1704
+
1705
+ if (absRegexes.length > 0) {
1706
+ // A RELATIVE target is repo-root-relative here, exactly as the repo-relative
1707
+ // class already treats it (#994 R1) — otherwise `> ../../.claude/projects/
1708
+ // <enc>/memory/MEMORY.md` from the repo root names the very file the `~`
1709
+ // spelling blocks, and only the spelling would be denied (measured
1710
+ // 2026-09-13: relative `>`/`>>` ALLOW vs tilde/absolute BLOCK on one and
1711
+ // the same resolved path). This resolves TARGETS, never ENTRIES: a
1712
+ // repo-relative denylist entry keeps going through
1713
+ // `repoRelativeRedirectTarget` below and still cannot reach out of the root.
1714
+ const expandedTarget = expandLeadingHome(entry.target, home);
1715
+ const absTarget = path.isAbsolute(expandedTarget)
1716
+ ? expandedTarget
1717
+ : repoRoot && path.isAbsolute(repoRoot)
1718
+ ? path.resolve(repoRoot, expandedTarget)
1719
+ : null;
1720
+ if (absTarget !== null) {
1721
+ const folded = foldAbsoluteTarget(absTarget);
1722
+ if (absRegexes.some((re) => re.test(folded))) return true;
1723
+ }
1724
+ }
1725
+
1726
+ if (relRegexes.length > 0) {
1727
+ const target = repoRelativeRedirectTarget(entry.target, repoRoot, home);
1728
+ if (target !== null && relRegexes.some((re) => re.test(target))) return true;
1729
+ }
1674
1730
  }
1675
1731
  return false;
1676
1732
  }
1677
1733
 
1734
+ /**
1735
+ * Normalize an ABSOLUTE path for home-anchored denylist comparison (#1362):
1736
+ * same alias-collapse and case-fold `relativizeAgainstRoot` applies before its
1737
+ * containment test, so the two classes agree on what `/private/tmp` and a
1738
+ * case-insensitive volume mean. Applied to BOTH sides (pattern and target), so
1739
+ * the folding can never make a pattern match more than its literal spelling.
1740
+ *
1741
+ * @param {string} abs
1742
+ * @returns {string}
1743
+ */
1744
+ function foldAbsoluteTarget(abs) {
1745
+ return stripPathAliases(path.posix.normalize(String(abs).replace(/\\/g, '/'))).toLocaleLowerCase(
1746
+ 'en-US',
1747
+ );
1748
+ }
1749
+
1678
1750
  /**
1679
1751
  * Test whether a command string contains a blocked pattern with shell-aware
1680
1752
  * boundaries AND a quoted-payload guard (#641).
@@ -9,7 +9,8 @@
9
9
  * Exit codes:
10
10
  * 0 — all tests passed
11
11
  * 1 — at least one test failed (Playwright exit 1)
12
- * 2 — framework error, timeout, spawn failure, or invalid profile
12
+ * 2 — framework error, timeout, spawn failure, invalid profile, or the target
13
+ * repo has no local Playwright install (preflight, #1359)
13
14
  *
14
15
  * CLI args (all also accepted via env vars as fallback):
15
16
  * --run-dir <path> absolute path to artifact dir (env: RUN_DIR)
@@ -79,6 +80,43 @@ function checkAxeCore(targetPath, fsImpl) {
79
80
  return { present, skipped: false };
80
81
  }
81
82
 
83
+ // ---------------------------------------------------------------------------
84
+ // Playwright local-install preflight (#1359)
85
+ // ---------------------------------------------------------------------------
86
+
87
+ /** Packages that provide the `playwright test` runner, in preference order. */
88
+ const PLAYWRIGHT_PACKAGES = ['@playwright/test', 'playwright'];
89
+
90
+ /**
91
+ * Check whether the target repo can resolve the Playwright test runner from its
92
+ * OWN node_modules (walking up, so a monorepo hoist counts).
93
+ *
94
+ * Why a resolution probe and not `npx --no-install`: `--no-install` would change
95
+ * the spawn for every user — including the majority whose target repo has the
96
+ * devDependency — and its failure surfaces as npm's own message on a generic
97
+ * non-zero exit, which this runner would then map to exit 2 with no indication
98
+ * of what is missing. The probe is explicit, runs before any process starts, and
99
+ * leaves the spawned command byte-identical.
100
+ *
101
+ * @param {string} targetPath absolute path to the target repo
102
+ * @param {{ existsSync: Function }} fsImpl
103
+ * @returns {boolean} true when the runner binary resolves from the target
104
+ */
105
+ function checkPlaywrightInstalled(targetPath, fsImpl) {
106
+ let dir = targetPath;
107
+ // Bounded by the filesystem root: path.dirname('/') === '/'.
108
+ for (;;) {
109
+ for (const pkg of PLAYWRIGHT_PACKAGES) {
110
+ const manifest = path.join(dir, 'node_modules', ...pkg.split('/'), 'package.json');
111
+ if (fsImpl.existsSync(manifest)) return true;
112
+ }
113
+ const parent = path.dirname(dir);
114
+ if (parent === dir) break;
115
+ dir = parent;
116
+ }
117
+ return false;
118
+ }
119
+
82
120
  // ---------------------------------------------------------------------------
83
121
  // Main run() function
84
122
  // ---------------------------------------------------------------------------
@@ -212,6 +250,28 @@ export default async function run(opts = {}) {
212
250
  process.exit(0);
213
251
  }
214
252
 
253
+ // -------------------------------------------------------------------------
254
+ // Preflight: Playwright must resolve locally in the target repo (#1359)
255
+ // -------------------------------------------------------------------------
256
+
257
+ // `npx` resolves against the TARGET repo. Without a local install it falls
258
+ // back to downloading the package from the registry mid-run — a silent,
259
+ // unpinned network install inside what is supposed to be a test run. Abort
260
+ // first with an actionable message instead. Runs AFTER the dry-run exit
261
+ // above, so `--dry-run` still prints the resolved command on any machine.
262
+ if (!checkPlaywrightInstalled(targetPath, fsImpl)) {
263
+ console.error(
264
+ `runner: Playwright is not installed in the target repo (${targetPath}).`,
265
+ );
266
+ console.error(
267
+ 'runner: this driver runs the target repo\'s OWN Playwright — it never falls back to a global install.',
268
+ );
269
+ console.error(
270
+ `runner: install it there first — \`npm install --save-dev @playwright/test\` in ${targetPath}, then \`npx playwright install chromium\`.`,
271
+ );
272
+ process.exit(2);
273
+ }
274
+
215
275
  // -------------------------------------------------------------------------
216
276
  // Create artifact directories
217
277
  // -------------------------------------------------------------------------