session-orchestrator 5.9.0 → 5.11.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/AGENTS.md +2 -0
- package/CHANGELOG.md +65 -2
- package/README.md +11 -9
- package/SECURITY.md +1 -1
- package/agents/code-implementer.md +7 -1
- package/docs/USER-GUIDE.md +2 -0
- package/docs/agent-authoring.md +2 -0
- package/docs/ci-setup.md +46 -1
- package/docs/events-schema.md +37 -6
- package/docs/fleet-checkin-audit.md +172 -0
- package/docs/owner-config-schema.md +18 -1
- package/docs/owner-privacy-patterns.md +85 -0
- package/docs/role-profile-contract.md +139 -0
- package/docs/scope-collision-guard.md +15 -2
- package/docs/session-config-reference.md +1 -1
- package/evals/instruction-ablation/run.mjs +11 -6
- package/hooks/_lib/hook-import-set.json +27 -2
- package/hooks/_lib/orchestrator-ignore.mjs +112 -0
- 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/on-session-end.mjs +8 -1
- package/hooks/on-session-start.mjs +7 -0
- package/hooks/post-bash-write-verify.mjs +54 -24
- package/hooks/pre-task-scope-disjoint.mjs +12 -0
- package/hooks/subagent-telemetry.mjs +8 -3
- package/hooks/wave-scope-commit-guard.mjs +4 -1
- package/package.json +2 -2
- package/rules/opt-in-stack/security-web.md +1 -1
- package/scripts/emit-session.mjs +28 -0
- package/scripts/fleet-checkin-audit.mjs +138 -0
- package/scripts/lib/common.mjs +2 -4
- package/scripts/lib/context-overhead-format.mjs +57 -0
- package/scripts/lib/fleet-checkin-audit.mjs +244 -0
- package/scripts/lib/gates/gate-full.mjs +6 -0
- package/scripts/lib/gates/gate-helpers.mjs +2 -0
- package/scripts/lib/memory-proposals/sink.mjs +79 -17
- package/scripts/lib/memory-proposals/store.mjs +245 -9
- 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/project-hygiene.mjs +37 -0
- package/scripts/lib/quality-gate.mjs +6 -1
- package/scripts/lib/redact-spans.mjs +8 -17
- package/scripts/lib/role-profile/schema.mjs +219 -0
- package/scripts/lib/scope-gate.mjs +68 -5
- package/scripts/lib/session-lock.mjs +57 -10
- package/scripts/lib/session-record-repair.mjs +63 -30
- package/scripts/lib/session-schema/constants.mjs +8 -0
- package/scripts/lib/session-schema/validator.mjs +33 -0
- package/scripts/lib/subagents-schema.mjs +9 -0
- package/scripts/lib/telemetry/pricing.mjs +11 -0
- 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/measure-context-overhead.sh +4 -21
- package/scripts/migrate-vault-paths.mjs +57 -60
- package/scripts/release.mjs +463 -48
- package/scripts/run-quality-gate.mjs +47 -0
- package/scripts/validate-wave-scope.mjs +59 -6
- package/scripts/vault-consolidate.mjs +19 -4
- package/skills/claude-md-drift-check/SKILL.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +22 -9
- package/skills/npm-publish/SKILL.md +3 -3
- package/skills/release/SKILL.md +2 -2
- package/skills/repo-audit/SKILL.md +1 -1
- package/skills/session-end/metrics-collection.md +28 -4
- package/skills/session-end/session-metrics-write.md +1 -1
- package/skills/session-start/references/phase-1-2-session-lock.md +1 -1
- package/skills/wave-executor/circuit-breaker.md +1 -0
- package/skills/wave-executor/references/wave-loop-review.md +26 -2
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* `createStateLockExclusive` pattern (POSIX-atomic create-or-fail).
|
|
9
9
|
* - Proposals JSONL: `.orchestrator/metrics/proposals.jsonl` (O_APPEND)
|
|
10
10
|
* - Per-wave summary: `.orchestrator/metrics/proposals-summary-<wave-id>.json`
|
|
11
|
+
* - Overflow JSONL: `.orchestrator/metrics/proposals-overflow.jsonl` — validated
|
|
12
|
+
* proposals the quota rejected (#1027 1b). Never counted against the quota,
|
|
13
|
+
* never read by the collector; written under the same lock as the queue.
|
|
11
14
|
* - Confidence floor check: BEFORE lock acquisition (no I/O, no lock held).
|
|
12
15
|
* - Quota check: INSIDE lock (count + decide + write serialized).
|
|
13
16
|
*
|
|
@@ -16,6 +19,7 @@
|
|
|
16
19
|
|
|
17
20
|
import fs from 'node:fs';
|
|
18
21
|
import path from 'node:path';
|
|
22
|
+
import { randomUUID } from 'node:crypto';
|
|
19
23
|
import { serializeProposal } from './schema.mjs';
|
|
20
24
|
import { validatePathInsideProject } from '../path-utils.mjs';
|
|
21
25
|
import { tryAcquireFileLock, releaseFileLock } from '../file-lock.mjs';
|
|
@@ -26,6 +30,8 @@ import { writeJsonAtomicSync } from '../io.mjs';
|
|
|
26
30
|
// ---------------------------------------------------------------------------
|
|
27
31
|
|
|
28
32
|
const PROPOSALS_JSONL = '.orchestrator/metrics/proposals.jsonl';
|
|
33
|
+
const PROPOSALS_OVERFLOW_JSONL = '.orchestrator/metrics/proposals-overflow.jsonl';
|
|
34
|
+
const OVERFLOW_MAX_BYTES = 1024 * 1024;
|
|
29
35
|
const PROPOSALS_LOCK = '.orchestrator/metrics/proposals-write.lock';
|
|
30
36
|
const PROPOSALS_SUMMARY_PREFIX = '.orchestrator/metrics/proposals-summary-';
|
|
31
37
|
|
|
@@ -60,20 +66,86 @@ const LOCK_POLL_MS = 50;
|
|
|
60
66
|
*/
|
|
61
67
|
function safePath(repoRoot, relPath) {
|
|
62
68
|
const result = validatePathInsideProject(relPath, repoRoot, { canonicalizeRoot: true });
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
`store.mjs: path traversal blocked: ${resolved} is outside ${repoRoot} (reason: ${result.reason})`
|
|
69
|
+
// #548 A5 — surface result.reason ('lexical' | 'symlink' | 'input') in the
|
|
70
|
+
// error so operators can distinguish path-traversal classes at a glance:
|
|
71
|
+
// - 'lexical' → relPath escaped repoRoot via ../../ before symlink resolution
|
|
72
|
+
// - 'symlink' → relPath resolved through a symlink that points outside repoRoot
|
|
73
|
+
// - 'input' → relPath was malformed (empty, non-string, null byte)
|
|
74
|
+
const blocked = (reason) =>
|
|
75
|
+
new TypeError(
|
|
76
|
+
`store.mjs: path traversal blocked: ${path.resolve(repoRoot, relPath)} is outside ${repoRoot} (reason: ${reason})`
|
|
72
77
|
);
|
|
78
|
+
if (!result.ok) throw blocked(result.reason);
|
|
79
|
+
// The shared validator swallows ENOENT on the target and returns the lexical
|
|
80
|
+
// path unchecked, so a symlinked ancestor (or a dangling symlink AT the
|
|
81
|
+
// target) lets the first write land outside the repo (#1027 1b). Closed here,
|
|
82
|
+
// store-side, so the shared validator's other callers keep their behaviour.
|
|
83
|
+
if (result.realPath === undefined && !existingAncestorInsideRoot(repoRoot, result.lexicalPath)) {
|
|
84
|
+
throw blocked('symlink');
|
|
73
85
|
}
|
|
74
86
|
return result.realPath ?? result.lexicalPath;
|
|
75
87
|
}
|
|
76
88
|
|
|
89
|
+
/**
|
|
90
|
+
* True when the nearest EXISTING component of `lexicalPath` (the path itself
|
|
91
|
+
* or the closest ancestor) resolves inside the canonical `repoRoot`. A
|
|
92
|
+
* symlink there whose target is missing (dangling) counts as an escape — the
|
|
93
|
+
* write that follows would create the target wherever the link points.
|
|
94
|
+
*
|
|
95
|
+
* Only node:fs/node:path on purpose: store-safepath-reason.test.mjs mocks
|
|
96
|
+
* `@lib/path-utils.mjs` with a single export, so no second import from it.
|
|
97
|
+
*
|
|
98
|
+
* Check-then-write TOCTOU: a link swapped in between this check and the
|
|
99
|
+
* append is not caught (O_NOFOLLOW would narrow the final component only).
|
|
100
|
+
* Accepted under the local-trust model; revisit if the metrics dir ever
|
|
101
|
+
* becomes writable by another principal.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} repoRoot
|
|
104
|
+
* @param {string} lexicalPath — absolute, already known to be lexically inside repoRoot
|
|
105
|
+
* @returns {boolean}
|
|
106
|
+
*/
|
|
107
|
+
function existingAncestorInsideRoot(repoRoot, lexicalPath) {
|
|
108
|
+
let root = repoRoot;
|
|
109
|
+
try {
|
|
110
|
+
root = fs.realpathSync(repoRoot);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (err?.code !== 'ENOENT' && err?.code !== 'EACCES') throw err;
|
|
113
|
+
}
|
|
114
|
+
const fold = (p) => (process.platform === 'win32' ? p.toLowerCase() : p);
|
|
115
|
+
const inside = (p) => {
|
|
116
|
+
const rel = path.relative(fold(root), fold(p));
|
|
117
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
118
|
+
};
|
|
119
|
+
for (let cur = lexicalPath; ; cur = path.dirname(cur)) {
|
|
120
|
+
// Climbed above the root: root itself is missing, so nothing under it can
|
|
121
|
+
// be a link — the later mkdir creates a plain tree.
|
|
122
|
+
if (!inside(cur)) return true;
|
|
123
|
+
try {
|
|
124
|
+
fs.lstatSync(cur);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
if (err?.code !== 'ENOENT') throw err;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
let real;
|
|
130
|
+
try {
|
|
131
|
+
real = fs.realpathSync(cur);
|
|
132
|
+
} catch {
|
|
133
|
+
// Dangling link or loop at the nearest existing component → escape.
|
|
134
|
+
// But the lock file is created and unlinked by concurrent writers all
|
|
135
|
+
// the time: if the component vanished between lstat and realpath it was
|
|
136
|
+
// never a link, so step up to its parent instead of rejecting.
|
|
137
|
+
try {
|
|
138
|
+
fs.lstatSync(cur);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
if (err?.code === 'ENOENT') continue;
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
return inside(real);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
77
149
|
/**
|
|
78
150
|
* Ensure the parent directory of `filePath` exists.
|
|
79
151
|
* @param {string} filePath
|
|
@@ -100,6 +172,153 @@ function jsonlPathFor(repoRoot) {
|
|
|
100
172
|
return safePath(repoRoot, PROPOSALS_JSONL);
|
|
101
173
|
}
|
|
102
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Build the absolute path to the quota-overflow JSONL file.
|
|
177
|
+
* @param {string} repoRoot
|
|
178
|
+
* @returns {string}
|
|
179
|
+
*/
|
|
180
|
+
function overflowPathFor(repoRoot) {
|
|
181
|
+
return safePath(repoRoot, PROPOSALS_OVERFLOW_JSONL);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Flush a directory entry before depending on its persistence. */
|
|
185
|
+
function syncOverflowDirectory(directory) {
|
|
186
|
+
const fd = fs.openSync(directory, 'r');
|
|
187
|
+
try {
|
|
188
|
+
fs.fsyncSync(fd);
|
|
189
|
+
} finally {
|
|
190
|
+
fs.closeSync(fd);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Create a file without replacing anything, retaining leftovers on failure. */
|
|
195
|
+
function writeOverflowExclusive(file, bytes, mode) {
|
|
196
|
+
const fd = fs.openSync(file, 'wx', mode);
|
|
197
|
+
try {
|
|
198
|
+
// The umask may narrow the creation mode; restore exactly the old mode.
|
|
199
|
+
fs.fchmodSync(fd, mode);
|
|
200
|
+
fs.writeFileSync(fd, bytes);
|
|
201
|
+
fs.fsyncSync(fd);
|
|
202
|
+
} finally {
|
|
203
|
+
fs.closeSync(fd);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Copy legacy files with bounded memory into an exclusive `.partial` file,
|
|
209
|
+
* then hard-link it to the archive name. An interrupted copy leaves only the
|
|
210
|
+
* `.partial` name behind, never a truncated file under the archive name; the
|
|
211
|
+
* link fails with EEXIST instead of overwriting an existing archive. Unsupported
|
|
212
|
+
* link operations discard only our completed staging copy, leaving active intact.
|
|
213
|
+
*/
|
|
214
|
+
function archiveOverflowExclusive(active, archive, partial, mode) {
|
|
215
|
+
const source = fs.openSync(active, 'r');
|
|
216
|
+
try {
|
|
217
|
+
const destination = fs.openSync(partial, 'wx', mode);
|
|
218
|
+
try {
|
|
219
|
+
// The umask may narrow the creation mode; restore exactly the old mode.
|
|
220
|
+
fs.fchmodSync(destination, mode);
|
|
221
|
+
const chunk = Buffer.alloc(64 * 1024);
|
|
222
|
+
let count;
|
|
223
|
+
while ((count = fs.readSync(source, chunk, 0, chunk.length, null)) > 0) {
|
|
224
|
+
fs.writeFileSync(destination, chunk.subarray(0, count));
|
|
225
|
+
}
|
|
226
|
+
fs.fsyncSync(destination);
|
|
227
|
+
} finally {
|
|
228
|
+
fs.closeSync(destination);
|
|
229
|
+
}
|
|
230
|
+
} finally {
|
|
231
|
+
fs.closeSync(source);
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
fs.linkSync(partial, archive);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
if (!['EPERM', 'ENOTSUP', 'EXDEV'].includes(err?.code)) throw err;
|
|
237
|
+
// We exclusively created this complete copy and have not replaced active.
|
|
238
|
+
// Do not accumulate another full copy on every unsupported-link retry.
|
|
239
|
+
// Copy/flush failures and collisions deliberately retain their evidence.
|
|
240
|
+
let cleanupError = '';
|
|
241
|
+
try {
|
|
242
|
+
fs.unlinkSync(partial);
|
|
243
|
+
} catch (cleanup) {
|
|
244
|
+
cleanupError = `; partial cleanup failed (${cleanup?.code ?? 'unknown'}): ${cleanup.message}`;
|
|
245
|
+
}
|
|
246
|
+
// appendProposal exposes message, so preserve the filesystem code there.
|
|
247
|
+
// This statement is specific to publication failure, never post-rename I/O.
|
|
248
|
+
throw new Error(
|
|
249
|
+
`overflow archive publication failed (${err.code}): ${err.message}; new proposal not stored${cleanupError}`,
|
|
250
|
+
{ cause: err }
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
fs.unlinkSync(partial);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Preserve the old bytes durably before replacing the active overflow file.
|
|
258
|
+
* The proposals lock serializes writers of this append/rotation decision.
|
|
259
|
+
* Failures before rename leave the active file intact; after rename, the
|
|
260
|
+
* durable archive preserves the old bytes and the replacement has the new
|
|
261
|
+
* record. Incomplete archives/temps remain for inspection, never deleted.
|
|
262
|
+
* BV-004: archive aggregate size is unbounded (no retention authorization).
|
|
263
|
+
* Revisit growth when active file >= 1 MiB or first rotation: measure it.
|
|
264
|
+
*/
|
|
265
|
+
function appendOverflow(repoRoot, overflowPath, line) {
|
|
266
|
+
ensureDir(overflowPath);
|
|
267
|
+
let oldSize = 0;
|
|
268
|
+
let oldMode = 0o600;
|
|
269
|
+
let unterminated = false;
|
|
270
|
+
try {
|
|
271
|
+
const fd = fs.openSync(overflowPath, 'r');
|
|
272
|
+
try {
|
|
273
|
+
const oldStat = fs.fstatSync(fd);
|
|
274
|
+
oldSize = oldStat.size;
|
|
275
|
+
oldMode = oldStat.mode & 0o777;
|
|
276
|
+
if (oldSize > 0) {
|
|
277
|
+
const lastByte = Buffer.alloc(1);
|
|
278
|
+
if (fs.readSync(fd, lastByte, 0, 1, oldSize - 1) !== 1) {
|
|
279
|
+
throw new Error('overflow changed while checking its final byte');
|
|
280
|
+
}
|
|
281
|
+
unterminated = lastByte[0] !== 10;
|
|
282
|
+
}
|
|
283
|
+
} finally {
|
|
284
|
+
fs.closeSync(fd);
|
|
285
|
+
}
|
|
286
|
+
} catch (err) {
|
|
287
|
+
if (err?.code !== 'ENOENT') throw err;
|
|
288
|
+
}
|
|
289
|
+
const lineBytes = Buffer.from(line, 'utf8');
|
|
290
|
+
// Optional schema fields have no aggregate size limit. A single oversized
|
|
291
|
+
// record is preserved whole even though the active file exceeds the cap;
|
|
292
|
+
// the next record rotates it. Empty/absent files need no recovery archive.
|
|
293
|
+
if (oldSize === 0 || (!unterminated && oldSize + lineBytes.length <= OVERFLOW_MAX_BYTES)) {
|
|
294
|
+
fs.appendFileSync(overflowPath, lineBytes);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const root = fs.realpathSync(repoRoot);
|
|
298
|
+
const directory = path.dirname(overflowPath);
|
|
299
|
+
const token = randomUUID();
|
|
300
|
+
const archive = safePath(root, path.relative(root, `${overflowPath}.archive-${token}.jsonl`));
|
|
301
|
+
const partial = safePath(root, path.relative(root, `${overflowPath}.archive-${token}.partial`));
|
|
302
|
+
const temporary = safePath(root, path.relative(root, `${overflowPath}.tmp-${token}`));
|
|
303
|
+
// Existing arbitrarily large files copy in bounded chunks.
|
|
304
|
+
// Preserve access restrictions; rotation must not widen a private inode's
|
|
305
|
+
// mode through the process umask's default creation permissions.
|
|
306
|
+
archiveOverflowExclusive(overflowPath, archive, partial, oldMode);
|
|
307
|
+
// Persist the archive's name (and the partial's removal) plus any newly
|
|
308
|
+
// created ancestors BEFORE replacing the sole active copy. All these
|
|
309
|
+
// directories are inside root.
|
|
310
|
+
for (let current = directory; ; current = path.dirname(current)) {
|
|
311
|
+
syncOverflowDirectory(current);
|
|
312
|
+
if (current === root) break;
|
|
313
|
+
if (current === path.dirname(current)) {
|
|
314
|
+
throw new Error('overflow directory is not inside the repository root');
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
writeOverflowExclusive(temporary, lineBytes, oldMode);
|
|
318
|
+
fs.renameSync(temporary, overflowPath);
|
|
319
|
+
syncOverflowDirectory(directory);
|
|
320
|
+
}
|
|
321
|
+
|
|
103
322
|
/**
|
|
104
323
|
* Build the absolute path to the per-wave summary JSON file.
|
|
105
324
|
* wave_id values are restricted to alphanumerics + hyphens/underscores to
|
|
@@ -372,6 +591,23 @@ export async function appendProposal({
|
|
|
372
591
|
// Step 3b: Quota check.
|
|
373
592
|
// ------------------------------------------------------------------
|
|
374
593
|
if (currentCount >= quotaPerWave) {
|
|
594
|
+
// Keep the validated proposal (#1027 1b): the quota caps what enters the
|
|
595
|
+
// queue, not what an agent found. A failed write is fs-error — never a
|
|
596
|
+
// quota-exceeded that implies the content was preserved. Rotation keeps
|
|
597
|
+
// every old byte in an exclusive archive before replacing the active file.
|
|
598
|
+
let overflowPath;
|
|
599
|
+
try {
|
|
600
|
+
overflowPath = overflowPathFor(repoRoot);
|
|
601
|
+
} catch (err) {
|
|
602
|
+
incrementSummary(summaryPath, 'fs_error'); // traversal still throws, like every store path
|
|
603
|
+
throw err;
|
|
604
|
+
}
|
|
605
|
+
try {
|
|
606
|
+
appendOverflow(repoRoot, overflowPath, serializeProposal(record) + '\n');
|
|
607
|
+
} catch (err) {
|
|
608
|
+
incrementSummary(summaryPath, 'fs_error');
|
|
609
|
+
return { status: 'fs-error', error: err.message };
|
|
610
|
+
}
|
|
375
611
|
const summary = readSummary(summaryPath);
|
|
376
612
|
const droppedSoFar = summary.dropped;
|
|
377
613
|
incrementSummary(summaryPath, 'dropped');
|
|
@@ -209,7 +209,7 @@ export function canonicalSuffixesFromVaults(vaults, envOverride) {
|
|
|
209
209
|
*
|
|
210
210
|
* First-match-wins; caller is responsible for WARN on ambiguity if needed.
|
|
211
211
|
*
|
|
212
|
-
* @param {string} repoSlug — e.g. '
|
|
212
|
+
* @param {string} repoSlug — e.g. 'example-org/foo'
|
|
213
213
|
* @param {Array<{name:string, suffix:string, root:string, match:{'org-prefix'?:string}}>} vaults
|
|
214
214
|
* @returns {{name:string, suffix:string, root:string, match:object}|null}
|
|
215
215
|
*/
|
|
@@ -41,6 +41,9 @@
|
|
|
41
41
|
* mode: 'off' | 'warn' | 'strict' (applied only when weaker than the committed mode;
|
|
42
42
|
* precedence env SO_VAULT_INTEGRATION > this > committed.
|
|
43
43
|
* An invalid value drops ONLY this section, see loadOwnerConfig)
|
|
44
|
+
* drift-check: (optional; host-local path exceptions — #937)
|
|
45
|
+
* foreign-host-prefixes: string[] (narrow absolute directory prefixes;
|
|
46
|
+
* missing matching paths warn, unmatched paths error)
|
|
44
47
|
* vault-dirs: (optional list; per-directory vault-dir — agents/vault#319)
|
|
45
48
|
* - path: string (the vault directory)
|
|
46
49
|
* match: { path-prefix: string } (required; cwd directory prefix, first match wins)
|
|
@@ -54,7 +57,7 @@
|
|
|
54
57
|
* validateOwnerSections(obj) — pure validation, no I/O; bucketed per section (#820)
|
|
55
58
|
* validateOwnerConfig(obj) — pure validation, no I/O; thin wrapper over validateOwnerSections
|
|
56
59
|
* loadOwnerConfig({path?}) — reads file; per-section tolerance for OPTIONAL
|
|
57
|
-
* sections (paths/dispatcher/vault-integration/vaults/
|
|
60
|
+
* sections (paths/dispatcher/vault-integration/drift-check/vaults/
|
|
58
61
|
* baselines/vault-dirs) — #820
|
|
59
62
|
* writeOwnerConfig(config, {path?}) — validates, writes YAML, creates dir
|
|
60
63
|
* getDefaults() — returns sensible default config object
|
|
@@ -218,7 +221,7 @@ const REQUIRED_SECTIONS = /** @type {const} */ (['owner', 'tone', 'efficiency',
|
|
|
218
221
|
* override", so a dropped section simply leaves the committed gate in force.
|
|
219
222
|
*/
|
|
220
223
|
export const OPTIONAL_OBJECT_SECTIONS = /** @type {const} */ (
|
|
221
|
-
Object.freeze(['paths', 'dispatcher', 'vault-integration'])
|
|
224
|
+
Object.freeze(['paths', 'dispatcher', 'vault-integration', 'drift-check'])
|
|
222
225
|
);
|
|
223
226
|
|
|
224
227
|
/**
|
|
@@ -397,6 +400,33 @@ export function validateOwnerSections(obj) {
|
|
|
397
400
|
sections['hardware-sharing'] = { valid: errors.length === 0, errors };
|
|
398
401
|
}
|
|
399
402
|
|
|
403
|
+
// Host-local exception list only; invalid entries drop the entire section.
|
|
404
|
+
{
|
|
405
|
+
const errors = [];
|
|
406
|
+
const drift = obj['drift-check'];
|
|
407
|
+
if (drift !== undefined && drift !== null) {
|
|
408
|
+
if (!isPlainObject(drift)) {
|
|
409
|
+
errors.push('drift-check must be an object when present');
|
|
410
|
+
} else if (drift['foreign-host-prefixes'] !== undefined) {
|
|
411
|
+
const prefixes = drift['foreign-host-prefixes'];
|
|
412
|
+
if (!Array.isArray(prefixes)) {
|
|
413
|
+
errors.push('drift-check.foreign-host-prefixes must be an array');
|
|
414
|
+
} else {
|
|
415
|
+
for (const prefix of prefixes) {
|
|
416
|
+
const parts = typeof prefix === 'string' ? prefix.split('/').filter(Boolean) : [];
|
|
417
|
+
if (typeof prefix !== 'string' || !prefix.startsWith('/') ||
|
|
418
|
+
!/^\/[A-Za-z0-9._/-]+$/.test(prefix) || prefix.includes('//') ||
|
|
419
|
+
parts.some((part) => part === '.' || part === '..') ||
|
|
420
|
+
parts.length < 3 || (['Users', 'home'].includes(parts[0]) && parts.length < 4)) {
|
|
421
|
+
errors.push('drift-check.foreign-host-prefixes entries must be narrow absolute directory prefixes (no roots, home directories, traversal or globs)');
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
sections['drift-check'] = { valid: errors.length === 0, errors };
|
|
428
|
+
}
|
|
429
|
+
|
|
400
430
|
// ── paths (optional; host-local path overrides — #653) ───────────────────────
|
|
401
431
|
{
|
|
402
432
|
const errors = [];
|
|
@@ -627,7 +657,7 @@ export function validateOwnerConfig(obj) {
|
|
|
627
657
|
* Per-section tolerance (#820): an invalid REQUIRED section (owner, tone,
|
|
628
658
|
* efficiency, hardware-sharing) still discards the whole file (legacy
|
|
629
659
|
* behaviour, unchanged). An invalid OPTIONAL object section (paths,
|
|
630
|
-
* dispatcher, vault-integration) is instead replaced by its default value — the rest of the
|
|
660
|
+
* dispatcher, vault-integration, drift-check) is instead replaced by its default value — the rest of the
|
|
631
661
|
* file survives, `source` becomes `'partial'`, and the drop is reported via
|
|
632
662
|
* `droppedSections` + a stderr WARN. OPTIONAL list sections (vaults,
|
|
633
663
|
* baselines, vault-dirs) are passed through UNTOUCHED even when strict-invalid — their
|
package/scripts/lib/platform.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* NOTHING in this module touches the filesystem at import time.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
16
|
+
import { existsSync, readFileSync, realpathSync, statSync, lstatSync, opendirSync } from 'node:fs';
|
|
17
17
|
import os from 'node:os';
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
import { resolvePluginRoot as _resolvePluginRootRobust } from './plugin-root.mjs';
|
|
@@ -458,6 +458,75 @@ export function resolveSessionRoot(cwd, toplevel = '') {
|
|
|
458
458
|
return launch !== '' && _isStrictlyInside(launch, root) ? launch : root;
|
|
459
459
|
}
|
|
460
460
|
|
|
461
|
+
/** Read small Git metadata after rejecting nonregular or symlink entries. */
|
|
462
|
+
function readWorktreeMetadata(file) {
|
|
463
|
+
const info = lstatSync(file);
|
|
464
|
+
if (!info.isFile() || info.size > 8192) throw new TypeError('Invalid worktree metadata');
|
|
465
|
+
return readFileSync(file, 'utf8').trim();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function worktreeBinding(root) {
|
|
469
|
+
const text = readWorktreeMetadata(path.join(root, '.git'));
|
|
470
|
+
const match = /^gitdir: (.+)$/.exec(text);
|
|
471
|
+
if (!match) throw new TypeError('Invalid worktree pointer');
|
|
472
|
+
const gitDir = _canonical(path.resolve(root, match[1]));
|
|
473
|
+
const commonDir = _canonical(path.resolve(gitDir, readWorktreeMetadata(path.join(gitDir, 'commondir'))));
|
|
474
|
+
if (path.dirname(gitDir) !== path.join(commonDir, 'worktrees')) throw new TypeError('Invalid worktree admin directory');
|
|
475
|
+
const backPointer = _canonical(readWorktreeMetadata(path.join(gitDir, 'gitdir')));
|
|
476
|
+
if (backPointer !== _canonical(path.join(root, '.git'))) throw new TypeError('Worktree pointer mismatch');
|
|
477
|
+
return { gitDir, commonDir };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Verified Claude harness agent checkout, or null for every ordinary checkout.
|
|
482
|
+
* Keeps resolveSessionRoot's lifecycle contract unchanged (#1504 items 1–2).
|
|
483
|
+
* @param {string} cwd
|
|
484
|
+
* @returns {{writeRoot: string, commonDir: string}|null}
|
|
485
|
+
*/
|
|
486
|
+
export function isolatedAgentCheckout(cwd) {
|
|
487
|
+
const root = typeof cwd === 'string' ? dotGitAncestor(cwd) : '';
|
|
488
|
+
if (!root || _liftHarnessAgentWorktree(root) === root) return null;
|
|
489
|
+
try {
|
|
490
|
+
const binding = worktreeBinding(root);
|
|
491
|
+
const launchRoot = _liftHarnessAgentWorktree(root);
|
|
492
|
+
const dotGit = path.join(launchRoot, '.git');
|
|
493
|
+
const launchCommon = lstatSync(dotGit).isDirectory()
|
|
494
|
+
? _canonical(dotGit) : worktreeBinding(launchRoot).commonDir;
|
|
495
|
+
if (binding.commonDir !== launchCommon) return null;
|
|
496
|
+
return { writeRoot: _canonical(root), commonDir: binding.commonDir };
|
|
497
|
+
} catch { return null; }
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Enumerate reciprocal worktree roots in one Git admin directory, without a
|
|
502
|
+
* recursive scan. Ceiling: 256 entries / 8 KiB metadata; revisit if a real repo
|
|
503
|
+
* exceeds this. An incomplete enumeration is an error, never a clean absence.
|
|
504
|
+
* @param {string} commonDir
|
|
505
|
+
* @returns {{roots: string[], error: string|null}}
|
|
506
|
+
*/
|
|
507
|
+
export function registeredWorktreeRoots(commonDir) {
|
|
508
|
+
const roots = new Set();
|
|
509
|
+
let dir;
|
|
510
|
+
try {
|
|
511
|
+
dir = opendirSync(path.join(commonDir, 'worktrees'));
|
|
512
|
+
let count = 0;
|
|
513
|
+
for (let entry; (entry = dir.readSync()) !== null;) {
|
|
514
|
+
if (++count > 256) return { roots: [], error: 'worktree-limit' };
|
|
515
|
+
if (!entry.isDirectory()) continue;
|
|
516
|
+
const admin = path.join(commonDir, 'worktrees', entry.name);
|
|
517
|
+
try {
|
|
518
|
+
const pointer = readWorktreeMetadata(path.join(admin, 'gitdir'));
|
|
519
|
+
if (!path.isAbsolute(pointer) || path.basename(pointer) !== '.git') continue;
|
|
520
|
+
const root = _canonical(path.dirname(pointer));
|
|
521
|
+
const binding = worktreeBinding(root);
|
|
522
|
+
if (binding.commonDir === commonDir && binding.gitDir === _canonical(admin)) roots.add(root);
|
|
523
|
+
} catch { /* stale or invalid registration cannot lend authority */ }
|
|
524
|
+
}
|
|
525
|
+
return { roots: [...roots], error: null };
|
|
526
|
+
} catch { return { roots: [], error: 'worktree-discovery-unreadable' }; }
|
|
527
|
+
finally { if (dir) dir.closeSync(); }
|
|
528
|
+
}
|
|
529
|
+
|
|
461
530
|
// ---------------------------------------------------------------------------
|
|
462
531
|
// resolveEventsLedgerRoot (#1514 point 1)
|
|
463
532
|
// ---------------------------------------------------------------------------
|
|
@@ -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();
|
|
@@ -555,6 +555,10 @@ const AUDIT_VALUE_FLAGS = new Set([
|
|
|
555
555
|
const AUDIT_BOOLEAN_FLAGS = new Set([
|
|
556
556
|
'-r', '--recursive', '-s', '--silent', '-g', '--global', '--workspaces', '--offline',
|
|
557
557
|
]);
|
|
558
|
+
// Python tool runners (#1538): subcommand words after the verb, and the runner
|
|
559
|
+
// options that consume the next token.
|
|
560
|
+
const RUNNER_SUBCOMMANDS = { uvx: [], pipx: ['run'], uv: ['tool', 'run'] };
|
|
561
|
+
const RUNNER_VALUE_FLAGS = new Set(['--from', '--spec', '--python', '-p', '--with']);
|
|
558
562
|
|
|
559
563
|
/** Inspect executable job/step locations, never arbitrary keys named run/script. */
|
|
560
564
|
function hasAuditStep({ text, platform }) {
|
|
@@ -683,6 +687,39 @@ function hasAuditSegment(segment, depth = 0) {
|
|
|
683
687
|
const trailingArgs = index < 0 ? [] : segment.slice(index);
|
|
684
688
|
return hasAuditSegment([...tokens, ...trailingArgs], depth + 1);
|
|
685
689
|
}
|
|
690
|
+
if (verb === 'uvx' || verb === 'pipx' || verb === 'uv') {
|
|
691
|
+
let i = index + 1;
|
|
692
|
+
// `uvx` is an alias of `uv tool run`; `pipx` executes only through `run`.
|
|
693
|
+
for (const word of RUNNER_SUBCOMMANDS[verb]) {
|
|
694
|
+
if (segment[i]?.text !== word) return false;
|
|
695
|
+
i++;
|
|
696
|
+
}
|
|
697
|
+
// Runner options before the tool. Unknown flags count as value-less, so an
|
|
698
|
+
// unlisted value-taking flag makes its value the "tool" and the line is
|
|
699
|
+
// missed — a false finding, never a false pass. Revisit if a real CI line
|
|
700
|
+
// with such a flag is reported as missing its audit.
|
|
701
|
+
// `--from`/`--spec` name the package that provides the tool; another
|
|
702
|
+
// package exposing a `pip-audit` entry point is not the audit.
|
|
703
|
+
let sourcePackage = null;
|
|
704
|
+
while (segment[i]) {
|
|
705
|
+
const { text, redirect } = segment[i];
|
|
706
|
+
// A redirect operand is data, even when it names the audit package.
|
|
707
|
+
if (redirect) return false;
|
|
708
|
+
if (text === '--') { i++; break; }
|
|
709
|
+
if (!text.startsWith('-')) break;
|
|
710
|
+
if (RUNNER_VALUE_FLAGS.has(text)) {
|
|
711
|
+
if (!segment[i + 1] || segment[i + 1].redirect) return false;
|
|
712
|
+
if (text === '--from' || text === '--spec') sourcePackage = segment[i + 1].text;
|
|
713
|
+
i += 2;
|
|
714
|
+
} else {
|
|
715
|
+
i++;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
if (!segment[i] || segment[i].redirect) return false;
|
|
719
|
+
if (segment.slice(index + 1).some((token) => ['-h', '--help', '--version'].includes(token.text))) return false;
|
|
720
|
+
if (sourcePackage !== null && !/^pip-audit(?:\s*(?:[=<>~!]=?|@)\s*[\w.+*-]+)?$/.test(sourcePackage)) return false;
|
|
721
|
+
return /^pip-audit(?:@[\w.+-]+)?$/.test(segment[i].text);
|
|
722
|
+
}
|
|
686
723
|
if (verb === 'corepack' || verb === 'npx') {
|
|
687
724
|
index++;
|
|
688
725
|
while (['-y', '--yes', '--no-install', '--'].includes(segment[index]?.text)) index++;
|
|
@@ -851,7 +851,12 @@ export function gateKillFields(run) {
|
|
|
851
851
|
* Count fallback is limited to pre-#954 sessions, gates outside the event
|
|
852
852
|
* wrapper, or missing wave attribution; step 7 falls back to this wave's own
|
|
853
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
|
|
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.
|
|
855
860
|
*
|
|
856
861
|
* `suite_platform` comes from this wave's gate run (step 7) or the STATE.md
|
|
857
862
|
* Wave History header `— suite <passed>/<failed> on <platform>` (session end);
|