@sabaiway/agent-workflow-kit 1.26.0 → 1.28.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 +53 -0
- package/README.md +2 -0
- package/SKILL.md +39 -7
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/agents/changelog-skeleton.md +23 -0
- package/references/agents/gate-triage.md +23 -0
- package/references/agents/mechanical-sweep.md +20 -0
- package/references/contracts.md +1 -0
- package/references/scripts/archive-decisions.mjs +424 -0
- package/references/scripts/archive-decisions.test.mjs +365 -0
- package/references/scripts/install-git-hooks.mjs +1 -0
- package/references/templates/agent_rules.md +2 -1
- package/references/templates/gates.json +4 -0
- package/tools/cheap-agents.mjs +239 -0
- package/tools/commands.mjs +29 -6
- package/tools/known-footprint.mjs +3 -0
- package/tools/procedures.mjs +19 -0
- package/tools/run-gates.mjs +299 -0
package/tools/commands.mjs
CHANGED
|
@@ -26,15 +26,19 @@ const BARE_INVOCATION = `/${SKILL_NAME}`;
|
|
|
26
26
|
const invocationOf = (token) => `${BARE_INVOCATION}${token ? ` ${token}` : ''}`;
|
|
27
27
|
|
|
28
28
|
// ── kinds ────────────────────────────────────────────────────────────────────────
|
|
29
|
-
// read-only
|
|
30
|
-
// writer
|
|
31
|
-
// guarded
|
|
29
|
+
// read-only — never writes, never commits, never runs a subscription CLI.
|
|
30
|
+
// writer — writes files (a project deployment or a settings/skill placement).
|
|
31
|
+
// guarded — a destructive teardown gated behind a mandatory dry-run-first + explicit consent.
|
|
32
|
+
// project-exec — the kit itself writes nothing, but the mode RUNS the project's own declared
|
|
33
|
+
// commands with the caller's privileges (the `gates` runner) — honest-tagged so a
|
|
34
|
+
// user never reads "read-only" on a surface that executes their gate matrix.
|
|
32
35
|
// `writer` and `guarded` are the "acts on the system" kinds; only an explicit known token may reach
|
|
33
36
|
// one (plus the bare bootstrap exception). Garbage routes to `help` (read-only) — see routeInvocation.
|
|
34
37
|
export const READ_ONLY = 'read-only';
|
|
35
38
|
export const WRITER = 'writer';
|
|
36
39
|
export const GUARDED = 'guarded';
|
|
37
|
-
const
|
|
40
|
+
export const PROJECT_EXEC = 'project-exec';
|
|
41
|
+
const KINDS = new Set([READ_ONLY, WRITER, GUARDED, PROJECT_EXEC]);
|
|
38
42
|
|
|
39
43
|
// ── groups (fixed render order) ────────────────────────────────────────────────────
|
|
40
44
|
export const GROUP_ORDER = Object.freeze(['Inspect', 'Configure', 'Orchestrate', 'Lifecycle']);
|
|
@@ -87,6 +91,13 @@ const CATALOG = [
|
|
|
87
91
|
kind: READ_ONLY,
|
|
88
92
|
oneLine: 'List every command, grouped, marking each as read-only or as one that makes changes.',
|
|
89
93
|
},
|
|
94
|
+
{
|
|
95
|
+
key: 'gates',
|
|
96
|
+
invocation: invocationOf('gates'),
|
|
97
|
+
group: 'Inspect',
|
|
98
|
+
kind: PROJECT_EXEC,
|
|
99
|
+
oneLine: 'Run the project’s own declared gate commands (docs/ai/gates.json) as one batch — a PASS/FAIL table, one summary line.',
|
|
100
|
+
},
|
|
90
101
|
{
|
|
91
102
|
key: 'setup',
|
|
92
103
|
invocation: invocationOf('setup'),
|
|
@@ -101,6 +112,13 @@ const CATALOG = [
|
|
|
101
112
|
kind: WRITER,
|
|
102
113
|
oneLine: 'Seed a read-only command allowlist so routine read-only commands stop prompting (Claude Code; opt-in; preview first).',
|
|
103
114
|
},
|
|
115
|
+
{
|
|
116
|
+
key: 'agents',
|
|
117
|
+
invocation: invocationOf('agents'),
|
|
118
|
+
group: 'Configure',
|
|
119
|
+
kind: WRITER,
|
|
120
|
+
oneLine: 'Place bundled cheap-model subagent definitions for mechanical work — sweeps, changelog skeletons, gate triage (Claude Code; opt-in; preview first).',
|
|
121
|
+
},
|
|
104
122
|
{
|
|
105
123
|
key: 'recipes',
|
|
106
124
|
invocation: invocationOf('recipes'),
|
|
@@ -165,7 +183,12 @@ export const routeInvocation = (token) => {
|
|
|
165
183
|
|
|
166
184
|
// ── render ──────────────────────────────────────────────────────────────────────────
|
|
167
185
|
|
|
168
|
-
const KIND_TAG = {
|
|
186
|
+
const KIND_TAG = {
|
|
187
|
+
[READ_ONLY]: '[read-only] ',
|
|
188
|
+
[WRITER]: '[writer] ',
|
|
189
|
+
[GUARDED]: '[guarded] ',
|
|
190
|
+
[PROJECT_EXEC]: '[runs project cmds]',
|
|
191
|
+
};
|
|
169
192
|
const pad = (s, n) => (s.length >= n ? s : s + ' '.repeat(n - s.length));
|
|
170
193
|
|
|
171
194
|
// formatHelp() → the grouped, kind-tagged human index. Deterministic; groups in GROUP_ORDER, modes in
|
|
@@ -175,7 +198,7 @@ export const formatHelp = () => {
|
|
|
175
198
|
const lines = [
|
|
176
199
|
`${SKILL_NAME} — command index (this list is read-only)`,
|
|
177
200
|
'',
|
|
178
|
-
'Each command is tagged read-only · writer (makes changes) · guarded (destructive, previews first).',
|
|
201
|
+
'Each command is tagged read-only · writer (makes changes) · guarded (destructive, previews first) · runs project cmds (executes your own declared commands).',
|
|
179
202
|
];
|
|
180
203
|
for (const group of GROUP_ORDER) {
|
|
181
204
|
const inGroup = COMMANDS.filter((c) => c.group === group);
|
|
@@ -43,6 +43,8 @@ export const KIT_OWN_PATHS = [
|
|
|
43
43
|
'/scripts/_expect-shim.mjs',
|
|
44
44
|
'/scripts/archive-changelog.mjs',
|
|
45
45
|
'/scripts/archive-changelog.test.mjs',
|
|
46
|
+
'/scripts/archive-decisions.mjs',
|
|
47
|
+
'/scripts/archive-decisions.test.mjs',
|
|
46
48
|
'/scripts/archive-issues.mjs',
|
|
47
49
|
'/scripts/archive-issues.test.mjs',
|
|
48
50
|
'/scripts/check-docs-size.mjs',
|
|
@@ -59,6 +61,7 @@ export const KIT_OWN_PATHS = [
|
|
|
59
61
|
// filesystem (expandGlob) to concrete present files before any git probe; never fed to ls-files.
|
|
60
62
|
export const KNOWN_FOOTPRINT = [
|
|
61
63
|
{ pattern: '/.claude/skills/', owner: 'Claude Code', type: 'dir', falsePositiveRisk: false, note: 'local-dev skills; absorbs the AD-013 one-off' },
|
|
64
|
+
{ pattern: '/.claude/agents/', owner: 'Claude Code', type: 'dir', falsePositiveRisk: false, note: 'project subagent definitions (incl. the kit-placed cheap-lane vehicles)' },
|
|
62
65
|
{ pattern: '/.cursor/rules/', owner: 'Cursor', type: 'dir', falsePositiveRisk: false, note: 'project rule files' },
|
|
63
66
|
{ pattern: '/.cursorrules', owner: 'Cursor (legacy)', type: 'file', falsePositiveRisk: true, note: 'legacy single-file rules' },
|
|
64
67
|
{ pattern: '/.codeium/', owner: 'Codeium/Windsurf', type: 'dir', falsePositiveRisk: false, note: 'home-scoped launchers live under ~/, out of scope' },
|
package/tools/procedures.mjs
CHANGED
|
@@ -188,6 +188,22 @@ const reviewLoopAdvice = (slots) =>
|
|
|
188
188
|
]
|
|
189
189
|
: [];
|
|
190
190
|
|
|
191
|
+
// The cost-lane advisory block (cost-tiered execution — orchestration.md §5 canon, paraphrased
|
|
192
|
+
// at the point of use like reviewLoopAdvice paraphrases §9/§4). Rendered UNCONDITIONALLY for
|
|
193
|
+
// every activity — the lanes route EVERY step, review-backed or not (unlike reviewLoopAdvice,
|
|
194
|
+
// which fires only when a review backend engages). It may name the kit's own GENERIC L0
|
|
195
|
+
// surfaces (the gate runner, the rotation checks, the cheap-agents vehicles) — point-of-use
|
|
196
|
+
// routing, still project-agnostic (never a project's publish mechanics). Its distinctive tokens
|
|
197
|
+
// are drift-guarded against the canon on both sides (procedures.test.mjs + the engine canon tests).
|
|
198
|
+
const costLanesAdvice = () => [
|
|
199
|
+
'Cost lanes (orchestration.md §5) — route every step to the cheapest adequate executor:',
|
|
200
|
+
' • L0 deterministic script — run the gate matrix as ONE batch: /agent-workflow-kit gates (the project-declared docs/ai/gates.json); rotation headroom: archive-changelog / archive-issues / archive-decisions --check. An exit code beats a model re-read.',
|
|
201
|
+
' • L1 cheap subagent (small model, low effort, read-only tools — /agent-workflow-kit agents places the vehicles) — mechanical sweeps, changelog fact-skeletons, gate-failure triage. Extraction/drafting ONLY; the orchestrator verifies the output and owns every conclusion.',
|
|
202
|
+
' • L2 subscription bridge (codex / agy) — reviews per the resolved recipe above, on frontier bridge models (quality-first).',
|
|
203
|
+
' • L3 frontier — judgment: plan/fold/synthesis, ADR/handover/changelog-entry wording, persuasive copy, go/no-go, real code.',
|
|
204
|
+
' • A step with no named guardrail does not move down a lane; red lines never move down (council review models · real code · memory/copy wording · the maintainer approval asks).',
|
|
205
|
+
];
|
|
206
|
+
|
|
191
207
|
// The verbatim per-backend DRIVING CONTRACT block (M-contract): the exact invocation descriptor(s),
|
|
192
208
|
// the closed flag set, the grounding note, the round-2/continue delta, and the guarded passthrough
|
|
193
209
|
// tiers — every descriptor printed VERBATIM from the registry mirror of the bridge manifest
|
|
@@ -224,6 +240,7 @@ const formatHuman = ({ activity, section, slots, warnings }) => {
|
|
|
224
240
|
}
|
|
225
241
|
const advice = reviewLoopAdvice(slots);
|
|
226
242
|
if (advice.length) lines.push('', ...advice);
|
|
243
|
+
lines.push('', ...costLanesAdvice());
|
|
227
244
|
if (warnings.length) {
|
|
228
245
|
lines.push('', 'warnings:');
|
|
229
246
|
for (const w of warnings) lines.push(` ⚠ ${w}`);
|
|
@@ -240,6 +257,8 @@ const buildJson = ({ activity, section, slots, configSource, warnings }) => ({
|
|
|
240
257
|
slots.map((s) => [s.slot, { recipe: s.recipe, source: s.source, degradedFrom: s.degradedFrom, reason: s.reason, backends: s.backends, contracts: s.contracts }]),
|
|
241
258
|
),
|
|
242
259
|
reviewLoop: reviewLoopAdvice(slots),
|
|
260
|
+
// ADDITIVE (cost-tiered execution): the unconditional cost-lane advisory, structured.
|
|
261
|
+
costLanes: costLanesAdvice(),
|
|
243
262
|
configSource,
|
|
244
263
|
warnings,
|
|
245
264
|
});
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// run-gates.mjs — the generic project gate runner behind `/agent-workflow-kit gates`.
|
|
3
|
+
//
|
|
4
|
+
// A project declares its verification gates ONCE in docs/ai/gates.json (seeded from
|
|
5
|
+
// references/templates/gates.json — the orchestration.json pattern: bootstrap seeds it, upgrade
|
|
6
|
+
// ensures-if-missing, the file stays hand-editable). This runner batches them: read the
|
|
7
|
+
// declaration, run each gate as ONE bash command line from the project root, print a per-gate
|
|
8
|
+
// PASS/FAIL table plus ONE machine-readable summary line, and exit 0 iff every selected gate is
|
|
9
|
+
// green. A failing gate's own output is preserved verbatim (triage without re-running); a green
|
|
10
|
+
// gate's output is not echoed — the table + summary line are the report. `--only <id>`
|
|
11
|
+
// (repeatable) re-runs a subset; an unknown id is a loud usage error.
|
|
12
|
+
//
|
|
13
|
+
// The declaration names WHAT to check — never who executes it: the schema has no lane/model/
|
|
14
|
+
// routing fields and rejects unknown keys loudly. Trust posture (stated in the template _README
|
|
15
|
+
// too): the runner executes the project's OWN declared commands with the caller's privileges —
|
|
16
|
+
// a batching convenience over commands the project already runs by hand, not a sandbox.
|
|
17
|
+
//
|
|
18
|
+
// Honest outcomes — each distinct, never a silent green (the exit-code table + the summary-line
|
|
19
|
+
// schema are pinned by run-gates.test.mjs):
|
|
20
|
+
// 0 ok · 1 gate failure · 2 usage · 3 missing declaration · 4 empty gates list ·
|
|
21
|
+
// 5 malformed/invalid declaration · 6 bash unavailable. Gate `cmd` lines are BASH command
|
|
22
|
+
// lines (brace/glob expansion); a host without bash gets the loud exit-6 preflight error,
|
|
23
|
+
// never a silent reinterpretation under another shell.
|
|
24
|
+
//
|
|
25
|
+
// The runner itself WRITES NOTHING. Dependency-free, Node >= 18. No side effects on import.
|
|
26
|
+
|
|
27
|
+
import { readFileSync, lstatSync } from 'node:fs';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
import { spawnSync } from 'node:child_process';
|
|
30
|
+
import { pathToFileURL } from 'node:url';
|
|
31
|
+
|
|
32
|
+
// The per-project declaration (strict JSON, hand-editable). cwd-relative — errors show a path the
|
|
33
|
+
// user can open (the orchestration-config CONFIG_REL idiom).
|
|
34
|
+
export const GATES_REL = 'docs/ai/gates.json';
|
|
35
|
+
|
|
36
|
+
// The full exit-code table — one distinct code per honest outcome (never a silent green).
|
|
37
|
+
export const EXIT = Object.freeze({
|
|
38
|
+
ok: 0,
|
|
39
|
+
fail: 1,
|
|
40
|
+
usage: 2,
|
|
41
|
+
missing: 3,
|
|
42
|
+
empty: 4,
|
|
43
|
+
malformed: 5,
|
|
44
|
+
noBash: 6,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// A tagged failure carrying its process exit code (the shared orchestration-config idiom).
|
|
48
|
+
export const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
|
|
49
|
+
|
|
50
|
+
const GATE_ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
51
|
+
const GATE_KEYS = Object.freeze(['id', 'title', 'cmd']);
|
|
52
|
+
const NO_FAILED_IDS = '-';
|
|
53
|
+
const SPAWN_FAILED_CODE = -1;
|
|
54
|
+
const MAX_GATE_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
55
|
+
|
|
56
|
+
const USAGE = [
|
|
57
|
+
'usage: run-gates.mjs [--cwd <dir>] [--only <id>]... [--help]',
|
|
58
|
+
'',
|
|
59
|
+
`Runs the gates declared in <cwd>/${GATES_REL} (one bash command line each, project root as cwd).`,
|
|
60
|
+
'Prints a per-gate PASS/FAIL table + one machine-readable summary line; exit 0 iff all green.',
|
|
61
|
+
`Exit codes: 0 ok · 1 gate failure · 2 usage · 3 missing declaration · 4 empty gates list ·`,
|
|
62
|
+
'5 malformed/invalid declaration · 6 bash unavailable.',
|
|
63
|
+
].join('\n');
|
|
64
|
+
|
|
65
|
+
// ── declaration validation (malformed → exit 5, loud `path: reason`) ─────────────────
|
|
66
|
+
|
|
67
|
+
// Validate a parsed gates.json object. Strict: only `_README` (string) + `gates` (array of
|
|
68
|
+
// { id, title, cmd }) are allowed; unknown keys anywhere are rejected loudly — the declaration
|
|
69
|
+
// names WHAT to check, never lanes/models/routing. Returns the validated gates array.
|
|
70
|
+
export const validateDeclaration = (parsed) => {
|
|
71
|
+
const reject = (reason) => {
|
|
72
|
+
throw fail(EXIT.malformed, `${GATES_REL}: ${reason}`);
|
|
73
|
+
};
|
|
74
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
75
|
+
reject('must be a JSON object { "_README"?: string, "gates": [{ id, title, cmd }, ...] }');
|
|
76
|
+
}
|
|
77
|
+
for (const key of Object.keys(parsed)) {
|
|
78
|
+
if (key !== '_README' && key !== 'gates') reject(`unknown top-level key "${key}" (allowed: _README, gates)`);
|
|
79
|
+
}
|
|
80
|
+
if (parsed._README !== undefined && typeof parsed._README !== 'string') reject('"_README" must be a string');
|
|
81
|
+
if (!Array.isArray(parsed.gates)) reject('"gates" must be an array of { id, title, cmd }');
|
|
82
|
+
const seenIds = new Set();
|
|
83
|
+
parsed.gates.forEach((gate, index) => {
|
|
84
|
+
const at = `gates[${index}]`;
|
|
85
|
+
if (gate === null || typeof gate !== 'object' || Array.isArray(gate)) {
|
|
86
|
+
reject(`${at}: must be an object { id, title, cmd }`);
|
|
87
|
+
}
|
|
88
|
+
for (const key of Object.keys(gate)) {
|
|
89
|
+
if (!GATE_KEYS.includes(key)) {
|
|
90
|
+
reject(`${at}: unknown key "${key}" (allowed: id, title, cmd — gates declare WHAT to check, never lane/model/routing)`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const key of GATE_KEYS) {
|
|
94
|
+
if (typeof gate[key] !== 'string' || gate[key].trim() === '') {
|
|
95
|
+
reject(`${at}: "${key}" must be a non-empty string`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (/[\r\n]/.test(gate.cmd)) {
|
|
99
|
+
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`);
|
|
100
|
+
}
|
|
101
|
+
if (!GATE_ID_RE.test(gate.id)) reject(`${at}: id "${gate.id}" must be kebab-case (lowercase [a-z0-9] groups separated by "-")`);
|
|
102
|
+
if (seenIds.has(gate.id)) reject(`${at}: duplicate id "${gate.id}"`);
|
|
103
|
+
seenIds.add(gate.id);
|
|
104
|
+
});
|
|
105
|
+
return parsed.gates;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// ── declaration IO ────────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
// Load the declaration from <cwd>/docs/ai/gates.json. A truly-absent file is the DISTINCT
|
|
111
|
+
// `missing` outcome (exit 3 upstream, with the recovery named) — never an error throw; anything
|
|
112
|
+
// present-but-unreadable / malformed / schema-invalid throws the loud exit-5 failure. lstat does
|
|
113
|
+
// not follow links, so a dangling symlink reads as present and its read failure surfaces loudly
|
|
114
|
+
// (no-silent-failures Hard Constraint — the loadConfig idiom).
|
|
115
|
+
export const loadDeclaration = (cwd, { readFile = readFileSync, lstat = lstatSync } = {}) => {
|
|
116
|
+
const full = join(cwd, GATES_REL);
|
|
117
|
+
try {
|
|
118
|
+
lstat(full);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
if (err && err.code === 'ENOENT') return { outcome: 'missing' };
|
|
121
|
+
throw fail(EXIT.malformed, `${GATES_REL}: unreadable (${(err && err.code) || (err && err.message) || err})`);
|
|
122
|
+
}
|
|
123
|
+
let raw;
|
|
124
|
+
try {
|
|
125
|
+
raw = readFile(full, 'utf8');
|
|
126
|
+
} catch (err) {
|
|
127
|
+
throw fail(EXIT.malformed, `${GATES_REL}: unreadable (${(err && err.code) || (err && err.message) || err})`);
|
|
128
|
+
}
|
|
129
|
+
let parsed;
|
|
130
|
+
try {
|
|
131
|
+
parsed = JSON.parse(raw);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
throw fail(EXIT.malformed, `${GATES_REL}: malformed JSON (${err.message})`);
|
|
134
|
+
}
|
|
135
|
+
return { outcome: 'loaded', gates: validateDeclaration(parsed) };
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// ── gate selection (--only) ───────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
// Resolve the `--only` subset against the declared gates: declaration order is preserved,
|
|
141
|
+
// duplicates collapse, an unknown id is a LOUD usage error naming the declared ids.
|
|
142
|
+
export const selectGates = (gates, onlyIds) => {
|
|
143
|
+
if (onlyIds.length === 0) return gates;
|
|
144
|
+
const declared = new Set(gates.map((gate) => gate.id));
|
|
145
|
+
const unknown = onlyIds.filter((id) => !declared.has(id));
|
|
146
|
+
if (unknown.length > 0) {
|
|
147
|
+
throw fail(
|
|
148
|
+
EXIT.usage,
|
|
149
|
+
`--only: unknown gate id(s): ${unknown.join(', ')} (declared: ${gates.map((gate) => gate.id).join(', ')})`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const wanted = new Set(onlyIds);
|
|
153
|
+
return gates.filter((gate) => wanted.has(gate.id));
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// ── the bash spawn (the ONE real-process boundary; injectable for hermetic tests) ─────
|
|
157
|
+
|
|
158
|
+
// Spawn one gate cmd via bash from the project root. `cmd` is a BASH command line by contract
|
|
159
|
+
// (the declaration's _README states it): this repo's own gate matrix needs brace+glob expansion,
|
|
160
|
+
// which /bin/sh does not perform — hence bash explicitly, never the platform default shell.
|
|
161
|
+
export const spawnGateViaBash = (cmd, cwd) =>
|
|
162
|
+
spawnSync('bash', ['-c', cmd], { cwd, encoding: 'utf8', maxBuffer: MAX_GATE_OUTPUT_BYTES });
|
|
163
|
+
|
|
164
|
+
// The command the bash preflight runs before ANY gate: proves bash itself spawns on this host,
|
|
165
|
+
// so "no bash" is one loud exit-6 error up front — never a per-gate spawn-failure cascade.
|
|
166
|
+
export const BASH_PROBE_CMD = 'true';
|
|
167
|
+
|
|
168
|
+
// ── run + report ──────────────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
const formatSeconds = (ms) => `${(ms / 1000).toFixed(1)}s`;
|
|
171
|
+
const trimTrailingNewline = (text) => text.replace(/\n$/, '');
|
|
172
|
+
|
|
173
|
+
// Run the selected gates sequentially (declaration order). A green gate logs one PASS line; a
|
|
174
|
+
// failing gate logs FAIL + its captured stdout/stderr VERBATIM (triage without re-running).
|
|
175
|
+
export const runGates = (gates, { cwd, spawn = spawnGateViaBash, now = Date.now, log = console.log }) => {
|
|
176
|
+
const results = [];
|
|
177
|
+
for (const gate of gates) {
|
|
178
|
+
log(`── ${gate.id} — ${gate.title}`);
|
|
179
|
+
const startedAt = now();
|
|
180
|
+
const res = spawn(gate.cmd, cwd);
|
|
181
|
+
const elapsedMs = now() - startedAt;
|
|
182
|
+
const spawnError = res.error ? `spawn error: ${res.error.code || res.error.message}` : null;
|
|
183
|
+
const ok = spawnError === null && res.status === 0;
|
|
184
|
+
const code = spawnError === null ? res.status : SPAWN_FAILED_CODE;
|
|
185
|
+
results.push({ id: gate.id, title: gate.title, ok, code, elapsedMs });
|
|
186
|
+
if (ok) {
|
|
187
|
+
log(` PASS (${formatSeconds(elapsedMs)})`);
|
|
188
|
+
} else {
|
|
189
|
+
log(` FAIL exit=${code} (${formatSeconds(elapsedMs)})`);
|
|
190
|
+
if (res.stdout) log(trimTrailingNewline(res.stdout));
|
|
191
|
+
if (res.stderr) log(trimTrailingNewline(res.stderr));
|
|
192
|
+
if (spawnError) log(` ${spawnError}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return results;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// The per-gate PASS/FAIL table (printed after every gate ran — failures never stop the matrix).
|
|
199
|
+
export const formatTable = (results) => {
|
|
200
|
+
const idWidth = Math.max(...results.map((result) => result.id.length), 'gate'.length);
|
|
201
|
+
const pad = (text) => text + ' '.repeat(idWidth - text.length);
|
|
202
|
+
const lines = ['', `${pad('gate')} result`];
|
|
203
|
+
for (const result of results) {
|
|
204
|
+
lines.push(`${pad(result.id)} ${result.ok ? 'PASS' : `FAIL (exit ${result.code})`}`);
|
|
205
|
+
}
|
|
206
|
+
return lines;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// The ONE machine-readable summary line — always the LAST line printed for every non-usage
|
|
210
|
+
// outcome. Schema (pinned by tests): status ∈ ok|fail|missing|empty|malformed|no-bash.
|
|
211
|
+
export const composeSummaryLine = ({ status, results = [] }) => {
|
|
212
|
+
const passed = results.filter((result) => result.ok).length;
|
|
213
|
+
const failed = results.filter((result) => !result.ok);
|
|
214
|
+
const failedIds = failed.length > 0 ? failed.map((result) => result.id).join(',') : NO_FAILED_IDS;
|
|
215
|
+
return `[run-gates] status=${status} gates=${results.length} passed=${passed} failed=${failed.length} failed_ids=${failedIds}`;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// ── CLI ───────────────────────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
const parseArgs = (argv) => {
|
|
221
|
+
const opts = { cwd: null, only: [], help: false };
|
|
222
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
223
|
+
const arg = argv[i];
|
|
224
|
+
if (arg === '--help' || arg === '-h') {
|
|
225
|
+
opts.help = true;
|
|
226
|
+
} else if (arg === '--cwd') {
|
|
227
|
+
i += 1;
|
|
228
|
+
if (argv[i] === undefined) throw fail(EXIT.usage, '--cwd requires a directory argument');
|
|
229
|
+
opts.cwd = argv[i];
|
|
230
|
+
} else if (arg === '--only') {
|
|
231
|
+
i += 1;
|
|
232
|
+
if (argv[i] === undefined) throw fail(EXIT.usage, '--only requires a gate id argument');
|
|
233
|
+
opts.only.push(argv[i]);
|
|
234
|
+
} else {
|
|
235
|
+
throw fail(EXIT.usage, `unknown argument "${arg}"\n${USAGE}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return opts;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// The full CLI, dependency-injected for hermetic tests. Returns the process exit code; the two
|
|
242
|
+
// output sinks split human-facing report (log) from error channel (logError). The summary line is
|
|
243
|
+
// emitted via `log` as the final line of every non-usage outcome.
|
|
244
|
+
export const runCli = (argv, deps = {}) => {
|
|
245
|
+
const {
|
|
246
|
+
cwd = process.cwd(),
|
|
247
|
+
log = console.log,
|
|
248
|
+
logError = console.error,
|
|
249
|
+
spawn = spawnGateViaBash,
|
|
250
|
+
readFile,
|
|
251
|
+
lstat,
|
|
252
|
+
now,
|
|
253
|
+
} = deps;
|
|
254
|
+
try {
|
|
255
|
+
const opts = parseArgs(argv);
|
|
256
|
+
if (opts.help) {
|
|
257
|
+
log(USAGE);
|
|
258
|
+
return EXIT.ok;
|
|
259
|
+
}
|
|
260
|
+
const projectDir = opts.cwd ?? cwd;
|
|
261
|
+
const declaration = loadDeclaration(projectDir, { readFile, lstat });
|
|
262
|
+
if (declaration.outcome === 'missing') {
|
|
263
|
+
logError(`[run-gates] no gate declaration found at ${GATES_REL} — nothing was run.`);
|
|
264
|
+
logError(
|
|
265
|
+
`Recovery: create ${GATES_REL} from the gates.json template (references/templates/gates.json — ` +
|
|
266
|
+
'bootstrap seeds it; /agent-workflow-kit upgrade re-seeds a missing one), declare { id, title, cmd } gates, re-run.',
|
|
267
|
+
);
|
|
268
|
+
log(composeSummaryLine({ status: 'missing' }));
|
|
269
|
+
return EXIT.missing;
|
|
270
|
+
}
|
|
271
|
+
if (declaration.gates.length === 0) {
|
|
272
|
+
logError(`[run-gates] ${GATES_REL} declares an empty "gates" list — nothing to run (add { id, title, cmd } entries).`);
|
|
273
|
+
log(composeSummaryLine({ status: 'empty' }));
|
|
274
|
+
return EXIT.empty;
|
|
275
|
+
}
|
|
276
|
+
const selected = selectGates(declaration.gates, opts.only);
|
|
277
|
+
const probe = spawn(BASH_PROBE_CMD, projectDir);
|
|
278
|
+
if (probe.error && probe.error.code === 'ENOENT') {
|
|
279
|
+
logError(
|
|
280
|
+
'[run-gates] bash is not available on this host — gate cmd lines are BASH command lines ' +
|
|
281
|
+
'(brace/glob expansion); refusing to silently reinterpret them under another shell. Install bash and re-run.',
|
|
282
|
+
);
|
|
283
|
+
log(composeSummaryLine({ status: 'no-bash' }));
|
|
284
|
+
return EXIT.noBash;
|
|
285
|
+
}
|
|
286
|
+
const results = runGates(selected, { cwd: projectDir, spawn, log, now });
|
|
287
|
+
for (const line of formatTable(results)) log(line);
|
|
288
|
+
const allGreen = results.every((result) => result.ok);
|
|
289
|
+
log(composeSummaryLine({ status: allGreen ? 'ok' : 'fail', results }));
|
|
290
|
+
return allGreen ? EXIT.ok : EXIT.fail;
|
|
291
|
+
} catch (err) {
|
|
292
|
+
logError(`[run-gates] ${err.message}`);
|
|
293
|
+
if (err.exitCode === EXIT.malformed) log(composeSummaryLine({ status: 'malformed' }));
|
|
294
|
+
return err.exitCode ?? EXIT.fail;
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
299
|
+
if (isDirectRun) process.exitCode = runCli(process.argv.slice(2));
|