session-orchestrator 4.0.1 → 4.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.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "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.",
10
- "version": "4.0.1"
10
+ "version": "4.1.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "session-orchestrator",
4
- "version": "4.0.1",
4
+ "version": "4.1.0",
5
5
  "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.",
6
6
  "author": {
7
7
  "name": "Bernhard Goetzendorfer",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "session-orchestrator",
3
- "version": "4.0.1+codex.20260907191442",
3
+ "version": "4.1.0+codex.20260908074427",
4
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.",
5
5
  "keywords": [
6
6
  "session",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "session-orchestrator",
3
- "version": "4.0.1",
3
+ "version": "4.1.0",
4
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.",
5
5
  "author": {
6
6
  "name": "Bernhard Goetzendorfer"
package/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [4.1.0] - 2026-09-08
11
+
12
+ ### Added
13
+
14
+ - The owner-privacy scanner accepts `--include-untracked` to check new files before staging while respecting Git ignore rules. Tracked-only remains the default (#1267).
15
+
16
+ ### Fixed
17
+
18
+ - Privacy scanning includes MDX and preserves filenames containing Unicode or newlines. Failure output includes the scanned-file count, and the pre-commit regression verifies that an unavailable confidential-name policy blocks the commit (#1267, #1269).
19
+ - Native Codex `CODEX_THREAD_ID` now participates in process-confirmed session ownership and event attribution. Explicit harness selection prevents inherited IDs from proving ownership; ambiguous IDs remain unconfirmed. Test workers isolate the invoking harness's identity (#1274).
20
+ - GitHub release recovery prints commands with the exact repository and tag, retains failed-create notes for retry, and inspects uncertain release state before any creation (#1095).
21
+ - CI audit detection examines executable GitHub steps and GitLab jobs, recognizes package-manager options and supported wrappers, and excludes metadata, comments, unused templates and help-only invocations. Local GitLab references, inheritance and `spec:inputs` headers are supported; external includes and dynamic conditions remain outside this heuristic (#1040).
22
+ - Vault session notes render lifecycle agent counts, preserve measured zero, and label counts known only as completed or planned (#1276).
23
+ - Guide copy buttons stay beside their command blocks, including multiline snippets and narrow screens (#1275).
24
+ - Full-repository validation runs once before Vitest workers start, and its result is shared by the existing smoke assertions. This avoids four competing scans during coverage while retaining mandatory validation and failure propagation (#1278).
25
+ - Coverage verification now requires generated reports and compares structured measurements with the canonical Vitest thresholds before writing its verified marker. Missing artifacts and low coverage previously passed through shell conditions that did not stop the CI job (#1279).
26
+
27
+ ### Changed
28
+
29
+ - README, English/German landing pages, guide and machine-readable summaries now agree on installation prerequisites, active plugin-cache selection, Codex command skills, Pi package management, platform-specific enforcement and update-cache behavior (#1275).
30
+ - Refreshed distribution evidence and submission drafts, corrected stable repository steering, and recorded the product-quality review with Chrome and backlog evidence (#824, #1080).
31
+ - Release instructions now place the fully green preflight after commit, both remote pushes and exact-commit CI, matching the executable gate's requirements (#1277).
32
+
10
33
  ## [4.0.1] - 2026-09-07
11
34
 
12
35
  A PATCH release, two work streams. Codex CLI command entrypoints (closing Epic #1263 and
package/README.md CHANGED
@@ -1,11 +1,13 @@
1
1
  # Session Orchestrator
2
2
 
3
3
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
4
- [![Version](https://img.shields.io/badge/version-4.0.1-blue.svg)](CHANGELOG.md)
4
+ [![Version](https://img.shields.io/badge/version-4.1.0-blue.svg)](CHANGELOG.md)
5
5
  [![npm](https://img.shields.io/npm/v/session-orchestrator.svg)](https://www.npmjs.com/package/session-orchestrator)
6
6
  [![Tests](https://img.shields.io/badge/tests-vitest-brightgreen.svg)](docs/telemetry/telemetry-claims.md)
7
7
 
8
- 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 IDE, and [Pi](docs/pi-setup.md)**, as a community plugin (MIT, community-maintained) for solo devs and small teams.
8
+ Plan, run and verify coding sessions with **Claude Code, Codex CLI, Cursor IDE, or [Pi](docs/pi-setup.md)**. Session Orchestrator reads your repository and issues, proposes a plan, coordinates work in waves, and records what passed and what remains. Free, MIT-licensed, and community-maintained.
9
+
10
+ [Website](https://session-orchestrator.com) · [User guide](docs/USER-GUIDE.md) · [Platform support](#platform-support) · [Changelog](CHANGELOG.md)
9
11
 
10
12
  The same workflows are available on all four harnesses; Codex exposes commands as selectable skills. **Enforcement depth differs** — scope enforcement is full on Claude Code, bridged on Cursor and Pi, and currently unavailable on Codex CLI (see [Platform support](#platform-support)).
11
13
 
@@ -16,7 +18,7 @@ The same workflows are available on all four harnesses; Codex exposes commands a
16
18
  | **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/). |
17
19
  | **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. |
18
20
  | **Harness version** | Codex CLI **0.144.4 or later** ([docs/codex-setup.md](docs/codex-setup.md)). No minimum is pinned for Claude Code, Cursor, or Pi — if `/plugin` (or the Cursor/Pi installer) runs, the plugin loads. |
19
- | **OS** | macOS and Linux are first-class and run in CI (`ubuntu-latest`, `macos-latest`). Windows is **not** covered by CI and has not been tested natively — treat it as best-effort. The Node core is portable (paths via `path.join`, tmp via `os.tmpdir()`), but `hooks/hooks.json` invokes hook commands via `sh` (see line 14) and the optional MCP server (`scripts/mcp-server.sh`) is a Bash script that needs `jq` on `PATH` — both need WSL or Git Bash on Windows. |
21
+ | **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. |
20
22
  | **Git** | A git repository. Session-orchestrator reads git state at every session start and commits at close. |
21
23
 
22
24
  ## Install
@@ -28,42 +30,21 @@ The same workflows are available on all four harnesses; Codex exposes commands a
28
30
  | **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` |
29
31
  | **Pi** | `pi install npm:session-orchestrator` — or 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` |
30
32
 
31
- For Claude Code, also install Node dependencies **once** (hooks import `zx`) and restart Claude Code:
33
+ For Claude Code, also install the package's Node dependencies **once** and restart Claude Code. First locate the installed plugin:
32
34
 
33
35
  ```bash
34
- # Claude Code has no `plugin dir` subcommand, so resolve the install path from the cache.
35
- SO_DIR="$(dirname "$(find ~/.claude/plugins/cache -path '*session-orchestrator*' -name package.json 2>/dev/null | head -1)")"
36
- cd "$SO_DIR" && npm install
36
+ claude plugin list --json
37
37
  ```
38
38
 
39
- If `SO_DIR` comes back empty, the plugin is not installed from a marketplace — check `/plugin list` inside Claude Code first.
40
-
41
- Setup guides: [Codex](docs/codex-setup.md) · [Cursor IDE](docs/cursor-setup.md) · [Pi](docs/pi-setup.md). Per-IDE notes on `CLAUDE.md` vs `AGENTS.md`: [instruction-file-resolution](skills/_shared/instruction-file-resolution.md).
42
-
43
- ## Upgrade
39
+ Find the enabled `session-orchestrator@kanevry` entry, then replace the placeholder below with its `installPath` value:
44
40
 
45
- ```text
46
- /plugin update session-orchestrator@kanevry # Claude Code
41
+ ```bash
42
+ cd "/absolute/installPath/from/the/list" && npm install
47
43
  ```
48
44
 
49
- Restart the harness afterwards, and re-run `npm install` in the plugin directory when the release adds dependencies. On Cursor and Pi the upgrade is `git pull` in your clone followed by the same install script you originally ran. For Codex, follow the [refresh instructions](docs/codex-setup.md#refresh-and-explicit-cache-invalidation) for your marketplace source, then reload the skill picker or restart Codex.
50
-
51
- Session-start tells you when the running copy is behind: `scripts/lib/plugin-update-banner.mjs` compares the version of the code **that is actually loaded** against the published npm version and warns in the session-start banner (minor or major; patch-only updates stay silent). It fails silent — offline, a non-2xx response, or a malformed answer produces *no statement*, never a false "up to date".
52
-
53
- Upgrading across a major version: **[docs/migration-v4.md](docs/migration-v4.md)** is the current one — v4.0.0 removes five skills, three commands and eight top-level scripts, each on a measured 90-day two-signal rule rather than a judgement call, and it names what replaces every removed invocation. [docs/migration-v3.md](docs/migration-v3.md) documents the older v2 → v3 path and the shape both guides follow (what changes · prerequisites · per-platform steps · what stays · known issues · rollback).
45
+ If that entry is missing or disabled, resolve it through `/plugin` first. Use the path reported for that entry; another cached version or a nested dependency is not the installed plugin.
54
46
 
55
- ## Uninstall
56
-
57
- Remove the plugin through your harness's own plugin manager — `/plugin` in Claude Code (marketplace entry `session-orchestrator@kanevry`), `codex plugin remove` on Codex CLI ([docs/codex-setup.md](docs/codex-setup.md)). On Cursor and Pi, delete the files the installer wrote into your project.
58
-
59
- **What stays behind in your repo** — none of it is removed by uninstalling, and all of it is plain text you can delete by hand:
60
-
61
- - `.orchestrator/` — `bootstrap.lock`, `metrics/` (your session and learning JSONL records), `policy/`, `steering/`, `runtime/`, `peers/`, `session.lock`
62
- - `STATE.md` under your harness's state directory (`.claude/STATE.md` on Claude Code — see [Platform support](#platform-support))
63
- - The `## Session Config` block you added to `CLAUDE.md` / `AGENTS.md`
64
- - `.claude/rules/*.md` if you vendored the rule library via `/bootstrap --sync-rules`
65
-
66
- Deleting `.orchestrator/metrics/` deletes your session history. Nothing is sent anywhere without your explicit consent (see [Data & telemetry](#safety--data--telemetry)) — the one exception is the session-start update check (`scripts/lib/plugin-update-banner.mjs`): a single anonymous `GET` to the npm registry, at most once per day per repo, comparing your installed version against the latest release. Set `SO_DISABLE_UPDATE_CHECK=1` (or `DO_NOT_TRACK=1`) to turn it off. Beyond that, there is nothing else to revoke.
47
+ Setup guides: [Codex](docs/codex-setup.md) · [Cursor IDE](docs/cursor-setup.md) · [Pi](docs/pi-setup.md). Per-IDE notes on `CLAUDE.md` vs `AGENTS.md`: [instruction-file-resolution](skills/_shared/instruction-file-resolution.md).
67
48
 
68
49
  ## Quick Start
69
50
 
@@ -117,6 +98,31 @@ $session-orchestrator:close
117
98
 
118
99
  These entries preserve each command's full workflow and prechecks. Codex's native `/goal` is a separate feature. `/plan` and `/evolve` extend the loop, but you can start with just these three.
119
100
 
101
+ ## Upgrade
102
+
103
+ ```text
104
+ /plugin update session-orchestrator@kanevry # Claude Code
105
+ ```
106
+
107
+ Restart the harness afterwards, and re-run `npm install` in the plugin directory when the release adds dependencies. On Cursor and the Pi clone fallback, upgrade with `git pull` in your clone followed by the same install script you originally ran. Manage npm-installed Pi packages through Pi's package manager. For Codex, follow the [refresh instructions](docs/codex-setup.md#refresh-and-explicit-cache-invalidation) for your marketplace source, then reload the skill picker or restart Codex.
108
+
109
+ Session-start tells you when the running copy is behind: `scripts/lib/plugin-update-banner.mjs` compares the version of the code **that is actually loaded** against the published npm version and warns in the session-start banner (minor or major; patch-only updates stay silent). It fails silent — offline, a non-2xx response, or a malformed answer produces *no statement*, never a false "up to date".
110
+
111
+ Upgrading across a major version: **[docs/migration-v4.md](docs/migration-v4.md)** is the current one — v4.0.0 removes five skills, three commands and eight top-level scripts, each on a measured 90-day two-signal rule rather than a judgement call, and it names what replaces every removed invocation. [docs/migration-v3.md](docs/migration-v3.md) documents the older v2 → v3 path and the shape both guides follow (what changes · prerequisites · per-platform steps · what stays · known issues · rollback).
112
+
113
+ ## Uninstall
114
+
115
+ Remove the plugin through your harness's own plugin manager — `/plugin` in Claude Code (marketplace entry `session-orchestrator@kanevry`), `codex plugin remove` on Codex CLI ([docs/codex-setup.md](docs/codex-setup.md)), or Pi's package manager for an npm-installed Pi package. On Cursor and the Pi clone fallback, delete the files the installer wrote into your project.
116
+
117
+ **What stays behind in your repo** — none of it is removed by uninstalling, and all of it is plain text you can delete by hand:
118
+
119
+ - `.orchestrator/` — `bootstrap.lock`, `metrics/` (your session and learning JSONL records), `policy/`, `steering/`, `runtime/`, `peers/`, `session.lock`
120
+ - `STATE.md` under your harness's state directory (`.claude/STATE.md` on Claude Code — see [Platform support](#platform-support))
121
+ - The `## Session Config` block you added to `CLAUDE.md` / `AGENTS.md`
122
+ - `.claude/rules/*.md` if you vendored the rule library via `/bootstrap --sync-rules`
123
+
124
+ Deleting `.orchestrator/metrics/` deletes your session history. Telemetry requires explicit consent (see [Data & telemetry](#safety--data--telemetry)). The session-start update check (`scripts/lib/plugin-update-banner.mjs`) makes an anonymous `GET` to the npm registry to compare your installed version against the latest release. Successful results are cached for 24 hours per repo; failed checks can retry at the next session start. Set `SO_DISABLE_UPDATE_CHECK=1` (or `DO_NOT_TRACK=1`) to turn it off.
125
+
120
126
  ## Lifecycle and waves
121
127
 
122
128
  ![Session Orchestrator wave lifecycle: /bootstrap once per repo, then /session, then /go running five typed waves — Discovery, G1, Impl-Core, G2, Impl-Polish, G3, Quality, G4 Full Gate, Finalization — followed by /close](assets/wave-lifecycle.svg)
@@ -177,8 +183,8 @@ Counts measured on 2026-09-07 with the command in brackets:
177
183
  - **25 slash commands** (`/session`, `/go`, `/close`, `/discovery`, `/plan`, `/grill`, `/evolve`, `/autopilot`, `/dispatcher`, `/reconcile`, `/eval`, `/test`, `/debug`, …) (`ls commands/*.md | wc -l`)
178
184
  - **14 typed subagents** (code-implementer, test-writer, security-reviewer, session-reviewer, qa-strategist, architect-reviewer, …) (`ls agents/*.md | wc -l`)
179
185
  - **27 hook files across 10 event types**, enforcing scope, blocking destructive commands, gating templates-first, and capturing telemetry — full on Claude Code; experimental, post-hoc, or bridged elsewhere ([Platform support](#platform-support)) (`ls hooks/*.mjs | wc -l`)
180
- - **26 always-on rule files** and **18 ADRs** carrying the reasoning behind the mechanisms (`ls .claude/rules/*.md | wc -l`, `ls docs/adr/*.md | wc -l`)
181
- - **667 vitest test files** run on every commit — 13,827 static `it()`/`test()` definitions at that measurement, and the runtime total is higher because of parameterised blocks ([methodology](docs/telemetry/telemetry-claims.md)) (`find tests -name '*.test.mjs' | wc -l`)
186
+ - **26 rule files** and **18 ADRs** carrying the reasoning behind the mechanisms (`ls .claude/rules/*.md | wc -l`, `ls docs/adr/*.md | wc -l`)
187
+ - **667 vitest test files** covered by the full quality gate and CI — 13,827 static `it()`/`test()` definitions at that measurement, and the runtime total is higher because of parameterised blocks ([methodology](docs/telemetry/telemetry-claims.md)) (`find tests -name '*.test.mjs' | wc -l`)
182
188
 
183
189
  **Portable across harnesses by construction.** `scripts/generate-agents-skills.mjs` generates root `AGENTS.md` byte-identical from `CLAUDE.md` and the `.agents/skills/<name>/SKILL.md` mirrors, with spec-legal frontmatter and pointers to canonical instructions. `scripts/generate-codex-skills.mjs` generates the Codex command entrypoints. Plugin validation checks both surfaces. Separate manifests under `.claude-plugin/`, `.codex-plugin/` and `.cursor-plugin/` register each harness's components; see [Codex manifest compatibility](docs/codex-setup.md#manifest-compatibility).
184
190
 
@@ -189,24 +195,22 @@ Full component inventory: [`docs/components.md`](docs/components.md). Version hi
189
195
  - **Typed waves, not one big batch.** Discovery first, so implementers start with shared context. Impl-Core before Impl-Polish, so architecture lands before integrations. Quality runs a *simplification pass* on AI-generated code **before** tests are written — otherwise tests pin the AI patterns into place.
190
196
  - **Inter-wave reviews, not just end-of-session.** Catching regressions between waves stops a bad pattern from propagating into later work; the confidence floor filters speculative criticism so only high-signal findings reach you.
191
197
  - **State persists across crashes.** `STATE.md` records wave progress and deviations; the next `/session` offers to resume from the last completed wave.
192
- - **Hooks enforce, not just warn.** A pre-Bash guard blocks destructive shell commands, and pre-Edit scope enforcement blocks writes outside an agent's allowed paths in main sessions and subagent waves alike ([Safety](#safety--data--telemetry)).
193
- - **Parallel *operator* sessions are treated as a hazard.** Two humans — or two of your own sessions — in the same working copy share one git index, one filesystem, one `STATE.md`. A heartbeat session lock, peer-scope manifests, and the PSA rule set in [`.claude/rules/parallel-sessions.md`](.claude/rules/parallel-sessions.md) exist for exactly that axis.
198
+ - **Hook enforcement has a defined platform boundary.** Claude Code hooks block covered destructive commands; writes outside declared paths warn in `warn` mode and block in `strict` mode. Cursor and Pi bridge supported events. Codex currently has no scope-enforcement adapter ([Platform support](#platform-support)).
199
+ - **Parallel *operator* sessions are treated as a hazard.** Two humans — or two of your own sessions — in the same working copy share one git index, one filesystem, one `STATE.md`. A heartbeat session lock, peer-scope manifests, and the PSA rule set in [`.claude/rules/parallel-sessions.md`](https://github.com/Kanevry/session-orchestrator/blob/main/.claude/rules/parallel-sessions.md) exist for exactly that axis.
194
200
  - **Cross-session learning is opt-in and inspectable.** Every session writes a record; after 5+ sessions `/evolve analyze` extracts confidence-scored patterns you can read and prune. Nothing is hidden.
195
201
  - **VCS dual support, no lock-in.** Auto-detects GitLab or GitHub from your remote and drives the full lifecycle for both.
196
202
 
197
203
  How this compares to other orchestrators — with the parts that are measured and the parts that are not: [`docs/components.md` § Comparisons](docs/components.md#comparisons).
198
204
 
199
- ## Recent highlights (v4.0.1)
205
+ ## Recent highlights (v4.1.0)
200
206
 
201
- 4.0.1 is a patch on top of 4.0.0 — if you're upgrading from before 4.0, read [docs/migration-v4.md](docs/migration-v4.md) first; nothing below removes anything further. Highlights of the v4.0.1 line: Codex command entrypoints, a redesigned public site, and a review-hardened owner-privacy scanner — plus the sixteen follow-ups the 4.0.0 review left open:
207
+ Highlights of the v4.1.0 line:
202
208
 
203
- - **4.0.0 removed public surfaces and split the largest instruction files.** Five skills, three commands and eight top-level scripts were dropped on a measured two-signal rule (0 telemetry ∧ 0 fleet invocation over 90 days ∧ no runtime consumer, never a judgement call); `.claude/rules/` went 61 26 files; `session-start`, `session-end` and the wave loop keep every phase, with bodies moved into per-phase `references/` files. Full detail and upgrade steps: [docs/migration-v4.md](docs/migration-v4.md).
204
- - **Codex command workflows are now selectable skills.** `scripts/generate-codex-skills.mjs` generates 51 entries (25 command-backed, 26 skill-backed); `go`, `close`, and 6 others that were previously absent from the skill surface (`harness-audit`, `portfolio`, `release`, `session`, `templates-ack`, `test`) are now discoverable and invocable as `$session-orchestrator:<name>`. Native `commands: []` stops the installer from separately aliasing the source commands into policy-less duplicates. The intercepting standard root manifest moved to [`.cursor-plugin/plugin.json`](.cursor-plugin/plugin.json) so it no longer shadows Codex's own manifest resolution (Refs #1263).
205
- - **Public website redesigned**, including a German `/de` landing page.
206
- - **Review-driven hardening.** The owner-privacy scanner (CP11) now fails CLOSED on a corrupted or env-configured-but-unresolvable confidential-names list instead of silently degrading to allow, and no longer prints the names-file path into logs; `check-unwired-features` splits 46 coordinator-invoked modules out of its actionable finding set (52 → 5 unreachable), so the report names what an operator can actually act on; a new session-start probe (`telemetry-flush-health`) surfaces when the sandbox refused a telemetry flush instead of that failure staying silent.
207
- - **Sixteen follow-ups from the 4.0.0 review closed, and the patch itself was reviewed before the cut.** A four-reviewer panel plus an external Codex gpt-6-astra pass over the packed npm tarball found two P1 and three P2 defects in this session's own changes — a names-file path printed into the scanner's failing output, a deep-import contract change, a flag swallowed as a value, a substring match that hid a real finding, a comment that counted as a target — all fixed before publishing. The residual list lives in GitLab #1268–#1273.
209
+ - **Privacy and session ownership.** The privacy scanner covers MDX and can include untracked files with `--include-untracked`. Native Codex task IDs now confirm session ownership; ambiguous inherited IDs remain unconfirmed.
210
+ - **CI and release recovery.** Coverage verification now requires valid reports and measured thresholds. Audit detection checks executable workflow commands; GitHub release recovery retains the exact repository, tag and retry notes.
211
+ - **Clearer onboarding.** README, English/German pages and the guide agree on installation and platform capabilities. Guide Copy buttons stay beside their command blocks, including on narrow screens.
208
212
 
209
- Full list, with the evidence for each claim: [CHANGELOG.md](CHANGELOG.md).
213
+ If upgrading from before 4.0, read [the v4 migration guide](docs/migration-v4.md). Full changes and verification: [CHANGELOG.md](CHANGELOG.md).
210
214
 
211
215
  ## Platform support
212
216
 
@@ -225,13 +229,13 @@ All platforms share the same skills, commands, and scripts; hooks use platform-s
225
229
 
226
230
  **Your data stays in your repo.** Session Orchestrator runs locally, requires no account, and writes its records as append-only JSONL under `.orchestrator/metrics/` in *your* repository — sessions, learnings, events, subagent records. Those files are yours: readable, greppable, deletable. Optional anonymous usage telemetry is **off until you explicitly consent** and is separate from the local records ([docs/telemetry.md](docs/telemetry.md) says exactly what it would collect and how to turn it off). Reported metrics describe *this* repository under its own conditions and will not transfer unchanged to yours ([details](docs/telemetry/telemetry-claims.md)).
227
231
 
228
- **Destructive-command guard.** `hooks/pre-bash-destructive-guard.mjs` enforces `.orchestrator/policy/blocked-commands.json` — 14 rules, of which 10 block outright (`git reset --hard`, `rm -rf`, `git push --force`, and more) and 4 warn — in the main session *and* in subagent waves. Bypass per session only for intentional maintenance:
232
+ **Destructive-command guard.** On Claude Code, `hooks/pre-bash-destructive-guard.mjs` enforces `.orchestrator/policy/blocked-commands.json` — 14 rules, of which 10 block outright (`git reset --hard`, `rm -rf`, `git push --force`, and more) and 4 warn — in the main session *and* in subagent waves. Other harnesses depend on their event adapters; see [Platform support](#platform-support). Bypass per session only for intentional maintenance:
229
233
 
230
234
  ```yaml
231
235
  allow-destructive-ops: true
232
236
  ```
233
237
 
234
- The rule source of truth is [`.claude/rules/parallel-sessions.md`](.claude/rules/parallel-sessions.md) (PSA-003), vendored to consumer repos via `/bootstrap`.
238
+ The rule source of truth is [`.claude/rules/parallel-sessions.md`](https://github.com/Kanevry/session-orchestrator/blob/main/.claude/rules/parallel-sessions.md) (PSA-003), vendored to consumer repos via `/bootstrap`.
235
239
 
236
240
  **Import probe.** `hooks/post-edit-import-probe.mjs` (PostToolUse on `Edit`/`Write`/`MultiEdit`) guards the other direction: a hook-reachable helper saved in a broken intermediate state makes *every* Bash/Edit/Write call fail with "Internal hook error — request blocked", host-wide, for every session sharing the working copy. Right after such a file is saved the probe runs ESLint `no-undef` on it (plus a child-process `import()` for `scripts/lib/**`) and reports the blast radius; it never blocks and always exits 0. It only fires for files listed in the committed allowlist [`hooks/_lib/hook-import-set.json`](hooks/_lib/hook-import-set.json), regenerated by `node scripts/generate-hook-import-set.mjs`. Kill switch: `SO_DISABLED_HOOKS=post-edit-import-probe`.
237
241
 
@@ -255,9 +259,9 @@ npm run typecheck # node --check on every .mjs file
255
259
 
256
260
  `.npmrc` ships with `ignore-scripts=true` (supply-chain defence), so Husky git hooks don't auto-wire on install — run `npx husky` once after cloning. `git commit` then runs gitleaks → owner-privacy scan → lint-staged → commitlint. CI re-runs everything, plus more.
257
261
 
258
- Two directories share the name *rules* and play opposite roles: [`rules/`](rules/README.md) is the **deliverable rule library** shipped *out* to consumer repos via `/bootstrap --sync-rules`, while [`.claude/rules/`](.claude/rules/) is this repo's own always-on rule set.
262
+ Two directories share the name *rules* and play opposite roles: [`rules/`](rules/README.md) is the **deliverable rule library** shipped *out* to consumer repos via `/bootstrap --sync-rules`, while [`.claude/rules/`](https://github.com/Kanevry/session-orchestrator/tree/main/.claude/rules/) is this repo's own rule set with always-on and path-scoped entries.
259
263
 
260
- Contributor docs: [Plugin Architecture (v3)](docs/plugin-architecture-v3.md) · [CONTRIBUTING.md](CONTRIBUTING.md) · [sub-agent authoring spec](docs/agent-authoring.md).
264
+ Contributor docs: [Plugin Architecture (v3)](docs/plugin-architecture-v3.md) · [CONTRIBUTING.md](https://github.com/Kanevry/session-orchestrator/blob/main/CONTRIBUTING.md) · [sub-agent authoring spec](docs/agent-authoring.md).
261
265
 
262
266
  ## Support & scope
263
267
 
@@ -278,12 +282,12 @@ What it is **not**:
278
282
  - [User Guide](docs/USER-GUIDE.md) — installation, config reference, workflow walkthrough, FAQ
279
283
  - [Components & Reference](docs/components.md) — full skill/command/agent/hook inventory, repository anatomy, comparisons
280
284
  - [Plugin Architecture (v3)](docs/plugin-architecture-v3.md) — contributor guide, layering, hook anatomy, testing
281
- - [Migration guide](docs/migration-v3.md) — upgrade path, known issues, rollback
285
+ - [Migration to v4](docs/migration-v4.md) — upgrade path, removed surfaces and replacements
282
286
  - [Telemetry](docs/telemetry.md) · [Telemetry claims](docs/telemetry/telemetry-claims.md) — what is collected, how metrics are measured, why they may not transfer
283
- - [Example Configs](docs/examples/) — Session Config examples for Next.js, Express, Swift
287
+ - [Example Configs](https://github.com/Kanevry/session-orchestrator/tree/main/docs/examples/) — Session Config examples for Next.js, Express, Swift
284
288
  - [CHANGELOG.md](CHANGELOG.md) — version history
285
289
 
286
- We follow [Conventional Commits](https://www.conventionalcommits.org/) — see [CONTRIBUTING.md](CONTRIBUTING.md).
290
+ We follow [Conventional Commits](https://www.conventionalcommits.org/) — see [CONTRIBUTING.md](https://github.com/Kanevry/session-orchestrator/blob/main/CONTRIBUTING.md).
287
291
 
288
292
  ## Learn the method behind it
289
293
 
@@ -29,14 +29,14 @@ The same reading shows the other half: the GitHub releases for 3.15/3.18/3.19/3.
29
29
  1. **Preconditions.** Working tree clean, on `main`, and `origin/main` **and** `github/main` both level with `HEAD`. The mirror is checked because the site deploy hangs off `github`, not `origin`.
30
30
  2. **Set the version.** `node scripts/release.mjs --set-version X.Y.Z` — rewrites every version surface, syncs `package-lock.json`, re-stamps the measured census on the site.
31
31
  3. **Write the editorial half.** The dated `## [X.Y.Z] - YYYY-MM-DD` CHANGELOG entry, `[Unreleased]` folded, README highlights. The script does not write these; `--check` enforces them.
32
- 4. **Preflight.** `node scripts/release.mjs --check --json` — every row green. This runs *after* step 2, never before: `--check` derives its target from `package.json`, so on the pre-bump version the registry- and tag-collision rows are red by construction.
33
- 5. **Gate, commit, push.** Full quality gate, then commit and push to **both** remotes.
34
- 6. **CI green on the commit that will be published, on BOTH platforms.** Not on its predecessor: a green pipeline from before step 5's commit is evidence about a different tree. And not on GitLab alone `--check` carries two CI rows, `ci-green-on-head` (GitLab, via `origin`) and `ci-green-on-head-github` (the mirror). The GitLab pipeline is Linux-only; the **macOS** matrix leg exists solely in `.github/workflows/test.yml`, i.e. on the operator's own platform. The github row self-disables (`skipped — no github remote`) in a checkout without a mirror, and fails on `unknown`/`degraded` exactly like the GitLab one — "could not read the mirror" is not "the mirror is green".
32
+ 4. **Gate, commit, push.** Full quality gate, then commit and push to **both** remotes.
33
+ 5. **CI green — on the commit that will be published, on BOTH platforms.** Not on its predecessor: a green pipeline from before step 4's commit is evidence about a different tree. And not on GitLab alone — `--check` carries two CI rows, `ci-green-on-head` (GitLab, via `origin`) and `ci-green-on-head-github` (the mirror). The GitLab pipeline is Linux-only; the **macOS** matrix leg exists solely in `.github/workflows/test.yml`, i.e. on the operator's own platform. The github row self-disables (`skipped — no github remote`) in a checkout without a mirror, and fails on `unknown`/`degraded` exactly like the GitLab one — "could not read the mirror" is not "the mirror is green".
34
+ 6. **Preflight.** `node scripts/release.mjs --check --json` every row green. Run this after the release commit is pushed and its CI succeeds: the preflight requires a clean working tree, exact HEAD parity on both remotes and green CI on that commit. It derives its target from `package.json`, so checking the pre-bump version instead produces registry- and tag-collision failures.
35
35
  7. **Publish.** `node scripts/release.mjs --publish` — **give it ≥600 s of wall clock, or run it in the background.** The script sets no spawn timeout on purpose (a kill mid-`npm publish` or mid-push is the very failure the receipt boundary exists to avoid), and the tail is slow by construction: each of the two `git push` remotes re-runs the husky pre-push full gate, plus up to 120 s of live-site polling. A 3-minute default command timeout kills it mid-tail.
36
36
 
37
37
  The target-confirmed npm receipt is the irreversible boundary. Before that receipt, any failure aborts normally. After it, never rerun `--publish`: registry propagation timeout/query/wait failures are reconciliation while the script still tags, pushes `main` + tag to both remotes, handles the GitHub release, and polls the live site. If tag/push fails after the receipt, the dependent GitHub-release and site steps are skipped and the script returns structured reconciliation guidance instead. Add the GitHub release for the new tag (`gh release create`) as part of this step, not "later" — "later" is what produced the three-second backfill above.
38
38
 
39
- Steps 2–7 are one continuous act. A release left parked between step 5 and step 7 is exactly the `3.18.0` state: every surface says released, the registry disagrees.
39
+ Steps 2–7 are one continuous act. A release left parked between step 4 and step 7 is exactly the `3.18.0` state: every surface says released, the registry disagrees.
40
40
 
41
41
  ## Abort criteria
42
42
 
@@ -7,7 +7,7 @@
7
7
  "hooks": [
8
8
  {
9
9
  "type": "command",
10
- "command": "echo '🎯 Session Orchestrator v4.0.1 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
10
+ "command": "echo '🎯 Session Orchestrator v4.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 v4.0.1 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
9
+ "command": "echo '🎯 Session Orchestrator v4.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,6 +1,6 @@
1
1
  {
2
2
  "name": "session-orchestrator",
3
- "version": "4.0.1",
3
+ "version": "4.1.0",
4
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.",
5
5
  "type": "module",
6
6
  "homepage": "https://session-orchestrator.com",
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ // Repository CI only: requires the checkout's Vitest config and dev dependencies.
3
+ // Test-result JSON does not prove coverage. Check the structured coverage summary
4
+ // against the same global percentage thresholds Vitest uses before writing a marker.
5
+ // The caller removes both reports and the marker before starting the current run.
6
+
7
+ import { readFileSync } from 'node:fs';
8
+ import config from '../../vitest.config.mjs';
9
+
10
+ const metrics = ['lines', 'functions', 'statements', 'branches'];
11
+ const [summaryPath = 'coverage/coverage-summary.json', xmlPath = 'coverage/cobertura-coverage.xml'] = process.argv.slice(2);
12
+
13
+ function requireValid(condition, reason) {
14
+ if (!condition) throw new Error(reason);
15
+ }
16
+
17
+ // Check completeness of the XML emitted by the configured Istanbul reporter.
18
+ // This is an artifact check, not a DTD/schema evaluator; never fetch the DOCTYPE.
19
+ function checkCobertura(xml, total) {
20
+ const stack = [];
21
+ let root;
22
+ let hasClassLine = false;
23
+ const parts = xml.match(/<[^>]*>|[^<]+/g) ?? [];
24
+ requireValid(parts.join('') === xml, 'malformed Cobertura artifact');
25
+ for (const part of parts) {
26
+ if (/^<\?xml\s[^<>]*\?>$|^<!DOCTYPE coverage SYSTEM "[^"<>]*">$/.test(part)) {
27
+ requireValid(!root, 'unexpected Cobertura declaration');
28
+ continue;
29
+ }
30
+ if (!part.startsWith('<')) {
31
+ requireValid(!part.trim() || stack.at(-1) === 'source', 'unexpected Cobertura content');
32
+ continue;
33
+ }
34
+ const tag = part.match(/^<(\/?)([A-Za-z][\w.-]*)((?:\s+[\w:-]+="[^"<>]*")*)\s*(\/?)>$/);
35
+ requireValid(tag, 'malformed Cobertura tag');
36
+ const [, closing, name, attributes, selfClosing] = tag;
37
+ if (closing) {
38
+ requireValid(!attributes && !selfClosing && stack.pop() === name, 'unbalanced Cobertura tags');
39
+ } else {
40
+ const entries = [...attributes.matchAll(/([\w:-]+)="([^"]*)"/g)].map((match) => [match[1], match[2]]);
41
+ requireValid(new Set(entries.map(([key]) => key)).size === entries.length, 'duplicate Cobertura attributes');
42
+ const values = Object.fromEntries(entries);
43
+ if (stack.length === 0) {
44
+ requireValid(!root && name === 'coverage' && !selfClosing, 'invalid Cobertura root');
45
+ root = values;
46
+ }
47
+ const path = [...stack, name].join('/');
48
+ if (path === 'coverage/packages/package/classes/class') {
49
+ requireValid(typeof values.filename === 'string' && values.filename.trim(), 'Cobertura class is missing a filename');
50
+ }
51
+ if (path === 'coverage/packages/package/classes/class/lines/line') {
52
+ requireValid(/^\d+$/.test(values.number) && Number(values.number) > 0
53
+ && /^\d+$/.test(values.hits), 'invalid Cobertura line record');
54
+ hasClassLine = true;
55
+ }
56
+ if (!selfClosing) stack.push(name);
57
+ }
58
+ }
59
+ requireValid(root && stack.length === 0, 'empty or incomplete Cobertura artifact');
60
+ requireValid(total.lines.total === 0 || hasClassLine, 'Cobertura artifact contains no class line records');
61
+ for (const [attribute, expected] of Object.entries({
62
+ 'lines-valid': total.lines.total,
63
+ 'lines-covered': total.lines.covered,
64
+ 'line-rate': total.lines.pct / 100,
65
+ 'branches-valid': total.branches.total,
66
+ 'branches-covered': total.branches.covered,
67
+ 'branch-rate': total.branches.pct / 100,
68
+ })) {
69
+ requireValid(root[attribute] !== undefined && root[attribute] !== ''
70
+ && Number(root[attribute]) === expected, `Cobertura ${attribute} disagrees with coverage summary`);
71
+ }
72
+ }
73
+
74
+ try {
75
+ const thresholds = config.test?.coverage?.thresholds;
76
+ requireValid(thresholds && Object.keys(thresholds).every((key) => metrics.includes(key)),
77
+ 'unsupported coverage thresholds: expected global percentage thresholds');
78
+ const { total } = JSON.parse(readFileSync(summaryPath, 'utf8'));
79
+ requireValid(total && typeof total === 'object', 'missing coverage totals');
80
+ for (const metric of metrics) {
81
+ const threshold = thresholds[metric];
82
+ requireValid(Number.isFinite(threshold) && threshold >= 0 && threshold <= 100,
83
+ `missing or invalid configured ${metric} threshold`);
84
+ const data = total[metric];
85
+ requireValid(data && ['total', 'covered', 'skipped'].every((key) => Number.isSafeInteger(data[key]) && data[key] >= 0)
86
+ && data.total > 0 && data.covered <= data.total && data.skipped <= data.total
87
+ && Number.isFinite(data.pct) && data.pct >= 0 && data.pct <= 100,
88
+ `missing or invalid ${metric} coverage data`);
89
+ // Istanbul truncates percentages to two decimals; compare the reported
90
+ // percentage (Vitest's threshold input) only after checking its counters.
91
+ const percentage = Math.floor((100000 * data.covered / data.total) / 10) / 100;
92
+ requireValid(data.pct === percentage, `inconsistent ${metric} coverage percentage`);
93
+ requireValid(data.pct >= threshold, `${metric} coverage ${data.pct}% is below configured ${threshold}%`);
94
+ }
95
+ checkCobertura(readFileSync(xmlPath, 'utf8'), total);
96
+ console.log(`[ci] coverage verified: ${metrics.map((metric) => `${metric}=${total[metric].pct}%`).join(' ')}`);
97
+ } catch (error) {
98
+ console.error(`[ci] coverage NOT VERIFIED: ${error.message}`);
99
+ process.exitCode = 1;
100
+ }
@@ -27,8 +27,8 @@
27
27
  * 3. **Never a peer's id (#1123).** A shared working copy means
28
28
  * `session.lock` can name a PEER session that won the acquire race. The
29
29
  * lock alone therefore does not prove ownership; the fill happens only
30
- * when a PROCESS-LOCAL id (`CLAUDE_CODE_SESSION_ID`, or a hook payload's
31
- * `session_id`) equals the lock's raw `session_id`. STATE.md is NOT a
30
+ * when a confirmed native PROCESS-LOCAL id (`CLAUDE_CODE_SESSION_ID` or
31
+ * `CODEX_THREAD_ID`) equals the lock's raw `session_id`. STATE.md is NOT a
32
32
  * witness here (#1177 FX1): it is a shared working-copy file written by
33
33
  * the lock holder, so under a peer-owned lock both agreed about the peer
34
34
  * and the union stamped the peer's ids. See {@link attributionForRecord}.
@@ -124,7 +124,7 @@ const STATE_DIR_CANDIDATES = ['.claude', '.codex', '.cursor', '.pi'];
124
124
  * when one exists it decides alone.**
125
125
  *
126
126
  * - No lock (CI, a bare script) → `{}`. Nothing to attribute to.
127
- * - No process-local id (`CLAUDE_CODE_SESSION_ID` absent) → `{}`. Ownership is
127
+ * - No confirmed process-local id (absent or ambiguous native env) → `{}`. Ownership is
128
128
  * UNPROVEN, and an unproven attribution is exactly the peer-id write #1123
129
129
  * forbids; an absent key costs a correlation, a wrong key costs a false one.
130
130
  * - A process-local id that equals the lock's raw `session_id` → fill BOTH
@@ -44,6 +44,8 @@
44
44
  import { execFileSync } from 'node:child_process';
45
45
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
46
46
  import { join } from 'node:path';
47
+ import yaml from 'js-yaml';
48
+ import { tokenizeCommand, splitChainSegments, resolveSegmentVerb } from './command-blocker.mjs';
47
49
 
48
50
  /** Commits past the newest tag before release hygiene is worth mentioning. */
49
51
  export const DEFAULT_RELEASE_DRIFT_COMMITS = 50;
@@ -408,8 +410,8 @@ export function checkStaleArtifacts(repoRoot, ageDays = DEFAULT_ARTIFACT_AGE_DAY
408
410
  /**
409
411
  * H4 — CI configuration hygiene.
410
412
  *
411
- * Measured 5/6. Reports only what is decidable by presence, never by parsing
412
- * pipeline semantics. The dependency-audit gap is the load-bearing one: three
413
+ * Measured 5/6. Checks locally declared executable commands without evaluating
414
+ * pipeline conditions or external includes. The dependency-audit gap is the load-bearing one: three
413
415
  * of the tested repos carried known-vulnerable dependencies that no pipeline
414
416
  * would ever surface.
415
417
  *
@@ -438,19 +440,21 @@ export function checkCiConfig(repoRoot) {
438
440
  return findings;
439
441
  }
440
442
 
441
- let ciText = '';
442
- if (hasGitlab) ciText += safeRead(gitlabCi);
443
+ const ciConfigs = [];
444
+ if (hasGitlab) ciConfigs.push({ text: safeRead(gitlabCi), platform: 'gitlab' });
443
445
  if (hasGithub) {
444
446
  try {
445
447
  for (const f of readdirSync(ghWorkflows)) {
446
- if (f.endsWith('.yml') || f.endsWith('.yaml')) ciText += safeRead(join(ghWorkflows, f));
448
+ if (f.endsWith('.yml') || f.endsWith('.yaml')) {
449
+ ciConfigs.push({ text: safeRead(join(ghWorkflows, f)), platform: 'github' });
450
+ }
447
451
  }
448
452
  } catch {
449
453
  /* unreadable workflows dir — fall through with what we have */
450
454
  }
451
455
  }
452
456
 
453
- if (ciText && !/\b(npm|pnpm|yarn) audit\b|pip-audit|cargo audit|osv-scanner|dependency.?check/i.test(ciText)) {
457
+ if (ciConfigs.some(({ text }) => text) && !ciConfigs.some(hasAuditStep)) {
454
458
  findings.push({
455
459
  check: 'ci-audit-job',
456
460
  fixable: true,
@@ -461,6 +465,178 @@ export function checkCiConfig(repoRoot) {
461
465
  return findings;
462
466
  }
463
467
 
468
+ const GITLAB_COMMAND_FIELDS = ['script', 'before_script', 'after_script'];
469
+ const GITLAB_GLOBAL_FIELDS = new Set([
470
+ 'stages', 'types', 'variables', 'default', 'include', 'workflow',
471
+ 'image', 'services', 'cache', 'before_script', 'after_script', 'spec',
472
+ ]);
473
+ const GITLAB_REFERENCE = Symbol('gitlab-reference');
474
+ const GITLAB_SCHEMA = yaml.DEFAULT_SCHEMA.extend([
475
+ new yaml.Type('!reference', {
476
+ kind: 'sequence',
477
+ construct: (path) => ({ [GITLAB_REFERENCE]: path }),
478
+ }),
479
+ ]);
480
+ const AUDIT_VALUE_FLAGS = new Set([
481
+ '--filter', '-F', '--dir', '-C', '--prefix', '--cwd', '--registry',
482
+ '--userconfig', '--globalconfig', '--workspace', '-w', '--cache', '--pm-on-fail',
483
+ ]);
484
+ const AUDIT_BOOLEAN_FLAGS = new Set([
485
+ '-r', '--recursive', '-s', '--silent', '-g', '--global', '--workspaces', '--offline',
486
+ ]);
487
+
488
+ /** Inspect executable job/step locations, never arbitrary keys named run/script. */
489
+ function hasAuditStep({ text, platform }) {
490
+ let config;
491
+ try {
492
+ if (platform === 'gitlab') {
493
+ const documents = yaml.loadAll(text, undefined, { schema: GITLAB_SCHEMA });
494
+ if (documents.length === 1) {
495
+ [config] = documents;
496
+ } else if (documents.length === 2 && isConfigMap(documents[0])
497
+ && Object.keys(documents[0]).length === 1 && isConfigMap(documents[0].spec)) {
498
+ // GitLab permits a spec header followed by --- and the job document.
499
+ // Header input defaults are data; unrelated multi-document YAML is ambiguous.
500
+ [, config] = documents;
501
+ } else {
502
+ return false;
503
+ }
504
+ } else {
505
+ config = yaml.load(text);
506
+ }
507
+ } catch {
508
+ return false;
509
+ }
510
+ if (!isConfigMap(config)) return false;
511
+ if (platform === 'github') {
512
+ if (!isConfigMap(config.jobs)) return false;
513
+ return Object.values(config.jobs).some((job) => isConfigMap(job)
514
+ && job.if !== false && Array.isArray(job.steps)
515
+ && job.steps.some((step) => isConfigMap(step) && step.if !== false
516
+ && typeof step.run === 'string' && hasAuditCommand(step.run)));
517
+ }
518
+
519
+ return Object.keys(config).some((name) => {
520
+ if (name.startsWith('.') || GITLAB_GLOBAL_FIELDS.has(name)) return false;
521
+ const job = resolveGitlabJob(config, name);
522
+ if (!job?.script || job.when === 'never') return false;
523
+ return GITLAB_COMMAND_FIELDS.some((field) => {
524
+ let commands = job[field];
525
+ if (!Object.hasOwn(job, field) && field !== 'script') {
526
+ const inherit = job.inherit?.default;
527
+ if (inherit === false || (Array.isArray(inherit) && !inherit.includes(field))) return false;
528
+ commands = Object.hasOwn(config.default ?? {}, field) ? config.default[field] : config[field];
529
+ }
530
+ return hasGitlabAuditCommands(commands, config);
531
+ });
532
+ });
533
+ }
534
+
535
+ function isConfigMap(value) {
536
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
537
+ && !Object.hasOwn(value, GITLAB_REFERENCE);
538
+ }
539
+
540
+ /** Local extends uses later-parent/child replacement for command arrays. */
541
+ function resolveGitlabJob(config, name, seen = new Set()) {
542
+ if (typeof name !== 'string' || !Object.hasOwn(config, name)
543
+ || !isConfigMap(config[name]) || seen.has(name)) return null;
544
+ seen.add(name);
545
+ const job = config[name];
546
+ let merged = {};
547
+ const parents = Array.isArray(job.extends) ? job.extends : [job.extends];
548
+ for (const parent of parents.filter((entry) => entry !== undefined)) {
549
+ const inherited = resolveGitlabJob(config, parent, seen);
550
+ // An unresolved later parent may override earlier commands. Only fields
551
+ // supplied by later local parents or the job itself remain evidence.
552
+ merged = inherited ? mergeGitlabJob(merged, inherited) : {};
553
+ }
554
+ seen.delete(name);
555
+ return mergeGitlabJob(merged, job);
556
+ }
557
+
558
+ function mergeGitlabJob(parent, child) {
559
+ const merged = { ...parent, ...child };
560
+ // Command fields replace wholesale; inherit is a map, so its default flag
561
+ // survives a child that overrides only inherit.variables.
562
+ if (isConfigMap(parent.inherit) && isConfigMap(child.inherit)) {
563
+ merged.inherit = { ...parent.inherit, ...child.inherit };
564
+ }
565
+ return merged;
566
+ }
567
+
568
+ /** Resolve only command strings/arrays and local !reference paths, with cycle guards. */
569
+ function hasGitlabAuditCommands(commands, config) {
570
+ const seen = new WeakSet();
571
+ function visit(value) {
572
+ if (typeof value === 'string') return hasAuditCommand(value);
573
+ if (!value || typeof value !== 'object' || seen.has(value)) return false;
574
+ seen.add(value);
575
+ let found = false;
576
+ if (Array.isArray(value)) {
577
+ found = value.some(visit);
578
+ } else if (Array.isArray(value[GITLAB_REFERENCE])) {
579
+ let target = config;
580
+ for (const key of value[GITLAB_REFERENCE]) {
581
+ target = target && typeof target === 'object' && Object.hasOwn(target, key) ? target[key] : undefined;
582
+ }
583
+ found = visit(target);
584
+ }
585
+ seen.delete(value);
586
+ return found;
587
+ }
588
+ return visit(commands);
589
+ }
590
+
591
+ /**
592
+ * Bounded command recognition: wrappers and options may precede the audit
593
+ * subcommand, but another verb (install/run/echo) ends the search. Unknown
594
+ * value-taking flags and commands hidden in external includes are not resolved.
595
+ */
596
+ function hasAuditCommand(command) {
597
+ return splitChainSegments(tokenizeCommand(command)).some((segment) => hasAuditSegment(segment));
598
+ }
599
+
600
+ function hasAuditSegment(segment, depth = 0) {
601
+ const resolved = resolveSegmentVerb(segment);
602
+ if (resolved.alt) return false;
603
+ let { verb, index } = resolved;
604
+ if (resolved.payloads.length > 0) {
605
+ if (depth >= 3 || resolved.payloads.length !== 1) return false;
606
+ const tokens = tokenizeCommand(resolved.payloads[0]);
607
+ const parts = splitChainSegments(tokens);
608
+ // env -S splits argv; it does not execute shell operators. Restrict this
609
+ // reuse of the shared tokenizer to one plain command with no redirects.
610
+ if (parts.length !== 1 || parts[0].length !== tokens.length
611
+ || tokens.some((token) => token.redirect)) return false;
612
+ const trailingArgs = index < 0 ? [] : segment.slice(index);
613
+ return hasAuditSegment([...tokens, ...trailingArgs], depth + 1);
614
+ }
615
+ if (verb === 'corepack' || verb === 'npx') {
616
+ index++;
617
+ while (['-y', '--yes', '--no-install', '--'].includes(segment[index]?.text)) index++;
618
+ verb = segment[index]?.text;
619
+ }
620
+ if (segment.slice(index + 1).some((token) => ['-h', '--help', '--version'].includes(token.text))) return false;
621
+ if (/^(?:pip-audit|osv-scanner|dependency-check(?:\.sh)?)$/.test(verb ?? '')) return true;
622
+ if (!/^(?:npm|pnpm|yarn|bun)(?:@[\w.+-]+)?$/.test(verb ?? '') && verb !== 'cargo') return false;
623
+ let i = index + 1;
624
+ while (segment[i]?.text.startsWith('-')) {
625
+ const flag = segment[i].text;
626
+ if (flag === '--') { i++; break; }
627
+ if (AUDIT_VALUE_FLAGS.has(flag)) {
628
+ if (!segment[i + 1] || segment[i + 1].redirect) return false;
629
+ i += 2;
630
+ } else if (AUDIT_BOOLEAN_FLAGS.has(flag) || /^--[\w-]+=/.test(flag)) {
631
+ i++;
632
+ } else {
633
+ return false;
634
+ }
635
+ }
636
+ if (/^yarn(?:@|$)/.test(verb) && segment[i]?.text === 'npm') i++;
637
+ return segment[i]?.text === 'audit';
638
+ }
639
+
464
640
  /** @param {string} p @returns {string} */
465
641
  function safeRead(p) {
466
642
  try {
@@ -80,8 +80,8 @@ function readLockIds(repoRoot) {
80
80
  * handled right now (`session_id` / `sessionId`, plus `parent_session_id`
81
81
  * for a sub-agent invocation, whose coordinator is equally us). The only
82
82
  * tier that is per-INVOCATION rather than per-working-copy.
83
- * 2. `CLAUDE_CODE_SESSION_ID` process-scoped, absent on harnesses that
84
- * export no session env var.
83
+ * 2. The native process-scoped id (`CLAUDE_CODE_SESSION_ID` or
84
+ * `CODEX_THREAD_ID`), selected by {@link readProcessLocalSessionIds}.
85
85
  * 3. `session.lock` `session_id` / `semantic_session_id` — repo-GLOBAL, and
86
86
  * the identity the WRITER of a manifest uses: `wave-scope.json`'s
87
87
  * `session` field comes from `sessionAttribution()`, which reads this same
@@ -138,20 +138,12 @@ function readLockIds(repoRoot) {
138
138
  * never as a mismatch.
139
139
  */
140
140
  export function readOwnSessionIds(repoRoot, { hookInput = null } = {}) {
141
- const ids = new Set();
141
+ const ids = new Set(readProcessLocalSessionIds({ hookInput }));
142
142
  const add = (value) => {
143
143
  const trimmed = typeof value === 'string' ? value.trim() : '';
144
144
  if (trimmed) ids.add(trimmed);
145
145
  };
146
146
 
147
- // Source 1 — the harness's statement about THIS invocation.
148
- if (hookInput && typeof hookInput === 'object') {
149
- for (const key of ['session_id', 'sessionId', 'parent_session_id']) add(hookInput[key]);
150
- }
151
-
152
- // Source 2 — process-scoped env var.
153
- add(process.env.CLAUDE_CODE_SESSION_ID);
154
-
155
147
  // Source 3 — repo-global lock file (the manifest writer's own identity).
156
148
  try {
157
149
  const lock = readLockIds(repoRoot);
@@ -190,6 +182,14 @@ export function readOwnSessionIds(repoRoot, { hookInput = null } = {}) {
190
182
  * rather than unioning is the fix — a better signal REPLACES a worse one
191
183
  * (`.claude/rules/host-resources.md` § HR-102).
192
184
  *
185
+ * Native env identity (#1274): an explicit SO_PLATFORM selects ONLY its own
186
+ * variable (claude → CLAUDE_CODE_SESSION_ID, codex → CODEX_THREAD_ID). Cursor
187
+ * and Pi have no native env source here. Without a valid explicit selection,
188
+ * a single non-empty native id, or two equal ids, is usable; conflicting ids
189
+ * are ambiguous and contribute nothing. This prevents an inherited parent
190
+ * harness's id from confirming a peer lock. Hook invocation/parent ids remain
191
+ * valid independently. No filesystem/platform detection is an identity proof.
192
+ *
193
193
  * Never throws.
194
194
  *
195
195
  * @param {{ env?: object, hookInput?: object|null }} [opts]
@@ -211,8 +211,19 @@ export function readProcessLocalSessionIds({ env = process.env, hookInput = null
211
211
  if (hookInput && typeof hookInput === 'object') {
212
212
  for (const key of ['session_id', 'sessionId', 'parent_session_id']) add(hookInput[key]);
213
213
  }
214
- // Tier 2 — process-scoped env var.
215
- add(env?.CLAUDE_CODE_SESSION_ID);
214
+ // Tier 2 — select a native process id without importing the platform module
215
+ // into this hook-safe leaf or using shared repository markers as evidence.
216
+ const trim = (value) => typeof value === 'string' ? value.trim() : '';
217
+ const platform = trim(env?.SO_PLATFORM);
218
+ const claudeId = trim(env?.CLAUDE_CODE_SESSION_ID);
219
+ const codexId = trim(env?.CODEX_THREAD_ID);
220
+ if (platform === 'claude') add(claudeId);
221
+ else if (platform === 'codex') add(codexId);
222
+ else if (platform !== 'cursor' && platform !== 'pi') {
223
+ // Invalid/absent SO_PLATFORM is not an explicit choice (platform.mjs uses
224
+ // the same trimmed allowlist). Disagreement must never become a union.
225
+ if (!claudeId || !codexId || claudeId === codexId) add(claudeId || codexId);
226
+ }
216
227
 
217
228
  return ids;
218
229
  }
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * check-owner-leakage.mjs — Scan tracked files for owner-privacy leakage patterns.
4
+ * Add --include-untracked to also scan new files that Git does not ignore.
4
5
  *
5
6
  * Implements the #462 audit trail durable CI guard (#471).
6
7
  * Canonicalization-before-matching refactor (issue #661): the historical
@@ -19,7 +20,7 @@
19
20
  * toward over-matching: this is a security guard, a false-positive is cheap, a
20
21
  * false-negative ships a leak to the public mirror.
21
22
  *
22
- * Usage: check-owner-leakage.mjs <plugin-root>
23
+ * Usage: check-owner-leakage.mjs <plugin-root> [--include-untracked]
23
24
  *
24
25
  * Forbidden patterns (canonical rules CP1–CP10):
25
26
  * CP1 personal home path `/Users/bernhardg…` — matched on the CANONICAL form,
@@ -128,8 +129,9 @@ const isMain =
128
129
 
129
130
  // CLI: single positional arg required (only enforced when run directly).
130
131
  const pluginRoot = argv[2];
132
+ const includeUntracked = argv.slice(3).includes('--include-untracked');
131
133
  if (isMain && !pluginRoot) {
132
- console.error('Usage: check-owner-leakage.mjs <plugin-root>');
134
+ console.error('Usage: check-owner-leakage.mjs <plugin-root> [--include-untracked]');
133
135
  process.exit(1);
134
136
  }
135
137
 
@@ -789,9 +791,10 @@ function rawConfidentialNamesKeyState(ownerYamlPath) {
789
791
  * exists to block. The operator knows their own path; the CLASS of failure is what
790
792
  * this line has to convey.
791
793
  *
794
+ * @param {{ loadHostPaths?: typeof import('../config/host-paths.mjs').loadHostPaths }} [opts]
792
795
  * @returns {Promise<{ patterns: RegExp[], disabledReason?: string, inertWarn?: string }>}
793
796
  */
794
- async function getConfidentialNamePatterns() {
797
+ export async function getConfidentialNamePatterns({ loadHostPaths } = {}) {
795
798
  let helpers;
796
799
  try {
797
800
  helpers = {
@@ -833,7 +836,7 @@ async function getConfidentialNamePatterns() {
833
836
  }
834
837
 
835
838
  try {
836
- const { loadHostPaths, resolveHostPath } = helpers.hostPaths;
839
+ const { resolveHostPath } = helpers.hostPaths;
837
840
  const { inspectConfidentialNames } = helpers.confidentialNames;
838
841
  const { resolveOwnerYamlPath } = helpers.ownerYaml;
839
842
 
@@ -841,7 +844,7 @@ async function getConfidentialNamePatterns() {
841
844
  // health (source / reason / droppedSections) straight through, so the
842
845
  // env>owner.yaml>default precedence and the load's diagnosis come from the
843
846
  // same read — no second loadOwnerConfig() call to recover what was discarded.
844
- const hostCtx = loadHostPaths();
847
+ const hostCtx = (loadHostPaths ?? helpers.hostPaths.loadHostPaths)();
845
848
 
846
849
  // FAIL-CLOSED on an unknowable load (LOW-2). `loadHostPaths` has its own
847
850
  // defensive catch that swallows a THROWING owner loader and returns
@@ -987,6 +990,7 @@ async function getConfidentialNamePatterns() {
987
990
  // adding: all 7 tracked `.jsonl` files pass (1525 -> 1532 scanned, 0 findings).
988
991
  const TEXT_EXTS = new Set([
989
992
  '.md',
993
+ '.mdx',
990
994
  '.mjs',
991
995
  '.js',
992
996
  '.ts',
@@ -1021,9 +1025,14 @@ function isTextFile(filePath) {
1021
1025
 
1022
1026
  function getTrackedFiles() {
1023
1027
  try {
1024
- const output = execFileSync('git', ['ls-files'], { cwd: pluginRoot, encoding: 'utf8' });
1028
+ // NUL delimiters preserve Unicode, spaces and newlines without Git's quoted
1029
+ // filename encoding. --exclude-standard applies only to untracked entries;
1030
+ // tracked files remain visible even if an ignore rule now matches them.
1031
+ const args = ['ls-files', '-z', '--cached'];
1032
+ if (includeUntracked) args.push('--others', '--exclude-standard');
1033
+ const output = execFileSync('git', args, { cwd: pluginRoot, encoding: 'utf8' });
1025
1034
  return output
1026
- .split('\n')
1035
+ .split('\0')
1027
1036
  .filter(Boolean)
1028
1037
  .map((f) => join(pluginRoot, f));
1029
1038
  } catch {
@@ -1402,7 +1411,7 @@ if (violations.length === 0) {
1402
1411
  }
1403
1412
 
1404
1413
  console.log('');
1405
- console.log(`Results: ${passed} passed, ${failed} failed`);
1414
+ console.log(`Results: ${passed} passed, ${failed} failed (${scanFiles.length} scanned files)`);
1406
1415
  process.exit(failed === 0 ? 0 : 1);
1407
1416
  }
1408
1417
 
@@ -473,12 +473,19 @@ export function generateSessionNote(entry, options = {}) {
473
473
  // that "repaired" that one too would be inventing a measurement.
474
474
  const waveRows = waves
475
475
  .map((w) => {
476
+ // #1276: lifecycle-only records omit every older count alias. Started
477
+ // measures participation; completed/planned-only counts retain their
478
+ // labels so a plan or a completion count never claims dispatch coverage.
479
+ const completedAgents = waveCount(w.agent_count_completed);
480
+ const plannedAgents = waveCount(w.agent_count_planned);
476
481
  const agentsCell =
477
482
  waveCount(w.agent_count) ??
478
483
  waveCount(w.agents) ??
479
484
  waveCount(w.agents_dispatched) ??
480
485
  waveCount(w.dispatched) ??
481
- MISSING_CELL;
486
+ waveCount(w.agent_count_started) ??
487
+ (completedAgents === undefined ? undefined : `${completedAgents} completed`) ??
488
+ (plannedAgents === undefined ? MISSING_CELL : `${plannedAgents} planned`);
482
489
  const filesCell = waveCount(w.files_changed) ?? waveCount(w.files) ?? MISSING_CELL;
483
490
  const qualityCell =
484
491
  w.quality ?? w.quality_check ?? w.status ?? w.result ?? w.outcome ?? MISSING_CELL;
@@ -1520,10 +1520,15 @@ export function printPublishOutcome(outcome, target, io = {}) {
1520
1520
  log(` ${outcome.release.detail}.`);
1521
1521
  } else {
1522
1522
  error(`\nRECONCILIATION: ${outcome.release.detail}`);
1523
- if (outcome.release.state === 'create-failed') {
1524
- error(` Recover with: gh release create ${outcome.tag} --verify-tag --title ${outcome.tag} --notes-file <changelog excerpt>`);
1523
+ const recovery = outcome.release.recovery;
1524
+ if (recovery?.inspect) {
1525
+ error(` Inspect with: ${renderRecoveryCommand(recovery.inspect)}`);
1526
+ if (outcome.release.state === 'create-failed' && recovery.create) {
1527
+ error(` Recover with: ${renderRecoveryCommand(recovery.create)}`);
1528
+ error(' The notes file is retained for recovery; remove it after the release is reconciled.');
1529
+ }
1525
1530
  } else {
1526
- error(' Inspect `gh release view` and its authentication/network state before attempting any create.');
1531
+ error(' Resolve the GitHub repository identity before inspecting or creating the release.');
1527
1532
  }
1528
1533
  }
1529
1534
 
@@ -1553,6 +1558,11 @@ export function printPublishOutcome(outcome, target, io = {}) {
1553
1558
  return 0;
1554
1559
  }
1555
1560
 
1561
+ /** Render argv for a POSIX shell without interpreting paths as shell code. */
1562
+ function renderRecoveryCommand(argv) {
1563
+ return argv.map((arg) => /^[\w./:@=+-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", "'\\''")}'`).join(' ');
1564
+ }
1565
+
1556
1566
  /**
1557
1567
  * Create the GitHub release for `v<target>`, or confirm the existing one.
1558
1568
  *
@@ -1582,18 +1592,23 @@ export function printPublishOutcome(outcome, target, io = {}) {
1582
1592
  * @param {string} repoRoot
1583
1593
  * @param {string} target
1584
1594
  * @param {{runImpl?: Function, repoSpec?: string}} [deps] — injection seam for tests
1585
- * @returns {{ok: boolean, created: boolean, tag: string, state: 'exists'|'created'|'unknown'|'create-failed', detail: string, argv?: string[]}}
1595
+ * @returns {{ok: boolean, created: boolean, tag: string, state: 'exists'|'created'|'unknown'|'create-failed', detail: string, argv?: string[], recovery?: {inspect: string[], create?: string[]}}}
1586
1596
  */
1587
1597
  export function ensureGithubRelease(repoRoot, target, deps = {}) {
1588
1598
  const runImpl = deps.runImpl ?? run;
1589
1599
  const tag = `v${target}`;
1590
- const spec = deps.repoSpec ?? resolveRepoSpec({ repoRoot, vcs: 'github' });
1591
- // resolveRepoSpec returns undefined when it cannot auto-detect; its contract
1592
- // is that callers OMIT the flag rather than pass `-R undefined`.
1593
- const repoFlag = spec ? ['--repo', spec] : [];
1600
+ let recovery;
1594
1601
 
1595
1602
  try {
1596
- const existing = runImpl('gh', ['release', 'view', tag, ...repoFlag], { cwd: repoRoot });
1603
+ const spec = deps.repoSpec ?? resolveRepoSpec({ repoRoot, vcs: 'github' });
1604
+ if (typeof spec !== 'string' || !spec.trim()) {
1605
+ return { ok: false, created: false, tag, state: 'unknown', detail: 'GitHub repository identity could not be resolved' };
1606
+ }
1607
+ // Recovery must carry the same resolved identity as the real invocation.
1608
+ // An absent identity cannot safely fall back to the caller's ambient repo.
1609
+ const repoFlag = ['--repo', spec];
1610
+ recovery = { inspect: ['gh', 'release', 'view', tag, ...repoFlag] };
1611
+ const existing = runImpl(recovery.inspect[0], recovery.inspect.slice(1), { cwd: repoRoot });
1597
1612
  const viewOutput = `${existing.stdout || ''}\n${existing.stderr || ''}`.trim();
1598
1613
  if (existing.status === 0 && viewOutput) {
1599
1614
  return { ok: true, created: false, tag, state: 'exists', detail: `GitHub release ${tag} already exists — no-op` };
@@ -1607,6 +1622,7 @@ export function ensureGithubRelease(repoRoot, target, deps = {}) {
1607
1622
  created: false,
1608
1623
  tag,
1609
1624
  state: 'unknown',
1625
+ recovery,
1610
1626
  detail: `could not determine whether GitHub release ${tag} exists (gh release view exited ${existing.status}: ${viewOutput.slice(0, 300) || 'empty output'})`,
1611
1627
  };
1612
1628
  }
@@ -1614,26 +1630,31 @@ export function ensureGithubRelease(repoRoot, target, deps = {}) {
1614
1630
  const notesDir = mkdtempSync(join(tmpdir(), 'release-ghnotes-'));
1615
1631
  const notesFile = join(notesDir, 'notes.md');
1616
1632
  let argv;
1633
+ let retainNotes = false;
1617
1634
  try {
1618
1635
  writeFileSync(notesFile, `${changelogExcerpt(repoRoot, target)}\n`);
1619
1636
  argv = ['release', 'create', tag, ...repoFlag, '--verify-tag', '--title', tag, '--notes-file', notesFile];
1620
1637
  const created = runImpl('gh', argv, { cwd: repoRoot });
1621
1638
  if (created.status !== 0) {
1639
+ // A recovery argv pointing to a file deleted by finally is unusable.
1640
+ // Preserve only this failed-create excerpt; successful runs still clean up.
1641
+ retainNotes = true;
1622
1642
  return {
1623
1643
  ok: false,
1624
1644
  created: false,
1625
1645
  tag,
1626
1646
  state: 'create-failed',
1627
1647
  argv,
1648
+ recovery: { ...recovery, create: ['gh', ...argv] },
1628
1649
  detail: `gh release create exited ${created.status}: ${(created.stderr || created.stdout || '').trim().slice(0, 300)}`,
1629
1650
  };
1630
1651
  }
1631
1652
  return { ok: true, created: true, tag, state: 'created', argv, detail: `GitHub release ${tag} created (--verify-tag)` };
1632
1653
  } finally {
1633
- rmSync(notesDir, { recursive: true, force: true });
1654
+ if (!retainNotes) rmSync(notesDir, { recursive: true, force: true });
1634
1655
  }
1635
1656
  } catch (err) {
1636
- return { ok: false, created: false, tag, state: 'unknown', detail: `gh could not be run: ${err.message}` };
1657
+ return { ok: false, created: false, tag, state: 'unknown', ...(recovery ? { recovery } : {}), detail: `gh could not be run: ${err.message}` };
1637
1658
  }
1638
1659
  }
1639
1660