@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
@@ -0,0 +1,128 @@
1
+ // fs-read-nofollow.mjs — the race-free no-follow read primitive (flow-orchestration, Plan 4
2
+ // Phase 2). A LEAF: imports Node built-ins only, owns no write API, no CLI, no side effects on
3
+ // import — extracted from flow-store-read.mjs so consumers on BOTH sides of the flow-record →
4
+ // core-evidence import edge (the receipts reader lives in core-evidence) can share the ONE
5
+ // no-follow read without a cycle. flow-store-read.mjs re-exports everything here, so every
6
+ // existing consumer keeps its import site. Dependency-free, Node >= 22.
7
+
8
+ import { readFileSync, lstatSync, openSync, closeSync, fstatSync, constants as fsConstants } from 'node:fs';
9
+
10
+ // Local no-follow lstat (null ONLY on a true ENOENT).
11
+ export const lstatNoFollowRead = (path, lstat = lstatSync) => {
12
+ try {
13
+ return lstat(path);
14
+ } catch (err) {
15
+ if (err && err.code === 'ENOENT') return null;
16
+ throw err;
17
+ }
18
+ };
19
+
20
+ export const describeNonRegular = (st) =>
21
+ st.isSymbolicLink() ? 'symlink' : st.isFIFO() ? 'FIFO' : st.isDirectory() ? 'directory' : 'non-regular file';
22
+
23
+ // fatal: a lossy decode would fold invalid bytes to U+FFFD and silently fork a record's digest.
24
+ // ignoreBOM: a BOM must surface as malformed line 1, not vanish and get rewritten without it.
25
+ const FATAL_UTF8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
26
+ const hasOpenFlag = (v) => typeof v === 'number' && v !== 0;
27
+
28
+ // The ONE race-free read for store/lock bytes: open O_NOFOLLOW|O_NONBLOCK, fstat the DESCRIPTOR,
29
+ // read through it, decode fatally — a pathname swapped after the open cannot change what the fd
30
+ // reads. Without O_NONBLOCK (nonzero-integer check) it fails closed before any open — a FIFO open
31
+ // would block and no check helps a blocked syscall; without only O_NOFOLLOW it lstat-classifies
32
+ // first and binds the open to the observed {dev, ino}. Open failures outside ENOENT/ELOOP/EISDIR
33
+ // fall back to a classification-only lstat (no read ever follows a path-based check). A close
34
+ // failure on an OK read converts to outcome:error (the wrapper below — the bytes-twin discipline);
35
+ // a non-ok outcome keeps its own class and carries the failure as `closeFailure` (the lock
36
+ // holder's custody lane consumes it); consumers get a structured result, never a throw.
37
+ export const readRegularFileNoFollow = (path, io = {}) => {
38
+ const closeBox = { failure: null };
39
+ const r = readNoFollowCore(path, io, closeBox);
40
+ if (closeBox.failure !== null) {
41
+ if (r.outcome === 'ok') return { outcome: 'error', code: `descriptor close failed (${closeBox.failure}) — fail closed` };
42
+ return { ...r, closeFailure: closeBox.failure };
43
+ }
44
+ return r;
45
+ };
46
+
47
+ const readNoFollowCore = (path, io, closeBox) => {
48
+ const consts = io.constants ?? fsConstants;
49
+ const open = io.open ?? openSync;
50
+ const fstat = io.fstat ?? fstatSync;
51
+ const readFd = io.readFile ?? readFileSync;
52
+ const close = io.close ?? closeSync;
53
+ const lstat = io.lstat ?? lstatSync;
54
+ if (!hasOpenFlag(consts.O_NONBLOCK)) {
55
+ return { outcome: 'error', code: 'this platform exposes no usable O_NONBLOCK open flag — refusing to open (a FIFO would block forever; fail closed)' };
56
+ }
57
+ const noFollow = hasOpenFlag(consts.O_NOFOLLOW);
58
+ let preStat = null;
59
+ if (!noFollow) {
60
+ try {
61
+ preStat = lstatNoFollowRead(path, lstat);
62
+ } catch (err) {
63
+ return { outcome: 'error', code: (err && err.code) || (err && err.message) || 'lstat failed' };
64
+ }
65
+ if (preStat == null) return { outcome: 'absent' };
66
+ if (!preStat.isFile()) return { outcome: 'foreign', className: describeNonRegular(preStat), isDirectory: preStat.isDirectory() };
67
+ }
68
+ let fd = null;
69
+ try {
70
+ fd = open(path, (consts.O_RDONLY ?? 0) | (noFollow ? consts.O_NOFOLLOW : 0) | consts.O_NONBLOCK);
71
+ const st = fstat(fd);
72
+ if (!st.isFile()) return { outcome: 'foreign', className: describeNonRegular(st), isDirectory: st.isDirectory() };
73
+ if (preStat !== null && (st.dev !== preStat.dev || st.ino !== preStat.ino)) {
74
+ return { outcome: 'error', code: 'the leaf changed identity between lstat and open (fail closed)' };
75
+ }
76
+ const bytes = readFd(fd);
77
+ let content;
78
+ try {
79
+ content = typeof bytes === 'string' ? bytes : FATAL_UTF8.decode(bytes);
80
+ } catch {
81
+ return { outcome: 'error', code: 'invalid UTF-8 in the file (fail closed)' };
82
+ }
83
+ if (io.keepFd) {
84
+ // While the caller holds this fd the inode cannot be recycled — a later pathname stat
85
+ // matching {dev, ino} is proof of the same file.
86
+ const heldFd = fd;
87
+ fd = null;
88
+ return { outcome: 'ok', content, dev: st.dev, ino: st.ino, nlink: st.nlink, fd: heldFd, bytes: typeof bytes === 'string' ? Buffer.from(bytes, 'utf8') : bytes };
89
+ }
90
+ return { outcome: 'ok', content, dev: st.dev, ino: st.ino };
91
+ } catch (err) {
92
+ if (err && err.code === 'ENOENT') return { outcome: 'absent' };
93
+ if (err && err.code === 'ELOOP') return { outcome: 'foreign', className: 'symlink', isDirectory: false };
94
+ if (err && err.code === 'EISDIR') return { outcome: 'foreign', className: 'directory', isDirectory: true };
95
+ try {
96
+ const st = lstatNoFollowRead(path, lstat);
97
+ if (st && !st.isFile()) return { outcome: 'foreign', className: describeNonRegular(st), isDirectory: st.isDirectory() };
98
+ } catch { /* the open error stays the surfaced one */ }
99
+ return { outcome: 'error', code: (err && err.code) || (err && err.message) || 'read failed' };
100
+ } finally {
101
+ if (fd !== null) {
102
+ try {
103
+ close(fd);
104
+ } catch (err) {
105
+ closeBox.failure = (err && err.code) || (err && err.message) || 'close failed';
106
+ }
107
+ }
108
+ }
109
+ };
110
+
111
+ // readFileBytesNoFollow(path, io?) → { outcome: 'ok', bytes } | absent | foreign | error — the
112
+ // BYTES twin of the reader above for consumers whose domain is byte offsets (the Phase-4
113
+ // receipt-deadline watermark, the finding-manifest digest domain): keepFd internally so the raw
114
+ // bytes come back, then the fd is closed HERE with a fail-closed close (a close failure becomes
115
+ // an error outcome, never a leak and never a swallowed throw). Balance: one open, one close, on
116
+ // every outcome — pinned by an injectable-io counting test.
117
+ export const readFileBytesNoFollow = (path, io = {}) => {
118
+ const r = readRegularFileNoFollow(path, { ...io, keepFd: true });
119
+ if (r.outcome !== 'ok') return r;
120
+ let closeFailure = null;
121
+ try {
122
+ (io.close ?? closeSync)(r.fd);
123
+ } catch (err) {
124
+ closeFailure = (err && err.code) || (err && err.message) || 'close failed';
125
+ }
126
+ if (closeFailure !== null) return { outcome: 'error', code: `held-descriptor close failed (${closeFailure}) — fail closed` };
127
+ return { outcome: 'ok', bytes: r.bytes };
128
+ };
@@ -0,0 +1,184 @@
1
+ // gates-declaration.mjs — the gates.json declaration (load + strict validation) and the canonical
2
+ // checker predicate family (flow Plan 4 Phase 2, FLOW-READ-GRAPH-PURITY / the R10 rider). A LEAF
3
+ // below both run-gates.mjs and flow-store.mjs: run-gates re-exports the public surface (every
4
+ // historical consumer keeps its import site), and the locked subset-attempt factory imports the
5
+ // derivation DIRECTLY — re-deriving the pregate subset itself was blocked exactly by the
6
+ // run-gates↔flow-store import cycle this extraction removes. No CLI, no side effects on import,
7
+ // no fs writes. Dependency-free, Node >= 22.
8
+
9
+ import { readFileSync, lstatSync, realpathSync } from 'node:fs';
10
+ import { join, isAbsolute } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { fail, loadConfig, CONFIG_REL } from './orchestration-config.mjs';
13
+
14
+ // The per-project declaration (strict JSON, hand-editable). cwd-relative — errors show a path the
15
+ // user can open (the orchestration-config CONFIG_REL idiom).
16
+ export const GATES_REL = 'docs/ai/gates.json';
17
+
18
+ // Parity: run-gates.mjs EXIT.malformed — its exit-code table stays the CLI authority (pinned by
19
+ // run-gates.test.mjs); this module throws the same tagged shape so the CLI surfaces it unchanged.
20
+ const EXIT_MALFORMED = 5;
21
+
22
+ const GATE_ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
23
+ const GATE_KEYS = Object.freeze(['id', 'title', 'cmd']);
24
+
25
+ // ── declaration validation (malformed → exit 5, loud `path: reason`) ─────────────────
26
+
27
+ // Validate a parsed gates.json object. Strict: only `_README` (string) + `gates` (array of
28
+ // { id, title, cmd }) are allowed; unknown keys anywhere are rejected loudly — the declaration
29
+ // names WHAT to check, never lanes/models/routing. Returns the validated gates array.
30
+ export const validateDeclaration = (parsed) => {
31
+ const reject = (reason) => {
32
+ throw fail(EXIT_MALFORMED, `${GATES_REL}: ${reason}`);
33
+ };
34
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
35
+ reject('must be a JSON object { "_README"?: string, "gates": [{ id, title, cmd }, ...] }');
36
+ }
37
+ for (const key of Object.keys(parsed)) {
38
+ if (key !== '_README' && key !== 'gates') reject(`unknown top-level key "${key}" (allowed: _README, gates)`);
39
+ }
40
+ if (parsed._README !== undefined && typeof parsed._README !== 'string') reject('"_README" must be a string');
41
+ if (!Array.isArray(parsed.gates)) reject('"gates" must be an array of { id, title, cmd }');
42
+ const seenIds = new Set();
43
+ parsed.gates.forEach((gate, index) => {
44
+ const at = `gates[${index}]`;
45
+ if (gate === null || typeof gate !== 'object' || Array.isArray(gate)) {
46
+ reject(`${at}: must be an object { id, title, cmd }`);
47
+ }
48
+ for (const key of Object.keys(gate)) {
49
+ if (!GATE_KEYS.includes(key)) {
50
+ reject(`${at}: unknown key "${key}" (allowed: id, title, cmd — gates declare WHAT to check, never lane/model/routing)`);
51
+ }
52
+ }
53
+ for (const key of GATE_KEYS) {
54
+ if (typeof gate[key] !== 'string' || gate[key].trim() === '') {
55
+ reject(`${at}: "${key}" must be a non-empty string`);
56
+ }
57
+ }
58
+ if (/[\r\n]/.test(gate.cmd)) {
59
+ 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`);
60
+ }
61
+ if (!GATE_ID_RE.test(gate.id)) reject(`${at}: id "${gate.id}" must be kebab-case (lowercase [a-z0-9] groups separated by "-")`);
62
+ if (seenIds.has(gate.id)) reject(`${at}: duplicate id "${gate.id}"`);
63
+ seenIds.add(gate.id);
64
+ });
65
+ return parsed.gates;
66
+ };
67
+
68
+ // ── declaration IO ────────────────────────────────────────────────────────────────────
69
+
70
+ // Load the declaration from <cwd>/docs/ai/gates.json. A truly-absent file is the DISTINCT
71
+ // `missing` outcome (exit 3 upstream, with the recovery named) — never an error throw; anything
72
+ // present-but-unreadable / malformed / schema-invalid throws the loud exit-5 failure. lstat does
73
+ // not follow links, so a dangling symlink reads as present and its read failure surfaces loudly
74
+ // (no-silent-failures Hard Constraint — the loadConfig idiom).
75
+ export const loadDeclaration = (cwd, { readFile = readFileSync, lstat = lstatSync } = {}) => {
76
+ const full = join(cwd, GATES_REL);
77
+ try {
78
+ lstat(full);
79
+ } catch (err) {
80
+ if (err && err.code === 'ENOENT') return { outcome: 'missing' };
81
+ throw fail(EXIT_MALFORMED, `${GATES_REL}: unreadable (${(err && err.code) || (err && err.message) || err})`);
82
+ }
83
+ let raw;
84
+ try {
85
+ raw = readFile(full, 'utf8');
86
+ } catch (err) {
87
+ throw fail(EXIT_MALFORMED, `${GATES_REL}: unreadable (${(err && err.code) || (err && err.message) || err})`);
88
+ }
89
+ let parsed;
90
+ try {
91
+ parsed = JSON.parse(raw);
92
+ } catch (err) {
93
+ throw fail(EXIT_MALFORMED, `${GATES_REL}: malformed JSON (${err.message})`);
94
+ }
95
+ return { outcome: 'loaded', gates: validateDeclaration(parsed) };
96
+ };
97
+
98
+ // ── the canonical core-check matcher (STRICT full-command shape + realpath anchor) ────
99
+
100
+ // The canonical core checks a --final declaration must carry (D3(a)), matched as STRICT FULL
101
+ // commands: `node` + ONE (quoted or bare) path token + the exact tool basename + ` --check` +
102
+ // END — and the path token must REALPATH-RESOLVE to the kit's OWN tool (the canonical sibling of
103
+ // this runner). Masked forms (`--check --help`, `--check || true`, prefix commands) never match
104
+ // the shape; a lookalike file that merely carries the basename — whatever it prints — never
105
+ // resolves to the canonical tool. Any form that DOES resolve (bare, relative, absolute, quoted)
106
+ // is accepted, so the anchor adds no false refusals.
107
+ const coreCheckRe = (basename) => new RegExp(`^node\\s+(?:"((?:[^"]*[/\\\\])?${basename})"|((?:[^\\s"]*[/\\\\])?${basename}))\\s+--check$`);
108
+ export const FINAL_CORE_CHECKS = [
109
+ { name: 'review-state', re: coreCheckRe('review-state\\.mjs'), canonical: fileURLToPath(new URL('./review-state.mjs', import.meta.url)) },
110
+ { name: 'coverage-check', re: coreCheckRe('coverage-check\\.mjs'), canonical: fileURLToPath(new URL('./coverage-check.mjs', import.meta.url)) },
111
+ ];
112
+ export const matchesCanonicalCheck = (check, cmd, projectDir) => {
113
+ const m = check.re.exec(cmd.trim());
114
+ if (!m) return false;
115
+ const token = m[1] ?? m[2];
116
+ const abs = isAbsolute(token) ? token : join(projectDir, token);
117
+ try {
118
+ return realpathSync(abs) === realpathSync(check.canonical);
119
+ } catch {
120
+ return false; // unresolvable → never canonical (fail closed)
121
+ }
122
+ };
123
+
124
+ // canonicalCheckerGates(gates, projectDir) → every gate that IS the canonical coverage-check. The
125
+ // count is load-bearing twice over: --final refuses more than one (the attestation capability would
126
+ // reach more than one process) and this predicate must refuse the same declaration, or a consumer
127
+ // would advertise final-capability for a declaration --final then rejects.
128
+ export const canonicalCheckerGates = (gates, projectDir) =>
129
+ gates.filter((g) => matchesCanonicalCheck(FINAL_CORE_CHECKS[1], g.cmd, projectDir));
130
+
131
+ // isFinalCapableDeclaration(gates, projectDir) → whether --final would accept this declaration
132
+ // (every canonical core check present + EXACTLY ONE canonical checker + that checker LAST) — the
133
+ // ONE home consumers (the recommendations guard-install probe, the worktrees report) read instead
134
+ // of re-deriving the rule.
135
+ export const isFinalCapableDeclaration = (gates, projectDir) => {
136
+ if (!Array.isArray(gates) || gates.length === 0) return false;
137
+ const missing = FINAL_CORE_CHECKS.filter((c) => !gates.some((g) => matchesCanonicalCheck(c, g.cmd, projectDir)));
138
+ if (missing.length > 0) return false;
139
+ if (canonicalCheckerGates(gates, projectDir).length !== 1) return false;
140
+ return matchesCanonicalCheck(FINAL_CORE_CHECKS[1], gates[gates.length - 1].cmd, projectDir);
141
+ };
142
+
143
+ // The review-dependent predicate (#66/P14): a gate is review-dependent iff its cmd IS the plain
144
+ // canonical `--check` invocation of one of the kit's OWN checkers, resolved by realpath — never a
145
+ // project-authored id. A project abstracting the invocation behind its own script declares it in
146
+ // flow.pregateExclude (the mode doc states this plainly).
147
+ const REVIEW_DEPENDENT_CHECKS = ['review-state', 'commit-guard', 'coverage-check', 'flow-check'].map((name) => ({
148
+ name,
149
+ re: coreCheckRe(`${name}\\.mjs`),
150
+ canonical: fileURLToPath(new URL(`./${name}.mjs`, import.meta.url)),
151
+ }));
152
+
153
+ export const isReviewDependentGate = (gate, projectDir) =>
154
+ REVIEW_DEPENDENT_CHECKS.some((check) => matchesCanonicalCheck(check, gate.cmd, projectDir));
155
+
156
+ // ── the pregate subset derivation (#66 / Decision 7 — ONE home for producer and factory) ─────
157
+
158
+ export const unknownPregateExcludeIds = (gates, exclude) => {
159
+ const declaredIds = new Set(gates.map((gate) => gate.id));
160
+ return exclude.filter((id) => !declaredIds.has(id));
161
+ };
162
+
163
+ // The derived --pre-review subset: the full declaration minus the DERIVED review-dependent gates
164
+ // minus a validated flow.pregateExclude — declaration order preserved.
165
+ export const derivePregateSubsetGates = (gates, exclude, projectDir) =>
166
+ gates.filter((gate) => !isReviewDependentGate(gate, projectDir) && !exclude.includes(gate.id));
167
+
168
+ // derivePregateSubsetIds(cwd, io?) → the ordered gate-id array the subsetDigest is allowed to
169
+ // bind, derived from <cwd>'s declaration + orchestration config (the R10 rider consumer: the
170
+ // locked subset-attempt factory re-derives instead of trusting its caller). Throws the loud
171
+ // tagged failure on every underivable state — an underivable subset never mints an attempt.
172
+ export const derivePregateSubsetIds = (cwd, io = {}) => {
173
+ const declaration = loadDeclaration(cwd, io);
174
+ if (declaration.outcome === 'missing') {
175
+ throw fail(EXIT_MALFORMED, `no gate declaration found at ${GATES_REL} — the pregate subset is underivable`);
176
+ }
177
+ const { config } = loadConfig(cwd);
178
+ const exclude = config?.flow?.pregateExclude ?? [];
179
+ const unknown = unknownPregateExcludeIds(declaration.gates, exclude);
180
+ if (unknown.length > 0) {
181
+ throw fail(EXIT_MALFORMED, `${CONFIG_REL} flow.pregateExclude names gate id(s) not declared in ${GATES_REL}: ${unknown.join(', ')} (declared: ${declaration.gates.map((gate) => gate.id).join(', ')})`);
182
+ }
183
+ return derivePregateSubsetGates(declaration.gates, exclude, cwd).map((gate) => gate.id);
184
+ };
@@ -33,9 +33,10 @@
33
33
  // • ids derive kebab-case from script names (build:prod → build-prod) and every offered entry
34
34
  // passes the runner's validateDeclaration (this module imports the validator — NEVER the
35
35
  // reverse: run-gates.mjs stays a runner that writes nothing);
36
- // • the review-state candidate appears ONLY when docs/ai/orchestration.json DECLARES
37
- // reviewed/council on plan-execution.review the slot the checker enforceswith the
38
- // resolved, QUOTED tool path (spaces survive; executes from the project root).
36
+ // • the review-state candidate appears when docs/ai/orchestration.json DECLARES
37
+ // reviewed/council on plan-execution.review OR carries a flow block (the P21 triounder
38
+ // flow + solo the internal-only arm runs INSIDE review-state), with the resolved, QUOTED
39
+ // tool path (spaces survive; executes from the project root).
39
40
  //
40
41
  // Write discipline: preview (dry-run) is the DEFAULT and writes NOTHING — a declined offer leaves
41
42
  // the file byte-identical. `--apply` appends EXACTLY the consented entries (`--only <id>`
@@ -61,6 +62,7 @@ const KIT_ROOT = resolve(HERE, '..');
61
62
  const TEMPLATE_PATH = join(KIT_ROOT, 'references', 'templates', 'gates.json');
62
63
  const REVIEW_STATE_TOOL = join(KIT_ROOT, 'tools', 'review-state.mjs');
63
64
  const COVERAGE_CHECK_TOOL = join(KIT_ROOT, 'tools', 'coverage-check.mjs');
65
+ const FLOW_CHECK_TOOL = join(KIT_ROOT, 'tools', 'flow-check.mjs');
64
66
  const STAMP_REL = join('docs', 'ai', '.workflow-version');
65
67
 
66
68
  const EXIT_OK = 0;
@@ -274,20 +276,28 @@ const deriveScripts = (cwd, deps = {}) => {
274
276
  export const deriveScriptEntries = (cwd, deps = {}) => deriveScripts(cwd, deps).entries;
275
277
 
276
278
  // The conditional review-state candidate — keyed on the SLOT the checker enforces
277
- // (plan-execution.review, tools/review-state.mjs), read via the shared config reader. Offered only
278
- // when the config DECLARES reviewed/council there; solo configs and a council-on-plan-authoring-only
279
- // config never see it. The cmd carries the resolved, QUOTED tool path and passes the validator.
279
+ // (plan-execution.review, tools/review-state.mjs), read via the shared config reader. Offered when
280
+ // the config DECLARES reviewed/council there OR carries a flow block (reviewSlotWantsChecker); a
281
+ // plain solo config never sees it. The cmd carries the resolved, QUOTED tool path and passes the
282
+ // validator.
280
283
  // Double-quote-unsafe shell metacharacters: inside `"…"` bash still expands `$`, backticks and
281
284
  // backslashes, and a `"` breaks the quoting entirely. A candidate cmd is hook-auto-approvable, so a
