@sabaiway/agent-workflow-kit 5.1.0 → 5.3.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/CHANGELOG.md +95 -0
- package/SKILL.md +13 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +14 -3
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +220 -30
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +264 -8
- package/bridges/antigravity-cli-bridge/bin/agy.sh +12 -2
- package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +18 -0
- package/bridges/antigravity-cli-bridge/capability.json +19 -13
- package/bridges/antigravity-cli-bridge/references/driving-agy.md +3 -2
- package/bridges/codex-cli-bridge/SKILL.md +17 -7
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +156 -36
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +228 -4
- package/bridges/codex-cli-bridge/bin/codex-review.sh +205 -34
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +276 -5
- package/bridges/codex-cli-bridge/capability.json +10 -7
- package/bridges/codex-cli-bridge/references/driving-codex.md +7 -5
- package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +26 -12
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/flow-writer.md +37 -0
- package/references/modes/gates.md +4 -4
- package/references/modes/procedures.md +4 -2
- package/references/modes/receipt-deadline.md +16 -0
- package/references/modes/review-state.md +1 -1
- package/references/modes/set-flow.md +22 -0
- package/references/scripts/archive-decisions.mjs +340 -15
- package/references/scripts/archive-decisions.test.mjs +522 -2
- package/tools/cheap-agents.mjs +8 -2
- package/tools/commands.mjs +24 -2
- package/tools/commit-guard.mjs +44 -9
- package/tools/core-evidence.mjs +25 -22
- package/tools/detect-backends.mjs +33 -11
- package/tools/dispatch-record.mjs +926 -0
- package/tools/doc-parity.mjs +21 -6
- package/tools/flow-check.mjs +842 -0
- package/tools/flow-record.mjs +795 -0
- package/tools/flow-store-read.mjs +114 -0
- package/tools/flow-store.mjs +1178 -0
- package/tools/flow-writer.mjs +1265 -0
- package/tools/fs-read-nofollow.mjs +128 -0
- package/tools/gates-declaration.mjs +184 -0
- package/tools/gates-init.mjs +59 -17
- package/tools/orchestration-config.mjs +87 -10
- package/tools/orchestration-write.mjs +3 -3
- package/tools/plan-files.mjs +35 -0
- package/tools/procedures.mjs +75 -11
- package/tools/receipt-deadline.mjs +242 -0
- package/tools/recipes.mjs +21 -0
- package/tools/repo-lex.mjs +22 -0
- package/tools/review-state.mjs +240 -80
- package/tools/run-gates.mjs +361 -139
- package/tools/set-flow.mjs +465 -0
- package/tools/velocity-profile.mjs +8 -2
package/tools/run-gates.mjs
CHANGED
|
@@ -22,19 +22,35 @@
|
|
|
22
22
|
// Gate `cmd` lines are BASH command lines (brace/glob expansion); a host without bash gets the
|
|
23
23
|
// loud exit-6 preflight error, never a silent reinterpretation under another shell.
|
|
24
24
|
//
|
|
25
|
-
// The runner itself WRITES NOTHING on a plain run. `--final` (D3(a))
|
|
26
|
-
// every attempt
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
// The runner itself WRITES NOTHING on a plain run. Two modes write state: `--final` (D3(a))
|
|
26
|
+
// records every attempt in the core-evidence store via its sole writer, and an ARMED
|
|
27
|
+
// `--pre-review` records its subset-attempt through the flow store's locked append factory
|
|
28
|
+
// (Plan 4 Decision 7/8 — an unarmed repo's `--pre-review` stays byte-unchanged, writing
|
|
29
|
+
// nothing). Both flow lanes resolve the CANONICAL git-derived store only — a SET AW_FLOW_STORE
|
|
30
|
+
// refuses up front. Dependency-free. No side effects on import.
|
|
31
|
+
|
|
32
|
+
import { readFileSync, lstatSync, unlinkSync } from 'node:fs';
|
|
33
|
+
import { join } from 'node:path';
|
|
31
34
|
import { spawnSync } from 'node:child_process';
|
|
32
|
-
import { pathToFileURL
|
|
35
|
+
import { pathToFileURL } from 'node:url';
|
|
33
36
|
import { createHash, randomUUID } from 'node:crypto';
|
|
34
37
|
import { computeTreeFingerprint } from './review-state.mjs';
|
|
38
|
+
import { CONFIG_REL, loadConfig } from './orchestration-config.mjs';
|
|
35
39
|
// The D3(a) final receipt rides the core-evidence SOLE WRITER (the sole-writer boundary — this
|
|
36
40
|
// runner never opens the store itself) + the canonical per-kind serialization its hashes bind.
|
|
37
41
|
import { appendEvidenceRecord, resolveEvidencePath, readEvidence, canonicalKindSerialization, EVIDENCE_SCHEMA_VERSION, resolveBase } from './core-evidence.mjs';
|
|
42
|
+
// The Decision-7/8 recording lane (--pre-review under an ARMED flow) and the D10 flow→final
|
|
43
|
+
// binding (Plan 4): the subset attempt rides the flow store's locked append factory; the final
|
|
44
|
+
// receipt hashes the owner-scoped projection through the ONE shared pure helper.
|
|
45
|
+
import {
|
|
46
|
+
resolveFlowStorePath, readFlowStore, deriveFlowOwner, walkChainState,
|
|
47
|
+
appendSubsetAttempt, acquireSubsetRunLock, probeFlowAppendLock, subsetAttemptState, subsetExhaustionRemedy,
|
|
48
|
+
SUBSET_ATTEMPT_MAX_REDS, SUBSET_ATTEMPT_DIAGNOSIS_REDS,
|
|
49
|
+
} from './flow-store.mjs';
|
|
50
|
+
import {
|
|
51
|
+
CHAIN_KIND, validateChainSequence, validateSupersessions,
|
|
52
|
+
subsetFoldBatchDigest, subsetGateIdsDigest, flowProjectionHash, SUBSET_ATTEMPT_DIAGNOSIS_FROM,
|
|
53
|
+
} from './flow-record.mjs';
|
|
38
54
|
import {
|
|
39
55
|
LCOV_BASENAME,
|
|
40
56
|
commitmentFor,
|
|
@@ -42,10 +58,17 @@ import {
|
|
|
42
58
|
ATTEST_FINGERPRINT_ENV,
|
|
43
59
|
ATTEST_BASE_ENV,
|
|
44
60
|
} from './coverage-check.mjs';
|
|
61
|
+
// The declaration load/validation + the canonical checker predicate family live in the
|
|
62
|
+
// gates-declaration.mjs LEAF (FLOW-READ-GRAPH-PURITY / the R10 rider: the locked subset-attempt
|
|
63
|
+
// factory re-derives the pregate subset from the same home); re-exported here so every historical
|
|
64
|
+
// consumer keeps its import site.
|
|
65
|
+
import {
|
|
66
|
+
GATES_REL, loadDeclaration, validateDeclaration,
|
|
67
|
+
FINAL_CORE_CHECKS, matchesCanonicalCheck, canonicalCheckerGates, isFinalCapableDeclaration,
|
|
68
|
+
isReviewDependentGate, unknownPregateExcludeIds, derivePregateSubsetGates,
|
|
69
|
+
} from './gates-declaration.mjs';
|
|
45
70
|
|
|
46
|
-
|
|
47
|
-
// user can open (the orchestration-config CONFIG_REL idiom).
|
|
48
|
-
export const GATES_REL = 'docs/ai/gates.json';
|
|
71
|
+
export { GATES_REL, loadDeclaration, validateDeclaration, canonicalCheckerGates, isFinalCapableDeclaration, isReviewDependentGate };
|
|
49
72
|
|
|
50
73
|
// The full exit-code table — one distinct code per honest outcome (never a silent green).
|
|
51
74
|
// 7 is RETIRED (the deleted --record arm's outcome) — never reused for a new meaning.
|
|
@@ -65,21 +88,38 @@ export const EXIT = Object.freeze({
|
|
|
65
88
|
// A tagged failure carrying its process exit code (the shared orchestration-config idiom).
|
|
66
89
|
export const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
|
|
67
90
|
|
|
68
|
-
const GATE_ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
69
|
-
const GATE_KEYS = Object.freeze(['id', 'title', 'cmd']);
|
|
70
91
|
const NO_FAILED_IDS = '-';
|
|
71
92
|
const SPAWN_FAILED_CODE = -1;
|
|
72
93
|
const MAX_GATE_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
73
94
|
|
|
74
95
|
const USAGE = [
|
|
75
|
-
'usage: run-gates.mjs [--cwd <dir>] [--only <id>]... [--final] [--help]',
|
|
96
|
+
'usage: run-gates.mjs [--cwd <dir>] [--only <id>]... [--final] [--pre-review [--diagnosis <text>]] [--help]',
|
|
97
|
+
'',
|
|
98
|
+
'--pre-review runs the DERIVED mechanical subset (#66): the full matrix minus every gate whose',
|
|
99
|
+
'cmd is a canonical kit checker invocation (review-state / commit-guard / coverage-check /',
|
|
100
|
+
'flow-check --check, resolved by realpath — never by gate id) minus a validated',
|
|
101
|
+
'flow.pregateExclude from docs/ai/orchestration.json (an unknown exclude id refuses loudly).',
|
|
102
|
+
'A failing subset gate gets the named review-dependent diagnosis. Under an ARMED flow (exactly',
|
|
103
|
+
'one open adopted chain owned by this worktree) every subset run is RECORDED as a',
|
|
104
|
+
'subset-attempt through the flow store\'s locked append factory; the counting context keys',
|
|
105
|
+
'{planId, cycle, stepId, foldBatch, subsetDigest}. Hard stop (Decision 7/8): the run producing',
|
|
106
|
+
'the SECOND red at a context completes, records, and exits red; the next attempt there needs',
|
|
107
|
+
'--diagnosis "<non-empty, byte-distinct from the prior>" (a recorded, self-servable',
|
|
108
|
+
'continuation — never a maintainer wait-state); after the THIRD red every further solo run',
|
|
109
|
+
'refuses — no diagnosis reopens it; a recorded FRESH-EYES consult verdict does (a grounded',
|
|
110
|
+
'bridge consult-attestation at this round context reopens exactly ONE further diagnosed',
|
|
111
|
+
'attempt), else park the stuck work or open a fresh context (a new round, or a declared',
|
|
112
|
+
'pregateExclude change). An unarmed repo is byte-unchanged; a spawn/declaration failure',
|
|
113
|
+
'records NO attempt. Mutually exclusive with --only and --final.',
|
|
76
114
|
'',
|
|
77
115
|
'--final runs the FULL declared matrix as the D3(a) final verification run: it refuses a',
|
|
78
116
|
'declaration lacking the canonical core checks (the review-state + coverage-check gates),',
|
|
79
117
|
'deletes the stale git-dir lcov first, exports AW_GIT_DIR to every gate cmd, records EVERY',
|
|
80
118
|
'attempt (start + completed green/red) in the core-evidence store, and binds the receipt to',
|
|
81
119
|
'{ fingerprint before/after, the full declaration, per-gate results, the canonical red-proof +',
|
|
82
|
-
'degrade evidence hashes, the lcov sha
|
|
120
|
+
'degrade evidence hashes, the lcov sha, and — when a flow store exists — evidenceHashes.flow,',
|
|
121
|
+
'the owner-scoped flow projection hash (D10; projection movement under the run is a red',
|
|
122
|
+
'integrityFailure) }. --final refuses --only (a subset never attests).',
|
|
83
123
|
'',
|
|
84
124
|
`Runs the gates declared in <cwd>/${GATES_REL} (one bash command line each, project root as cwd).`,
|
|
85
125
|
'Prints a per-gate PASS/FAIL table + one machine-readable summary line; exit 0 iff all green.',
|
|
@@ -97,79 +137,6 @@ const USAGE = [
|
|
|
97
137
|
'8 --final asked but its receipt could not be written (green gates never read as success without it).',
|
|
98
138
|
].join('\n');
|
|
99
139
|
|
|
100
|
-
// ── declaration validation (malformed → exit 5, loud `path: reason`) ─────────────────
|
|
101
|
-
|
|
102
|
-
// Validate a parsed gates.json object. Strict: only `_README` (string) + `gates` (array of
|
|
103
|
-
// { id, title, cmd }) are allowed; unknown keys anywhere are rejected loudly — the declaration
|
|
104
|
-
// names WHAT to check, never lanes/models/routing. Returns the validated gates array.
|
|
105
|
-
export const validateDeclaration = (parsed) => {
|
|
106
|
-
const reject = (reason) => {
|
|
107
|
-
throw fail(EXIT.malformed, `${GATES_REL}: ${reason}`);
|
|
108
|
-
};
|
|
109
|
-
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
110
|
-
reject('must be a JSON object { "_README"?: string, "gates": [{ id, title, cmd }, ...] }');
|
|
111
|
-
}
|
|
112
|
-
for (const key of Object.keys(parsed)) {
|
|
113
|
-
if (key !== '_README' && key !== 'gates') reject(`unknown top-level key "${key}" (allowed: _README, gates)`);
|
|
114
|
-
}
|
|
115
|
-
if (parsed._README !== undefined && typeof parsed._README !== 'string') reject('"_README" must be a string');
|
|
116
|
-
if (!Array.isArray(parsed.gates)) reject('"gates" must be an array of { id, title, cmd }');
|
|
117
|
-
const seenIds = new Set();
|
|
118
|
-
parsed.gates.forEach((gate, index) => {
|
|
119
|
-
const at = `gates[${index}]`;
|
|
120
|
-
if (gate === null || typeof gate !== 'object' || Array.isArray(gate)) {
|
|
121
|
-
reject(`${at}: must be an object { id, title, cmd }`);
|
|
122
|
-
}
|
|
123
|
-
for (const key of Object.keys(gate)) {
|
|
124
|
-
if (!GATE_KEYS.includes(key)) {
|
|
125
|
-
reject(`${at}: unknown key "${key}" (allowed: id, title, cmd — gates declare WHAT to check, never lane/model/routing)`);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
for (const key of GATE_KEYS) {
|
|
129
|
-
if (typeof gate[key] !== 'string' || gate[key].trim() === '') {
|
|
130
|
-
reject(`${at}: "${key}" must be a non-empty string`);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
if (/[\r\n]/.test(gate.cmd)) {
|
|
134
|
-
reject(`${at}: "cmd" must be ONE bash command line — embedded newlines (a multi-line script) are rejected; chain with && or move the script into a file`);
|
|
135
|
-
}
|
|
136
|
-
if (!GATE_ID_RE.test(gate.id)) reject(`${at}: id "${gate.id}" must be kebab-case (lowercase [a-z0-9] groups separated by "-")`);
|
|
137
|
-
if (seenIds.has(gate.id)) reject(`${at}: duplicate id "${gate.id}"`);
|
|
138
|
-
seenIds.add(gate.id);
|
|
139
|
-
});
|
|
140
|
-
return parsed.gates;
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
// ── declaration IO ────────────────────────────────────────────────────────────────────
|
|
144
|
-
|
|
145
|
-
// Load the declaration from <cwd>/docs/ai/gates.json. A truly-absent file is the DISTINCT
|
|
146
|
-
// `missing` outcome (exit 3 upstream, with the recovery named) — never an error throw; anything
|
|
147
|
-
// present-but-unreadable / malformed / schema-invalid throws the loud exit-5 failure. lstat does
|
|
148
|
-
// not follow links, so a dangling symlink reads as present and its read failure surfaces loudly
|
|
149
|
-
// (no-silent-failures Hard Constraint — the loadConfig idiom).
|
|
150
|
-
export const loadDeclaration = (cwd, { readFile = readFileSync, lstat = lstatSync } = {}) => {
|
|
151
|
-
const full = join(cwd, GATES_REL);
|
|
152
|
-
try {
|
|
153
|
-
lstat(full);
|
|
154
|
-
} catch (err) {
|
|
155
|
-
if (err && err.code === 'ENOENT') return { outcome: 'missing' };
|
|
156
|
-
throw fail(EXIT.malformed, `${GATES_REL}: unreadable (${(err && err.code) || (err && err.message) || err})`);
|
|
157
|
-
}
|
|
158
|
-
let raw;
|
|
159
|
-
try {
|
|
160
|
-
raw = readFile(full, 'utf8');
|
|
161
|
-
} catch (err) {
|
|
162
|
-
throw fail(EXIT.malformed, `${GATES_REL}: unreadable (${(err && err.code) || (err && err.message) || err})`);
|
|
163
|
-
}
|
|
164
|
-
let parsed;
|
|
165
|
-
try {
|
|
166
|
-
parsed = JSON.parse(raw);
|
|
167
|
-
} catch (err) {
|
|
168
|
-
throw fail(EXIT.malformed, `${GATES_REL}: malformed JSON (${err.message})`);
|
|
169
|
-
}
|
|
170
|
-
return { outcome: 'loaded', gates: validateDeclaration(parsed) };
|
|
171
|
-
};
|
|
172
|
-
|
|
173
140
|
// ── gate selection (--only) ───────────────────────────────────────────────────────────
|
|
174
141
|
|
|
175
142
|
// Resolve the `--only` subset against the declared gates: declaration order is preserved,
|
|
@@ -293,7 +260,7 @@ export const composeSummaryLine = ({ status, results = [] }) => {
|
|
|
293
260
|
// ── CLI ───────────────────────────────────────────────────────────────────────────────
|
|
294
261
|
|
|
295
262
|
const parseArgs = (argv) => {
|
|
296
|
-
const opts = { cwd: null, only: [], final: false, help: false };
|
|
263
|
+
const opts = { cwd: null, only: [], final: false, preReview: false, diagnosis: null, help: false };
|
|
297
264
|
for (let i = 0; i < argv.length; i += 1) {
|
|
298
265
|
const arg = argv[i];
|
|
299
266
|
if (arg === '--help' || arg === '-h') {
|
|
@@ -308,6 +275,12 @@ const parseArgs = (argv) => {
|
|
|
308
275
|
opts.only.push(argv[i]);
|
|
309
276
|
} else if (arg === '--final') {
|
|
310
277
|
opts.final = true;
|
|
278
|
+
} else if (arg === '--pre-review') {
|
|
279
|
+
opts.preReview = true;
|
|
280
|
+
} else if (arg === '--diagnosis') {
|
|
281
|
+
i += 1;
|
|
282
|
+
if (argv[i] === undefined || argv[i].length === 0) throw fail(EXIT.usage, '--diagnosis requires a non-empty text argument (Decision 8 — the recorded continuation states a hypothesis)');
|
|
283
|
+
opts.diagnosis = argv[i];
|
|
311
284
|
} else {
|
|
312
285
|
throw fail(EXIT.usage, `unknown argument "${arg}"\n${USAGE}`);
|
|
313
286
|
}
|
|
@@ -315,51 +288,18 @@ const parseArgs = (argv) => {
|
|
|
315
288
|
if (opts.final && opts.only.length > 0) {
|
|
316
289
|
throw fail(EXIT.usage, '--final refuses --only — a subset never attests (the D3(a) receipt binds the FULL declaration)');
|
|
317
290
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
// The canonical core checks a --final declaration must carry (D3(a)), matched as STRICT FULL
|
|
322
|
-
// commands: `node` + ONE (quoted or bare) path token + the exact tool basename + ` --check` +
|
|
323
|
-
// END — and the path token must REALPATH-RESOLVE to the kit's OWN tool (the canonical sibling of
|
|
324
|
-
// this runner). Masked forms (`--check --help`, `--check || true`, prefix commands) never match
|
|
325
|
-
// the shape; a lookalike file that merely carries the basename — whatever it prints — never
|
|
326
|
-
// resolves to the canonical tool. Any form that DOES resolve (bare, relative, absolute, quoted)
|
|
327
|
-
// is accepted, so the anchor adds no false refusals.
|
|
328
|
-
const coreCheckRe = (basename) => new RegExp(`^node\\s+(?:"((?:[^"]*[/\\\\])?${basename})"|((?:[^\\s"]*[/\\\\])?${basename}))\\s+--check$`);
|
|
329
|
-
const FINAL_CORE_CHECKS = [
|
|
330
|
-
{ name: 'review-state', re: coreCheckRe('review-state\\.mjs'), canonical: fileURLToPath(new URL('./review-state.mjs', import.meta.url)) },
|
|
331
|
-
{ name: 'coverage-check', re: coreCheckRe('coverage-check\\.mjs'), canonical: fileURLToPath(new URL('./coverage-check.mjs', import.meta.url)) },
|
|
332
|
-
];
|
|
333
|
-
const matchesCanonicalCheck = (check, cmd, projectDir) => {
|
|
334
|
-
const m = check.re.exec(cmd.trim());
|
|
335
|
-
if (!m) return false;
|
|
336
|
-
const token = m[1] ?? m[2];
|
|
337
|
-
const abs = isAbsolute(token) ? token : join(projectDir, token);
|
|
338
|
-
try {
|
|
339
|
-
return realpathSync(abs) === realpathSync(check.canonical);
|
|
340
|
-
} catch {
|
|
341
|
-
return false; // unresolvable → never canonical (fail closed)
|
|
291
|
+
if (opts.preReview && opts.only.length > 0) {
|
|
292
|
+
throw fail(EXIT.usage, '--pre-review refuses --only — the subset is DERIVED from canonical checker paths, never hand-picked (P27)');
|
|
342
293
|
}
|
|
294
|
+
if (opts.preReview && opts.final) {
|
|
295
|
+
throw fail(EXIT.usage, '--final refuses --pre-review — the final run attests the FULL declared matrix, never the derived subset (P27)');
|
|
296
|
+
}
|
|
297
|
+
if (opts.diagnosis !== null && !opts.preReview) {
|
|
298
|
+
throw fail(EXIT.usage, '--diagnosis rides --pre-review only (Decision 8 — the diagnosed continuation of a subset hard stop)');
|
|
299
|
+
}
|
|
300
|
+
return opts;
|
|
343
301
|
};
|
|
344
302
|
|
|
345
|
-
// canonicalCheckerGates(gates, projectDir) → every gate that IS the canonical coverage-check. The
|
|
346
|
-
// count is load-bearing twice over: --final refuses more than one (the attestation capability would
|
|
347
|
-
// reach more than one process) and this predicate must refuse the same declaration, or a consumer
|
|
348
|
-
// would advertise final-capability for a declaration --final then rejects.
|
|
349
|
-
export const canonicalCheckerGates = (gates, projectDir) =>
|
|
350
|
-
gates.filter((g) => matchesCanonicalCheck(FINAL_CORE_CHECKS[1], g.cmd, projectDir));
|
|
351
|
-
|
|
352
|
-
// isFinalCapableDeclaration(gates, projectDir) → whether --final would accept this declaration
|
|
353
|
-
// (every canonical core check present + EXACTLY ONE canonical checker + that checker LAST) — the
|
|
354
|
-
// ONE home consumers (the recommendations guard-install probe, the worktrees report) read instead
|
|
355
|
-
// of re-deriving the rule.
|
|
356
|
-
export const isFinalCapableDeclaration = (gates, projectDir) => {
|
|
357
|
-
if (!Array.isArray(gates) || gates.length === 0) return false;
|
|
358
|
-
const missing = FINAL_CORE_CHECKS.filter((c) => !gates.some((g) => matchesCanonicalCheck(c, g.cmd, projectDir)));
|
|
359
|
-
if (missing.length > 0) return false;
|
|
360
|
-
if (canonicalCheckerGates(gates, projectDir).length !== 1) return false;
|
|
361
|
-
return matchesCanonicalCheck(FINAL_CORE_CHECKS[1], gates[gates.length - 1].cmd, projectDir);
|
|
362
|
-
};
|
|
363
303
|
const sha256Hex = (data) => createHash('sha256').update(data).digest('hex');
|
|
364
304
|
|
|
365
305
|
// The full CLI, dependency-injected for hermetic tests. Returns the process exit code; the two
|
|
@@ -376,7 +316,20 @@ export const runCli = (argv, deps = {}) => {
|
|
|
376
316
|
lstat,
|
|
377
317
|
now,
|
|
378
318
|
fingerprint = computeTreeFingerprint,
|
|
319
|
+
flowLockDeps = {},
|
|
379
320
|
} = deps;
|
|
321
|
+
// The Decision-7 subset-run lock (round-6 fold) — held across the WHOLE armed --pre-review
|
|
322
|
+
// cycle; released exactly once on EVERY exit lane, BEFORE the machine summary composes
|
|
323
|
+
// (round-7 fold: a custody violation must never print after the "last" line), loud always.
|
|
324
|
+
let subsetRunLock = null;
|
|
325
|
+
const releaseSubsetRunLock = () => {
|
|
326
|
+
if (subsetRunLock == null) return null;
|
|
327
|
+
const lock = subsetRunLock;
|
|
328
|
+
subsetRunLock = null;
|
|
329
|
+
const issue = lock.release();
|
|
330
|
+
if (issue != null) logError(`[run-gates] --pre-review: ${issue.message}`);
|
|
331
|
+
return issue;
|
|
332
|
+
};
|
|
380
333
|
try {
|
|
381
334
|
const opts = parseArgs(argv);
|
|
382
335
|
if (opts.help) {
|
|
@@ -399,7 +352,148 @@ export const runCli = (argv, deps = {}) => {
|
|
|
399
352
|
log(composeSummaryLine({ status: 'empty' }));
|
|
400
353
|
return EXIT.empty;
|
|
401
354
|
}
|
|
402
|
-
|
|
355
|
+
let selected = selectGates(declaration.gates, opts.only);
|
|
356
|
+
// --pre-review (#66, Decision 7): the full matrix minus the DERIVED review-dependent gates
|
|
357
|
+
// minus a validated flow.pregateExclude. The orchestration config is loaded ONLY here — a
|
|
358
|
+
// plain run and --final stay byte-neutral to it.
|
|
359
|
+
let preReviewFlow = null;
|
|
360
|
+
let preReviewUnarmedCheck = null;
|
|
361
|
+
if (opts.preReview) {
|
|
362
|
+
// loadConfig pins malformed config as exit 1; the summary-line contract still holds — the
|
|
363
|
+
// machine line is the LAST line for every non-usage outcome (M5).
|
|
364
|
+
let config;
|
|
365
|
+
try {
|
|
366
|
+
({ config } = loadConfig(projectDir));
|
|
367
|
+
} catch (err) {
|
|
368
|
+
logError(`[run-gates] --pre-review: ${err.message}`);
|
|
369
|
+
log(composeSummaryLine({ status: 'fail' }));
|
|
370
|
+
return EXIT.fail;
|
|
371
|
+
}
|
|
372
|
+
const exclude = config?.flow?.pregateExclude ?? [];
|
|
373
|
+
const unknownExcludes = unknownPregateExcludeIds(declaration.gates, exclude);
|
|
374
|
+
if (unknownExcludes.length > 0) {
|
|
375
|
+
throw fail(EXIT.malformed, `--pre-review: ${CONFIG_REL} flow.pregateExclude names gate id(s) not declared in ${GATES_REL}: ${unknownExcludes.join(', ')} (declared: ${declaration.gates.map((gate) => gate.id).join(', ')})`);
|
|
376
|
+
}
|
|
377
|
+
selected = derivePregateSubsetGates(declaration.gates, exclude, projectDir);
|
|
378
|
+
// Decision 7/8 (Plan 4): under an ARMED flow the subset run is a RECORDED attempt with a
|
|
379
|
+
// hard-stop budget; an unarmed repo stays byte-unchanged (the compatibility floor). The
|
|
380
|
+
// pre-gate checks here are the cheap honest half — the locked append factory re-derives
|
|
381
|
+
// and re-checks everything under the lock at record time.
|
|
382
|
+
const armedRefusal = (message) => {
|
|
383
|
+
logError(`[run-gates] --pre-review: ${message}`);
|
|
384
|
+
releaseSubsetRunLock();
|
|
385
|
+
log(composeSummaryLine({ status: 'fail' }));
|
|
386
|
+
return EXIT.fail;
|
|
387
|
+
};
|
|
388
|
+
// The recording lane resolves the CANONICAL git-derived store ONLY (round-5 fold): a SET
|
|
389
|
+
// AW_FLOW_STORE — valid or not — could hide the armed store and bypass the hard-stop
|
|
390
|
+
// budget, so it refuses up front, before any read, spawn, or write.
|
|
391
|
+
if (env.AW_FLOW_STORE) {
|
|
392
|
+
return armedRefusal('AW_FLOW_STORE is set — the --pre-review recording lane resolves the CANONICAL git-derived flow store only (an override could hide the armed store and bypass the hard-stop budget; fail closed): unset AW_FLOW_STORE and re-run');
|
|
393
|
+
}
|
|
394
|
+
let flowPath;
|
|
395
|
+
try {
|
|
396
|
+
flowPath = resolveFlowStorePath(projectDir, env);
|
|
397
|
+
} catch (err) {
|
|
398
|
+
return armedRefusal(`the flow store path cannot be resolved (${err.message}) — an undecidable armed state refuses the run (fail closed)`);
|
|
399
|
+
}
|
|
400
|
+
const flowRead = flowPath == null ? null : readFlowStore(flowPath);
|
|
401
|
+
if (flowRead != null && (flowRead.readError != null || flowRead.malformed > 0)) {
|
|
402
|
+
return armedRefusal(`the flow store is not readable (${flowRead.readError ?? `${flowRead.malformed} malformed line(s): ${flowRead.malformedReasons[0]}`}) — an undecidable armed state refuses the run (fail closed)`);
|
|
403
|
+
}
|
|
404
|
+
const armed = flowRead != null && flowRead.records.some((r) => r.kind === CHAIN_KIND && r.purpose === 'adoption');
|
|
405
|
+
if (!armed && opts.diagnosis !== null) {
|
|
406
|
+
return armedRefusal("--diagnosis rides an ARMED flow's recorded subset attempt — this repo's flow is unarmed (no adopted chain)");
|
|
407
|
+
}
|
|
408
|
+
// Round-11 fold: an UNARMED start stays lock-free (the byte-unchanged floor), but the
|
|
409
|
+
// post-run re-check below closes the silent lane where an adoption lands mid-run and the
|
|
410
|
+
// finished result would otherwise go unrecorded without a word.
|
|
411
|
+
if (!armed && flowPath != null && flowRead != null) preReviewUnarmedCheck = { flowPath };
|
|
412
|
+
if (armed) {
|
|
413
|
+
// G1 (round-6 fold): the WHOLE preflight→gates→append cycle is serialized by the
|
|
414
|
+
// subset-run lock — a parallel run would otherwise execute gates whose red can no
|
|
415
|
+
// longer be recorded once the winner lands (an unrecorded red undercounts the budget).
|
|
416
|
+
// The budget preflight below reads a FRESH snapshot taken AFTER acquisition, so a
|
|
417
|
+
// queued run re-decides against whatever the winner recorded.
|
|
418
|
+
try {
|
|
419
|
+
subsetRunLock = acquireSubsetRunLock({ cwd: projectDir, env, deps: flowLockDeps });
|
|
420
|
+
} catch (err) {
|
|
421
|
+
return armedRefusal(`the subset-run lock could not be acquired (${err.message}) — a parallel --pre-review may be running, or a crashed one left its lock; nothing was run`);
|
|
422
|
+
}
|
|
423
|
+
const lockedRead = readFlowStore(flowPath);
|
|
424
|
+
if (lockedRead.readError != null || lockedRead.malformed > 0) {
|
|
425
|
+
return armedRefusal(`the flow store is not readable under the run lock (${lockedRead.readError ?? `${lockedRead.malformed} malformed line(s): ${lockedRead.malformedReasons[0]}`}) — fail closed`);
|
|
426
|
+
}
|
|
427
|
+
// Append-READINESS preflight (round-8 fold): every append-refusal lane knowable NOW
|
|
428
|
+
// refuses BEFORE gates spend — store-wide supersession legality, the hard-link guard,
|
|
429
|
+
// and the ordinary append-lock probe. What remains post-run is only concurrent
|
|
430
|
+
// movement and raw I/O failure (stated residual — excluding those would mean holding
|
|
431
|
+
// the append lock across the whole gate run).
|
|
432
|
+
const lockedSup = validateSupersessions(lockedRead.records);
|
|
433
|
+
if (!lockedSup.ok) {
|
|
434
|
+
return armedRefusal(`the flow store cannot take the attempt append (${lockedSup.reason}) — fail closed before any gate spends`);
|
|
435
|
+
}
|
|
436
|
+
let storeLeaf = null;
|
|
437
|
+
try {
|
|
438
|
+
storeLeaf = lstatSync(flowPath);
|
|
439
|
+
} catch (err) {
|
|
440
|
+
if (!err || err.code !== 'ENOENT') {
|
|
441
|
+
return armedRefusal(`cannot stat the flow store (${(err && err.code) || (err && err.message) || err}) — fail closed`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (storeLeaf != null && storeLeaf.nlink !== 1) {
|
|
445
|
+
return armedRefusal(`the flow store has ${storeLeaf.nlink} hard links — the append would refuse (two path-derived locks would race one inode); remove the extra links and re-run`);
|
|
446
|
+
}
|
|
447
|
+
try {
|
|
448
|
+
probeFlowAppendLock({ cwd: projectDir, env, deps: flowLockDeps });
|
|
449
|
+
} catch (err) {
|
|
450
|
+
return armedRefusal(`the flow append lock is not acquirable (${err.message}) — the attempt could not have been recorded after the run; nothing was spent`);
|
|
451
|
+
}
|
|
452
|
+
const owner = deriveFlowOwner(projectDir);
|
|
453
|
+
const openOwnPlanIds = [...new Set(lockedRead.records.filter((r) => r.kind === CHAIN_KIND).map((r) => r.planId))].filter((planId) => {
|
|
454
|
+
const chain = lockedRead.records.filter((r) => r.kind === CHAIN_KIND && r.planId === planId);
|
|
455
|
+
if (chain[0].purpose !== 'adoption' || chain[0].owner !== owner || !validateChainSequence(chain).ok) return false;
|
|
456
|
+
const state = walkChainState(chain);
|
|
457
|
+
return !state.completed && !state.parked;
|
|
458
|
+
});
|
|
459
|
+
if (openOwnPlanIds.length !== 1) {
|
|
460
|
+
return armedRefusal(openOwnPlanIds.length === 0
|
|
461
|
+
? `the flow is armed but this worktree ("${owner}") owns no open (adopted, non-parked, non-complete) chain — an unrecordable subset run refuses (fail closed); adopt or resume the owning plan first`
|
|
462
|
+
: `the flow is armed and this worktree ("${owner}") owns ${openOwnPlanIds.length} open chains (${openOwnPlanIds.join(', ')}) — the attempt's owning context is ambiguous; park or complete the others first (fail closed)`);
|
|
463
|
+
}
|
|
464
|
+
const chain = lockedRead.records.filter((r) => r.kind === CHAIN_KIND && r.planId === openOwnPlanIds[0]);
|
|
465
|
+
const state = walkChainState(chain);
|
|
466
|
+
if (state.stepId == null && state.openers.length > 0) {
|
|
467
|
+
return armedRefusal(`the chain for plan "${openOwnPlanIds[0]}" sits at a post-convergence boundary — the stepId-null (adoption) context is legal only before the FIRST round (round-6 fold); open the next step round first, then re-run`);
|
|
468
|
+
}
|
|
469
|
+
const expected = { planId: openOwnPlanIds[0], cycle: state.cycle, stepId: state.stepId, round: state.round ?? 0 };
|
|
470
|
+
const subsetIds = selected.map((gate) => gate.id);
|
|
471
|
+
const { attempts, reds, exhausted, nextIndex } = subsetAttemptState(lockedRead.records, {
|
|
472
|
+
planId: expected.planId, cycle: expected.cycle, stepId: expected.stepId,
|
|
473
|
+
foldBatch: subsetFoldBatchDigest(expected), subsetDigest: subsetGateIdsDigest(subsetIds),
|
|
474
|
+
});
|
|
475
|
+
if (exhausted) {
|
|
476
|
+
return armedRefusal(`this counting context (plan "${expected.planId}", cycle ${expected.cycle}, step ${expected.stepId ?? 'adoption context'}) already holds ${reds} red attempts — EXHAUSTED (two blind + one diagnosed, Decision 8): no diagnosis reopens it and no gates were run; ${subsetExhaustionRemedy}`);
|
|
477
|
+
}
|
|
478
|
+
if (reds >= SUBSET_ATTEMPT_DIAGNOSIS_REDS && opts.diagnosis === null) {
|
|
479
|
+
return armedRefusal(`this counting context already recorded ${reds} red attempts — past the second red every attempt proceeds ONLY with a recorded diagnosis (Decision 8, never a maintainer wait-state): investigate, then re-run with --diagnosis '<a new hypothesis, byte-distinct from the prior attempt>'; no gates were run`);
|
|
480
|
+
}
|
|
481
|
+
// The distinctness pre-check mirrors the locked gate (which stays authoritative): a
|
|
482
|
+
// replayed diagnosis must refuse BEFORE any gate spawns — the gates are never spent on
|
|
483
|
+
// a run whose append is already known to refuse (round-4 fold).
|
|
484
|
+
if (opts.diagnosis !== null && attempts.find((a) => a.attemptIndex === nextIndex - 1)?.diagnosis === opts.diagnosis) {
|
|
485
|
+
return armedRefusal(`the supplied --diagnosis is byte-identical to the prior attempt's — a diagnosed continuation states a NEW hypothesis (Decision 8); re-run with --diagnosis '<a new hypothesis, byte-distinct from the prior attempt>'; no gates were run`);
|
|
486
|
+
}
|
|
487
|
+
if (nextIndex < SUBSET_ATTEMPT_DIAGNOSIS_FROM && opts.diagnosis !== null) {
|
|
488
|
+
return armedRefusal(`attempt ${nextIndex} is inside the blind budget (attempts 1-2) — --diagnosis rides only attempt ${SUBSET_ATTEMPT_DIAGNOSIS_FROM} and later (Decision 8); the captured context may be stale, so it is refused, never silently dropped`);
|
|
489
|
+
}
|
|
490
|
+
const attemptFingerprint = fingerprint(projectDir);
|
|
491
|
+
if (attemptFingerprint == null) {
|
|
492
|
+
return armedRefusal('cannot compute the tree fingerprint — the recorded attempt binds {base, fingerprint} (fail closed)');
|
|
493
|
+
}
|
|
494
|
+
preReviewFlow = { expected, subsetIds, base: resolveBase(projectDir), fingerprint: attemptFingerprint, diagnosis: opts.diagnosis };
|
|
495
|
+
}
|
|
496
|
+
}
|
|
403
497
|
// AW_GIT_DIR rides EVERY gate child inside a git tree (plain and --only alike): declared
|
|
404
498
|
// cmds reference fixed git-dir artifacts (the unit-tests lcov destination) — a plain red-run
|
|
405
499
|
// must exercise the SAME cmd line --final will, never a broken-only-outside---final variant.
|
|
@@ -447,6 +541,7 @@ export const runCli = (argv, deps = {}) => {
|
|
|
447
541
|
logError(`[run-gates] gate "${id}" references $${name}, which this run will not set — it would expand to empty and fail far from its cause.`);
|
|
448
542
|
logError(` Recovery: ${PRODUCER_RECOVERY[name]}`);
|
|
449
543
|
}
|
|
544
|
+
releaseSubsetRunLock();
|
|
450
545
|
log(composeSummaryLine({ status: 'fail' }));
|
|
451
546
|
return EXIT.fail;
|
|
452
547
|
}
|
|
@@ -456,6 +551,7 @@ export const runCli = (argv, deps = {}) => {
|
|
|
456
551
|
'[run-gates] bash is not available on this host — gate cmd lines are BASH command lines ' +
|
|
457
552
|
'(brace/glob expansion); refusing to silently reinterpret them under another shell. Install bash and re-run.',
|
|
458
553
|
);
|
|
554
|
+
releaseSubsetRunLock();
|
|
459
555
|
log(composeSummaryLine({ status: 'no-bash' }));
|
|
460
556
|
return EXIT.noBash;
|
|
461
557
|
}
|
|
@@ -484,6 +580,57 @@ export const runCli = (argv, deps = {}) => {
|
|
|
484
580
|
});
|
|
485
581
|
};
|
|
486
582
|
}
|
|
583
|
+
// D10 (Plan 4 Decision 2): the flow→final binding — hash the OWNER-SCOPED projection at
|
|
584
|
+
// final start, re-hash at final end (movement under the run = integrityFailure, the #64
|
|
585
|
+
// mirror of the evidence-store arm), and carry the start hash on the receipt as
|
|
586
|
+
// evidenceHashes.flow. Absent store → absent field; a broken store refuses the attempt
|
|
587
|
+
// up front with ZERO writes (fail closed). Stated residual (round-2 disposition): the
|
|
588
|
+
// re-hash→receipt-write window is open by construction (holding the flow lock across a
|
|
589
|
+
// CORE-store append would couple two stores' locks) — an append landing in it yields a
|
|
590
|
+
// green receipt whose stale binding the commit guard's LIVE comparison refuses, exactly
|
|
591
|
+
// as it refuses a post-write append.
|
|
592
|
+
let finalFlow = null;
|
|
593
|
+
if (opts.final) {
|
|
594
|
+
// The same canonical-lane rule as --pre-review (round-5 fold): a SET AW_FLOW_STORE could
|
|
595
|
+
// bind the receipt to the wrong store — refuse BEFORE any flow read and BEFORE the
|
|
596
|
+
// final-start evidence write (zero writes on this lane).
|
|
597
|
+
if (env.AW_FLOW_STORE) {
|
|
598
|
+
logError('[run-gates] --final: AW_FLOW_STORE is set — the flow→final binding resolves the CANONICAL git-derived flow store only (an override would bind the receipt to the wrong store; fail closed): unset AW_FLOW_STORE and re-run; nothing was recorded');
|
|
599
|
+
log(composeSummaryLine({ status: 'fail' }));
|
|
600
|
+
return EXIT.finalFailed;
|
|
601
|
+
}
|
|
602
|
+
let flowPath;
|
|
603
|
+
try {
|
|
604
|
+
flowPath = resolveFlowStorePath(projectDir, env);
|
|
605
|
+
} catch (err) {
|
|
606
|
+
logError(`[run-gates] --final: the flow store path cannot be resolved (${err.message}) — the flow→final binding fails closed; nothing was recorded`);
|
|
607
|
+
log(composeSummaryLine({ status: 'fail' }));
|
|
608
|
+
return EXIT.finalFailed;
|
|
609
|
+
}
|
|
610
|
+
const flowOwner = deriveFlowOwner(projectDir);
|
|
611
|
+
const readFlowProjection = () => {
|
|
612
|
+
if (flowPath == null) return { present: false, hash: null };
|
|
613
|
+
let leaf = null;
|
|
614
|
+
try {
|
|
615
|
+
leaf = lstatSync(flowPath);
|
|
616
|
+
} catch (err) {
|
|
617
|
+
if (!err || err.code !== 'ENOENT') return { failure: `cannot stat the flow store (${(err && err.code) || (err && err.message) || err})` };
|
|
618
|
+
}
|
|
619
|
+
if (leaf == null) return { present: false, hash: null };
|
|
620
|
+
if (flowOwner == null) return { failure: 'the owning worktree identity is unresolvable — the projection is owner-scoped' };
|
|
621
|
+
const read = readFlowStore(flowPath);
|
|
622
|
+
if (read.readError) return { failure: `the flow store is unreadable (${read.readError})` };
|
|
623
|
+
if (read.malformed > 0) return { failure: `the flow store carries ${read.malformed} malformed line(s) (${read.malformedReasons[0]})` };
|
|
624
|
+
return { present: true, hash: flowProjectionHash(read.records, { owner: flowOwner, currentFingerprint: finalFingerprintBefore }) };
|
|
625
|
+
};
|
|
626
|
+
const startFlow = readFlowProjection();
|
|
627
|
+
if (startFlow.failure) {
|
|
628
|
+
logError(`[run-gates] --final: ${startFlow.failure} — the flow→final binding fails closed; nothing was recorded`);
|
|
629
|
+
log(composeSummaryLine({ status: 'fail' }));
|
|
630
|
+
return EXIT.finalFailed;
|
|
631
|
+
}
|
|
632
|
+
finalFlow = { ...startFlow, recheck: readFlowProjection };
|
|
633
|
+
}
|
|
487
634
|
let finalError = null;
|
|
488
635
|
let startEvidenceHashes = null;
|
|
489
636
|
if (opts.final) {
|
|
@@ -511,6 +658,64 @@ export const runCli = (argv, deps = {}) => {
|
|
|
511
658
|
const results = runGates(selected, { cwd: projectDir, spawn: gateSpawn, log, now });
|
|
512
659
|
for (const line of formatTable(results)) log(line);
|
|
513
660
|
const allGreen = results.every((result) => result.ok);
|
|
661
|
+
if (opts.preReview) {
|
|
662
|
+
// The named diagnosis (#66): review-dependence is derived, so an abstracted checker can only
|
|
663
|
+
// surface as an ordinary failure — say so, and name the mechanical reset (#47: a declared
|
|
664
|
+
// exclude changes the subsetDigest, so the counting context opens fresh).
|
|
665
|
+
for (const failed of results.filter((result) => !result.ok)) {
|
|
666
|
+
log(`[run-gates] "${failed.id}" failed under --pre-review — review-dependent? declare it in ${CONFIG_REL} flow.pregateExclude and the subset will skip it (a declared exclude opens a FRESH counting context — the subsetDigest changes)`);
|
|
667
|
+
}
|
|
668
|
+
// Round-11 fold: a run that STARTED unarmed re-checks arming after the gates — an
|
|
669
|
+
// adoption landing mid-run makes the finished, unrecordable result a LOUD refusal,
|
|
670
|
+
// never a silent plain exit (the stable unarmed repo stays byte-identical: one read,
|
|
671
|
+
// zero writes, zero locks). An adoption landing after THIS check is the stated residual.
|
|
672
|
+
if (preReviewFlow === null && preReviewUnarmedCheck !== null) {
|
|
673
|
+
const endRead = readFlowStore(preReviewUnarmedCheck.flowPath);
|
|
674
|
+
const armedNow = endRead.readError == null && endRead.malformed === 0
|
|
675
|
+
&& endRead.records.some((r) => r.kind === CHAIN_KIND && r.purpose === 'adoption');
|
|
676
|
+
if (armedNow) {
|
|
677
|
+
logError('[run-gates] --pre-review: the flow was ARMED while this run executed — the run started unarmed, so its result is NOT recorded (round-11 fold); re-run under the armed flow');
|
|
678
|
+
log(composeSummaryLine({ status: 'fail', results }));
|
|
679
|
+
return EXIT.fail;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
// Decision 7/8 (Plan 4): the recorded attempt. A spawn failure is an infrastructure
|
|
683
|
+
// failure, not a gate red — it records NOTHING; an armed-but-unrecordable run refuses
|
|
684
|
+
// (an unrecorded attempt would be a silent budget bypass).
|
|
685
|
+
if (preReviewFlow !== null) {
|
|
686
|
+
if (results.some((result) => result.code === SPAWN_FAILED_CODE)) {
|
|
687
|
+
logError('[run-gates] --pre-review: a gate could not SPAWN — an infrastructure failure is not a gate red, so NO subset-attempt was recorded; fix the spawn failure and re-run');
|
|
688
|
+
releaseSubsetRunLock();
|
|
689
|
+
log(composeSummaryLine({ status: 'fail', results }));
|
|
690
|
+
return EXIT.fail;
|
|
691
|
+
}
|
|
692
|
+
const attemptStatus = allGreen ? 'green' : 'red';
|
|
693
|
+
try {
|
|
694
|
+
const minted = appendSubsetAttempt({
|
|
695
|
+
cwd: projectDir, env,
|
|
696
|
+
expected: preReviewFlow.expected, subsetGateIds: preReviewFlow.subsetIds,
|
|
697
|
+
status: attemptStatus, diagnosis: preReviewFlow.diagnosis,
|
|
698
|
+
base: preReviewFlow.base, fingerprint: preReviewFlow.fingerprint,
|
|
699
|
+
timestamp: new Date().toISOString(),
|
|
700
|
+
});
|
|
701
|
+
log(`[run-gates] pre-review subset attempt #${minted.attemptIndex} recorded (${attemptStatus}) → ${minted.writtenPath}`);
|
|
702
|
+
if (attemptStatus === 'red' && minted.redsAtKey === 2) {
|
|
703
|
+
log(`[run-gates] SECOND red at this counting context — the next attempt here proceeds ONLY with a recorded diagnosis (Decision 8, never a maintainer wait-state): investigate, then re-run with --diagnosis '<a new hypothesis, byte-distinct from the prior attempt>'. The budget is ${SUBSET_ATTEMPT_MAX_REDS} reds total; the third exhausts this context.`);
|
|
704
|
+
}
|
|
705
|
+
if (attemptStatus === 'red' && minted.exhaustedAfter) {
|
|
706
|
+
log(minted.reopened
|
|
707
|
+
? `[run-gates] the REOPENED attempt went red (red attempt ${minted.redsAtKey} at this counting context) — the budget is exhausted AGAIN; ${subsetExhaustionRemedy}.`
|
|
708
|
+
: `[run-gates] red attempt ${minted.redsAtKey} EXHAUSTS this counting context (the budget is spent: two blind + one diagnosed, Decision 8); no diagnosis reopens it. Instead, ${subsetExhaustionRemedy}.`);
|
|
709
|
+
}
|
|
710
|
+
} catch (err) {
|
|
711
|
+
logError(`[run-gates] --pre-review: the subset attempt could not be recorded — ${err.message}`);
|
|
712
|
+
logError("[run-gates] an armed flow's subset run IS a recorded attempt; an unrecordable run refuses (fail closed)");
|
|
713
|
+
releaseSubsetRunLock();
|
|
714
|
+
log(composeSummaryLine({ status: 'fail', results }));
|
|
715
|
+
return EXIT.fail;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
514
719
|
// A green gate's stdout is deliberately not echoed — the table IS the report. But the checker
|
|
515
720
|
// exits 0 both when it certifies and when it WITHHOLDS a verdict, so on a plain run the table
|
|
516
721
|
// would read PASS over a coverage claim that was never made: the same false reassurance one
|
|
@@ -553,6 +758,16 @@ export const runCli = (argv, deps = {}) => {
|
|
|
553
758
|
integrityFailure = 'the evidence store moved under the final run (the canonical red-proof/degrade serialization changed)';
|
|
554
759
|
}
|
|
555
760
|
}
|
|
761
|
+
// The D10 movement arm: the OWNER-SCOPED projection must not move under the run — a
|
|
762
|
+
// foreign worktree's append changes nothing here (it is outside the projection).
|
|
763
|
+
if (integrityFailure === null && finalFlow !== null) {
|
|
764
|
+
const endFlow = finalFlow.recheck();
|
|
765
|
+
if (endFlow.failure) {
|
|
766
|
+
integrityFailure = `the flow store became unreadable under the final run (${endFlow.failure})`;
|
|
767
|
+
} else if (endFlow.present !== finalFlow.present || endFlow.hash !== finalFlow.hash) {
|
|
768
|
+
integrityFailure = 'the flow store moved under the final run (the owner-scoped projection changed)';
|
|
769
|
+
}
|
|
770
|
+
}
|
|
556
771
|
// Exactly ONE full machine line binds the receipt — an unanchored first-match would let
|
|
557
772
|
// an injected/duplicated line shadow the real one and skip the end re-hash.
|
|
558
773
|
const shaLineRe = /^coverage-check: lcov-sha256=([0-9a-f]{64}|none)$/;
|
|
@@ -601,12 +816,15 @@ export const runCli = (argv, deps = {}) => {
|
|
|
601
816
|
fingerprintAfter: fingerprint(projectDir),
|
|
602
817
|
declared: declaration.gates.map(({ id, cmd }) => ({ id, cmd })),
|
|
603
818
|
results: results.map(({ id, ok, code }) => ({ id, ok, code })),
|
|
604
|
-
evidenceHashes:
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
819
|
+
evidenceHashes: {
|
|
820
|
+
...(endBroken
|
|
821
|
+
? startEvidenceHashes
|
|
822
|
+
: {
|
|
823
|
+
redProof: sha256Hex(canonicalKindSerialization(endRead.records, 'red-proof')),
|
|
824
|
+
degrade: sha256Hex(canonicalKindSerialization(endRead.records, 'degrade')),
|
|
825
|
+
}),
|
|
826
|
+
...(finalFlow?.present ? { flow: finalFlow.hash } : {}),
|
|
827
|
+
},
|
|
610
828
|
lcovSha256,
|
|
611
829
|
integrityFailure,
|
|
612
830
|
timestamp: new Date().toISOString(),
|
|
@@ -627,11 +845,15 @@ export const runCli = (argv, deps = {}) => {
|
|
|
627
845
|
}
|
|
628
846
|
// The summary line is the MACHINE report, so it must agree with the exit code: an integrity
|
|
629
847
|
// failure mints a RED receipt and exits finalFailed, and a line still saying status=ok there
|
|
630
|
-
// would be a silent green in the one place a reader parses instead of reads.
|
|
631
|
-
|
|
848
|
+
// would be a silent green in the one place a reader parses instead of reads. The run lock
|
|
849
|
+
// releases BEFORE the line composes so a custody violation can never hide behind status=ok.
|
|
850
|
+
const runLockIssue = releaseSubsetRunLock();
|
|
851
|
+
log(composeSummaryLine({ status: allGreen && finalError === null && runLockIssue == null ? 'ok' : 'fail', results }));
|
|
632
852
|
if (finalError) return EXIT.finalFailed;
|
|
853
|
+
if (runLockIssue != null) return EXIT.fail;
|
|
633
854
|
return allGreen ? EXIT.ok : EXIT.fail;
|
|
634
855
|
} catch (err) {
|
|
856
|
+
releaseSubsetRunLock();
|
|
635
857
|
logError(`[run-gates] ${err.message}`);
|
|
636
858
|
if (err.exitCode === EXIT.malformed) log(composeSummaryLine({ status: 'malformed' }));
|
|
637
859
|
return err.exitCode ?? EXIT.fail;
|