@sabaiway/agent-workflow-kit 5.1.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.
- package/CHANGELOG.md +55 -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 +8 -5
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +3 -2
- 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 +8 -6
- package/bridges/codex-cli-bridge/references/driving-codex.md +2 -2
- package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +2 -2
- 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/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 +32 -11
- package/tools/doc-parity.mjs +21 -6
- package/tools/flow-check.mjs +806 -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
|
@@ -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
|
+
};
|
package/tools/gates-init.mjs
CHANGED
|
@@ -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
|
|
37
|
-
// reviewed/council on plan-execution.review
|
|
38
|
-
//
|
|
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 trio — under
|
|
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
|
|
278
|
-
//
|
|
279
|
-
// config never
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
365
|
-
// (coverage-check LAST — the `run-gates --final` declaration-shape rule requires the
|
|
366
|
-
// the last declared gate, so a whole-offer apply is final-ready by construction).
|
|
367
|
-
//
|
|
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
|
|
19
|
-
//
|
|
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';
|
|
@@ -118,18 +118,88 @@ export const parseOp = (kind, token) => {
|
|
|
118
118
|
// pinned NUMERIC (the string form is a named refusal case).
|
|
119
119
|
export const FLOW_SCHEMA_VERSION = 1;
|
|
120
120
|
|
|
121
|
-
// The honest lagging-kit contract sentence
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
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
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);
|
|
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
|
+
};
|
|
127
197
|
|
|
128
198
|
// Validate a parsed orchestration.json object against the schema. Strict: an unknown top-level
|
|
129
199
|
// activity, an unknown slot for an activity, or a recipe invalid-for-slot is an error. All slots are
|
|
130
|
-
// optional. An optional "_README" string key is allowed + ignored (self-documentation).
|
|
131
|
-
// "flow" object key
|
|
132
|
-
//
|
|
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).
|
|
133
203
|
// Never a silent fallback — every rejection is a loud `path: reason` (exit 1). Returns the config on success.
|
|
134
204
|
export const validateConfig = (config) => {
|
|
135
205
|
if (config === null || typeof config !== 'object' || Array.isArray(config)) {
|
|
@@ -148,6 +218,13 @@ export const validateConfig = (config) => {
|
|
|
148
218
|
const got = 'schema' in val ? JSON.stringify(val.schema) : 'absent';
|
|
149
219
|
throw fail(1, `${CONFIG_REL}: "flow".schema must be the number ${FLOW_SCHEMA_VERSION} (got ${got})`);
|
|
150
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
|
+
}
|
|
151
228
|
continue;
|
|
152
229
|
}
|
|
153
230
|
const activityDef = ACTIVITIES[key];
|
|
@@ -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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
+
};
|