session-orchestrator 5.8.0 → 5.9.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": "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.",
10
- "version": "5.8.0"
10
+ "version": "5.9.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": "5.8.0",
4
+ "version": "5.9.0",
5
5
  "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.",
6
6
  "author": {
7
7
  "name": "Bernhard Goetzendorfer",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "session-orchestrator",
3
- "version": "5.8.0+codex.20261005120103",
3
+ "version": "5.9.0+codex.20261006063654",
4
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
  "keywords": [
6
6
  "session",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "session-orchestrator",
3
- "version": "5.8.0",
3
+ "version": "5.9.0",
4
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
  "author": {
6
6
  "name": "Bernhard Goetzendorfer"
package/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [5.9.0] - 2026-10-06
11
+
12
+ ### Added
13
+
14
+ - **Lead page on the website**: `site/lead/` introduces the sibling project lead-session-orchestrator in the site design, linked from the main, German and guide pages, the sitemap and llms.txt; the README and the fleet contract point to it.
15
+
16
+ ### Changed
17
+
18
+ - **CI status banner, GitHub path**: unfinished check runs get their own reasons `check-runs-in-progress`, `check-runs-queued` and `check-runs-cancelled` (several kinds at once: `check-runs-mixed`) instead of `check-runs-not-complete`, and `details.checkRunCounts` carries additive counts per kind, shown by the session-start banner for `unknown`/`red`; the gh hint applies to every GitHub reason. Status vocabulary and severity are unchanged (#856).
19
+ - **CI status banner** names GitHub check runs in the valid states `skipped`, `neutral` and `waiting`/`pending`/`requested` with their own reasons (`check-runs-skipped`, `-neutral`, `-waiting`) and banner labels instead of `check-runs-not-complete`; the status vocabulary `green | red | unknown` is unchanged (#1526).
20
+ - **Session-record validator** rejects millisecond-precise but calendar-invalid timestamps (round-trip check); this deliberately also rejects the ISO end-of-day form `T24:00:00Z`, which no producer writes (#1526).
21
+ - **Prose readers of the gate result** (`skills/wave-executor/references/wave-loop-review.md`, `skills/session-end/metrics-collection.md`) take `suite_passed`/`suite_failed` from the `orchestrator.quality_gate.*` event selected by `semantic_session_id` + `wave_number` (last record of the wave) with one identical `jq` selector that skips unreadable ledger lines instead of silently returning nothing; the counts fallback applies only to pre-#954 sessions, a gate run outside the wrapper (including an event without `semantic_session_id`) and a missing wave, and reads this wave's own gate output in wave-loop step 7 (which runs before the header is written) and the STATE.md header at session end, the same sources as `suite_platform`. `docs/events-schema.md` and the emitter docblocks describe that contract (#966 steps 2–3).
22
+ - **Quality-gate events** carry an optional `wave_number` (positive safe integer from the session's own wave scope, omitted when unknown) on `orchestrator.quality_gate.*` from both the CLI and the library path; the auto-fix-loop record therefore now matches the per-wave selector (#966 step 1).
23
+
24
+ ### Fixed
25
+
26
+ - **Remote-offload readiness** (#1524): Claude dispatch authorizes on the new `headless-ready=yes` marker; the legacy `ready=yes` is accepted only when no new readiness marker (`gate-ready`, `headless-ready`) is on the line, and `gate-ready` alone no longer authorizes Claude dispatch. Conflicting or unknown headless values fail closed.
27
+ - **`session-record-repair`** (#1525) also normalizes microsecond-precise timestamps with a `+00:00` offset to milliseconds, after a calendar round-trip check; the original value is preserved, and an invalid `ended_at` produces no substitute.
28
+
10
29
  ## [5.8.0] - 2026-10-05
11
30
 
12
31
  ### Added
package/README.md CHANGED
@@ -1,7 +1,7 @@
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-5.8.0-blue.svg)](CHANGELOG.md)
4
+ [![Version](https://img.shields.io/badge/version-5.9.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
 
7
7
  **Give your agents a working rhythm.**
@@ -139,15 +139,14 @@ How this compares to other orchestrators, with measured results kept separate fr
139
139
 
140
140
  All four platforms share the same skills, commands and scripts; only the hooks differ, because each harness fires different events. Codex leaves its `PreToolUse` handlers empty because these guards do not yet match its tool names and edit payloads ([why](docs/codex-setup.md#why-our-pretooluse-guards-stay-unwired--the-reason-corrected)). Cursor and Pi have known event-coverage limits — see [`docs/cursor-setup.md`](docs/cursor-setup.md) and [`docs/pi-setup.md`](docs/pi-setup.md).
141
141
 
142
- ## Recent highlights (v5.8.0)
142
+ ## Recent highlights (v5.9.0)
143
143
 
144
- Highlights of the v5.8.0 line:
144
+ Highlights of the v5.9.0 line:
145
145
 
146
- - **Your secrets stay out of the plugin cache.** Claude and Codex installs now come from the packed package (`npm pack`), so `.env.local` and session state no longer land in `~/.claude` or `~/.codex` plugin caches (#1515, #1518).
147
- - **`mirror: none` means none.** `/close` no longer pushes to a GitHub remote when the repo opted out, and a fresh shell without `PLUGIN_ROOT` no longer breaks the mirror push (#1034).
148
- - **Honest gate scores.** Eval rubric v3 counts a gate run that was killed but exited 0 as failed; older records keep their own rubric version and are never re-scored silently (#1487).
149
- - **Rules live through their expiry day.** A rule's `expires-at` date is inclusive in the loader, the budget probe, the drift check and the expiry sweep alike (#1521).
150
- - **vault-sync needs no second install.** The validator resolves `zod`/`yaml` from the plugin's own dependencies and, if they are missing, says exactly which command fixes it (#1070).
146
+ - **Gate counts per wave.** Quality-gate events now carry an optional `wave_number`, and the wave loop and `/close` read `suite_passed`/`suite_failed` from the gate event of exactly that wave, skipping unreadable ledger lines instead of silently returning nothing (#966).
147
+ - **The CI banner says why a check is not green.** GitHub check runs that are in progress, queued, cancelled, `skipped`, `neutral` or waiting get their own reason, with counts per kind, instead of `check-runs-not-complete` (#856, #1526).
148
+ - **Impossible timestamps are rejected.** The session-record validator refuses millisecond-precise timestamps that are not a real calendar date (#1526).
149
+ - **A page for the lead session.** `site/lead/` introduces the sibling project lead-session-orchestrator, linked from the main, German and guide pages.
151
150
 
152
151
  Full changes and verification: [CHANGELOG.md](CHANGELOG.md).
153
152
 
@@ -159,6 +158,10 @@ Full changes and verification: [CHANGELOG.md](CHANGELOG.md).
159
158
  - [Telemetry](docs/telemetry.md) — what stays on your machine, what the optional anonymous telemetry would send, and every switch that turns it off
160
159
  - [Contributing](https://github.com/Kanevry/session-orchestrator/blob/main/CONTRIBUTING.md) · [Plugin architecture](docs/plugin-architecture-v3.md) · [docs/ router](docs/README.md)
161
160
 
161
+ ## Running many sessions at once
162
+
163
+ session-orchestrator makes one session reliable. When several sessions run in parallel across repositories, a lead session can coordinate them: who runs next, what each may do, one queue for your decisions, one stop for everything. That is the sibling project [lead-session-orchestrator](https://session-orchestrator.com/lead) ([GitHub](https://github.com/Kanevry/lead-session-orchestrator), npm `lead-session-orchestrator`). Each works alone; together they use the shared fleet contract in `skills/_shared/fleet-protocol.md`.
164
+
162
165
  ## Scope and support
163
166
 
164
167
  Provided **as-is**: a community project, best-effort maintenance, no SLA. Questions and ideas go to [Discussions](https://github.com/Kanevry/session-orchestrator/discussions), bugs to [Issues](https://github.com/Kanevry/session-orchestrator/issues).
@@ -66,6 +66,46 @@ instead of `jq >> events.jsonl`. For callers that manage their own stream path,
66
66
  `emitEvent(type, payload, { filePath })` accepts an additive optional path override
67
67
  (default = the resolved `eventsFilePath()`); existing two-argument callers are unchanged.
68
68
 
69
+ ## Quality-gate wave metrics consumers
70
+
71
+ The readers are two skill readers,
72
+ [`wave-loop-review.md` step 7](../skills/wave-executor/references/wave-loop-review.md)
73
+ and [`metrics-collection.md` § 1.7](../skills/session-end/metrics-collection.md),
74
+ and the convergence monitor (`scripts/lib/convergence-monitor.mjs`), which folds
75
+ `counts` only from gate records that also carry `wave_number`. The two skill readers read `orchestrator.quality_gate.{passed,failed}` as flat records, filtering
76
+ by the existing own `semantic_session_id` and `wave_number`, then taking the
77
+ last matching record with `counts`. `counts.passed` supplies `suite_passed`;
78
+ `counts.failed` supplies `suite_failed`. Their identical jq selectors exclude
79
+ other sessions, other waves, and unrelated events without a time-window join.
80
+ They read the ledger raw (`jq -R` with `fromjson? | objects`), so an unreadable
81
+ line such as a torn tail is skipped instead of aborting `jq` and emptying the result.
82
+ An event without `semantic_session_id` (emitters set it only from the session lock)
83
+ never matches and is treated like a gate outside the event wrapper.
84
+
85
+ Both emitters supply the same optional counts and wave keys: the CLI emits once
86
+ per CLI run; the library emits once per `runQualityGateWithRetry` call, never
87
+ per retry (`attempts` describes retries). Missing `counts` or `wave_number` stays
88
+ absent, never zero-filled. An empty selector or an event without counts, such
89
+ as fail-fast before the test suite, does not alone permit header fallback.
90
+ Telemetry remains best-effort; emission does not change gate exits or verdicts.
91
+ The event-name and reporting-variant contracts in the catalogue below still apply.
92
+
93
+ Count fallback is limited to three cases: pre-#954 sessions, a gate outside the
94
+ event wrapper, or missing wave attribution. Its source differs by reader: step 7
95
+ runs before § 3a writes this wave's header and falls back to the output of this
96
+ wave's own gate run; session end falls back to the STATE.md Wave History header
97
+ `— suite <passed>/<failed> on <platform>`.
98
+ An auto-fix call with a known wave uses the event selector. When neither an event
99
+ measurement nor an applicable header fallback supplies counts, omit the two
100
+ count fields: absence means unmeasured; a present zero means measured zero.
101
+
102
+ `suite_platform` comes separately from the platform of this wave's gate run
103
+ (step 7) or the header that run wrote (session end); there is **no platform field
104
+ in the event payload**. Omit it without such a measurement, and never infer it
105
+ from `variant`. Event-derived
106
+ counts may still be mirrored into the existing header as compatibility output,
107
+ not a second measurement source. Header and sessions.jsonl contracts are unchanged.
108
+
69
109
  ## Naming convention
70
110
 
71
111
  Orchestrator-owned events use a dotted namespace:
@@ -105,7 +145,7 @@ namespace we own. The validator + regex live in `scripts/lib/events-schema.mjs`
105
145
  | `orchestrator.memory.propose_invoked` | `hooks/pre-bash-memory-propose-audit.mjs` | PreToolUse(Bash), once per `node … memory-propose.mjs …` invocation the G3 regex matches — observe-only, the hook never denies. **Payload:** `session_id` (or `null`), `wave`, `cwd` (the working directory RELATIVE to the resolved project dir — `.` when equal, `scripts/lib` from a subdirectory; a directory OUTSIDE the project is written as `sha256:<12 hex>` of its resolved path, and so is EVERY cwd when the project dir is the filesystem root, the home dir or an ancestor of it (`/Users`), where the relative form would read `Users/<name>/…`; that 12-hex sha256 prefix is PSEUDONYMISATION, not anonymisation — a guessable path (a home dir, a usual checkout location) is recovered offline by hashing candidates; **never an absolute path** — until 2026-09-25 (#1439) it was `process.cwd()` verbatim, i.e. the operator's home path in an `events.jsonl` line; zero production readers), `exit_code` (always `null` — a PreToolUse hook runs BEFORE the command), plus the #1415 argv summary: `command_hash` (**sha256 of the RAW command, truncated to 16 hex — same recipe as `enforce-commands` after `8f15f77b`; never the command text**), `flags_present` (string[], the subset of `memory-propose.mjs`'s OWN flag NAMES — `type`, `subject`, `insight`, `evidence`, `confidence`, `dry-run`, `file-paths`, `help` — present in the command, in that fixed order; built by intersecting a CLOSED list, never by scraping `--\S+`, so a flag-shaped VALUE can never be reported as a flag) and `argv_length` (number, the raw command's character count). Until 2026-09-21 this row carried `argv_truncated`: 512 characters of command TEXT, redacted only per-VALUE for five flags, whose own caveat named its gaps (`--insight=$VAR`, `--insight=$(cat secret)`, a secret written anywhere else on the line). It went into an `events.jsonl` line (gitignored here via `.gitignore:57`; consumer repos may track it) and, with the Clank webhook configured, onto the network. **Measured 2026-09-21** (`rg argv_truncated` over `scripts/ hooks/ skills/ docs/`): **zero production readers** — the field's only consumers were this hook's own tests. Pinned in `tests/hooks/pre-bash-memory-propose-audit.test.mjs` in BOTH directions (summary fields present AND `argv_truncated`/`command` undefined), plus a negative test that string-searches the serialized record for every sentinel value — so nobody puts the raw field back beside the hash |
106
146
  | `orchestrator.wave.completed` | `hooks/post-tool-batch-wave-signal.mjs` · `hooks/on-session-end.mjs` (`emitFinalWaveCompleted`) | PostToolBatch — fires live via `.claude/wave-scope.json` `.wave` increase (mechanical fallback, #612), closing wave N at the N→N+1 transition; an explicit injected `wave_signal: 'wave-complete'` still takes precedence. **The former `started` sibling was REMOVED 2026-09-19** (operator decision, #1202 §11): it carried no measurement, its only reader was `scripts/lib/convergence-monitor.mjs`, and there it cost the one signal it could not feed — the batch hook wrote it in the same millisecond as completed{N}, so both shared a tail tick and it masked the (N-1, N) `shrinking_diff` pair. An injected `wave_signal: 'wave-start'` is still accepted and emits nothing; historical records stay in the ledgers and nothing reads them. **Re-emission ceiling (open):** a session that does not own `.orchestrator/current-session.json` never advances the marks below, so each of its batches re-emits completed{lastWave} until its own SessionStart takes the file over — measured 2026-09-19 as up to 579 emissions for one (session_id, wave_number) group. **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 |
107
147
  | `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") |
108
- | `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. **Name (CLI, #1459, 2026-10-01):** `.failed` when `exit_code` ≠ 0 OR the joined kill fields say `timed_out: true`, in every variant. The reporting variants (`baseline` \| `incremental` \| `per-file`) exit 0 even after a failing check, so for them `.passed` means "not killed", not "checks green" — the check verdict lives in the gate output, not in the event name. **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`, and `failed_files` (string[] — the failing test FILES `gate-full.mjs` names when the runner printed a file-level summary; **omitted, never `[]`**, when nothing named a file — an empty array would read as "no file failed"). **Library only:** `attempts` (1…`maxRetries+1`) and `gate` (`lint`\|`typecheck`\|`test` — the fail-fast gate of the last attempt; omitted on the passing path). **Kill-ladder fields (#1439, #1457, 2026-09-25):** `timed_out` (boolean — the run hit its wall-clock ceiling and was killed), `survivors` (integer — pids still alive after the SIGKILL step, `0` when none; the pids themselves stay in the gate output / diagnostics bundle) and `kill_signals` (string[] — the signals actually sent, in order, e.g. `["SIGTERM","SIGKILL"]`; `[]` when none). Both emitters start from the ONE shared `gateKillFields()` (`scripts/lib/quality-gate.mjs`); from the library emitter `false` / `0` / `[]` are therefore measurements ("not killed"), not absence — the CLI emitter additionally passes them through `joinGateKillFields()` and may omit the keys (see below, #1457). The library always carries all three. The CLI JOINS two ladders with `joinGateKillFields()` (`scripts/lib/gates/gate-helpers.mjs`): the OUTER one over the gate sub-script as a whole (that run exits 124), and the INNER per-command ones the gate's JSON envelope publishes — every variant (`gate-full.mjs` on `typecheck`/`test`/`lint`, `gate-{baseline,incremental,per-file}.mjs` on `typecheck`/`test` since #1457 pkt 12) puts `timed_out` / `kill_signals` / `survivors` (pids) on each command object it actually spawned, so a single command killed by the per-command ceiling (exit 2) now reads `timed_out: true`. Join: `timed_out` = outer OR any inner, `kill_signals` = outer followed by each command's in execution order, `survivors` = outer count plus every inner pid count. **The CLI omits all three keys** when the inner side was not measured — an envelope without the per-command ladder (all four variants publish it since #1457 pkt 12, so today this means an older gate script) or an unparseable envelope — and the outer ladder did not fire; a fired outer ladder is reported alone. The library reports the failing gate command of the last attempt, and `false` / `0` / `[]` on the passing path (every gate exited 0, so none was killed). **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`) |
148
+ | `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. **Name (CLI, #1459, 2026-10-01):** `.failed` when `exit_code` ≠ 0 OR the joined kill fields say `timed_out: true`, in every variant. The reporting variants (`baseline` \| `incremental` \| `per-file`) exit 0 even after a failing check, so for them `.passed` means "not killed", not "checks green" — the check verdict lives in the gate output, not in the event name. **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'`. **Both emitters:** optional `wave_number` (positive safe integer from the running own-session manifest at the event root). **CLI only:** `failed_files` (string[] — the failing test FILES `gate-full.mjs` names when the runner printed a file-level summary; **omitted, never `[]`**, when nothing named a file — an empty array would read as "no file failed"). **Library only:** `attempts` (1…`maxRetries+1`) and `gate` (`lint`\|`typecheck`\|`test` — the fail-fast gate of the last attempt; omitted on the passing path). **Kill-ladder fields (#1439, #1457, 2026-09-25):** `timed_out` (boolean — the run hit its wall-clock ceiling and was killed), `survivors` (integer — pids still alive after the SIGKILL step, `0` when none; the pids themselves stay in the gate output / diagnostics bundle) and `kill_signals` (string[] — the signals actually sent, in order, e.g. `["SIGTERM","SIGKILL"]`; `[]` when none). Both emitters start from the ONE shared `gateKillFields()` (`scripts/lib/quality-gate.mjs`); from the library emitter `false` / `0` / `[]` are therefore measurements ("not killed"), not absence — the CLI emitter additionally passes them through `joinGateKillFields()` and may omit the keys (see below, #1457). The library always carries all three. The CLI JOINS two ladders with `joinGateKillFields()` (`scripts/lib/gates/gate-helpers.mjs`): the OUTER one over the gate sub-script as a whole (that run exits 124), and the INNER per-command ones the gate's JSON envelope publishes — every variant (`gate-full.mjs` on `typecheck`/`test`/`lint`, `gate-{baseline,incremental,per-file}.mjs` on `typecheck`/`test` since #1457 pkt 12) puts `timed_out` / `kill_signals` / `survivors` (pids) on each command object it actually spawned, so a single command killed by the per-command ceiling (exit 2) now reads `timed_out: true`. Join: `timed_out` = outer OR any inner, `kill_signals` = outer followed by each command's in execution order, `survivors` = outer count plus every inner pid count. **The CLI omits all three keys** when the inner side was not measured — an envelope without the per-command ladder (all four variants publish it since #1457 pkt 12, so today this means an older gate script) or an unparseable envelope — and the outer ladder did not fire; a fired outer ladder is reported alone. The library reports the failing gate command of the last attempt, and `false` / `0` / `[]` on the passing path (every gate exited 0, so none was killed). **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` (both emitters, #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 not a positive safe integer; 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`) |
109
149
  | `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 -->
110
150
  | `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. Since #1504 point 6 only when EVERY existing candidate (`.pi` > `.cursor` > `.codex` > `.claude`) is foreign — a peer's higher-precedence manifest no longer hides this session's own one (`findOwnScopeFile()`, `scripts/lib/scope-gate.mjs`); `manifest` then names the first foreign candidate. **Payload:** `hook`, `manifest` (path), `manifest_session` (string[]), `own_session` (string[]), `wave`; additionally `file_path` from `enforce-scope` (PreToolUse Edit/Write) and `command_hash` from `enforce-commands` (PreToolUse Bash) — **sha256 auf 16 Hex gekuerzt, NIE das rohe Kommando.** Bis 2026-09-19 stand hier `command` im Klartext, und zwar als dokumentierter Vertrag, nicht als Versehen. Gemessen in EventDrop.at (#1140): **3.816 Zeilen** der GETRACKTEN `.orchestrator/metrics/events.jsonl` trugen ein rohes Kommando, darin **24 distinkte echte Produktions-Share-Codes** aus **17 fremden Kundenkonten** plus ein protokollierter `select access_pin_hash` — bei 23 dieser Events ist der Share-Code die VOLLSTAENDIGE Capability (`/event/<code>` oeffnet das Album ohne Anmeldung), und das Journal geht bei jedem Klon mit. Der Geschwisterhook `pre-bash-destructive-guard.mjs` fuehrte von Anfang an `command_hash` und sagt den Grund im eigenen Kopf; diese Zeile war die Ausnahme. Der Hash haelt das Ereignis zaehlbar und gruppierbar — genau die Eigenschaft, fuer die es laut `docs/scope-collision-guard.md` existiert; das Rohfeld hatte keinen Leser. Gepinnt in `tests/hooks/enforce-commands.test.mjs` in BEIDE Richtungen (Hash vorhanden UND `command` undefined), damit niemand das Rohfeld neben den Hash zuruecklegt. **Der zweite rohe Schreiber derselben Klasse — `hooks/pre-bash-staging-fence.mjs` (512 Zeichen rohes Kommando je Eintrag) — ist seit #1404 (2026-09-20) geschlossen:** der Fence speichert jetzt die Pfad-Operanden plus `command_hash`, nie den Kommandotext. Belegt mit einem Test-Secret im Kommando: `grep -c` auf die Fence-Datei findet es 0-mal, ebenso 0 Vorkommen eines `command`-Feldes. Der einzige Leser (`hooks/wave-scope-commit-guard.mjs`) vergleicht seitdem Pfadlisten statt den Rohtext per Regex zu durchsuchen — was nebenbei `git add -A` abdeckt, das der Regex-Leser nie erkannte. **Die DRITTE und letzte Stelle derselben Klasse — `hooks/pre-bash-memory-propose-audit.mjs` (`argv_truncated`: 512 Zeichen nur flag-redigiertes Kommando in `orchestrator.memory.propose_invoked`, also in die GETRACKTE `events.jsonl` und optional an den Webhook) — ist seit #1415 (2026-09-21) geschlossen, analog `8f15f77b` und #1404:** der Hook schreibt jetzt `command_hash` + `flags_present` + `argv_length`, nie den Kommandotext. Gemessen vor dem Fix: 0 Produktionsleser des Rohfelds. Damit ist die Klasse zu — kein Hook dieses Repos legt mehr rohen Kommandotext in ein Event oder eine Kontrolldatei. `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 |
111
151
  | `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 |
@@ -7,7 +7,7 @@
7
7
  "hooks": [
8
8
  {
9
9
  "type": "command",
10
- "command": "echo '🎯 Session Orchestrator v5.8.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
10
+ "command": "echo '🎯 Session Orchestrator v5.9.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.8.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
9
+ "command": "echo '🎯 Session Orchestrator v5.9.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": "5.8.0",
3
+ "version": "5.9.0",
4
4
  "description": "Your coding agents get an agreed plan, a check after each round of changes and a handover to the next session. Plan, Go, Close: /session reads your repo and agrees the scope, /go runs the work in waves with your tests and typecheck after each implementation wave, /close runs the full gate, verifies, commits and files the rest as issues. Scope and destructive-command guards, crash-safe resume, learnings you approve. For Claude Code, Codex CLI, Cursor and Pi.",
5
5
  "type": "module",
6
6
  "homepage": "https://session-orchestrator.com",
@@ -1089,8 +1089,23 @@ async function checkGithub(repoRoot, deps = {}) {
1089
1089
  };
1090
1090
  }
1091
1091
 
1092
+ // #856: lifecycle and conclusion are separate facts. Failure retains
1093
+ // priority even in an inconsistent row; only completed success grants green.
1094
+ // Counts are additive diagnostics, never a new status or gate authority.
1095
+ const checkRunCounts = { success: 0, failure: 0, inProgress: 0, queued: 0, cancelled: 0, skipped: 0, neutral: 0, waiting: 0, other: 0 };
1096
+ for (const run of checkRuns) {
1097
+ if (run?.conclusion === 'failure' || run?.conclusion === 'action_required') checkRunCounts.failure++;
1098
+ else if (run?.status === 'completed' && run?.conclusion === 'success') checkRunCounts.success++;
1099
+ else if (run?.status === 'completed' && run?.conclusion === 'cancelled') checkRunCounts.cancelled++;
1100
+ else if (run?.status === 'completed' && run?.conclusion === 'skipped') checkRunCounts.skipped++;
1101
+ else if (run?.status === 'completed' && run?.conclusion === 'neutral') checkRunCounts.neutral++;
1102
+ else if (['waiting', 'pending', 'requested'].includes(run?.status) && run?.conclusion === null) checkRunCounts.waiting++;
1103
+ else if (run?.status === 'in_progress' && run?.conclusion === null) checkRunCounts.inProgress++;
1104
+ else if (run?.status === 'queued' && run?.conclusion === null) checkRunCounts.queued++;
1105
+ else checkRunCounts.other++;
1106
+ }
1092
1107
  const failedRun = checkRuns.find(
1093
- (r) => r.conclusion === 'failure' || r.conclusion === 'action_required',
1108
+ (r) => r?.conclusion === 'failure' || r?.conclusion === 'action_required',
1094
1109
  );
1095
1110
 
1096
1111
  if (failedRun) {
@@ -1101,11 +1116,12 @@ async function checkGithub(repoRoot, deps = {}) {
1101
1116
  details: {
1102
1117
  cliUsed: 'gh',
1103
1118
  reason: 'lastGreen-not-implemented-for-github',
1119
+ checkRunCounts,
1104
1120
  },
1105
1121
  };
1106
1122
  }
1107
1123
 
1108
- const allSuccess = checkRuns.every((r) => r.conclusion === 'success');
1124
+ const allSuccess = checkRunCounts.success === checkRuns.length;
1109
1125
  if (allSuccess) {
1110
1126
  return {
1111
1127
  status: 'green',
@@ -1116,13 +1132,24 @@ async function checkGithub(repoRoot, deps = {}) {
1116
1132
  };
1117
1133
  }
1118
1134
 
1119
- // Some runs pending / in-progress / etc.
1135
+ const states = [
1136
+ ['inProgress', 'check-runs-in-progress'],
1137
+ ['queued', 'check-runs-queued'],
1138
+ ['cancelled', 'check-runs-cancelled'],
1139
+ ['skipped', 'check-runs-skipped'],
1140
+ ['neutral', 'check-runs-neutral'],
1141
+ ['waiting', 'check-runs-waiting'],
1142
+ ['other', 'check-runs-unrecognised'],
1143
+ ].filter(([key]) => checkRunCounts[key] > 0);
1144
+ // Successes may accompany one unknown state; counts expose the whole mix.
1145
+ const reason = states.length === 1 ? states[0][1] : 'check-runs-mixed';
1120
1146
  return {
1121
1147
  status: 'unknown',
1122
1148
  ok: false,
1123
1149
  details: {
1124
1150
  cliUsed: 'gh',
1125
- reason: 'check-runs-not-complete',
1151
+ reason,
1152
+ checkRunCounts,
1126
1153
  },
1127
1154
  };
1128
1155
  }
@@ -1189,6 +1216,7 @@ async function checkGithub(repoRoot, deps = {}) {
1189
1216
  * cliUsed: 'glab'|'gh',
1190
1217
  * reason?: string,
1191
1218
  * error?: string,
1219
+ * checkRunCounts?: { success: number, failure: number, inProgress: number, queued: number, cancelled: number, skipped: number, neutral: number, waiting: number, other: number },
1192
1220
  * matchedRef?: string,
1193
1221
  * candidateCount?: number,
1194
1222
  * candidateStatuses?: string[],
@@ -1202,7 +1230,12 @@ async function checkGithub(repoRoot, deps = {}) {
1202
1230
  * #857 "several contradicting pipelines" and "only foreign-ref pipelines"
1203
1231
  * findings — and the #1390 "same-sha rows the ref preference set aside"
1204
1232
  * (`droppedCount`/`droppedStatuses`) — are delivered through ADDITIVE `details`
1205
- * fields and `details.reason`,
1233
+ * fields and `details.reason`. GitHub's `checkRunCounts` counts each row once:
1234
+ * `failure` includes action_required; `other` includes malformed/inconsistent
1235
+ * rows and unsupported statuses/conclusions. Non-success reasons are
1236
+ * check-runs-in-progress, check-runs-queued, check-runs-cancelled,
1237
+ * check-runs-skipped, check-runs-neutral, check-runs-waiting (#1526),
1238
+ * check-runs-unrecognised, or check-runs-mixed (multiple non-success kinds),
1206
1239
  * never as a new status value — consumers fail open on an unknown status string
1207
1240
  * and the session-start renderer prints nothing for one.
1208
1241
  */
@@ -239,5 +239,13 @@ export function validateEventRecord(record) {
239
239
  );
240
240
  }
241
241
 
242
+ if (
243
+ ['orchestrator.quality_gate.passed', 'orchestrator.quality_gate.failed'].includes(record.event) &&
244
+ Object.hasOwn(record, 'wave_number') &&
245
+ (!Number.isSafeInteger(record.wave_number) || record.wave_number <= 0)
246
+ ) {
247
+ errors.push('quality_gate wave_number must be a positive safe integer when present');
248
+ }
249
+
242
250
  return { valid: errors.length === 0, errors };
243
251
  }
@@ -0,0 +1,40 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { classifyManifestSession, readProcessLocalSessionIds } from './session-identity/own-session.mjs';
3
+ import { findOwnScopeFile } from './scope-gate.mjs';
4
+
5
+ /**
6
+ * Resolve the active wave number from the wave-scope sidecar (#966 step 1).
7
+ *
8
+ * Mirrors `resolveWave()` in `hooks/pre-bash-memory-propose-audit.mjs` — the
9
+ * same `.{pi,cursor,codex,claude}/wave-scope.json` precedence via
10
+ * {@link findOwnScopeFile}, skipping a PEER session's manifest in the same
11
+ * working copy (#1504: a peer-only result is no wave of ours, never the peer's
12
+ * number) — with ONE deliberate difference: the hook returns `0` for "no
13
+ * wave-scope file", this returns `null`.
14
+ *
15
+ * Absent is not zero. A human running `npm run quality-gate` from a `git push`
16
+ * has no wave at all, and that is the common case; publishing `wave_number: 0`
17
+ * would invent a wave 0 that every consumer then has to special-case. The
18
+ * caller spreads the result so the KEY is omitted, exactly as `counts` is.
19
+ *
20
+ * A non-positive, unsafe or non-integer `wave` field is treated the same way — waves
21
+ * are 1-indexed, so `0` on disk carries no more information than an absent file.
22
+ *
23
+ * Never throws.
24
+ *
25
+ * @param {string} projectDir — directory whose wave-scope sidecar to read.
26
+ * @returns {number|null} positive safe integer wave number, or `null` when there is no wave.
27
+ */
28
+ export function resolveWaveNumber(projectDir) {
29
+ try {
30
+ const ownIds = new Set(readProcessLocalSessionIds({ env: process.env, hookInput: null }));
31
+ const { path: waveFile } = findOwnScopeFile(projectDir, ownIds, classifyManifestSession);
32
+ if (!waveFile || !existsSync(waveFile)) return null;
33
+ const wave = JSON.parse(readFileSync(waveFile, 'utf8'))?.wave;
34
+ if (!Number.isSafeInteger(wave) || wave <= 0) return null;
35
+ return wave;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
@@ -67,6 +67,7 @@ import { dirname, join, resolve } from 'node:path';
67
67
  import { fileURLToPath } from 'node:url';
68
68
 
69
69
  import { emitEvent, sessionAttribution } from './events.mjs';
70
+ import { resolveWaveNumber } from './quality-gate-wave.mjs';
70
71
  import { parsePorcelainZ } from './git-porcelain.mjs';
71
72
  import { admitSuiteCounts, extractTestCounts } from './gates/gate-helpers.mjs';
72
73
  import {
@@ -834,32 +835,30 @@ export function gateKillFields(run) {
834
835
  * test gate (fail-fast on lint/typecheck) or its output carried no parseable
835
836
  * count. See {@link suiteCountsFromOutput}.
836
837
  *
837
- * IT DOES NOT YET REPLACE THE PROSE PATH (#957/F1 — the earlier wording here
838
- * claimed it did). `waves[].suite_passed` / `suite_failed` still travel as prose
839
- * through two LLM hops: `skills/wave-executor/wave-loop.md` step 7 hand-writes
840
- * them, `skills/session-end/metrics-collection.md` § 1.7 parses them back out of
841
- * the STATE.md Wave History header into sessions.jsonl. `counts` is a SECOND,
842
- * machine-measured emission of the same fact. Measured 2026-10-05 against this
843
- * repo's `.orchestrator/metrics/events.jsonl` (main checkout @ 1386a98e; the
844
- * file has been rotated since the 4404-record 2026-07-31 reading this note
845
- * used to quote): 82 `orchestrator.quality_gate.*` records, 46 carrying
846
- * `counts` (`grep '"orchestrator.quality_gate' <file> | grep -c '"counts"'`).
847
- *
848
- * `waves[].*` is PER-WAVE, so the event needs a per-wave key. The
849
- * `scripts/run-quality-gate.mjs` wrapper emits `wave_number` when a wave-scope
850
- * sidecar names one (#966 step 1, `resolveWaveNumber`) and OMITS it otherwise —
851
- * 37 of the same 82 records carry it
852
- * (`grep '"orchestrator.quality_gate' <file> | grep -c '"wave_number"'`).
853
- * Readers exist: `skills/session-end/metrics-collection.md` § 1.7 reads
854
- * `counts` by `wave_number` first and keeps the STATE.md header only as a
855
- * fallback (#966 step 3), and `scripts/lib/convergence-monitor.mjs` folds a
856
- * gate record only when it carries both. THIS emitter still sets no
857
- * `wave_number` — its records are mid-wave auto-fix retries, so they fall to
858
- * that fallback by design. A record without the key could only be attributed
859
- * to a wave by a wall-clock window whose own boundaries (`waves[].started_at`
860
- * / `completed_at`) are themselves LLM-written, against the posture
861
- * `scripts/lib/eval/session-resolve.mjs` documents for window-attributed gate
862
- * events ("a contaminated window means gate-attribution is unsafe").
838
+ * Both skill readers now take `counts` from `orchestrator.quality_gate.*`:
839
+ * `skills/wave-executor/references/wave-loop-review.md` step 7 and
840
+ * `skills/session-end/metrics-collection.md` § 1.7. They filter the flat record
841
+ * by the same `semantic_session_id` + `wave_number` and select the last
842
+ * matching gate run; `counts.passed` / `counts.failed` supply the per-wave
843
+ * `suite_passed` / `suite_failed`. A third reader,
844
+ * `scripts/lib/convergence-monitor.mjs`, folds `counts` from gate records
845
+ * that also carry `wave_number`. This library emits ONE record per
846
+ * `runQualityGateWithRetry` call, not per retry; `attempts` carries retries.
847
+ *
848
+ * Both emitters resolve `wave_number` from the existing own-session manifest
849
+ * at the event's repoRoot and omit it without a usable source. No wall-clock
850
+ * attribution or guessed wave number is added. Missing counts are not zeros.
851
+ * Count fallback is limited to pre-#954 sessions, gates outside the event
852
+ * wrapper, or missing wave attribution; step 7 falls back to this wave's own
853
+ * gate output (its header is not written yet), session end to the header. Auto-fix with a known wave
854
+ * uses the event; missing counts alone do not permit header fallback.
855
+ *
856
+ * `suite_platform` comes from this wave's gate run (step 7) or the STATE.md
857
+ * Wave History header `— suite <passed>/<failed> on <platform>` (session end);
858
+ * the payload has NO platform field.
859
+ * Event-derived counts may be mirrored into that existing header as
860
+ * compatibility output, not a second measurement source. Neither the header
861
+ * nor sessions.jsonl changes its contract.
863
862
  *
864
863
  * Note also that THIS emitter only runs under `verification-auto-fix.enabled:
865
864
  * true` (default `false`, and `false` in this repo's Session Config). The gate
@@ -891,12 +890,14 @@ export function gateKillFields(run) {
891
890
  */
892
891
  async function emitGateEvent(repoRoot, ok, attempts, gate, counts, killRun = null) {
893
892
  try {
893
+ const waveNumber = resolveWaveNumber(repoRoot);
894
894
  await emitEvent(
895
895
  `orchestrator.quality_gate.${ok ? 'passed' : 'failed'}`,
896
896
  {
897
897
  variant: 'auto-fix-loop',
898
898
  exit_code: ok ? 0 : 1,
899
899
  attempts,
900
+ ...(waveNumber !== null ? { wave_number: waveNumber } : {}),
900
901
  ...(gate ? { gate } : {}),
901
902
  ...(counts ? { counts } : {}),
902
903
  ...gateKillFields(killRun),
@@ -124,12 +124,11 @@ const INCOMPLETE_FIELD_ORDER = Object.freeze([
124
124
  const MS_PRECISION_FIELDS = Object.freeze(['started_at', 'completed_at', 'lease_acquired_at']);
125
125
 
126
126
  /**
127
- * ISO-8601 UTC with MORE than three fractional digits (`.994600Z`) — the one
128
- * rejected shape that has an unambiguous canonical form. Group 1 is the value
129
- * cut at the millisecond. Offsets, `.3Z` and non-ISO strings do not match and
130
- * stay for the validator to reject: there is no defensible default for them.
127
+ * ISO-8601 UTC with MORE than three fractional digits and exactly `Z` or
128
+ * `+00:00` (#1525). Group 1 is the value cut at the millisecond. Nonzero
129
+ * offsets, other timezone spellings and shorter fractions stay unrepaired.
131
130
  */
132
- const ISO_UTC_SUB_MS_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3})\d+Z$/;
131
+ const ISO_UTC_SUB_MS_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3})\d+(?:Z|\+00:00)$/;
133
132
 
134
133
  // ---------------------------------------------------------------------------
135
134
  // Helpers
@@ -155,7 +154,13 @@ function isCount(v) {
155
154
  function truncateToMs(value) {
156
155
  if (typeof value !== 'string') return null;
157
156
  const m = ISO_UTC_SUB_MS_RE.exec(value);
158
- return m ? `${m[1]}Z` : null;
157
+ if (!m) return null;
158
+ const canonical = `${m[1]}Z`;
159
+ const ms = Date.parse(canonical);
160
+ // Date.parse rolls over e.g. April 31, non-leap February 29 and 24:00.
161
+ // Equality with the original calendar components refuses those repairs.
162
+ if (!Number.isFinite(ms) || new Date(ms).toISOString() !== canonical) return null;
163
+ return canonical;
159
164
  }
160
165
 
161
166
  function orderIncompleteFields(fields) {
@@ -391,10 +396,16 @@ export function repairRecord(record) {
391
396
  preserveRaw(out, 'completed_at', record.completed_at, rescued);
392
397
  const endedMs = typeof out.ended_at === 'string' ? Date.parse(out.ended_at) : NaN;
393
398
  const startedMs = Date.parse(out.started_at);
394
- if (Number.isFinite(endedMs) && Number.isFinite(startedMs) && endedMs >= startedMs) {
399
+ const endedTruncated = truncateToMs(out.ended_at);
400
+ if (typeof out.ended_at === 'string' &&
401
+ (!Number.isFinite(endedMs) ||
402
+ (ISO_UTC_SUB_MS_RE.test(out.ended_at) && endedTruncated === null))) {
403
+ // Preserve an invalid legacy measurement for the final schema gate to
404
+ // reject; do not turn it into a valid default or a rolled-over date.
405
+ out.completed_at = out.ended_at;
406
+ } else if (Number.isFinite(endedMs) && Number.isFinite(startedMs) && endedMs >= startedMs) {
395
407
  // The COPY is cut to milliseconds; `ended_at` itself is not a validated
396
408
  // field and keeps its original bytes, so nothing needs a sidecar here.
397
- const endedTruncated = truncateToMs(out.ended_at);
398
409
  if (endedTruncated !== null) precisionCut = true;
399
410
  out.completed_at = endedTruncated ?? out.ended_at;
400
411
  } else {
@@ -182,6 +182,12 @@ function _validateTimestamps(entry) {
182
182
  `started_at must match ISO-8601 UTC ms format (YYYY-MM-DDTHH:MM:SS[.SSS]Z), got: ${entry.started_at}`
183
183
  );
184
184
  }
185
+ // Date.parse normalizes impossible calendar dates; compare without mutating the record.
186
+ const startedCanonical = entry.started_at.includes('.')
187
+ ? entry.started_at : entry.started_at.replace(/Z$/, '.000Z');
188
+ if (new Date(startedMs).toISOString() !== startedCanonical) {
189
+ throw new ValidationError(`started_at must be a valid calendar timestamp, got: ${entry.started_at}`);
190
+ }
185
191
  if (typeof entry.completed_at !== 'string') {
186
192
  throw new ValidationError('completed_at must be an ISO timestamp string');
187
193
  }
@@ -194,6 +200,12 @@ function _validateTimestamps(entry) {
194
200
  `completed_at must match ISO-8601 UTC ms format (YYYY-MM-DDTHH:MM:SS[.SSS]Z), got: ${entry.completed_at}`
195
201
  );
196
202
  }
203
+ // Date.parse normalizes impossible calendar dates; compare without mutating the record.
204
+ const completedCanonical = entry.completed_at.includes('.')
205
+ ? entry.completed_at : entry.completed_at.replace(/Z$/, '.000Z');
206
+ if (new Date(completedMs).toISOString() !== completedCanonical) {
207
+ throw new ValidationError(`completed_at must be a valid calendar timestamp, got: ${entry.completed_at}`);
208
+ }
197
209
  if (completedMs < startedMs) {
198
210
  throw new ValidationError(
199
211
  `completed_at (${entry.completed_at}) must be >= started_at (${entry.started_at})`
@@ -258,10 +258,11 @@ function commitRunsCommand(gh, sha) {
258
258
  * @returns {string}
259
259
  */
260
260
  function ciUnknownHint(result) {
261
- if (!PUSHED_FOLLOW_UP_REASONS.has(result?.details?.reason)) return CI_UNKNOWN_HINT_DEFAULT;
262
- // The GitHub reason names the gh CLI; a glab command there would be wrong.
263
- const gh = result.details.cliUsed === 'gh';
261
+ // Every GitHub unknown reason needs a GitHub hint, including running,
262
+ // queued and cancelled checks that do not trigger a pushed-commit query.
263
+ const gh = result?.details?.cliUsed === 'gh';
264
264
  const onDemand = gh ? 'run `gh run list` on demand' : CI_UNKNOWN_HINT_DEFAULT;
265
+ if (!PUSHED_FOLLOW_UP_REASONS.has(result?.details?.reason)) return onDemand;
265
266
  const pushed = result.pushed && typeof result.pushed === 'object' ? result.pushed : null;
266
267
  // Pure (#1396): the SHA is whatever the follow-up resolved inside the budget.
267
268
  // Without `pushed` (the follow-up overran or threw before resolving it) the
@@ -275,6 +276,20 @@ function ciUnknownHint(result) {
275
276
  return `last pushed: ${short} (pipeline not checked; run \`${commitRunsCommand(gh, sha)}\`)`;
276
277
  }
277
278
 
279
+ /** Additive GitHub diagnostics; absent counts leave existing banners unchanged. */
280
+ function checkRunCountsText(result) {
281
+ const counts = result?.details?.checkRunCounts;
282
+ if (!counts || result?.details?.cliUsed !== 'gh') return '';
283
+ const parts = [
284
+ ['success', 'successful'], ['failure', 'failed/action required'],
285
+ ['inProgress', 'in progress'], ['queued', 'queued'],
286
+ ['cancelled', 'cancelled'], ['skipped', 'skipped'],
287
+ ['neutral', 'neutral'], ['waiting', 'waiting'], ['other', 'unrecognised'],
288
+ ].filter(([key]) => Number.isSafeInteger(counts[key]) && counts[key] > 0)
289
+ .map(([key, label]) => `${label}: ${counts[key]}`);
290
+ return parts.length ? ` — Check runs: ${parts.join(', ')}` : '';
291
+ }
292
+
278
293
  /**
279
294
  * "Green, but allow_failure jobs failed" — the ONE predicate both the
280
295
  * `ci-status` renderer and its `severityOf` use (#1333). Two predicates once
@@ -584,7 +599,7 @@ export const PROBES = [
584
599
  // #1337: a RED pushed commit is an alert, and the banner says what the
585
600
  // rule judges (HR-106) — same 🚨 as a red HEAD.
586
601
  const mark = r.pushed?.verdict?.status === 'red' ? '🚨' : '⚠';
587
- return `${mark} ci-status: CI status for HEAD could not be determined (${reason}) — ${ciUnknownHint(r)}`;
602
+ return `${mark} ci-status: CI status for HEAD could not be determined (${reason})${checkRunCountsText(r)} — ${ciUnknownHint(r)}`;
588
603
  }
589
604
  if (r.status === 'red') {
590
605
  const pid = r.details?.currentPipelineId ?? '?';
@@ -592,7 +607,7 @@ export const PROBES = [
592
607
  ? ` — last green: #${r.lastGreen.pipelineId} (commit ${String(r.lastGreen.sha ?? '').slice(0, 7)}, ${r.redCount} pipelines ago)`
593
608
  : '';
594
609
  const job = r.failingJobName ? ` Failing job: ${r.failingJobName}` : '';
595
- return `🚨 CI RED on HEAD (pipeline #${pid})${green}.${job}`;
610
+ return `🚨 CI RED on HEAD (pipeline #${pid})${green}.${job}${checkRunCountsText(r)}`;
596
611
  }
597
612
  if (r.status === 'green') {
598
613
  const lines = [];
@@ -412,6 +412,11 @@ export async function dispatchRemote(
412
412
  * `Ferdinands-Macbook-2 ready=yes · load 14.23 · mem free 96% · headless: slots
413
413
  * none, keychain-route ok · 9 jobs · claude procs 12`
414
414
  *
415
+ * New doctor lines split `gate-ready` and `headless-ready`; only the latter
416
+ * authorizes Claude dispatch. Legacy `ready` remains accepted without new markers.
417
+ * `load` also holds the new `load5 (5 min)` reading; `memFreePct` holds free
418
+ * (Darwin) or available (Linux) percent. No live new-format measurement is implied.
419
+ *
415
420
  * Every metric is `null` when its segment is absent — never a fabricated `0`,
416
421
  * which would read as "measured, idle" for a host that answered nothing.
417
422
  * @param {string} raw
@@ -420,19 +425,32 @@ export async function dispatchRemote(
420
425
  */
421
426
  export function parseDoctorLine(raw) {
422
427
  const text = String(raw ?? '');
423
- const num = (re) => {
428
+ // New-format markers suppress the legacy fallback, even if their value or
429
+ // key boundary is malformed. Conflicting/unknown headless values fail closed.
430
+ const key = /\b(?:gate-ready|headless-ready)\b/i.test(text) ? 'headless-ready' : 'ready';
431
+ const values = [...text.matchAll(new RegExp(`(?:^|\\s)${key}=([^\\s]*)`, 'gi'))];
432
+ const count = (re) => {
424
433
  const m = re.exec(text);
425
434
  return m ? Number(m[1]) : null;
426
435
  };
436
+ const segments = text.split(/[·\r\n]/).map((segment) => segment.trim());
437
+ const num = (re, max = Infinity) => {
438
+ const m = segments.map((segment) => re.exec(segment)).find(Boolean);
439
+ const value = m ? Number(m[1]) : NaN;
440
+ return Number.isFinite(value) && value <= max ? value : null;
441
+ };
427
442
  return {
428
- // `ready` is the ONLY decision field: anything that is not a literal
429
- // `ready=yes` is not-ready, including an unparseable line.
430
- ready: /\bready=yes\b/i.test(text),
443
+ // Claude dispatch uses headless readiness, independently of gate readiness.
444
+ // Only the old format falls back to an exact, standalone `ready=yes` token.
445
+ ready: values.length > 0 && values.every((m) => /^yes$/i.test(m[1])),
431
446
  raw: text,
432
- load: num(/\bload\s+([0-9]+(?:\.[0-9]+)?)/i),
433
- memFreePct: num(/\bmem free\s+([0-9]+(?:\.[0-9]+)?)\s*%/i),
434
- jobs: num(/\b([0-9]+)\s+jobs?\b/i),
435
- claudeProcs: num(/\bclaude procs\s+([0-9]+)/i),
447
+ load: num(/^load(?:5\s+\(5 min\))?\s+([0-9]+(?:\.[0-9]+)?)$/i),
448
+ // The existing field holds Darwin free % or Linux available %, never kB.
449
+ memFreePct:
450
+ num(/^mem (?:free|available)\s+([0-9]+(?:\.[0-9]+)?)\s*%$/i, 100) ??
451
+ num(/^mem available\s+[0-9]+\s+kB available\s*\/\s*[0-9]+\s+kB total\s+\(([0-9]+(?:\.[0-9]+)?)% available\)$/i, 100),
452
+ jobs: count(/\b([0-9]+)\s+jobs?\b/i),
453
+ claudeProcs: count(/\bclaude procs\s+([0-9]+)/i),
436
454
  };
437
455
  }
438
456
 
@@ -491,7 +509,7 @@ export function remoteDoctor({ host, execFn = execFileSync, timeoutMs = 90_000 }
491
509
  * @param {string} alias — declared host alias (`remote-hosts[].alias`).
492
510
  * @param {object} [opts]
493
511
  * @param {Function} [opts.execFn] — `child_process.execFileSync` seam.
494
- * @returns {Promise<boolean>} true only on a literal `ready=yes` line.
512
+ * @returns {Promise<boolean>} true only for headless-ready=yes (legacy ready=yes without new markers).
495
513
  */
496
514
  export async function remoteReadyProbe(alias, { execFn } = {}) {
497
515
  try {
@@ -134,7 +134,8 @@ async function applyDecisionRules(measurements, opts) {
134
134
  * The gate does NOT probe the network — a placement decision must stay a pure
135
135
  * function of its inputs. The caller supplies a readiness WITNESS:
136
136
  * - `opts.remoteReady` — `{ [alias]: boolean }`, e.g. built from the
137
- * SessionStart `Offload m5: ready=yes` banner or `remoteDoctor()`.
137
+ * SessionStart `Offload <alias>: headless-ready=yes` banner (legacy `ready=yes` only
138
+ * without new readiness markers) or `remoteDoctor().ready`.
138
139
  * - `opts.probeFn` — `async (alias) => boolean`, consulted only for hosts the
139
140
  * `remoteReady` map does not already answer for. Default `null`. A probeFn
140
141
  * that REJECTS is read as not-ready and its message is appended to
@@ -387,11 +387,14 @@ const INTENTIONAL_TEST_ASSET_PATHS = new Set([
387
387
  // `scripts/lib/locks/index.mjs` is the one-cycle deprecation shim (5.2.0 CHANGELOG, Changed): its
388
388
  // runtime warning names the version it was deprecated IN, so it legitimately carries the previous
389
389
  // release literal until its 6.0.0 removal — version history by construction, not drift.
390
+ // `skills/eval/rubric-v3.md` (5.9.0 sweep, measured 2026-10-06): its change log quotes the dated
391
+ // owner decision "rubric-v3 in 5.8.0" (2026-10-05, #1487) — the release that introduced the rubric.
392
+ // Prose history, not a version surface; bumping it would falsify the quote.
390
393
  // `site/_census.json` is deliberately NOT allowlisted: it carries a `"version"` field that
391
394
  // `--set-version` re-stamps (scripts/site-numbers.mjs --write, see the post-bump hint in main()),
392
395
  // so leaving it under the drift sweep makes the sweep double as its staleness gate — a census
393
396
  // not re-stamped after a bump fails `--check` instead of shipping the previous version.
394
- export const HISTORY_ALLOWLIST = /^(CHANGELOG\.md|README\.md|scripts\/lib\/locks\/index\.mjs|CLAUDE\.md|AGENTS\.md|CONTRIBUTING\.md|NOTICE|\.husky\/pre-push|docs\/|tests\/|skills\/npm-publish\/|skills\/architecture\/references\/domain-model\.md|skills\/autopilot\/SKILL\.md|scripts\/release\.mjs|\.orchestrator\/|site\/leaderboard\.json|site\/guide\/index\.html|site\/llms-full\.txt|skills\/release\/SKILL\.md|marketing\/remotion\/(?:README\.md|campaign\.json|render-receipt\.json|src\/ReleaseFilm\.tsx)$)/;
397
+ export const HISTORY_ALLOWLIST = /^(CHANGELOG\.md|README\.md|scripts\/lib\/locks\/index\.mjs|CLAUDE\.md|AGENTS\.md|CONTRIBUTING\.md|NOTICE|\.husky\/pre-push|docs\/|tests\/|skills\/npm-publish\/|skills\/architecture\/references\/domain-model\.md|skills\/autopilot\/SKILL\.md|skills\/eval\/rubric-v3\.md|scripts\/release\.mjs|\.orchestrator\/|site\/leaderboard\.json|site\/guide\/index\.html|site\/llms-full\.txt|skills\/release\/SKILL\.md|marketing\/remotion\/(?:README\.md|campaign\.json|render-receipt\.json|src\/ReleaseFilm\.tsx)$)/;
395
398
 
396
399
  /** Pure check over packed-entry lines. Returns violations: {name, line}[]. */
397
400
  export function checkLeakage(lines) {
@@ -55,8 +55,8 @@ import {
55
55
  resolveGateTimeoutMs,
56
56
  } from './lib/gates/gate-helpers.mjs';
57
57
  import { buildCommandSignature, spawnInGroup } from './lib/process-group.mjs';
58
- import { classifyManifestSession, readProcessLocalSessionIds } from './lib/session-identity/own-session.mjs';
59
- import { findOwnScopeFile } from './lib/scope-gate.mjs';
58
+ import { resolveWaveNumber } from './lib/quality-gate-wave.mjs';
59
+ import { readProcessLocalSessionIds } from './lib/session-identity/own-session.mjs';
60
60
  import { gateKillFields } from './lib/quality-gate.mjs';
61
61
  import { isMainModule } from './lib/is-main-module.mjs';
62
62
  import { shellQuote } from './lib/sh-quote.mjs';
@@ -266,42 +266,6 @@ function resolveLedgerRoot(value) {
266
266
  return null;
267
267
  }
268
268
 
269
- /**
270
- * Resolve the active wave number from the wave-scope sidecar (#966 step 1).
271
- *
272
- * Mirrors `resolveWave()` in `hooks/pre-bash-memory-propose-audit.mjs` — the
273
- * same `.{pi,cursor,codex,claude}/wave-scope.json` precedence via
274
- * {@link findOwnScopeFile}, skipping a PEER session's manifest in the same
275
- * working copy (#1504: a peer-only result is no wave of ours, never the peer's
276
- * number) — with ONE deliberate difference: the hook returns `0` for "no
277
- * wave-scope file", this returns `null`.
278
- *
279
- * Absent is not zero. A human running `npm run quality-gate` from a `git push`
280
- * has no wave at all, and that is the common case; publishing `wave_number: 0`
281
- * would invent a wave 0 that every consumer then has to special-case. The
282
- * caller spreads the result so the KEY is omitted, exactly as `counts` is.
283
- *
284
- * A non-positive or non-numeric `wave` field is treated the same way — waves
285
- * are 1-indexed, so `0` on disk carries no more information than an absent file.
286
- *
287
- * Never throws.
288
- *
289
- * @param {string} projectDir — directory whose wave-scope sidecar to read.
290
- * @returns {number|null} positive wave number, or `null` when there is no wave.
291
- */
292
- function resolveWaveNumber(projectDir) {
293
- try {
294
- const ownIds = new Set(readProcessLocalSessionIds({ env: process.env, hookInput: null }));
295
- const { path: waveFile } = findOwnScopeFile(projectDir, ownIds, classifyManifestSession);
296
- if (!waveFile || !existsSync(waveFile)) return null;
297
- const wave = JSON.parse(readFileSync(waveFile, 'utf8'))?.wave;
298
- if (typeof wave !== 'number' || !Number.isFinite(wave) || wave <= 0) return null;
299
- return Math.trunc(wave);
300
- } catch {
301
- return null;
302
- }
303
- }
304
-
305
269
  // ---------------------------------------------------------------------------
306
270
  // Main — the CLI body. Guarded below so a bare `import()` runs none of it.
307
271
  // ---------------------------------------------------------------------------
@@ -471,7 +435,13 @@ async function main() {
471
435
  //
472
436
  // stdout is PIPED (not inherited) so the suite counts the gate already computed
473
437
  // can be lifted straight off its JSON envelope into telemetry (#954) instead of
474
- // travelling as prose through the STATE.md header. The envelope is re-emitted
438
+ // travelling as prose through the STATE.md header. Both skill readers select
439
+ // the last matching flat event by semantic_session_id + wave_number; there is
440
+ // no platform field on the payload. Count fallback (wave-loop step 7: this
441
+ // wave's own gate output; session end: the STATE.md header) is limited to
442
+ // pre-#954 sessions, gates outside the event wrapper, or missing wave
443
+ // attribution (see docs/events-schema.md).
444
+ // The envelope is re-emitted
475
445
  // verbatim below, so the stdout contract is unchanged — a gate sub-script writes
476
446
  // exactly one JSON line at the very end (its own child commands are captured by
477
447
  // `runCheck`), so nothing streamed before and nothing streams now.
@@ -996,11 +996,15 @@ export const SITEMAP_FILE = 'sitemap.xml';
996
996
  * Unknown either way is an ERROR, never a skip (the `METRIC_IDS` rule): a
997
997
  * sitemap `<loc>` missing here would never be dated, and a page carrying cells
998
998
  * that is missing here would drift unseen.
999
+ *
1000
+ * A page without counted cells (for example /lead) never has its <lastmod>
1001
+ * raised automatically: bump it by hand whenever its content changes.
999
1002
  */
1000
1003
  export const SITEMAP_PAGES = Object.freeze([
1001
1004
  { page: 'index.html', loc: 'https://session-orchestrator.com/' },
1002
1005
  { page: 'de/index.html', loc: 'https://session-orchestrator.com/de' },
1003
1006
  { page: 'guide/index.html', loc: 'https://session-orchestrator.com/guide' },
1007
+ { page: 'lead/index.html', loc: 'https://session-orchestrator.com/lead' },
1004
1008
  { page: 'impressum/index.html', loc: 'https://session-orchestrator.com/impressum' },
1005
1009
  { page: 'datenschutz/index.html', loc: 'https://session-orchestrator.com/datenschutz' },
1006
1010
  ]);
@@ -8,6 +8,8 @@ knows paths, fields, deadlines and lease validity; `scripts/fleet-checkin.mjs` w
8
8
  This file describes them and never redefines them. The coordinator itself is not part of this
9
9
  package: it is operated outside the plugin, which owns its operational detail (how it measures,
10
10
  which conditions it issues, how it acquires and hands over the lease).
11
+ A public coordinator that implements this contract is lead-session-orchestrator
12
+ (https://session-orchestrator.com/lead); any other tool that writes the same files works too.
11
13
 
12
14
  **Grundsatz (CSM-003).** Zustand liegt in Dateien; Nachrichten sind nur Hinweise auf eine Datei.
13
15
  Der Navigator erteilt keine Freigaben — maßgeblich ist der Owner-Auftrag der Session. Eine Datei,
@@ -30,7 +30,7 @@ Exit codes (`offload --help`, measured 2026-09-02): `0` ok · `1` usage/config
30
30
 
31
31
  The gate decides, not the coordinator. `applyOffloadDecision()` in `scripts/lib/wave-resource-gate.mjs` fires when the resource verdict is `reduce` or `coordinator-direct` (and, with `offload-first: true`, on `proceed` as well — see below), and only AFTER the HR-004 heavy-repo cap — a capped wave that offloads still respects the cap. It never probes the network; the coordinator supplies a readiness WITNESS:
32
32
 
33
- - `opts.remoteReady` — `{ [alias]: boolean }`, built from the SessionStart banner line `Offload <alias>: ready=yes …`, or
33
+ - `opts.remoteReady` — `{ [alias]: boolean }`, built from the SessionStart banner line `Offload <alias>: headless-ready=yes …` (legacy `ready=yes` only without new readiness markers; `gate-ready` does not authorize Claude dispatch), or
34
34
  - `opts.probeFn` — an async `(alias) => boolean` fallback, consulted only for aliases `remoteReady` doesn't answer for (backed by `remoteDoctor()`, i.e. `offload doctor -H <alias> --brief` parsed by `parseDoctorLine()`).
35
35
 
36
36
  With neither supplied, no host counts as ready and the wave stays local — the gate fails toward local, never toward an unverified host. A role in `NEVER_FOREIGN_ROLES` (`scripts/lib/wave-executor/dispatch-common.mjs`: `impl-core`, `security-review`, `migration`, `release`, `secrets`, `incident`, `refactor-crosscut`) is never offloaded regardless of readiness.
@@ -172,19 +172,25 @@ Finalize session metrics by reading the wave data accumulated during execution:
172
172
  > - `effectiveness`: ALWAYS populated from Phase 1 plan verification results, and CONSTRUCTED EXPLICITLY in the METRICS_ENTRY snippet (#773) — never deferred to a "remember to add" optional step (that omission is how `carryover: 0` slipped past 41 records). `completion_rate` = `completed / planned_issues` (0.0-1.0, where 0.0 means nothing was completed). **`carryover` counting rule (#773):** `carryover` is the **length of the Phase 1.65 gate carry-list** — `autoCarry` ∪ the middle-band `ask` items the operator LEFT SELECTED ∪ the answered-question `impliesWork: true` candidates — NOT the raw Phase 1.2+1.3 candidate count. On the fail-open skip (gate disabled / headless / AUQ unavailable), EVERY candidate carries, so `carryover` = the full candidate-list length. Count the gate's OUTPUT (what reaches Phase 5 Step 3 filing), not its INPUT.
173
173
  > - `effectiveness.override_ratio` (#730/H5): OPTIONAL nested field = `overridden_findings / max(total_findings_surfaced, 1)` (float 0.0-1.0). Populate ONLY when Phase 2.6 (Broken-Window Budget) ran this session (`broken-window-budget.enabled: true`). OMIT (do NOT write null/0) otherwise — **absent = "not measured"**, `0.0` = "measured, nothing overridden". `overridden_findings` = the summed `count` of the `orchestrator.finding.overridden` events emitted this session; `total_findings_surfaced` = every MED/LOW+ finding surfaced across Phase 1.8 + wave reviewers.
174
174
  > - `waves[].agent_count_planned` / `waves[].agent_count_started` / `waves[].agent_count_completed` (#724/#1115): OPTIONAL per-wave fields, sourced from `wave-loop.md` § Capture wave metrics step 7 — mirror its definitions exactly, do not re-derive them here. `agent_count_planned` = agents named in the session plan for this wave. `agent_count_started` = distinct agents whose `agent-<id>.meta.json` sidecar is present, after any silent-drop re-dispatch — NOT "produced a tool-result" (under background dispatch the launch ack is a result and would count an agent that never ran). `agent_count_completed` = distinct agents whose task-notification (`<status>completed</status>`) arrived. Omit each field when the wave did not measure it — **absent = "not measured"**, never zero-fill; `0` would read as "measured, no agent started", which is the opposite of an unmeasured wave. The two gaps carry the diagnosis: `agent_count_planned > agent_count_started` after re-dispatch is a persistent silent drop, `agent_count_started > agent_count_completed` at wave end is an agent that started and never returned. Both are also logged to STATE.md `## Deviations` by wave-loop.md, so a record and a deviation entry should agree.
175
- > - `waves[].suite_passed` / `waves[].suite_failed` / `waves[].suite_platform` (#944): OPTIONAL per-wave fields. Omit all three when absent — absent = "not measured", `suite_failed: 0` = "measured, zero failures".
176
- > **`suite_passed` / `suite_failed`: read the event FIRST, the STATE.md header only as fallback (#966 step 3).** Since #954/#967 the between-waves gate wrapper `scripts/run-quality-gate.mjs` emits `orchestrator.quality_gate.{passed,failed}` with a machine-measured `counts: {passed, failed, total}` AND the `wave_number` it resolved from the `wave-scope.json` sidecar, so per-wave attribution needs no wall-clock window join. Payload fields are flat at the record's top level; for each wave `N` of this session:
175
+ > - `waves[].suite_passed` / `waves[].suite_failed` / `waves[].suite_platform` (#944): OPTIONAL per-wave fields. Treat counts and header platform independently; absent = "not measured", `suite_failed: 0` = "measured, zero failures".
176
+ > **`suite_passed` / `suite_failed`: read the event FIRST, the STATE.md header only as fallback (#966 step 3).** Since #954/#967 the between-waves gate wrapper `scripts/run-quality-gate.mjs` emits `orchestrator.quality_gate.{passed,failed}` with a machine-measured `counts: {passed, failed, total}` AND the `wave_number` it resolved from the `wave-scope.json` sidecar, so per-wave attribution needs no wall-clock window join. Payload fields are flat at the record's top level; for each wave of this session:
177
177
  >
178
178
  > ```bash
179
- > jq -c --argjson w N --arg s "<semantic_session_id>" '
180
- > select(.event | startswith("orchestrator.quality_gate."))
181
- > | select(.semantic_session_id == $s and .wave_number == $w and .counts != null)
182
- > | .counts' .orchestrator/metrics/events.jsonl | tail -1
179
+ > jq -cR --argjson w "$WAVE" --arg s "$SEMANTIC_SESSION_ID" '
180
+ > fromjson? | objects
181
+ > | select((.event | type) == "string" and (.event | startswith("orchestrator.quality_gate.")))
182
+ > | select(.semantic_session_id == $s and .wave_number == $w and .["counts"] != null)
183
+ > | .["counts"]
184
+ > ' .orchestrator/metrics/events.jsonl | tail -1
183
185
  > ```
184
186
  >
185
- > Filtering by `semantic_session_id` is mandatory — `events.jsonl` accumulates across sessions and every past session also had a wave `N`. Take the LAST matching record (the wave's final gate run); `counts.passed` → `suite_passed`, `counts.failed` → `suite_failed`. No match = the field was not measured for that wave → omit, never zero-fill (the producer already omits `counts` rather than zero-filling when a run fail-fast'd before the test gate).
186
- > **Fallback, still live:** when no event matches, fall back to the STATE.md Wave History header `— suite <passed>/<failed> on <platform>` (written by wave-executor §3a since #944). Three cases genuinely need it: pre-#954 sessions, a gate run outside the `run-quality-gate.mjs` wrapper, and the `verification-auto-fix` producer in `scripts/lib/quality-gate.mjs`, which emits `counts` but no `wave_number` (its records are mid-wave retries, so not matching the selector is correct).
187
- > **`suite_platform` has no event source at all** and is read from the STATE.md header, unchanged. **Remaining work to retire the prose path fully:** (1) carry the platform on the gate event payload; (2) once a session has landed with the event path green and no fallback hits, drop the hand-written trio from `wave-loop.md` step 7. Until both hold, the trio stays written — deleting the writer before the reader is proven loses the numbers for sessions in flight.
187
+ > `WAVE` and `SEMANTIC_SESSION_ID` come from the existing own session/wave identity; never construct either for this lookup. Filtering by both is mandatory: the ledger accumulates across sessions, and a different session or wave must not supply these counts. `counts.passed` → `suite_passed`, `counts.failed` → `suite_failed`. `tail -1` selects the last matching gate run of the wave, including auto-fix. `-R` with `fromjson? | objects` skips an unreadable ledger line (a torn tail from a killed writer, #1401) instead of letting one parse error end `jq` and silently empty the selector behind `| tail -1`. The library emits one record per `runQualityGateWithRetry` call, not per retry; `attempts` is not a record count.
188
+ >
189
+ > **Header fallback for counts is limited to three cases:** pre-#954 sessions, a gate run outside the event wrapper, or missing wave attribution (including an auto-fix call without a readable own wave scope). An event without `semantic_session_id` cannot match the selector — emitters set it only from the session lock via `sessionAttribution()` — and is treated like a gate run outside the wrapper. In those cases only, read the STATE.md Wave History header `— suite <passed>/<failed> on <platform>`. An auto-fix call with a known wave uses the event selector above. An empty selector alone is not a fallback case: an event without `counts`, for example after fail-fast before the test suite, supplies no replacement zeros and does not by itself permit header fallback.
190
+ >
191
+ > If no counts are available and none of those fallback cases applies, omit `suite_passed` and `suite_failed`. Absent means "not measured"; a present `suite_failed: 0` means "measured, zero failures". Never zero-fill missing measurements.
192
+ >
193
+ > **`suite_platform` comes separately and exclusively from the STATE.md Wave History header. There is no platform field in the event payload.** Omit it when no header platform measurement is available; do not infer it from `variant`, runner OS, or session platform. Event-derived counts may still be mirrored into the existing header as compatibility output, not a second measurement source; keep the header and sessions.jsonl contracts unchanged.
188
194
  > - `open_questions_asked` / `open_questions_answered` / `open_questions_deferred` (#773): the three open-question counts from the Phase 1.65 gate's AUQ Call 2 (identical to the `questions_*` payload fields on the `orchestrator.handover.gated` event). Top-level, additive, non-negative integers. Populate ONLY when the gate ran an interactive triage ("Closen + Triage" path). OMIT all three (do NOT write `0`) when the gate was skipped (fail-open / headless / disabled) or took the fast-path — absent = "not measured", `0` = "measured, zero questions". Validator accepts absent/null/non-negative-integer.
189
195
  > - `stagnation_events`: populated ONLY when ≥1 stagnation event was logged to `events.jsonl` during this session. When `total == 0`, the field is omitted from the JSONL entry.
190
196
  > - `grounding_injections`: populated ONLY when ≥1 `orchestrator.grounding.injected` event was logged to `events.jsonl` during this session. When `count == 0`, the field is omitted from the JSONL entry.
@@ -84,7 +84,7 @@ const gate = await evaluateWaveResourceGate({
84
84
 
85
85
  **The offload witness is supplied, never probed (#1160).** The gate's own JSDoc states it: `@param {Record<string, boolean>} [opts.remoteReady] — #1160 readiness witness per declared host alias. The gate never probes the network itself; without a witness no host counts as ready and the decision stays local.` So an `offload` decision is impossible unless the coordinator passes one of two things alongside `config`/`plannedAgents`/`waveRole`:
86
86
 
87
- - `remoteReady: { '<alias>': true }` — built from the SessionStart banner line `Offload <alias>: ready=yes`, which was measured at session start and needs no new network call here; or
87
+ - `remoteReady: { '<alias>': true }` — built from the SessionStart banner line `Offload <alias>: headless-ready=yes` (legacy `ready=yes` only without new readiness markers; `gate-ready` does not authorize Claude dispatch), which was measured at session start and needs no new network call here; or
88
88
  - `probeFn: remoteReadyProbe` from `scripts/lib/wave-executor/remote-dispatch.mjs` — `@param {(alias: string) => Promise<boolean>} [opts.probeFn] — optional async witness, consulted only for aliases absent from `remoteReady`. Default null.` Use it when the banner is stale or absent; it costs a live probe per alias.
89
89
 
90
90
  Passing neither is a valid choice, not a bug: the gate then behaves exactly as it did before #1160 and reduces locally.
@@ -378,15 +378,21 @@ If the commit itself fails (e.g., nothing to commit, pre-commit hook rejects), d
378
378
  **Copy the two counts off the gate's own event — do not re-read them from the terminal (#966 step 3).** `scripts/run-quality-gate.mjs`, the wrapper that fires between waves, emits `orchestrator.quality_gate.{passed,failed}` carrying a machine-measured `counts: {passed, failed, total}` (admitted by `admitSuiteCounts()`) plus the `wave_number` it resolved from `wave-scope.json`. Payload fields are flat at the record's top level:
379
379
 
380
380
  ```bash
381
- jq -c --argjson w <wave_number> --arg s "<semantic_session_id>" '
382
- select(.event | startswith("orchestrator.quality_gate."))
383
- | select(.semantic_session_id == $s and .wave_number == $w and .counts != null)
384
- | .counts' .orchestrator/metrics/events.jsonl | tail -1
381
+ jq -cR --argjson w "$WAVE" --arg s "$SEMANTIC_SESSION_ID" '
382
+ fromjson? | objects
383
+ | select((.event | type) == "string" and (.event | startswith("orchestrator.quality_gate.")))
384
+ | select(.semantic_session_id == $s and .wave_number == $w and .["counts"] != null)
385
+ | .["counts"]
386
+ ' .orchestrator/metrics/events.jsonl | tail -1
385
387
  ```
386
388
 
387
- The session filter is not optional — `events.jsonl` accumulates across sessions and every past session also had a wave with this number.
388
- **OMIT all three when that selector returns nothing** — absent = "not measured", `suite_failed: 0` = "measured, zero failures". Never write `0` for a suite that did not run. The event enforces the same distinction at the producer: `counts` is omitted, never zero-filled, when the run fail-fast'd before the test gate or its output carried no parseable count.
389
- > **What is NOT on the event, and stays hand-written:** `suite_platform` — the payload has no platform field, so keep writing it from the § 3a header as before. Likewise, the auto-fix-loop producer (`scripts/lib/quality-gate.mjs`, active only under `verification-auto-fix.enabled: true`) emits `counts` WITHOUT `wave_number`, so its retry records correctly never match the selector above; they are mid-wave attempts, not the wave's verdict. If the wave's gate ran outside `run-quality-gate.mjs` entirely, no event exists — fall back to the gate output you read, and say so in the progress update. The reader side (`skills/session-end/metrics-collection.md` § 1.7) reads the event first and this hand-written trio second, so keep writing the trio: it is the compatibility path for those two cases and for sessions already in flight.
389
+ `WAVE` and `SEMANTIC_SESSION_ID` come from the existing own session/wave identity; never construct either for this lookup. Filtering by both is mandatory: the ledger accumulates across sessions, and a different session or wave must not supply these counts. `counts.passed` → `suite_passed`, `counts.failed` → `suite_failed`. `tail -1` selects the last matching gate run of the wave, including auto-fix. `-R` with `fromjson? | objects` skips an unreadable ledger line (a torn tail from a killed writer, #1401) instead of letting one parse error end `jq` and silently empty the selector behind `| tail -1`. The library emits one record per `runQualityGateWithRetry` call, not per retry; `attempts` is not a record count.
390
+
391
+ **Fallback for counts is limited to three cases:** pre-#954 sessions, a gate run outside the event wrapper, or missing wave attribution (including an auto-fix call without a readable own wave scope). An event without `semantic_session_id` cannot match the selector — emitters set it only from the session lock via `sessionAttribution()` — and is treated like a gate run outside the wrapper. In those cases only, fall back to the gate output you read for this wave — the same run whose counts go into the § 3a header — and say so in the progress update. Do not read the STATE.md Wave History header here: this step runs before § 3a writes this wave's header, so the newest header line there belongs to the previous wave. An auto-fix call with a known wave uses the event selector above. An empty selector alone is not a fallback case: an event without `counts`, for example after fail-fast before the test suite, supplies no replacement zeros and does not by itself permit header fallback.
392
+
393
+ If no counts are available and none of those fallback cases applies, omit `suite_passed` and `suite_failed`. Absent means "not measured"; a present `suite_failed: 0` means "measured, zero failures". Never zero-fill missing measurements.
394
+
395
+ **`suite_platform` comes separately from that same gate run of this wave — the platform it ran on, which § 3a then writes as `on <platform>` into the header. There is no platform field in the event payload.** Omit it when the platform of that run is not known; never infer it from `variant`. Event-derived counts may still be mirrored into the existing header as compatibility output, not a second measurement source; keep the header and sessions.jsonl contracts unchanged.
390
396
  Append this wave record to the session metrics `waves` array.
391
397
 
392
398
  7a. **Scope drift tripwire (S2 — #896, warn-only)**: session-cumulative and filtered through `DRIFT_EXCLUDE_PATTERNS`, so its `filesRatio` is NOT expected to agree with this wave's unfiltered `files_changed` from step 7. Only the committed part of its numerator starts at `session-start-ref`; the uncommitted and untracked parts read the checkout as it is now, so files already dirty at session start and a same-checkout peer's files count too (an over-report, named as a ceiling in `computeDrift()`'s JSDoc). Call `computeDrift()` from the same `scripts/lib/scope-baseline.mjs` module as `wave-loop-dispatch.md` § 0a Scope Baseline Freeze. Never blocks — exit code stays 0 and the next wave is dispatched regardless of the result.