@sabaiway/agent-workflow-kit 5.0.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/SKILL.md +13 -1
  3. package/bridges/antigravity-cli-bridge/SKILL.md +14 -3
  4. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +220 -30
  5. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +264 -8
  6. package/bridges/antigravity-cli-bridge/bin/agy.sh +12 -2
  7. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +18 -0
  8. package/bridges/antigravity-cli-bridge/capability.json +19 -13
  9. package/bridges/antigravity-cli-bridge/references/driving-agy.md +3 -2
  10. package/bridges/codex-cli-bridge/SKILL.md +8 -5
  11. package/bridges/codex-cli-bridge/bin/codex-exec.sh +3 -2
  12. package/bridges/codex-cli-bridge/bin/codex-review.sh +205 -34
  13. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +276 -5
  14. package/bridges/codex-cli-bridge/capability.json +8 -6
  15. package/bridges/codex-cli-bridge/references/driving-codex.md +2 -2
  16. package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +2 -2
  17. package/capability.json +1 -1
  18. package/package.json +1 -1
  19. package/references/modes/flow-writer.md +37 -0
  20. package/references/modes/gates.md +4 -4
  21. package/references/modes/procedures.md +4 -2
  22. package/references/modes/receipt-deadline.md +16 -0
  23. package/references/modes/review-state.md +1 -1
  24. package/references/modes/set-flow.md +22 -0
  25. package/tools/cheap-agents.mjs +8 -2
  26. package/tools/commands.mjs +24 -2
  27. package/tools/commit-guard.mjs +44 -9
  28. package/tools/core-evidence.mjs +25 -22
  29. package/tools/detect-backends.mjs +32 -11
  30. package/tools/doc-parity.mjs +29 -2
  31. package/tools/flow-check.mjs +806 -0
  32. package/tools/flow-record.mjs +795 -0
  33. package/tools/flow-store-read.mjs +114 -0
  34. package/tools/flow-store.mjs +1178 -0
  35. package/tools/flow-writer.mjs +1265 -0
  36. package/tools/fs-read-nofollow.mjs +128 -0
  37. package/tools/gates-declaration.mjs +184 -0
  38. package/tools/gates-init.mjs +59 -17
  39. package/tools/orchestration-config.mjs +105 -4
  40. package/tools/orchestration-write.mjs +3 -3
  41. package/tools/plan-files.mjs +35 -0
  42. package/tools/procedures.mjs +75 -11
  43. package/tools/receipt-deadline.mjs +242 -0
  44. package/tools/recipes.mjs +21 -0
  45. package/tools/repo-lex.mjs +22 -0
  46. package/tools/review-state.mjs +240 -80
  47. package/tools/run-gates.mjs +361 -139
  48. package/tools/set-flow.mjs +465 -0
  49. package/tools/velocity-profile.mjs +8 -2
@@ -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)) is the ONE writing mode:
26
- // every attempt lands in the core-evidence store via its sole writer. Dependency-free.
27
- // No side effects on import.
28
-
29
- import { readFileSync, lstatSync, unlinkSync, realpathSync } from 'node:fs';
30
- import { join, isAbsolute } from 'node:path';
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, fileURLToPath } from 'node:url';
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
- // The per-project declaration (strict JSON, hand-editable). cwd-relative — errors show a path the
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 }. --final refuses --only (a subset never attests).',
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
- return opts;
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
- const selected = selectGates(declaration.gates, opts.only);
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: endBroken
605
- ? startEvidenceHashes
606
- : {
607
- redProof: sha256Hex(canonicalKindSerialization(endRead.records, 'red-proof')),
608
- degrade: sha256Hex(canonicalKindSerialization(endRead.records, 'degrade')),
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
- log(composeSummaryLine({ status: allGreen && finalError === null ? 'ok' : 'fail', results }));
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;