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
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ // Optional pre-push router: 0 verified, 1 blocked, 10 transport/readiness fallback.
3
+ import { spawnSync } from 'node:child_process';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { readConfigFile } from './config/io.mjs';
6
+ import { _parseRemoteHosts } from './config/remote-hosts.mjs';
7
+ import { isMainModule } from './is-main-module.mjs';
8
+ import { admitSuiteCounts, joinGateKillFields } from './gates/gate-helpers.mjs';
9
+ import { resolveWaveNumber } from './quality-gate-wave.mjs';
10
+
11
+ const log = (message) => process.stderr.write(`pre-push: ${message}\n`);
12
+
13
+ // offload sync creates a snapshot commit even for a clean source. Check out the
14
+ // pushed SHA again and reject extra source files before invoking the real gate.
15
+ // A nonce-bound receipt separates command failures from offload's overlapping
16
+ // transport exit codes. No receipt is ever interpreted as a passing gate.
17
+ function remoteCommand(sha, nonce) {
18
+ return `
19
+ const { spawnSync } = require('node:child_process');
20
+ const { writeSync } = require('node:fs');
21
+ const sha = ${JSON.stringify(sha)}, nonce = ${JSON.stringify(nonce)};
22
+ const env = { ...process.env };
23
+ for (const key of Object.keys(env)) {
24
+ if (/(PROJECT_DIR|PLUGIN_ROOT|RULES_DIR)$/.test(key) ||
25
+ /^GIT_/.test(key) && !/^GIT_(AUTHOR_|COMMITTER_|EDITOR$|ASKPASS$|SSH$|SSH_COMMAND$|TERMINAL_PROMPT$|EXEC_PATH$)/.test(key)) delete env[key];
26
+ }
27
+ const git = (...args) => spawnSync('git', args, { env, encoding: 'utf8' });
28
+ let result = { nonce, sha, exit_code: 1, report: null };
29
+ const checkout = git('checkout', '--quiet', '--detach', sha);
30
+ const head = git('rev-parse', 'HEAD');
31
+ const tracked = git('status', '--porcelain', '--untracked-files=no');
32
+ const extras = git('ls-files', '--others', '--exclude-standard', '--exclude=node_modules');
33
+ if (checkout.status === 0 && head.status === 0 && head.stdout.trim() === sha &&
34
+ tracked.status === 0 && !tracked.stdout.trim() && extras.status === 0 && !extras.stdout.trim()) {
35
+ const gate = spawnSync('npm', ['run', '--silent', 'quality-gate'],
36
+ { env, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
37
+ if (gate.stdout) writeSync(1, gate.stdout);
38
+ if (gate.stderr) writeSync(2, gate.stderr);
39
+ result.exit_code = gate.status;
40
+ try { result.report = JSON.parse(gate.stdout); } catch {}
41
+ }
42
+ writeSync(1, '\\nSO_PRE_PUSH_RESULT=' + JSON.stringify(result) + '\\n');
43
+ process.exit(result.exit_code === 0 ? 0 : 1);
44
+ `;
45
+ }
46
+
47
+ async function main() {
48
+ const [tree, sha, ledgerRoot] = process.argv.slice(2);
49
+ let hosts;
50
+ try {
51
+ hosts = _parseRemoteHosts(await readConfigFile(tree)).filter((host) => host['roles-allowed'].includes('test'));
52
+ } catch {
53
+ log('remote configuration unavailable — using local gate.');
54
+ return 10;
55
+ }
56
+ for (const { alias } of hosts) {
57
+ log(`checking remote readiness (${alias}).`);
58
+ const doctor = spawnSync('offload', ['doctor', '-H', alias, '--brief', '--no-probe'],
59
+ { encoding: 'utf8', timeout: 30_000, maxBuffer: 1024 * 1024 });
60
+ if (doctor.error?.code === 'ENOENT') break;
61
+ if (doctor.error || doctor.status !== 0) {
62
+ log(`remote ${alias} not ready (exit ${doctor.status ?? doctor.error?.code}) — trying next route.`);
63
+ continue;
64
+ }
65
+ const nonce = randomUUID();
66
+ log(`running full quality gate remotely on ${alias} against pushed SHA ${sha}.`);
67
+ const run = spawnSync('offload', ['run', '-H', alias, tree, '--job', `pre-push-${nonce}`,
68
+ '--klasse', 'gate', '--link-modules', '--rm', '--timeout', '1800', '--', 'node', '-e', remoteCommand(sha, nonce)],
69
+ { encoding: 'utf8', timeout: 1_860_000, maxBuffer: 64 * 1024 * 1024 });
70
+ process.stdout.write(run.stdout ?? '');
71
+ process.stderr.write(run.stderr ?? '');
72
+ const receipts = (run.stdout ?? '').split(/\r?\n/).filter((line) => line.startsWith('SO_PRE_PUSH_RESULT='));
73
+ let receipt;
74
+ try { if (receipts.length === 1) receipt = JSON.parse(receipts[0].slice('SO_PRE_PUSH_RESULT='.length)); } catch { /* unknown blocks */ }
75
+ if (receipts.length === 0 && !run.error && [2, 4, 8, 10, 255].includes(run.status)) {
76
+ log(`remote ${alias} transport/readiness failure (exit ${run.status}) — trying next route.`);
77
+ continue;
78
+ }
79
+ const report = receipt?.report;
80
+ const stubbedChecks = Object.keys(report?.stubbed ?? {});
81
+ if (stubbedChecks.length > 0) log(`remote gate contains stubbed checks: ${stubbedChecks.join(', ')} — Push blocked.`);
82
+ const passed = !run.error && run.status === 0 && receipt?.nonce === nonce && receipt.sha === sha &&
83
+ receipt.exit_code === 0 && report?.variant === 'full-gate' && report.test?.status === 'pass' &&
84
+ stubbedChecks.length === 0 && !(report.test.failed > 0) && report.test.suite_died !== true &&
85
+ ['pass', 'skip'].includes(report.typecheck?.status) && ['pass', 'skip'].includes(report.lint?.status) &&
86
+ ![report.test, report.typecheck, report.lint].some((check) => check.timed_out === true);
87
+ try {
88
+ const { emitEvent, sessionAttribution } = await import('./events.mjs');
89
+ const counts = admitSuiteCounts(report?.test);
90
+ const waveNumber = resolveWaveNumber(ledgerRoot);
91
+ await emitEvent(`orchestrator.quality_gate.${passed ? 'passed' : 'failed'}`, {
92
+ variant: 'full-gate', exit_code: passed ? 0 : 1, remote_host: alias,
93
+ pushed_sha: sha, ...sessionAttribution(ledgerRoot),
94
+ ...joinGateKillFields({ outer: {}, gateStdout: JSON.stringify(report) }),
95
+ ...(counts ? { counts } : {}),
96
+ ...(waveNumber !== null ? { wave_number: waveNumber } : {}),
97
+ ...(report?.test?.failed_files?.length ? { failed_files: report.test.failed_files } : {}),
98
+ }, { repoRoot: ledgerRoot });
99
+ } catch { /* telemetry cannot change the blocking verdict */ }
100
+ log(`remote quality gate ${passed ? 'passed' : 'FAILED — Push blocked'} (${alias}).`);
101
+ return passed ? 0 : 1;
102
+ }
103
+ log('remote routes unavailable — using local full quality gate.');
104
+ return 10;
105
+ }
106
+
107
+ if (isMainModule(import.meta.url)) process.exitCode = await main();
@@ -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,35 @@ export function gateKillFields(run) {
834
835
  * test gate (fail-fast on lint/typecheck) or its output carried no parseable
835
836
  * count. See {@link suiteCountsFromOutput}.
836
837
  *
837
- * IT DOES NOT YET REPLACE THE PROSE PATH (#957/F1 — the earlier wording here
838
- * claimed it did). `waves[].suite_passed` / `suite_failed` still travel as prose
839
- * through two LLM hops: `skills/wave-executor/wave-loop.md` step 7 hand-writes
840
- * them, `skills/session-end/metrics-collection.md` § 1.7 parses them back out of
841
- * the STATE.md Wave History header into sessions.jsonl. `counts` is a SECOND,
842
- * machine-measured emission of the same fact. Measured 2026-10-05 against this
843
- * repo's `.orchestrator/metrics/events.jsonl` (main checkout @ 1386a98e; the
844
- * file has been rotated since the 4404-record 2026-07-31 reading this note
845
- * used to quote): 82 `orchestrator.quality_gate.*` records, 46 carrying
846
- * `counts` (`grep '"orchestrator.quality_gate' <file> | grep -c '"counts"'`).
847
- *
848
- * `waves[].*` is PER-WAVE, so the event needs a per-wave key. The
849
- * `scripts/run-quality-gate.mjs` wrapper emits `wave_number` when a wave-scope
850
- * sidecar names one (#966 step 1, `resolveWaveNumber`) and OMITS it otherwise —
851
- * 37 of the same 82 records carry it
852
- * (`grep '"orchestrator.quality_gate' <file> | grep -c '"wave_number"'`).
853
- * Readers exist: `skills/session-end/metrics-collection.md` § 1.7 reads
854
- * `counts` by `wave_number` first and keeps the STATE.md header only as a
855
- * fallback (#966 step 3), and `scripts/lib/convergence-monitor.mjs` folds a
856
- * gate record only when it carries both. THIS emitter still sets no
857
- * `wave_number` — its records are mid-wave auto-fix retries, so they fall to
858
- * that fallback by design. A record without the key could only be attributed
859
- * to a wave by a wall-clock window whose own boundaries (`waves[].started_at`
860
- * / `completed_at`) are themselves LLM-written, against the posture
861
- * `scripts/lib/eval/session-resolve.mjs` documents for window-attributed gate
862
- * events ("a contaminated window means gate-attribution is unsafe").
838
+ * Both skill readers now take `counts` from `orchestrator.quality_gate.*`:
839
+ * `skills/wave-executor/references/wave-loop-review.md` step 7 and
840
+ * `skills/session-end/metrics-collection.md` § 1.7. They filter the flat record
841
+ * by the same `semantic_session_id` + `wave_number` and select the last
842
+ * matching gate run; `counts.passed` / `counts.failed` supply the per-wave
843
+ * `suite_passed` / `suite_failed`. A third reader,
844
+ * `scripts/lib/convergence-monitor.mjs`, folds `counts` from gate records
845
+ * that also carry `wave_number`. This library emits ONE record per
846
+ * `runQualityGateWithRetry` call, not per retry; `attempts` carries retries.
847
+ *
848
+ * Both emitters resolve `wave_number` from the existing own-session manifest
849
+ * at the event's repoRoot and omit it without a usable source. No wall-clock
850
+ * attribution or guessed wave number is added. Missing counts are not zeros.
851
+ * Count fallback is limited to pre-#954 sessions, gates outside the event
852
+ * wrapper, or missing wave attribution; step 7 falls back to this wave's own
853
+ * gate output (its header is not written yet), session end to the header. Auto-fix with a known wave
854
+ * uses the event; missing counts alone do not permit count fallback.
855
+ * Diagnose own-session events without the counts filter to distinguish absent
856
+ * events, missing wave attribution, and fail-fast events without counts; never
857
+ * adopt counts from another session/wave or an unattributed row. A truncated
858
+ * append can be skipped by fromjson?, leaving an older matching run: that alone
859
+ * does not establish the current gate result.
860
+ *
861
+ * `suite_platform` comes from this wave's gate run (step 7) or the STATE.md
862
+ * Wave History header `— suite <passed>/<failed> on <platform>` (session end);
863
+ * the payload has NO platform field.
864
+ * Event-derived counts may be mirrored into that existing header as
865
+ * compatibility output, not a second measurement source. Neither the header
866
+ * nor sessions.jsonl changes its contract.
863
867
  *
864
868
  * Note also that THIS emitter only runs under `verification-auto-fix.enabled:
865
869
  * true` (default `false`, and `false` in this repo's Session Config). The gate
@@ -891,12 +895,14 @@ export function gateKillFields(run) {
891
895
  */
892
896
  async function emitGateEvent(repoRoot, ok, attempts, gate, counts, killRun = null) {
893
897
  try {
898
+ const waveNumber = resolveWaveNumber(repoRoot);
894
899
  await emitEvent(
895
900
  `orchestrator.quality_gate.${ok ? 'passed' : 'failed'}`,
896
901
  {
897
902
  variant: 'auto-fix-loop',
898
903
  exit_code: ok ? 0 : 1,
899
904
  attempts,
905
+ ...(waveNumber !== null ? { wave_number: waveNumber } : {}),
900
906
  ...(gate ? { gate } : {}),
901
907
  ...(counts ? { counts } : {}),
902
908
  ...gateKillFields(killRun),
@@ -9,28 +9,19 @@
9
9
  *
10
10
  * Behaviour is unchanged from the original: this module is a move, not a rewrite.
11
11
  *
12
- * DELIBERATE SECOND COPY — `check-owner-leakage.mjs` still carries its OWN inline
13
- * `redactSpans()` and must keep it. That scanner is a documented standalone
14
- * single-file vendoring target (`.claude/rules/security.md` § "Owner-Privacy
15
- * Pre-Commit Hook"): consumer repos copy that ONE file, so a static import of this
16
- * module throws ERR_MODULE_NOT_FOUND in every vendored copy and blocks all commits
17
- * (proven by the three cases in `tests/husky/pre-commit-owner-leakage.test.mjs`).
18
- * Unlike CP11's confidential-names helpers, a redaction sink cannot degrade to
19
- * inert — an absent redaction prints confidential names into a PUBLIC CI log.
20
- * So: THIS module serves in-tree consumers, the inline copy serves the vendored
21
- * path, and `tests/lib/redact-spans.test.mjs` pins the two byte-for-byte against
22
- * each other. Edit one → that drift guard goes red until both agree again.
12
+ * The owner-leakage scanner remains a standalone vendoring target. Since #1530
13
+ * it omits offending line content entirely and redacts private filename segments,
14
+ * so it no longer carries a second copy of this primitive. In-tree consumers
15
+ * that retain surrounding text use this module; its overlap and ordering
16
+ * contracts are covered by tests/lib/redact-spans.test.mjs.
23
17
  */
24
18
 
25
19
  /**
26
20
  * Redact every confidential-name span from `line`, ORDER-INDEPENDENTLY (Fix 1 + Fix 2).
27
21
  *
28
- * This is the single redaction sink for the confidential-names privacy invariant.
29
- * In `check-owner-leakage.mjs` it is applied at the print choke-point over EVERY
30
- * violation's lineContent — not only CP11 hits — because that scanner runs in a
31
- * PUBLIC GitHub-Actions mirror: a confidential customer/repo name that co-occurs
32
- * with a CP1–CP10 hit on the same line (e.g. a name beside an RFC1918 IP that
33
- * fails CP8) would otherwise be echoed verbatim to the public CI log (Fix 1).
22
+ * Consumers must pass every relevant confidential pattern before emitting text.
23
+ * This helper matches the supplied text literally; callers handling reversible
24
+ * encodings must normalize first or omit the sensitive text entirely.
34
25
  *
35
26
  * ORDER-INDEPENDENCE (Fix 2): a naïve chain of `.replace()` calls is order-dependent
36
27
  * — when one configured name is a PREFIX of another (`['acme','acme-corp-secret']`),
@@ -5,14 +5,15 @@
5
5
  * hot-paths — all sync. Re-exported by hardening.mjs as a barrel so existing
6
6
  * importers keep working unchanged.
7
7
  *
8
- * Layering: hook-safe — pure functions only; no I/O at import time;
9
- * ESM-pure for fast hook hot-paths. Hooks (under `hooks/`) import from
10
- * this lib; this lib MUST NOT reverse-import from `hooks/`. Cross-cutting
11
- * invariant for all exports below — see #554 A2.
8
+ * Layering: hook-safe synchronous helpers; no I/O at import time. Guard
9
+ * context lookups read bounded filesystem state when called. Hooks (under
10
+ * `hooks/`) import this ESM-only lib; it MUST NOT reverse-import from hooks.
11
+ * Cross-cutting invariant for all exports below — see #554 A2.
12
12
  */
13
13
 
14
- import { existsSync, readFileSync, realpathSync } from 'node:fs';
14
+ import { existsSync, readFileSync, realpathSync, lstatSync, openSync, fstatSync, readSync, closeSync, constants } from 'node:fs';
15
15
  import path from 'node:path';
16
+ import { resolveSessionRoot, isolatedAgentCheckout, registeredWorktreeRoots } from './platform.mjs';
16
17
 
17
18
  import { tokenizeCommand, splitChainSegments, resolveSegmentVerb } from './command-blocker.mjs';
18
19
 
@@ -106,6 +107,68 @@ export function findOwnScopeFile(projectRoot, ownIds, classify) {
106
107
  return { path: null, foreignPath };
107
108
  }
108
109
 
110
+ /**
111
+ * Guard context: control state belongs to the coordinator, relative grants to
112
+ * the actual isolated checkout. Remote adoption requires unique OWN identity;
113
+ * local unknown/corrupt/foreign/missing dispositions remain unchanged.
114
+ * Root-only discovery deliberately excludes arbitrary worktree subdirectories;
115
+ * revisit when the harness supplies a coordinator-root witness (#1504).
116
+ * @param {{cwd: string, ownIds: Set<string>, classify: Function}} options
117
+ * @returns {{manifestRoot: string, writeRoot: string, path: string|null,
118
+ * foreignPath: string|null, isolated: boolean, error: string|null}}
119
+ */
120
+ export function resolveScopeContext({ cwd, ownIds, classify }) {
121
+ let manifestRoot = resolveSessionRoot(cwd);
122
+ try { manifestRoot = realpathSync.native(manifestRoot); } catch { /* preserve non-existing-root fallback */ }
123
+ const local = findOwnScopeFile(manifestRoot, ownIds, classify);
124
+ const agent = isolatedAgentCheckout(cwd);
125
+ const context = { manifestRoot, writeRoot: agent?.writeRoot ?? manifestRoot,
126
+ ...local, isolated: agent !== null, error: null };
127
+ if (!agent || local.path || ownIds.size === 0) return context;
128
+ const candidates = registeredWorktreeRoots(agent.commonDir);
129
+ if (candidates.error) return { ...context, error: candidates.error };
130
+ const matches = new Map();
131
+ for (const root of candidates.roots) {
132
+ if (root === agent.writeRoot || root === manifestRoot || isolatedAgentCheckout(root)) continue;
133
+ // Inspect precedence candidates separately. An unsafe candidate has unknown
134
+ // ownership: it must never turn an isolated session into a no-scope ALLOW.
135
+ for (const dir of SCOPE_FILE_DIRS) {
136
+ const file = path.join(root, dir, 'wave-scope.json');
137
+ let fd;
138
+ let raw;
139
+ try {
140
+ const before = lstatSync(file);
141
+ if (!before.isFile() || before.size > 1024 * 1024) return { ...context, error: 'unsafe-coordinator-scope' };
142
+ fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
143
+ const opened = fstatSync(fd);
144
+ if (!opened.isFile() || opened.ino !== before.ino || opened.dev !== before.dev || opened.size > 1024 * 1024) return { ...context, error: 'unsafe-coordinator-scope' };
145
+ const buffer = Buffer.alloc(1024 * 1024 + 1);
146
+ let length = 0;
147
+ while (length < buffer.length) {
148
+ const n = readSync(fd, buffer, length, buffer.length - length, null);
149
+ if (n === 0) break;
150
+ length += n;
151
+ }
152
+ if (length > 1024 * 1024) return { ...context, error: 'unsafe-coordinator-scope' };
153
+ raw = buffer.toString('utf8', 0, length);
154
+ } catch (err) {
155
+ if (err?.code === 'ENOENT') continue;
156
+ return { ...context, error: 'unsafe-coordinator-scope' };
157
+ } finally { if (fd !== undefined) closeSync(fd); }
158
+ try {
159
+ const scope = JSON.parse(raw);
160
+ if (classify(scope, ownIds).verdict === 'own') {
161
+ matches.set(realpathSync(file), { root, path: file, foreignPath: null });
162
+ break; // highest-precedence positively owned manifest in this root
163
+ }
164
+ } catch { /* unbound/corrupt remote state is never authority */ }
165
+ }
166
+ }
167
+ if (matches.size > 1) return { ...context, error: 'ambiguous-coordinator-scope' };
168
+ const match = matches.values().next().value;
169
+ return match ? { ...context, manifestRoot: match.root, path: match.path, foreignPath: null } : context;
170
+ }
171
+
109
172
  /**
110
173
  * Read the enforcement level from a scope file.
111
174
  * Defaults to "strict" (fail-closed) on parse error or missing field.
@@ -124,12 +124,11 @@ const INCOMPLETE_FIELD_ORDER = Object.freeze([
124
124
  const MS_PRECISION_FIELDS = Object.freeze(['started_at', 'completed_at', 'lease_acquired_at']);
125
125
 
126
126
  /**
127
- * ISO-8601 UTC with MORE than three fractional digits (`.994600Z`) — the one
128
- * rejected shape that has an unambiguous canonical form. Group 1 is the value
129
- * cut at the millisecond. Offsets, `.3Z` and non-ISO strings do not match and
130
- * stay for the validator to reject: there is no defensible default for them.
127
+ * ISO-8601 UTC with MORE than three fractional digits and exactly `Z` or
128
+ * `+00:00` (#1525). Group 1 is the value cut at the millisecond. Nonzero
129
+ * offsets, other timezone spellings and shorter fractions stay unrepaired.
131
130
  */
132
- const ISO_UTC_SUB_MS_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3})\d+Z$/;
131
+ const ISO_UTC_SUB_MS_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3})\d+(?:Z|\+00:00)$/;
133
132
 
134
133
  // ---------------------------------------------------------------------------
135
134
  // Helpers
@@ -155,7 +154,13 @@ function isCount(v) {
155
154
  function truncateToMs(value) {
156
155
  if (typeof value !== 'string') return null;
157
156
  const m = ISO_UTC_SUB_MS_RE.exec(value);
158
- return m ? `${m[1]}Z` : null;
157
+ if (!m) return null;
158
+ const canonical = `${m[1]}Z`;
159
+ const ms = Date.parse(canonical);
160
+ // Date.parse rolls over e.g. April 31, non-leap February 29 and 24:00.
161
+ // Equality with the original calendar components refuses those repairs.
162
+ if (!Number.isFinite(ms) || new Date(ms).toISOString() !== canonical) return null;
163
+ return canonical;
159
164
  }
160
165
 
161
166
  function orderIncompleteFields(fields) {
@@ -391,10 +396,16 @@ export function repairRecord(record) {
391
396
  preserveRaw(out, 'completed_at', record.completed_at, rescued);
392
397
  const endedMs = typeof out.ended_at === 'string' ? Date.parse(out.ended_at) : NaN;
393
398
  const startedMs = Date.parse(out.started_at);
394
- if (Number.isFinite(endedMs) && Number.isFinite(startedMs) && endedMs >= startedMs) {
399
+ const endedTruncated = truncateToMs(out.ended_at);
400
+ if (typeof out.ended_at === 'string' &&
401
+ (!Number.isFinite(endedMs) ||
402
+ (ISO_UTC_SUB_MS_RE.test(out.ended_at) && endedTruncated === null))) {
403
+ // Preserve an invalid legacy measurement for the final schema gate to
404
+ // reject; do not turn it into a valid default or a rolled-over date.
405
+ out.completed_at = out.ended_at;
406
+ } else if (Number.isFinite(endedMs) && Number.isFinite(startedMs) && endedMs >= startedMs) {
395
407
  // The COPY is cut to milliseconds; `ended_at` itself is not a validated
396
408
  // field and keeps its original bytes, so nothing needs a sidecar here.
397
- const endedTruncated = truncateToMs(out.ended_at);
398
409
  if (endedTruncated !== null) precisionCut = true;
399
410
  out.completed_at = endedTruncated ?? out.ended_at;
400
411
  } else {
@@ -182,6 +182,12 @@ function _validateTimestamps(entry) {
182
182
  `started_at must match ISO-8601 UTC ms format (YYYY-MM-DDTHH:MM:SS[.SSS]Z), got: ${entry.started_at}`
183
183
  );
184
184
  }
185
+ // Date.parse normalizes impossible calendar dates; compare without mutating the record.
186
+ const startedCanonical = entry.started_at.includes('.')
187
+ ? entry.started_at : entry.started_at.replace(/Z$/, '.000Z');
188
+ if (new Date(startedMs).toISOString() !== startedCanonical) {
189
+ throw new ValidationError(`started_at must be a valid calendar timestamp, got: ${entry.started_at}`);
190
+ }
185
191
  if (typeof entry.completed_at !== 'string') {
186
192
  throw new ValidationError('completed_at must be an ISO timestamp string');
187
193
  }
@@ -194,6 +200,12 @@ function _validateTimestamps(entry) {
194
200
  `completed_at must match ISO-8601 UTC ms format (YYYY-MM-DDTHH:MM:SS[.SSS]Z), got: ${entry.completed_at}`
195
201
  );
196
202
  }
203
+ // Date.parse normalizes impossible calendar dates; compare without mutating the record.
204
+ const completedCanonical = entry.completed_at.includes('.')
205
+ ? entry.completed_at : entry.completed_at.replace(/Z$/, '.000Z');
206
+ if (new Date(completedMs).toISOString() !== completedCanonical) {
207
+ throw new ValidationError(`completed_at must be a valid calendar timestamp, got: ${entry.completed_at}`);
208
+ }
197
209
  if (completedMs < startedMs) {
198
210
  throw new ValidationError(
199
211
  `completed_at (${entry.completed_at}) must be >= started_at (${entry.started_at})`
@@ -258,10 +258,11 @@ function commitRunsCommand(gh, sha) {
258
258
  * @returns {string}
259
259
  */
260
260
  function ciUnknownHint(result) {
261
- if (!PUSHED_FOLLOW_UP_REASONS.has(result?.details?.reason)) return CI_UNKNOWN_HINT_DEFAULT;
262
- // The GitHub reason names the gh CLI; a glab command there would be wrong.
263
- const gh = result.details.cliUsed === 'gh';
261
+ // Every GitHub unknown reason needs a GitHub hint, including running,
262
+ // queued and cancelled checks that do not trigger a pushed-commit query.
263
+ const gh = result?.details?.cliUsed === 'gh';
264
264
  const onDemand = gh ? 'run `gh run list` on demand' : CI_UNKNOWN_HINT_DEFAULT;
265
+ if (!PUSHED_FOLLOW_UP_REASONS.has(result?.details?.reason)) return onDemand;
265
266
  const pushed = result.pushed && typeof result.pushed === 'object' ? result.pushed : null;
266
267
  // Pure (#1396): the SHA is whatever the follow-up resolved inside the budget.
267
268
  // Without `pushed` (the follow-up overran or threw before resolving it) the
@@ -275,6 +276,20 @@ function ciUnknownHint(result) {
275
276
  return `last pushed: ${short} (pipeline not checked; run \`${commitRunsCommand(gh, sha)}\`)`;
276
277
  }
277
278
 
279
+ /** Additive GitHub diagnostics; absent counts leave existing banners unchanged. */
280
+ function checkRunCountsText(result) {
281
+ const counts = result?.details?.checkRunCounts;
282
+ if (!counts || result?.details?.cliUsed !== 'gh') return '';
283
+ const parts = [
284
+ ['success', 'successful'], ['failure', 'failed/action required'],
285
+ ['inProgress', 'in progress'], ['queued', 'queued'],
286
+ ['cancelled', 'cancelled'], ['skipped', 'skipped'],
287
+ ['neutral', 'neutral'], ['waiting', 'waiting'], ['other', 'unrecognised'],
288
+ ].filter(([key]) => Number.isSafeInteger(counts[key]) && counts[key] > 0)
289
+ .map(([key, label]) => `${label}: ${counts[key]}`);
290
+ return parts.length ? ` — Check runs: ${parts.join(', ')}` : '';
291
+ }
292
+
278
293
  /**
279
294
  * "Green, but allow_failure jobs failed" — the ONE predicate both the
280
295
  * `ci-status` renderer and its `severityOf` use (#1333). Two predicates once
@@ -584,7 +599,7 @@ export const PROBES = [
584
599
  // #1337: a RED pushed commit is an alert, and the banner says what the
585
600
  // rule judges (HR-106) — same 🚨 as a red HEAD.
586
601
  const mark = r.pushed?.verdict?.status === 'red' ? '🚨' : '⚠';
587
- return `${mark} ci-status: CI status for HEAD could not be determined (${reason}) — ${ciUnknownHint(r)}`;
602
+ return `${mark} ci-status: CI status for HEAD could not be determined (${reason})${checkRunCountsText(r)} — ${ciUnknownHint(r)}`;
588
603
  }
589
604
  if (r.status === 'red') {
590
605
  const pid = r.details?.currentPipelineId ?? '?';
@@ -592,7 +607,7 @@ export const PROBES = [
592
607
  ? ` — last green: #${r.lastGreen.pipelineId} (commit ${String(r.lastGreen.sha ?? '').slice(0, 7)}, ${r.redCount} pipelines ago)`
593
608
  : '';
594
609
  const job = r.failingJobName ? ` Failing job: ${r.failingJobName}` : '';
595
- return `🚨 CI RED on HEAD (pipeline #${pid})${green}.${job}`;
610
+ return `🚨 CI RED on HEAD (pipeline #${pid})${green}.${job}${checkRunCountsText(r)}`;
596
611
  }
597
612
  if (r.status === 'green') {
598
613
  const lines = [];