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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +19 -0
- package/README.md +11 -8
- package/docs/events-schema.md +41 -1
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/package.json +1 -1
- package/scripts/lib/ci-status-banner.mjs +38 -5
- package/scripts/lib/events-schema.mjs +8 -0
- package/scripts/lib/quality-gate-wave.mjs +40 -0
- package/scripts/lib/quality-gate.mjs +27 -26
- package/scripts/lib/session-record-repair.mjs +19 -8
- package/scripts/lib/session-schema/validator.mjs +12 -0
- package/scripts/lib/session-start-probes.mjs +20 -5
- package/scripts/lib/wave-executor/remote-dispatch.mjs +27 -9
- package/scripts/lib/wave-resource-gate.mjs +2 -1
- package/scripts/release.mjs +4 -1
- package/scripts/run-quality-gate.mjs +9 -39
- package/scripts/site-numbers.mjs +4 -0
- package/skills/_shared/fleet-protocol.md +2 -0
- package/skills/remote-offload/SKILL.md +1 -1
- package/skills/session-end/metrics-collection.md +15 -9
- package/skills/wave-executor/references/wave-loop-dispatch.md +1 -1
- package/skills/wave-executor/references/wave-loop-review.md +13 -7
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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)
|
|
4
|
-
[](CHANGELOG.md)
|
|
5
5
|
[](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.
|
|
142
|
+
## Recent highlights (v5.9.0)
|
|
143
143
|
|
|
144
|
-
Highlights of the v5.
|
|
144
|
+
Highlights of the v5.9.0 line:
|
|
145
145
|
|
|
146
|
-
- **
|
|
147
|
-
-
|
|
148
|
-
- **
|
|
149
|
-
- **
|
|
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).
|
package/docs/events-schema.md
CHANGED
|
@@ -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'`. **
|
|
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 |
|
package/hooks/hooks-codex.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"hooks": [
|
|
8
8
|
{
|
|
9
9
|
"type": "command",
|
|
10
|
-
"command": "echo '🎯 Session Orchestrator v5.
|
|
10
|
+
"command": "echo '🎯 Session Orchestrator v5.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.
|
|
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.
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
838
|
-
*
|
|
839
|
-
*
|
|
840
|
-
*
|
|
841
|
-
*
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
*
|
|
845
|
-
*
|
|
846
|
-
*
|
|
847
|
-
*
|
|
848
|
-
*
|
|
849
|
-
*
|
|
850
|
-
*
|
|
851
|
-
*
|
|
852
|
-
* (
|
|
853
|
-
*
|
|
854
|
-
*
|
|
855
|
-
*
|
|
856
|
-
*
|
|
857
|
-
*
|
|
858
|
-
*
|
|
859
|
-
*
|
|
860
|
-
*
|
|
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
|
|
128
|
-
*
|
|
129
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
//
|
|
263
|
-
const gh = result
|
|
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
|
-
|
|
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
|
-
//
|
|
429
|
-
//
|
|
430
|
-
ready:
|
|
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(
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
|
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
|
|
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
|
package/scripts/release.mjs
CHANGED
|
@@ -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 {
|
|
59
|
-
import {
|
|
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.
|
|
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.
|
package/scripts/site-numbers.mjs
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
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 -
|
|
180
|
-
>
|
|
181
|
-
> | select(.
|
|
182
|
-
> | .
|
|
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
|
-
>
|
|
186
|
-
>
|
|
187
|
-
>
|
|
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
|
|
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 -
|
|
382
|
-
|
|
383
|
-
| select(.
|
|
384
|
-
| .
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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.
|