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.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +2 -2
- package/.cursor-plugin/plugin.json +2 -2
- package/.orchestrator/policy/blocked-commands.json +10 -0
- package/CHANGELOG.md +19 -0
- package/README.md +72 -235
- package/docs/USER-GUIDE.md +24 -0
- package/docs/components.md +2 -0
- package/docs/events-schema.md +1 -0
- package/docs/install.md +112 -0
- package/docs/session-config-template.md +38 -0
- package/docs/telemetry.md +15 -0
- package/hooks/enforce-scope.mjs +42 -1
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/package.json +2 -2
- package/scripts/lib/command-blocker.mjs +76 -4
- package/scripts/lib/playwright-driver/runner.mjs +61 -1
- package/scripts/lib/validate/check-unwired-features.mjs +175 -4
- package/scripts/release.mjs +44 -0
- package/skills/playwright-driver/SKILL.md +7 -10
package/docs/events-schema.md
CHANGED
|
@@ -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 |
|
package/docs/install.md
ADDED
|
@@ -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
|
package/hooks/enforce-scope.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/hooks/hooks-codex.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"hooks": [
|
|
8
8
|
{
|
|
9
9
|
"type": "command",
|
|
10
|
-
"command": "echo '🎯 Session Orchestrator v5.
|
|
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.
|
|
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.
|
|
4
|
-
"description": "
|
|
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
|
-
|
|
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
|
-
|
|
1672
|
-
if (
|
|
1673
|
-
|
|
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
|
|
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
|
// -------------------------------------------------------------------------
|