282
285
  // path that cannot be safely double-quoted is WITHHELD with a loud note — never offered wrongly.
283
286
  const DQ_UNSAFE_PATH_PATTERN = /["$`\\\r\n]/;
284
287
 
288
+ // Under a flow block the offer covers the full checker TRIO whatever the recipe (P21): with flow +
289
+ // a solo recipe the internal-only arm executes INSIDE review-state, so offering flow-check alone
290
+ // would arm half the surface.
291
+ const reviewSlotWantsChecker = (config) => {
292
+ const declared = config?.['plan-execution']?.review;
293
+ return declared === 'reviewed' || declared === 'council' || config?.flow != null;
294
+ };
295
+
285
296
  export const reviewStateCandidate = (cwd, deps = {}) => {
286
297
  const toolPath = deps.reviewStateTool ?? REVIEW_STATE_TOOL;
287
298
  try {
288
299
  const { config } = loadConfig(resolve(cwd), deps.readFile ?? readFileSync, deps.lstat ?? lstatSync);
289
- const declared = config?.['plan-execution']?.review;
290
- if (declared !== 'reviewed' && declared !== 'council') return { candidate: null, note: null };
300
+ if (!reviewSlotWantsChecker(config)) return { candidate: null, note: null };
291
301
  if (DQ_UNSAFE_PATH_PATTERN.test(toolPath)) {
292
302
  return {
293
303
  candidate: null,
@@ -314,7 +324,7 @@ export const reviewStateCandidate = (cwd, deps = {}) => {
314
324
  };
315
325
 
316
326
  // The conditional COVERAGE-CHECK candidate (D3(a)) — the SAME consent + conditional rule as the
317
- // review-state candidate (offered ONLY when plan-execution.review is reviewed/council), keyed on
327
+ // review-state candidate (reviewSlotWantsChecker: reviewed/council OR a flow block), keyed on
318
328
  // the same slot, path resolved + QUOTED. Together they are the canonical core pair `run-gates
319
329
  // --final` requires (the checker declared LAST — buildOffer appends it last so a whole-offer
320
330
  // apply lands final-ready); review-state gates receipt satisfaction, coverage-check verifies the
@@ -323,8 +333,7 @@ export const coverageCheckCandidate = (cwd, deps = {}) => {
323
333
  const toolPath = deps.coverageCheckTool ?? COVERAGE_CHECK_TOOL;
324
334
  try {
325
335
  const { config } = loadConfig(resolve(cwd), deps.readFile ?? readFileSync, deps.lstat ?? lstatSync);
326
- const declared = config?.['plan-execution']?.review;
327
- if (declared !== 'reviewed' && declared !== 'council') return { candidate: null, note: null };
336
+ if (!reviewSlotWantsChecker(config)) return { candidate: null, note: null };
328
337
  if (DQ_UNSAFE_PATH_PATTERN.test(toolPath)) {
329
338
  return {
330
339
  candidate: null,
@@ -350,6 +359,37 @@ export const coverageCheckCandidate = (cwd, deps = {}) => {
350
359
  }
351
360
  };
352
361
 
362
+ // The conditional FLOW-CHECK candidate (P21) — offered ONLY when the orchestration config carries
363
+ // a `flow` block; the same consent + path-quoting discipline as the pair above.
364
+ export const flowCheckCandidate = (cwd, deps = {}) => {
365
+ const toolPath = deps.flowCheckTool ?? FLOW_CHECK_TOOL;
366
+ try {
367
+ const { config } = loadConfig(resolve(cwd), deps.readFile ?? readFileSync, deps.lstat ?? lstatSync);
368
+ if (config?.flow == null) return { candidate: null, note: null };
369
+ if (DQ_UNSAFE_PATH_PATTERN.test(toolPath)) {
370
+ return {
371
+ candidate: null,
372
+ note:
373
+ `the flow-check candidate was withheld: the resolved kit path contains shell ` +
374
+ `metacharacters that do not survive double-quoting (${toolPath}) — declare the gate by hand`,
375
+ };
376
+ }
377
+ return {
378
+ candidate: {
379
+ id: 'flow-check',
380
+ title: 'Flow-store chain state clean for this worktree (flow-orchestration)',
381
+ cmd: `node "${toolPath}" --check`,
382
+ },
383
+ note: null,
384
+ };
385
+ } catch (err) {
386
+ return {
387
+ candidate: null,
388
+ note: `orchestration config unreadable (${err.message}) — the flow-check candidate was not evaluated`,
389
+ };
390
+ }
391
+ };
392
+
353
393
  // Every --only id must name an OFFERED entry — enforced in BOTH paths (dry-run and apply), before
354
394
  // any empty-offer shortcut, so a typo is a loud usage error, never a silent filter or a silent
355
395
  // "nothing to offer" success.
@@ -361,18 +401,20 @@ const assertOnlyIdsOffered = (offer, onlyIds = []) => {
361
401
  }
362
402
  };
363
403
 
364
- // The full offer: script entries + the conditional review-state + coverage-check candidates
365
- // (coverage-check LAST — the `run-gates --final` declaration-shape rule requires the checker as
366
- // the last declared gate, so a whole-offer apply is final-ready by construction). Both key on the
367
- // same slot (plan-execution.review reviewed/council) but gate distinct axes.
404
+ // The full offer: script entries + the conditional review-state / flow-check / coverage-check
405
+ // candidates (coverage-check LAST — the `run-gates --final` declaration-shape rule requires the
406
+ // checker as the last declared gate, so a whole-offer apply is final-ready by construction). The
407
+ // pair keys on plan-execution.review reviewed/council OR a flow block (the P21 trio); flow-check
408
+ // itself appears only under a flow block.
368
409
  export const buildOffer = (cwd, deps = {}) => {
369
410
  const scripts = deriveScripts(cwd, deps);
370
411
  const rs = reviewStateCandidate(cwd, deps);
412
+ const fc = flowCheckCandidate(cwd, deps);
371
413
  const cc = coverageCheckCandidate(cwd, deps);
372
- const candidates = [rs.candidate, cc.candidate].filter(Boolean);
414
+ const candidates = [rs.candidate, fc.candidate, cc.candidate].filter(Boolean);
373
415
  return {
374
416
  entries: [...scripts.entries, ...candidates],
375
- notes: [...scripts.notes, rs.note, cc.note].filter(Boolean),
417
+ notes: [...scripts.notes, rs.note, fc.note, cc.note].filter(Boolean),
376
418
  };
377
419
  };
378
420
 
@@ -15,8 +15,8 @@
15
15
  // by the _README refresh and the injected-slot refresh.
16
16
  //
17
17
  // This module performs NO filesystem WRITES — only reads (loadConfig). The single fs-writer lives in
18
- // orchestration-write.mjs, which procedures.mjs never imports, so "procedures never reaches a writer"
19
- // is structurally true. Pure-where-possible (fs injectable), dependency-free, Node >= 22. No side
18
+ // orchestration-write.mjs, which procedures.mjs never imports DIRECTLY (the pinned import-split
19
+ // rule). Pure-where-possible (fs injectable), dependency-free, Node >= 22. No side
20
20
  // effects on import.
21
21
 
22
22
  import { readFileSync, lstatSync } from 'node:fs';
@@ -113,10 +113,94 @@ export const parseOp = (kind, token) => {
113
113
 
114
114
  // ── config validation (config errors → exit 1) ──────────────────────────────────────
115
115
 
116
+ // The accepted `flow` schema version — the SINGLE source both the acceptance check and the refusal
117
+ // message use; future flow-aware releases IMPORT this constant, never re-type it. The wire value is
118
+ // pinned NUMERIC (the string form is a named refusal case).
119
+ export const FLOW_SCHEMA_VERSION = 1;
120
+
121
+ // The honest lagging-kit contract sentence: what a kit WITHOUT the flow branch does when it meets
122
+ // a `flow` block, and what the now-armed `set-flow` floor can and cannot reach. doc-parity binds
123
+ // it VERBATIM into the procedures and set-flow mode docs, so the admission can never be reworded
124
+ // away: no in-config floor protects a reader that dies on the unknown key itself.
125
+ export const FLOW_LAGGING_KIT_CONTRACT =
126
+ 'a kit predating the `"flow"` key that reads a config carrying one fails this config load loudly (exit `1`, reddening its full gate matrix); the `set-flow` arming path now enforces the declared `kitMinVersion` floor with a null-guarded comparison (an unparseable version never passes), while tolerate-first ordering remains the only protection for readers older than the `"flow"` key itself — no in-config floor can reach a kit that dies on the unknown key';
127
+
128
+ // ── the closed flow schema-1 surface (P20) — ONE literal fixture for BOTH consumers ─────
129
+ // The structural validator below and the set-flow arming path (Phase 3) walk the SAME closed key
130
+ // set; a drift-guarded named test validates the literal fixture. Shape-only here (#31): every
131
+ // environment floor (tracked-ness, symlink/dir classes, the kit min-version comparison) lives
132
+ // exclusively on the arming path.
133
+
134
+ export const FLOW_SCHEMA_1_KEYS = Object.freeze([
135
+ 'schema', 'preset', 'candidates', 'councilRounds', 'debtQueue', 'convergenceSummary',
136
+ 'debtQueueExcluded', 'convergenceSummaryExcluded', 'pregateExclude', 'kitMinVersion',
137
+ ]);
138
+ export const FLOW_PRESET_VALUES = Object.freeze(['council', 'reviewed', 'internal-only']);
139
+ export const FLOW_CANDIDATE_CLASSES = Object.freeze(['review', 'execution']);
140
+ // The arming path (set-flow, Phase 3) compares kitMinVersion via the null-guarded semver shape
141
+ // characterized in the FLOW-VERSION-FLOORS block of semver-lite.test.mjs (Decision 6).
142
+ export const FLOW_MIN_VERSION_COMPARISON = 'semver-lite null-guarded comparison (FLOW-VERSION-FLOORS characterization)';
143
+ export const FLOW_SCHEMA_1_FIXTURE = Object.freeze({
144
+ schema: FLOW_SCHEMA_VERSION,
145
+ preset: 'council',
146
+ candidates: Object.freeze([
147
+ Object.freeze({ name: 'codex', class: 'review' }),
148
+ Object.freeze({ name: 'agy', class: 'review' }),
149
+ ]),
150
+ councilRounds: 3,
151
+ debtQueue: 'docs/debt.md',
152
+ convergenceSummary: 'docs/convergence.md',
153
+ debtQueueExcluded: false,
154
+ convergenceSummaryExcluded: false,
155
+ pregateExclude: Object.freeze([]),
156
+ kitMinVersion: '5.1.0',
157
+ });
158
+
159
+ // The per-key STRUCTURAL checks of the schema-1 flow block (P7/P20) — shape only, loud
160
+ // `path: reason`; every environment floor lives on the set-flow arming path (#31). A failure
161
+ // message, or null when the value fits the key's shape.
162
+ const flowKeyFailure = (key, value) => {
163
+ if (key === 'preset') {
164
+ return FLOW_PRESET_VALUES.includes(value) ? null : `must be one of ${FLOW_PRESET_VALUES.join(' | ')} (got ${JSON.stringify(value)})`;
165
+ }
166
+ if (key === 'candidates') {
167
+ if (!Array.isArray(value)) return 'must be an array of typed { name, class } objects (#30)';
168
+ for (const c of value) {
169
+ if (c === null || typeof c !== 'object' || Array.isArray(c)) return 'must hold only { name, class } objects';
170
+ const stray = Object.keys(c).find((k) => k !== 'name' && k !== 'class');
171
+ if (stray !== undefined) return `candidate objects carry exactly { name, class } — unknown field "${stray}"`;
172
+ if (typeof c.name !== 'string' || c.name === '') return 'every candidate name must be a non-empty string';
173
+ if (!FLOW_CANDIDATE_CLASSES.includes(c.class)) return `every candidate class must be one of ${FLOW_CANDIDATE_CLASSES.join(' | ')} (got ${JSON.stringify(c.class)})`;
174
+ }
175
+ return null;
176
+ }
177
+ if (key === 'councilRounds') {
178
+ return Number.isInteger(value) && value >= 1 ? null : `must be a positive integer — the #45 refresh-cap source (got ${JSON.stringify(value)})`;
179
+ }
180
+ if (key === 'debtQueue' || key === 'convergenceSummary') {
181
+ return typeof value === 'string' && value.length > 0 ? null : `must be a non-empty repo-relative path string (got ${JSON.stringify(value)})`;
182
+ }
183
+ if (key === 'debtQueueExcluded' || key === 'convergenceSummaryExcluded') {
184
+ return typeof value === 'boolean' ? null : `must be a boolean (the declared-excluded form, #31/#37; got ${JSON.stringify(value)})`;
185
+ }
186
+ if (key === 'pregateExclude') {
187
+ if (!Array.isArray(value) || !value.every((id) => typeof id === 'string' && id.length > 0)) {
188
+ return `must be an array of non-empty gate-id strings (#47; got ${JSON.stringify(value)})`;
189
+ }
190
+ return new Set(value).size === value.length ? null : 'must not carry duplicate gate ids (#47)';
191
+ }
192
+ if (key === 'kitMinVersion') {
193
+ return typeof value === 'string' && value.length > 0 ? null : `must be a non-empty version string — the #54 floor, compared on the arming path via the ${FLOW_MIN_VERSION_COMPARISON} (got ${JSON.stringify(value)})`;
194
+ }
195
+ return null; // schema — checked before the key walk
196
+ };
197
+
116
198
  // Validate a parsed orchestration.json object against the schema. Strict: an unknown top-level
117
199
  // activity, an unknown slot for an activity, or a recipe invalid-for-slot is an error. All slots are
118
- // optional. An optional "_README" string key is allowed + ignored (self-documentation). Never a silent
119
- // fallback every rejection is a loud `path: reason` (exit 1). Returns the config on success.
200
+ // optional. An optional "_README" string key is allowed + ignored (self-documentation). The
201
+ // versioned "flow" object key validates against the FULL structural schema-1 surface (closed key
202
+ // set + per-key shapes — P7/P20); deep environment floors stay on the set-flow arming path (#31).
203
+ // Never a silent fallback — every rejection is a loud `path: reason` (exit 1). Returns the config on success.
120
204
  export const validateConfig = (config) => {
121
205
  if (config === null || typeof config !== 'object' || Array.isArray(config)) {
122
206
  throw fail(1, `${CONFIG_REL}: must be a JSON object of activity → { slot: recipe }`);
@@ -126,6 +210,23 @@ export const validateConfig = (config) => {
126
210
  if (typeof val !== 'string') throw fail(1, `${CONFIG_REL}: "_README" must be a string`);
127
211
  continue;
128
212
  }
213
+ if (key === 'flow') {
214
+ if (val === null || typeof val !== 'object' || Array.isArray(val)) {
215
+ throw fail(1, `${CONFIG_REL}: "flow" must be a JSON object carrying { "schema": ${FLOW_SCHEMA_VERSION} }`);
216
+ }
217
+ if (val.schema !== FLOW_SCHEMA_VERSION) {
218
+ const got = 'schema' in val ? JSON.stringify(val.schema) : 'absent';
219
+ throw fail(1, `${CONFIG_REL}: "flow".schema must be the number ${FLOW_SCHEMA_VERSION} (got ${got})`);
220
+ }
221
+ for (const [flowKey, flowValue] of Object.entries(val)) {
222
+ if (!FLOW_SCHEMA_1_KEYS.includes(flowKey)) {
223
+ throw fail(1, `${CONFIG_REL}: "flow" carries unknown key "${flowKey}" — the schema-1 key set is closed (${FLOW_SCHEMA_1_KEYS.join(', ')})`);
224
+ }
225
+ const failure = flowKeyFailure(flowKey, flowValue);
226
+ if (failure !== null) throw fail(1, `${CONFIG_REL}: "flow".${flowKey} ${failure}`);
227
+ }
228
+ continue;
229
+ }
129
230
  const activityDef = ACTIVITIES[key];
130
231
  if (!activityDef) {
131
232
  throw fail(1, `${CONFIG_REL}: unknown activity "${key}" (known: ${KNOWN_ACTIVITIES()})`);
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  // orchestration-write.mjs — the ONLY filesystem WRITER for docs/ai/orchestration.json. It is imported
3
- // by the set-recipe writer alone; procedures.mjs never imports it, so "the read-only procedures advisor
4
- // can never reach a writer" is a STRUCTURAL invariant (an import-split test pins it), not just an
5
- // assertion. Splitting the writer out of the schema/read module keeps the read surface fs-write-free.
3
+ // by the config WRITERS alone (set-recipe, set-flow); procedures.mjs never imports it DIRECTLY
4
+ // the import-split test pins that direct-import rule. Splitting the writer out of the schema/read
5
+ // module keeps the read surface fs-write-free.
6
6
  //
7
7
  // The hardened write flow (deployment gate → symlink STOPs → containment guard → exclusive-create
8
8
  // tmp + rename with a TOCTOU re-check → tmp cleanup; LAST-WRITER-WINS, documented) lives in the
@@ -0,0 +1,35 @@
1
+ // plan-files.mjs — the in-flight-plan file convention (FLOW-READ-GRAPH-PURITY, flow Plan 4
2
+ // Phase 2). A LEAF: Node built-ins only, read-only fs (one injectable readdir), no CLI, no side
3
+ // effects on import — extracted so the procedures read surface lists plans without importing
4
+ // review-state (whose graph reaches the flow-store write API). review-state re-exports everything
5
+ // here, so every existing consumer keeps its import site. Dependency-free, Node >= 22.
6
+
7
+ import { readdirSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+
10
+ export const PLANS_REL = 'docs/plans';
11
+
12
+ // Scratch by the naming convention: EXECUTE-/FEEDBACK- prefixes, or a name carrying PROMPT/prompt/
13
+ // handoff. queue.md is the series index, never a plan.
14
+ export const isScratchPlanName = (name) =>
15
+ name === 'queue.md' ||
16
+ name.startsWith('EXECUTE-') ||
17
+ name.startsWith('FEEDBACK-') ||
18
+ name.includes('PROMPT') ||
19
+ name.includes('prompt') ||
20
+ name.includes('handoff');
21
+
22
+ // The in-flight plan files: top-level docs/plans/*.md minus queue.md minus scratch. [] when the
23
+ // directory is absent (no plans → nothing in flight). STRING-typed for every consumer.
24
+ export const plansInFlight = (cwd, readdir = readdirSync) => {
25
+ let entries;
26
+ try {
27
+ entries = readdir(join(cwd, PLANS_REL), { withFileTypes: true });
28
+ } catch {
29
+ return [];
30
+ }
31
+ return entries
32
+ .filter((e) => e.isFile() && e.name.endsWith('.md') && !isScratchPlanName(e.name))
33
+ .map((e) => e.name)
34
+ .sort();
35
+ };