session-orchestrator 5.8.0 → 5.10.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.
Files changed (52) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/CHANGELOG.md +52 -2
  6. package/README.md +14 -10
  7. package/docs/USER-GUIDE.md +2 -0
  8. package/docs/ci-setup.md +46 -1
  9. package/docs/events-schema.md +71 -1
  10. package/docs/owner-config-schema.md +18 -1
  11. package/docs/owner-privacy-patterns.md +85 -0
  12. package/docs/scope-collision-guard.md +12 -2
  13. package/hooks/enforce-commands.mjs +9 -19
  14. package/hooks/enforce-scope.mjs +23 -42
  15. package/hooks/hooks-codex.json +1 -1
  16. package/hooks/hooks.json +1 -1
  17. package/hooks/post-bash-write-verify.mjs +38 -21
  18. package/package.json +2 -2
  19. package/scripts/emit-session.mjs +15 -0
  20. package/scripts/lib/ci-status-banner.mjs +38 -5
  21. package/scripts/lib/events-schema.mjs +8 -0
  22. package/scripts/lib/gates/gate-full.mjs +6 -0
  23. package/scripts/lib/gates/gate-helpers.mjs +2 -0
  24. package/scripts/lib/named-vault-resolver.mjs +1 -1
  25. package/scripts/lib/owner-yaml.mjs +33 -3
  26. package/scripts/lib/platform.mjs +70 -1
  27. package/scripts/lib/pre-push-remote-gate.mjs +107 -0
  28. package/scripts/lib/quality-gate-wave.mjs +40 -0
  29. package/scripts/lib/quality-gate.mjs +32 -26
  30. package/scripts/lib/redact-spans.mjs +8 -17
  31. package/scripts/lib/scope-gate.mjs +68 -5
  32. package/scripts/lib/session-record-repair.mjs +19 -8
  33. package/scripts/lib/session-schema/validator.mjs +12 -0
  34. package/scripts/lib/session-start-probes.mjs +20 -5
  35. package/scripts/lib/validate/check-owner-leakage.mjs +198 -1015
  36. package/scripts/lib/vault-backfill/template.mjs +1 -1
  37. package/scripts/lib/vault-mirror/render-sessions.mjs +15 -3
  38. package/scripts/lib/vault-status/board-writer.mjs +15 -7
  39. package/scripts/lib/wave-executor/remote-dispatch.mjs +27 -9
  40. package/scripts/lib/wave-resource-gate.mjs +2 -1
  41. package/scripts/migrate-vault-paths.mjs +57 -60
  42. package/scripts/release.mjs +107 -37
  43. package/scripts/run-quality-gate.mjs +9 -39
  44. package/scripts/site-numbers.mjs +4 -0
  45. package/scripts/vault-consolidate.mjs +19 -4
  46. package/skills/_shared/fleet-protocol.md +2 -0
  47. package/skills/claude-md-drift-check/SKILL.md +1 -1
  48. package/skills/claude-md-drift-check/checker.mjs +14 -3
  49. package/skills/remote-offload/SKILL.md +1 -1
  50. package/skills/session-end/metrics-collection.md +39 -9
  51. package/skills/wave-executor/references/wave-loop-dispatch.md +1 -1
  52. package/skills/wave-executor/references/wave-loop-review.md +37 -7
@@ -23,7 +23,7 @@
23
23
  // + Unreleased folded, drift sweep, tag collision
24
24
  // (local, origin, github), github/main mirror parity,
25
25
  // npm registry collision, npm token liveness, CI green
26
- // on HEAD, leakage gate over `npm pack --dry-run`.
26
+ // on HEAD, leakage gate over retained archive filenames and contents.
27
27
  // --publish Runs --check first, then token publish via temp
28
28
  // userconfig (NPM_TOKEN from .env.local). A
29
29
  // target-confirmed npm receipt is the irreversible
