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.
- 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 +52 -2
- package/README.md +14 -10
- package/docs/USER-GUIDE.md +2 -0
- package/docs/ci-setup.md +46 -1
- package/docs/events-schema.md +71 -1
- package/docs/owner-config-schema.md +18 -1
- package/docs/owner-privacy-patterns.md +85 -0
- package/docs/scope-collision-guard.md +12 -2
- package/hooks/enforce-commands.mjs +9 -19
- package/hooks/enforce-scope.mjs +23 -42
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/post-bash-write-verify.mjs +38 -21
- package/package.json +2 -2
- package/scripts/emit-session.mjs +15 -0
- package/scripts/lib/ci-status-banner.mjs +38 -5
- package/scripts/lib/events-schema.mjs +8 -0
- package/scripts/lib/gates/gate-full.mjs +6 -0
- package/scripts/lib/gates/gate-helpers.mjs +2 -0
- package/scripts/lib/named-vault-resolver.mjs +1 -1
- package/scripts/lib/owner-yaml.mjs +33 -3
- package/scripts/lib/platform.mjs +70 -1
- package/scripts/lib/pre-push-remote-gate.mjs +107 -0
- package/scripts/lib/quality-gate-wave.mjs +40 -0
- package/scripts/lib/quality-gate.mjs +32 -26
- package/scripts/lib/redact-spans.mjs +8 -17
- package/scripts/lib/scope-gate.mjs +68 -5
- 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/validate/check-owner-leakage.mjs +198 -1015
- package/scripts/lib/vault-backfill/template.mjs +1 -1
- package/scripts/lib/vault-mirror/render-sessions.mjs +15 -3
- package/scripts/lib/vault-status/board-writer.mjs +15 -7
- package/scripts/lib/wave-executor/remote-dispatch.mjs +27 -9
- package/scripts/lib/wave-resource-gate.mjs +2 -1
- package/scripts/migrate-vault-paths.mjs +57 -60
- package/scripts/release.mjs +107 -37
- package/scripts/run-quality-gate.mjs +9 -39
- package/scripts/site-numbers.mjs +4 -0
- package/scripts/vault-consolidate.mjs +19 -4
- package/skills/_shared/fleet-protocol.md +2 -0
- package/skills/claude-md-drift-check/SKILL.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +14 -3
- package/skills/remote-offload/SKILL.md +1 -1
- package/skills/session-end/metrics-collection.md +39 -9
- package/skills/wave-executor/references/wave-loop-dispatch.md +1 -1
- 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
|
-
*
|
|
838
|
-
*
|
|
839
|
-
*
|
|
840
|
-
*
|
|
841
|
-
*
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
*
|
|
845
|
-
*
|
|
846
|
-
*
|
|
847
|
-
*
|
|
848
|
-
*
|
|
849
|
-
*
|
|
850
|
-
*
|
|
851
|
-
*
|
|
852
|
-
* (
|
|
853
|
-
*
|
|
854
|
-
*
|
|
855
|
-
*
|
|
856
|
-
*
|
|
857
|
-
*
|
|
858
|
-
*
|
|
859
|
-
*
|
|
860
|
-
*
|
|
861
|
-
*
|
|
862
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
9
|
-
*
|
|
10
|
-
* this lib;
|
|
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
|
|
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 = [];
|