@@ -80,10 +80,14 @@ import {
80
80
  chmodSync,
81
81
  rmSync,
82
82
  realpathSync,
83
+ mkdirSync,
84
+ lstatSync,
83
85
  } from 'node:fs';
84
- import { join, relative as relativePath, sep } from 'node:path';
86
+ import { join, relative as relativePath, sep, basename, isAbsolute } from 'node:path';
85
87
  import { tmpdir } from 'node:os';
86
88
  import { spawnSync } from 'node:child_process';
89
+ import { createHash } from 'node:crypto';
90
+ import { packedFilename } from './lib/plugin-package-stage.mjs';
87
91
  import { parseArgs } from 'node:util';
88
92
  import { fileURLToPath } from 'node:url';
89
93
 
@@ -387,11 +391,14 @@ const INTENTIONAL_TEST_ASSET_PATHS = new Set([
387
391
  // `scripts/lib/locks/index.mjs` is the one-cycle deprecation shim (5.2.0 CHANGELOG, Changed): its
388
392
  // runtime warning names the version it was deprecated IN, so it legitimately carries the previous
389
393
  // release literal until its 6.0.0 removal — version history by construction, not drift.
394
+ // `skills/eval/rubric-v3.md` (5.9.0 sweep, measured 2026-10-06): its change log quotes the dated
395
+ // owner decision "rubric-v3 in 5.8.0" (2026-10-05, #1487) — the release that introduced the rubric.
396
+ // Prose history, not a version surface; bumping it would falsify the quote.
390
397
  // `site/_census.json` is deliberately NOT allowlisted: it carries a `"version"` field that
391
398
  // `--set-version` re-stamps (scripts/site-numbers.mjs --write, see the post-bump hint in main()),
392
399
  // so leaving it under the drift sweep makes the sweep double as its staleness gate — a census
393
400
  // 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)$)/;
401
+ 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
402
 
396
403
  /** Pure check over packed-entry lines. Returns violations: {name, line}[]. */
397
404
  export function checkLeakage(lines) {
@@ -825,6 +832,80 @@ export function evaluateLeakageGate(pack, { minEntries = MIN_PACKED_ENTRIES } =
825
832
  : { ok: true, detail: `${entries} packed entries, 0 leaks` };
826
833
  }
827
834
 
835
+ /** Strict npm 11/12 inventory, preserving the existing filename policy/floor. */
836
+ export function evaluatePackedInventory(pack, { minEntries = MIN_PACKED_ENTRIES } = {}) {
837
+ if (pack.status !== 0) return { ok: false, detail: 'npm pack failed' };
838
+ try {
839
+ const json = JSON.parse(pack.stdout);
840
+ const records = Array.isArray(json) ? json : json && typeof json === 'object' ? Object.values(json) : [];
841
+ if (records.length !== 1) throw new Error('inventory-shape');
842
+ const record = records[0];
843
+ const filename = packedFilename([record]);
844
+ if (!filename || basename(filename) !== filename || !/^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$/.test(filename)) throw new Error('archive-name');
845
+ if (!Array.isArray(record.files) || record.files.length > 20000 || record.files.length < minEntries) throw new Error('inventory-floor');
846
+ const paths = record.files.map((entry) => entry?.path);
847
+ if (paths.some((name) => typeof name !== 'string' || !name || name.length > 4096 || isAbsolute(name) || name.split('/').some((part) => !part || part === '.' || part === '..') || (name.includes('\\') || [...name].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127))) || new Set(paths).size !== paths.length) throw new Error('inventory-path');
848
+ const violations = checkLeakage(paths.map((name) => `npm notice 1B ${name}`));
849
+ if (violations.length) return { ok: false, detail: 'forbidden packed filename class: ' + [...new Set(violations.map((v) => v.name))].join(', ') };
850
+ return { ok: true, detail: `${paths.length} packed entries, 0 filename leaks`, record, filename, paths };
851
+ } catch { return { ok: false, detail: 'invalid packed inventory or insufficient entry count' }; }
852
+ }
853
+
854
+ function archiveDigest(tarballPath) {
855
+ if (!lstatSync(tarballPath).isFile()) throw new Error('checked archive is not a regular file');
856
+ return createHash('sha256').update(readFileSync(tarballPath)).digest('hex');
857
+ }
858
+
859
+ /**
860
+ * Scan extracted archive bytes, retain that archive for the callback, and always
861
+ * clean up. Command/scan failures never call the publication callback. The
862
+ * 20,000-entry inventory ceiling matches the scanner; revisit for larger packs.
863
+ */
864
+ export async function withCheckedPackage(repoRoot, callback, {
865
+ runImpl = run, env = process.env, minEntries = MIN_PACKED_ENTRIES, cleanupImpl = rmSync,
866
+ } = {}) {
867
+ const work = mkdtempSync(join(tmpdir(), 'so-release-pack-'));
868
+ try {
869
+ let artifact;
870
+ let detail;
871
+ try {
872
+ const opts = { cwd: repoRoot, env: { ...env, npm_config_loglevel: 'notice' }, timeout: 60000 };
873
+ const pack = runImpl('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', work], opts);
874
+ const inventory = evaluatePackedInventory(pack, { minEntries });
875
+ if (!inventory.ok) return inventory;
876
+ const tarballPath = join(work, inventory.filename);
877
+ const digest = archiveDigest(tarballPath);
878
+ // Refuse links/special members BEFORE extraction and check the file census.
879
+ const members = runImpl('tar', ['-tzf', tarballPath], opts);
880
+ const types = runImpl('tar', ['-tvzf', tarballPath], opts);
881
+ if (members.status !== 0 || types.status !== 0 || types.stdout.trim().split('\n').some((line) => !/^[-d]/.test(line))) throw new Error('archive-list');
882
+ const names = members.stdout.trim().split('\n');
883
+ const files = names.filter((name) => !name.endsWith('/'));
884
+ const expected = new Set(inventory.paths.map((name) => `package/${name}`));
885
+ if (names.some((name) => !name.startsWith('package/') || name.split('/').some((part) => part === '..' || part === '.')) || files.length !== expected.size || new Set(files).size !== files.length || files.some((name) => !expected.has(name))) throw new Error('archive-census');
886
+ const unpacked = join(work, 'unpacked');
887
+ mkdirSync(unpacked);
888
+ const extracted = runImpl('tar', ['-xzf', tarballPath, '-C', unpacked], opts);
889
+ if (extracted.status !== 0) throw new Error('archive-extraction');
890
+ const inventoryPath = join(work, 'inventory.json');
891
+ writeFileSync(inventoryPath, JSON.stringify([inventory.record]), { mode: 0o600 });
892
+ const scanner = fileURLToPath(new URL('./lib/validate/check-owner-leakage.mjs', import.meta.url));
893
+ const scan = runImpl(process.execPath, [scanner, join(unpacked, 'package'), '--require-owner-patterns', '--packed-files', inventoryPath], opts);
894
+ const summaries = [...(scan.stdout || '').matchAll(/^Results: (\d+) passed, (\d+) failed \((\d+) scanned files\)$/gm)];
895
+ if (scan.status !== 0 || summaries.length !== 1 || Number(summaries[0][1]) < 1 || Number(summaries[0][2]) !== 0 || Number(summaries[0][3]) !== inventory.paths.length) return { ok: false, detail: 'required owner policy / packed content scan failed or incomplete' };
896
+ if (archiveDigest(tarballPath) !== digest) throw new Error('archive-changed');
897
+ chmodSync(tarballPath, 0o400);
898
+ artifact = { tarballPath, digest };
899
+ detail = `${inventory.paths.length} packed files, filenames and contents verified`;
900
+ } catch { return { ok: false, detail: 'packed archive preparation or content verification failed' }; }
901
+ return { ok: true, detail, value: await callback(artifact) };
902
+ } finally {
903
+ // Cleanup must never replace an irreversible receipt or the original error.
904
+ try { cleanupImpl(work, { recursive: true, force: true }); }
905
+ catch { console.error('release: temporary package cleanup failed; release outcome unchanged'); }
906
+ }
907
+ }
908
+
828
909
  /**
829
910
  * Remote-branch parity verdict over `git ls-remote <remote> refs/heads/<branch>`.
830
911
  *
@@ -1206,22 +1287,6 @@ async function preflight(repoRoot, target, { skipCi = false } = {}) {
1206
1287
  add('ci-green-on-head', ciRows.gitlab.ok, ciRows.gitlab.detail);
1207
1288
  add('ci-green-on-head-github', ciRows.github.ok, ciRows.github.detail);
1208
1289
 
1209
- // 7. Leakage gate over the actual pack file list.
1210
- // `npm_config_loglevel` is INHERITED, and `npm pack --dry-run` writes its whole
1211
- // file listing as `npm notice` lines. Any ancestor that ran under `npm run
1212
- // --silent` (the pre-push gate does exactly that) therefore hands this child a
1213
- // silent loglevel and the listing is EMPTY with exit 0 — measured: 818 notice
1214
- // lines normally, 0 under `npm_config_loglevel=silent`. The blind-scan floor
1215
- // turns that into a correct fail-closed verdict, but a red leakage row that
1216
- // means "we could not look" is not the row anyone reads it as. Pin the level
1217
- // rather than inherit it, so the gate's input never depends on its caller.
1218
- const pack = run('npm', ['pack', '--dry-run'], {
1219
- cwd: repoRoot,
1220
- env: { ...process.env, npm_config_loglevel: 'notice' },
1221
- });
1222
- const leakage = evaluateLeakageGate(pack);
1223
- add('leakage-gate', leakage.ok, leakage.detail);
1224
-
1225
1290
  return checks;
1226
1291
  }
1227
1292
 
@@ -1404,7 +1469,7 @@ export function waitForRegistryPropagation(repoRoot, target, deps = {}) {
1404
1469
  * reads as unconfirmed, `publish()` throws, and `main()` exits 2 = "pre-receipt,
1405
1470
  * safe to rerun" while the registry already holds the version. That is the worst
1406
1471
  * failure this file can produce, and it is the same inherited-silent trap the
1407
- * leakage gate's `npm pack --dry-run` was already pinned against (see preflight
1472
+ * leakage gate's `npm pack --json` was already pinned against (see preflight
1408
1473
  * step 7). The pin makes the receipt independent of the caller's environment.
1409
1474
  *
1410
1475
  * Exported because `publish()` itself deliberately is not (it is the
@@ -1415,10 +1480,11 @@ export function waitForRegistryPropagation(repoRoot, target, deps = {}) {
1415
1480
  * @param {string} userconfigPath — the 0600 temp npmrc carrying the token
1416
1481
  * @returns {{cmd: string, args: string[], opts: {cwd: string, env: object}}}
1417
1482
  */
1418
- export function publishInvocation(repoRoot, userconfigPath) {
1483
+ export function publishInvocation(repoRoot, userconfigPath, artifact) {
1484
+ if (!artifact?.tarballPath || !artifact.digest || archiveDigest(artifact.tarballPath) !== artifact.digest) throw new Error('refusing publish without unchanged checked archive');
1419
1485
  return {
1420
1486
  cmd: 'npm',
1421
- args: ['publish', '--access', 'public', '--userconfig', userconfigPath],
1487
+ args: ['publish', artifact.tarballPath, '--ignore-scripts', '--access', 'public', '--userconfig', userconfigPath],
1422
1488
  opts: { cwd: repoRoot, env: { ...process.env, npm_config_loglevel: 'notice' } },
1423
1489
  };
1424
1490
  }
@@ -1431,7 +1497,7 @@ function publish(repoRoot, target, deps = {}) {
1431
1497
  const token = loadNpmToken(repoRoot);
1432
1498
  const runImpl = deps.runImpl ?? run;
1433
1499
  const res = withTempUserconfig(token, (tmpRc) => {
1434
- const call = publishInvocation(repoRoot, tmpRc);
1500
+ const call = publishInvocation(repoRoot, tmpRc, deps.artifact);
1435
1501
  return runImpl(call.cmd, call.args, call.opts);
1436
1502
  });
1437
1503
  const receipt = evaluatePublishReceipt(res, target);
@@ -1916,20 +1982,24 @@ async function main() {
1916
1982
  if (values.check || values.publish) {
1917
1983
  const target = readPackageVersion(repoRoot);
1918
1984
  const checks = await preflight(repoRoot, target, { skipCi: values['skip-ci'] });
1919
- const ok = printChecks(checks, values.json && !values.publish, target);
1920
- if (!ok) return 1;
1921
- if (!values.publish) return 0;
1922
-
1923
- console.log(`\nPublishing ${PACKAGE_NAME}@${target} ...`);
1924
- // No spawn timeout is set anywhere in this file, on purpose: a kill in the
1925
- // middle of `npm publish` or a tag push is the very failure the receipt
1926
- // boundary exists to avoid. The wall-clock cost is real, though — each of
1927
- // the two `git push` remotes re-runs the husky pre-push full gate (~2.5 min
1928
- // each, measured) and the site poll waits up to 120 s.
1929
- console.log(' This tail can run ~7 minutes (pre-push gate x2 remotes + up to 120s site poll).');
1930
- console.log(' Run it with a >=600s command timeout or in the background — do NOT kill it mid-run.');
1931
- const outcome = await runPublishRelease(repoRoot, target, { publishImpl: publish });
1932
- return printPublishOutcome(outcome, target);
1985
+ const checked = await withCheckedPackage(repoRoot, async (artifact) => {
1986
+ checks.push({ name: 'leakage-gate', ok: true, detail: 'packed filenames and required owner-policy content scan verified' });
1987
+ const ok = printChecks(checks, values.json && !values.publish, target);
1988
+ if (!ok) return 1;
1989
+ if (!values.publish) return 0;
1990
+ console.log(`\nPublishing ${PACKAGE_NAME}@${target} ...`);
1991
+ console.log(' Publish and post-receipt reconciliation may take several minutes; do not kill it mid-run.');
1992
+ const outcome = await runPublishRelease(repoRoot, target, {
1993
+ publishImpl: (root, version) => publish(root, version, { artifact }),
1994
+ });
1995
+ return printPublishOutcome(outcome, target);
1996
+ });
1997
+ if (!checked.ok) {
1998
+ checks.push({ name: 'leakage-gate', ok: false, detail: checked.detail });
1999
+ printChecks(checks, values.json && !values.publish, target);
2000
+ return 1;
2001
+ }
2002
+ return checked.value;
1933
2003
  }
1934
2004
 
1935
2005
  console.error('Nothing to do — pass --check, --publish, or --set-version X.Y.Z (see --help).');
@@ -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
  ]);
@@ -3,7 +3,7 @@
3
3
  * vault-consolidate.mjs — One-shot vault consolidation migration (PRD F1.1, Issue #499).
4
4
  *
5
5
  * Folds the redundant vault at `~/Projects/vault/` into the canonical
6
- * `~/Projects/Bernhard/vault/`, with per-file conflict resolution, idempotent
6
+ * host-configured destination, with per-file conflict resolution, idempotent
7
7
  * re-runs, and a compressed backup of the source side BEFORE any write.
8
8
  *
9
9
  * Design — two-phase coordinator-driven flow for merge resolution
@@ -95,10 +95,10 @@ import {
95
95
  stageBackup,
96
96
  compressAndCleanupBackup,
97
97
  } from './lib/vault-consolidate-fs.mjs';
98
+ import { loadHostPaths, resolveHostPath } from './lib/config/host-paths.mjs';
98
99
  import { isMainModule } from './lib/is-main-module.mjs';
99
100
 
100
101
  const DEFAULT_SOURCE = '~/Projects/vault';
101
- const DEFAULT_CANONICAL = '~/Projects/Bernhard/vault';
102
102
 
103
103
  // ---------------------------------------------------------------------------
104
104
  // CLI parsing
@@ -122,7 +122,7 @@ Options:
122
122
  --source <path> Path to the redundant (source) vault.
123
123
  Default: ${DEFAULT_SOURCE}
124
124
  --canonical <path> Path to the canonical vault.
125
- Default: ${DEFAULT_CANONICAL}
125
+ Default: SO_VAULT_DIR or owner.yaml paths.vault-dir (required when source exists)
126
126
  --json Newline-delimited JSON output (one record per
127
127
  file, plus a final summary record).
128
128
  --resolve <rel>=<choice> Record a merge decision for the file at the
@@ -215,7 +215,9 @@ async function main() {
215
215
  const isJson = parsed.values.json === true;
216
216
 
217
217
  const sourceRoot = path.resolve(expandTilde(parsed.values.source ?? DEFAULT_SOURCE));
218
- const canonicalRoot = path.resolve(expandTilde(parsed.values.canonical ?? DEFAULT_CANONICAL));
218
+ const canonicalTarget = parsed.values.canonical ?? resolveHostPath('vault-dir', null, loadHostPaths());
219
+ const canonicalRoot = typeof canonicalTarget === 'string' && canonicalTarget.trim()
220
+ ? path.resolve(expandTilde(canonicalTarget.trim())) : null;
219
221
 
220
222
  // Parse --resolve flags into a Map<relPath, "src"|"dst"|"skip">
221
223
  const resolutions = new Map();
@@ -255,6 +257,11 @@ async function main() {
255
257
  process.exit(0);
256
258
  }
257
259
 
260
+ if (!canonicalRoot) {
261
+ process.stderr.write('ERROR: canonical vault is unconfigured — pass --canonical or set SO_VAULT_DIR / owner.yaml paths.vault-dir\n');
262
+ process.exit(1);
263
+ }
264
+
258
265
  if (!(await isDir(canonicalRoot))) {
259
266
  process.stderr.write(
260
267
  `ERROR: canonical vault not found at ${canonicalRoot} — refusing to consolidate\n`
@@ -262,6 +269,14 @@ async function main() {
262
269
  process.exit(2);
263
270
  }
264
271
 
272
+ if (await fs.realpath(sourceRoot) === await fs.realpath(canonicalRoot)) {
273
+ const summary = { kind:'summary', mode:isDryRun ? 'dry-run' : 'apply', source:sourceRoot,
274
+ canonical:canonicalRoot, counts:{copy:0,'skip-already-present':0,merge:0,'conflict-needs-review':0},
275
+ notice:'source and canonical resolve to the same vault — nothing to do' };
276
+ process.stdout.write(isJson ? JSON.stringify(summary) + '\n' : summary.notice + '\n');
277
+ process.exit(0);
278
+ }
279
+
265
280
  // -------------------------------------------------------------------------
266
281
  // Output helpers (close over isJson + the per-run records buffer)
267
282
  // -------------------------------------------------------------------------
@@ -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,
@@ -34,7 +34,7 @@ PHASE 1 IMPLEMENTED (2026-04-19). Session-end opt-in quality gate. Upstream of `
34
34
 
35
35
  | # | Check | What it scans | How |
36
36
  |---|-------|---------------|-----|
37
- | 1 | `path-resolver` | Every absolute path `/Users/…` in scope files | `existsSync(path)` |
37
+ | 1 | `path-resolver` | Every absolute path `/Users/…` in scope files | `existsSync(path)`; missing paths under validated owner.yaml `drift-check.foreign-host-prefixes` are warnings (#937) |
38
38
  | 2 | `project-count-sync` | Hardcoded "N registered" / "N projects" claims next to `01-projects/` | compare to `ls -d 01-projects/*/` |
39
39
  | 3 | `issue-reference-freshness` | `#NN` in forward-looking sections (What's Next, Backlog, Open Issues, Offene Themen, Todo, Next Steps) | `glab issue view NN --repo <origin>` |
40
40
  | 4 | `session-file-existence` | `50-sessions/YYYY-MM-DD-*.md` references anywhere in scope | `existsSync(vault/50-sessions/<file>)` |
@@ -31,7 +31,7 @@
31
31
  */
32
32
 
33
33
  import { readFileSync, readdirSync, existsSync, statSync, lstatSync, realpathSync } from 'node:fs';
34
- import { join, relative, resolve } from 'node:path';
34
+ import { join, relative, resolve, posix } from 'node:path';
35
35
  import { execFileSync } from 'node:child_process';
36
36
  import { createRequire } from 'node:module';
37
37
  import { resolveInstructionFile } from '../../scripts/lib/common.mjs';
@@ -41,6 +41,7 @@ import { isSessionConfigHeading } from '../../scripts/lib/config/section-extract
41
41
  import { FRONTMATTER_NOT_AT_TOP, isRuleExpired, parseGlobsFrontmatter } from '../../scripts/lib/rule-loader.mjs';
42
42
  import { resolveRepoSpec } from '../../scripts/lib/vcs-repo-spec.mjs';
43
43
  import { userInvocableSkills } from '../../scripts/lib/user-invocable-skills.mjs';
44
+ import { loadOwnerConfig } from '../../scripts/lib/owner-yaml.mjs';
44
45
  import { learningKeyOf } from '../../scripts/lib/learnings/kebab.mjs';
45
46
 
46
47
  const FORWARD_HEADING_RE =
@@ -1016,6 +1017,9 @@ function main() {
1016
1017
  if (!args.skipSessionFiles) checksRun.push('session-file-existence');
1017
1018
  for (const s of activeSurfaces) checksRun.push(s.id);
1018
1019
 
1020
+ const foreignHostPrefixes = args.skipPathResolver ? [] :
1021
+ (loadOwnerConfig().config['drift-check']?.['foreign-host-prefixes'] ?? [])
1022
+ .map((prefix) => prefix.replace(/\/+$/, ''));
1019
1023
  const errors = [];
1020
1024
  const warnings = [];
1021
1025
  // Informational findings — never block, never count as a warning. Currently
@@ -1731,9 +1735,16 @@ function main() {
1731
1735
  while ((m = pathRegex.exec(line)) !== null) {
1732
1736
  const p = m[0].replace(/[.,;:)\]`"']+$/, '');
1733
1737
  if (!existsSync(p)) {
1734
- errors.push({
1738
+ // Lexical containment must use the effective POSIX path: '..' can
1739
+ // escape an allowed foreign directory. Keep p for diagnostics.
1740
+ const normalizedPath = posix.normalize(p);
1741
+ const foreign = foreignHostPrefixes.some((prefix) =>
1742
+ normalizedPath === prefix || normalizedPath.startsWith(`${prefix}/`));
1743
+ (foreign ? warnings : errors).push({
1735
1744
  check: 'path-resolver', file: rel, line: lineNum,
1736
- message: `Absolute path does not exist: ${p}`,
1745
+ message: foreign
1746
+ ? `Known foreign-host path does not exist on this host: ${p}`
1747
+ : `Absolute path does not exist: ${p}`,
1737
1748
  extracted: p,
1738
1749
  });
1739
1750
  }
@@ -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,49 @@ 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`. The old pipeline could emit an earlier matching value before the parse error; `tail -1` retained that stale value and could hide the jq failure. Known limit: an interrupted `appendFile` can leave a truncated record that `fromjson?` discards completely; `tail -1` can still select an older matching run, which alone is not evidence of the current gate result. The library emits one record per `runQualityGateWithRetry` call, not per retry; `attempts` is not a record count.
188
+ >
189
+ > An empty counts selector cannot distinguish a missing event, missing wave attribution,
190
+ > or an attributed event without counts after fail-fast. Inspect the own session's
191
+ > records without the counts filter before deciding whether a documented count
192
+ > fallback case applies:
193
+ >
194
+ > ```bash
195
+ > jq -cR --argjson w "$WAVE" --arg s "$SEMANTIC_SESSION_ID" '
196
+ > fromjson? | objects
197
+ > | select((.event | type) == "string" and (.event | startswith("orchestrator.quality_gate.")))
198
+ > | select(.semantic_session_id == $s)
199
+ > | select(.wave_number == $w or .wave_number == null)
200
+ > | {event, semantic_session_id, wave_number, counts}
201
+ > ' .orchestrator/metrics/events.jsonl
202
+ > ```
203
+ >
204
+ > This is a diagnostic, not a replacement counts source. No rows means no readable
205
+ > matching event; a row without `wave_number` is not proof of this wave's run;
206
+ > a row for this wave without `counts` supplies no measurement. Keep the session
207
+ > filter and never copy counts from another session or wave, or from a row without
208
+ > wave attribution. An event without `semantic_session_id` is not attributable by
209
+ > this query either; diagnose missing session attribution from independent evidence
210
+ > of the own gate run, not by adopting an unrelated unattributed event. Missing
211
+ > attribution or an empty result alone does not prove a fallback source exists.
212
+ >
213
+ > **Count fallback 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 count fallback.
214
+ >
215
+ > 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.
216
+ >
217
+ > **`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
218
  > - `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
219
  > - `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
220
  > - `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,45 @@ 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`. The old pipeline could emit an earlier matching value before the parse error; `tail -1` retained that stale value and could hide the jq failure. Known limit: an interrupted `appendFile` can leave a truncated record that `fromjson?` discards completely; `tail -1` can still select an older matching run, which alone is not evidence of the current gate result. The library emits one record per `runQualityGateWithRetry` call, not per retry; `attempts` is not a record count.
390
+
391
+ An empty counts selector cannot distinguish a missing event, missing wave attribution,
392
+ or an attributed event without counts after fail-fast. Inspect the own session's
393
+ records without the counts filter before deciding whether a documented count
394
+ fallback case applies:
395
+
396
+ ```bash
397
+ jq -cR --argjson w "$WAVE" --arg s "$SEMANTIC_SESSION_ID" '
398
+ fromjson? | objects
399
+ | select((.event | type) == "string" and (.event | startswith("orchestrator.quality_gate.")))
400
+ | select(.semantic_session_id == $s)
401
+ | select(.wave_number == $w or .wave_number == null)
402
+ | {event, semantic_session_id, wave_number, counts}
403
+ ' .orchestrator/metrics/events.jsonl
404
+ ```
405
+
406
+ This is a diagnostic, not a replacement counts source. No rows means no readable
407
+ matching event; a row without `wave_number` is not proof of this wave's run;
408
+ a row for this wave without `counts` supplies no measurement. Keep the session
409
+ filter and never copy counts from another session or wave, or from a row without
410
+ wave attribution. An event without `semantic_session_id` is not attributable by
411
+ this query either; diagnose missing session attribution from independent evidence
412
+ of the own gate run, not by adopting an unrelated unattributed event. Missing
413
+ attribution or an empty result alone does not prove a fallback source exists.
414
+
415
+ **Count fallback 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 count fallback.
416
+
417
+ 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.
418
+
419
+ **`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
420
  Append this wave record to the session metrics `waves` array.
391
421
 
392
422
  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.