mandrel 1.77.0 → 1.78.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.
@@ -20,6 +20,13 @@ slash command (e.g. `/deliver`). The projection writes only
20
20
  `.claude/commands/<name>.md` — there is no plugin manifest and no
21
21
  marketplace listing. The commands load in every Claude Code environment.
22
22
 
23
+ Loop units are the one namespaced exception: files under
24
+ `.agents/workflows/loops/<name>.md` project to
25
+ `.claude/commands/loops/<name>.md` and are invoked as the namespaced
26
+ `/loops:<name>` command. On hosts that flatten subdirectory commands the
27
+ same unit surfaces under the flat fallback `/loops-<name>`. They are
28
+ listed separately in the **Loops namespace** section below.
29
+
23
30
  This index is regenerated from each workflow’s front-matter `description:`
24
31
  by `node .agents/scripts/generate-workflows-doc.js`; `npm run docs:check`
25
32
  fails when it drifts from the on-disk workflow set. To change a command’s
@@ -54,3 +61,15 @@ description, edit the workflow file’s front-matter and regenerate.
54
61
  | `/qa-assist` | Human-led QA assist loop — set up, then ride a rolling multi-observation intake session. The operator reports observations in any order; the agent enriches each (repro + root-cause file:line + coverage verdict for bugs; analysis + options + recommendation for enhancements), asks clarifying questions only when ambiguous, and appends a redacted ledger item — recording, never planning — to a persistent, resumable session under temp/qa/. Only when the operator says they are done does it review the full ledger and hand off to /plan. |
55
62
  | `/qa-explore` | Agent-led exploratory-QA loop — the agent Plans a surface with an explicit static-vs-drive method choice, drives it (browser MCP or static), and captures ledger items read-only, then Triages — a bounded per-surface session, HITL-gated at every phase transition, routed through the shared dedup/coverage/classification/missing-test/redaction/session core under temp/qa/ |
56
63
  | `/qa-run` | Drive Gherkin scenarios through a real browser as an agent-driven QA sweep |
64
+
65
+ ## Loops namespace (3)
66
+
67
+ Loop units project to `.claude/commands/loops/<name>.md` and are invoked
68
+ as `/loops:<name>` (flat fallback `/loops-<name>` on hosts that flatten
69
+ subdirectory commands).
70
+
71
+ | Command | Description |
72
+ | --- | --- |
73
+ | `/loops:fix-failing-tests` | Self-paced convergence loop that drives a red test suite to green. Each round reads the latest failure, applies the smallest fix, and re-runs the verify oracle (`npm test`); the loop terminates when the oracle exits 0. The host (`/loop`) owns iteration and pacing — mandrel supplies the action, the goal, and the terminating oracle. |
74
+ | `/loops:nightly-audit` | Cron maintenance loop that runs a nightly audit sweep over the repository and files actionable findings. Each run executes the audit workflows and routes the results; the host (`/schedule` or a cron-driven `/loop`) owns the cadence. verify is optional for a cron loop — the scheduler owns iteration, so this unit ships the action and goal, not a terminating oracle. |
75
+ | `/loops:watch-ci` | Interval watch loop that polls a pull request's CI checks until they settle. Each round runs `gh pr checks` and reports the delta; the host (`/loop 5m`) owns the cadence and re-invokes the unit on its schedule. verify is optional for an interval loop — the externally-scheduled host owns iteration, so this unit ships the action and goal, not a terminating oracle. |
@@ -0,0 +1,20 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/dsj1984/mandrel/blob/main/.agents/schemas/lifecycle/loop.tick.schema.json",
4
+ "title": "loop.tick",
5
+ "description": "Emitted once per pass of a host-driven loop (e.g. a recurring loop command or a long-running poll) so each round lands an inspectable ledger record. Surfaces the loop as forward-progress evidence the /deliver idle watchdog already scans — distinct from story.heartbeat, which carries Story-phase info for a single in-flight Story. A loop is not tied to a Story tier: loop.tick carries a free-form loopName, a monotonic round counter, the configured cadence, and a status so a host loop never runs silently. cadence is the loop's configured interval label (e.g. '5m', 'self-paced'); status is the per-round verdict (running while the loop continues, done when it terminates normally, blocked when it stalls).",
6
+ "type": "object",
7
+ "required": ["event", "loopName", "round", "cadence", "status", "timestamp"],
8
+ "properties": {
9
+ "event": { "type": "string", "const": "loop.tick" },
10
+ "loopName": { "type": "string", "minLength": 1 },
11
+ "round": { "type": "integer", "minimum": 0 },
12
+ "cadence": { "type": "string", "minLength": 1 },
13
+ "status": {
14
+ "type": "string",
15
+ "enum": ["running", "done", "blocked"]
16
+ },
17
+ "timestamp": { "type": "string", "format": "date-time" }
18
+ },
19
+ "additionalProperties": false
20
+ }
@@ -0,0 +1,70 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/dsj1984/mandrel/blob/main/.agents/schemas/loop-unit.schema.json",
4
+ "version": "1.0.0",
5
+ "title": "Loop Unit (recurring-work definition)",
6
+ "description": "Schema for the YAML frontmatter of a loop unit — a markdown file under .agents/workflows/loops/ that defines a recurring/iterative unit of work with a checkable definition of done. The loop block carries the cadence (how the loop is paced), the goal (what the loop is trying to achieve), an optional verify command/array (the at-keyboard checks proving a round is complete — required for self-paced cadence, optional for interval/cron), a maxRounds backstop, and an onExhaust policy describing what happens when the round cap is hit without the goal being met.",
7
+ "type": "object",
8
+ "additionalProperties": true,
9
+ "required": ["loop"],
10
+ "properties": {
11
+ "$schema": {
12
+ "type": "string",
13
+ "description": "Optional reference to this schema file so YAML editors with $schema-aware autocomplete can resolve it from the unit file itself."
14
+ },
15
+ "description": {
16
+ "type": "string",
17
+ "description": "Optional human-readable summary of what this loop unit does."
18
+ },
19
+ "loop": {
20
+ "type": "object",
21
+ "additionalProperties": false,
22
+ "required": ["cadence", "goal"],
23
+ "description": "The loop definition block. Carries the cadence, goal, conditional verify, round cap, and exhaustion policy.",
24
+ "properties": {
25
+ "cadence": {
26
+ "type": "string",
27
+ "enum": ["self-paced", "interval", "cron"],
28
+ "description": "How the loop is paced. 'self-paced' lets the agent decide when to run the next round and therefore MUST carry a verify[] so each round has a checkable definition of done. 'interval' and 'cron' are externally scheduled, so verify is optional."
29
+ },
30
+ "goal": {
31
+ "type": "string",
32
+ "minLength": 1,
33
+ "description": "Required. What the loop is trying to achieve — the standing objective each round works toward."
34
+ },
35
+ "verify": {
36
+ "description": "Command(s) that prove a round is complete. A single command string or an array of command strings. Required when cadence is 'self-paced'; optional for 'interval' / 'cron'.",
37
+ "oneOf": [
38
+ { "type": "string", "minLength": 1 },
39
+ {
40
+ "type": "array",
41
+ "items": { "type": "string", "minLength": 1 },
42
+ "minItems": 1
43
+ }
44
+ ]
45
+ },
46
+ "maxRounds": {
47
+ "type": "integer",
48
+ "minimum": 1,
49
+ "description": "Optional positive-integer backstop on the number of loop rounds before the onExhaust policy fires."
50
+ },
51
+ "onExhaust": {
52
+ "type": "string",
53
+ "enum": ["block", "report", "hand-back"],
54
+ "description": "What happens when maxRounds is hit without the goal met. 'block' transitions to a HITL gate; 'report' emits a summary and stops; 'hand-back' returns control to the caller."
55
+ }
56
+ },
57
+ "allOf": [
58
+ {
59
+ "if": {
60
+ "properties": { "cadence": { "const": "self-paced" } },
61
+ "required": ["cadence"]
62
+ },
63
+ "then": {
64
+ "required": ["verify"]
65
+ }
66
+ }
67
+ ]
68
+ }
69
+ }
70
+ }
@@ -258,7 +258,14 @@ function stripAnchorAndQuery(target) {
258
258
  // `temp/epic-[ID]/tickets.json` (preceded by `]`).
259
259
  // - the following char is NOT a word char, `-`, or `.`, so file
260
260
  // extensions like `/tickets.json` and identifier suffixes don't match.
261
- const SLASH_TOKEN_RE = /(?<![\w/:.>\])])\/([a-z][a-z0-9-]*)(?![\w.-])/g;
261
+ // The optional `(?::[a-z][a-z0-9-]*)?` tail captures the namespaced
262
+ // `/loops:<name>` command form (Story #4289). Without it the matcher would
263
+ // stop at `loops` and try to resolve `.agents/workflows/loops.md`, which does
264
+ // not exist — loop units live under `loops/<name>.md`. The resolver below
265
+ // splits the captured `loops:<name>` token on the `:` to resolve the
266
+ // namespaced path.
267
+ const SLASH_TOKEN_RE =
268
+ /(?<![\w/:.>\])])\/([a-z][a-z0-9-]*(?::[a-z][a-z0-9-]*)?)(?![\w.-])/g;
262
269
 
263
270
  export function extractSlashTokens(masked) {
264
271
  const out = [];
@@ -334,6 +341,22 @@ export function checkFile(absPath, repoRoot) {
334
341
  for (const { token, line } of slashTokens) {
335
342
  if (RETIRED_COMMANDS.has(token)) continue;
336
343
  if (SLASH_ALLOWLIST.has(token)) continue;
344
+ // Namespaced loop commands (`/loops:<name>`, Story #4289) resolve to a
345
+ // loop unit under `.agents/workflows/loops/<name>.md`. Split on the `:`
346
+ // and resolve the namespaced path rather than a flat `loops:<name>.md`.
347
+ if (token.includes(':')) {
348
+ const [ns, name] = token.split(':');
349
+ const nsFile = path.join(workflowsDir, ns, `${name}.md`);
350
+ if (!fs.existsSync(nsFile)) {
351
+ violations.push({
352
+ file: relFile,
353
+ line,
354
+ kind: 'unknown-command',
355
+ message: `slash command /${token} does not resolve to .agents/workflows/${ns}/${name}.md`,
356
+ });
357
+ }
358
+ continue;
359
+ }
337
360
  const workflowFile = path.join(workflowsDir, `${token}.md`);
338
361
  const helperFile = path.join(workflowsDir, 'helpers', `${token}.md`);
339
362
  if (!fs.existsSync(workflowFile) && !fs.existsSync(helperFile)) {
@@ -0,0 +1,204 @@
1
+ /**
2
+ * CLI: loop-unit lint gate (Story #4288, Epic #4284).
3
+ *
4
+ * Validates every loop-unit markdown file under `.agents/workflows/loops/`
5
+ * against `.agents/schemas/loop-unit.schema.json` via
6
+ * `lib/loop-units/validate-loop-unit.js`. An absent or empty loops
7
+ * directory is a **clean pass** (exit 0) — the gate only fails when a unit
8
+ * file is present and invalid.
9
+ *
10
+ * On any invalid (or structurally unparseable) unit the CLI prints a
11
+ * message naming the offending file and the missing/invalid field, then
12
+ * exits non-zero. This is wired into `npm run lint` so a malformed loop
13
+ * unit fails the lint gate.
14
+ *
15
+ * Flags:
16
+ * --dir <path> override the loops directory (default
17
+ * `.agents/workflows/loops`, resolved from cwd)
18
+ * --json write a structured envelope to stdout instead of the
19
+ * human-readable preview
20
+ */
21
+
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import process from 'node:process';
25
+ import { runAsCli } from './lib/cli-utils.js';
26
+ import {
27
+ LoopUnitParseError,
28
+ validateLoopUnit,
29
+ } from './lib/loop-units/validate-loop-unit.js';
30
+
31
+ export const DEFAULT_LOOPS_DIR = path.join('.agents', 'workflows', 'loops');
32
+
33
+ /**
34
+ * Parse argv for `--dir <path>` and `--json`. Exported so tests can pin
35
+ * the parser.
36
+ *
37
+ * @param {string[]} argv
38
+ * @returns {{ dir: string | null, json: boolean }}
39
+ */
40
+ export function parseArgv(argv = []) {
41
+ let dir = null;
42
+ let json = false;
43
+ for (let i = 0; i < argv.length; i += 1) {
44
+ const a = argv[i];
45
+ if (a === '--dir') {
46
+ const next = argv[i + 1];
47
+ if (next && !next.startsWith('--')) {
48
+ dir = next;
49
+ i += 1;
50
+ }
51
+ } else if (a === '--json') {
52
+ json = true;
53
+ }
54
+ }
55
+ return { dir, json };
56
+ }
57
+
58
+ /**
59
+ * `README.md` (any case) under the loops directory is namespace
60
+ * documentation, not a loop unit — it carries no `loop:` frontmatter and is
61
+ * not projected as a `/loops:` command (see `sync-claude-commands.js`). It is
62
+ * excluded from the loop-unit collector so the lint gate never flags the
63
+ * directory's own README as a malformed unit.
64
+ *
65
+ * @param {string} name a directory-entry basename
66
+ * @returns {boolean}
67
+ */
68
+ export function isLoopUnitFile(name) {
69
+ return name.endsWith('.md') && name.toLowerCase() !== 'readme.md';
70
+ }
71
+
72
+ /**
73
+ * Collect `*.md` loop-unit files directly under `dir`, sorted. Returns an
74
+ * empty array when the directory is absent (the clean-pass case). The
75
+ * directory's `README.md` is excluded — it is namespace documentation, not a
76
+ * unit (see `isLoopUnitFile`).
77
+ *
78
+ * @param {string} dir absolute path
79
+ * @returns {string[]} absolute paths
80
+ */
81
+ export function collectLoopUnitFiles(dir) {
82
+ let entries;
83
+ try {
84
+ entries = fs.readdirSync(dir, { withFileTypes: true });
85
+ } catch {
86
+ return [];
87
+ }
88
+ return entries
89
+ .filter((e) => e.isFile() && isLoopUnitFile(e.name))
90
+ .map((e) => path.join(dir, e.name))
91
+ .sort();
92
+ }
93
+
94
+ /**
95
+ * Validate every loop unit under `dir`. Returns the per-file results and a
96
+ * roll-up `failures` array carrying `{ file, issues }` for each invalid or
97
+ * unparseable unit.
98
+ *
99
+ * @param {string} dir absolute path
100
+ * @param {{ schemaPath?: string }} [opts]
101
+ * @returns {{ files: string[], failures: Array<{ file: string, issues: Array<{path:string,message:string}> }> }}
102
+ */
103
+ export function checkLoopUnits(dir, opts = {}) {
104
+ const files = collectLoopUnitFiles(dir);
105
+ const failures = [];
106
+ for (const file of files) {
107
+ try {
108
+ const { valid, issues } = validateLoopUnit(file, opts);
109
+ if (!valid) failures.push({ file, issues });
110
+ } catch (err) {
111
+ if (err instanceof LoopUnitParseError) {
112
+ failures.push({ file, issues: [{ path: '/', message: err.reason }] });
113
+ } else {
114
+ throw err;
115
+ }
116
+ }
117
+ }
118
+ return { files, failures };
119
+ }
120
+
121
+ /**
122
+ * Render the human-readable report. Each failure lists the offending file
123
+ * and one line per issue naming the field path and message.
124
+ *
125
+ * @param {{ files: string[], failures: Array<{ file: string, issues: Array<{path:string,message:string}> }> }} result
126
+ * @returns {string}
127
+ */
128
+ export function renderReport({ files, failures }) {
129
+ const lines = [];
130
+ if (files.length === 0) {
131
+ lines.push('[check-loop-units] no loop units found (ok)');
132
+ return lines.join('\n');
133
+ }
134
+ for (const { file, issues } of failures) {
135
+ lines.push(`✖ ${file}`);
136
+ for (const issue of issues) {
137
+ lines.push(` ${issue.path}: ${issue.message}`);
138
+ }
139
+ }
140
+ const tag = failures.length > 0 ? '(gate fail)' : '(ok)';
141
+ lines.push(
142
+ `[check-loop-units] checked=${files.length} invalid=${failures.length} ${tag}`,
143
+ );
144
+ return lines.join('\n');
145
+ }
146
+
147
+ /**
148
+ * Top-level CLI entry. Exported so tests can drive the full pipeline
149
+ * against a tmpdir fixture directory.
150
+ *
151
+ * @param {{
152
+ * argv?: string[],
153
+ * cwd?: string,
154
+ * stdout?: { write: (s: string) => void },
155
+ * stderr?: { write: (s: string) => void },
156
+ * }} [opts]
157
+ * @returns {Promise<number>} 0 = clean; 1 = at least one invalid unit
158
+ */
159
+ export async function runCli({
160
+ argv = process.argv.slice(2),
161
+ cwd = process.cwd(),
162
+ stdout = process.stdout,
163
+ stderr = process.stderr,
164
+ } = {}) {
165
+ const { dir, json } = parseArgv(argv);
166
+ const loopsDir = path.resolve(cwd, dir ?? DEFAULT_LOOPS_DIR);
167
+ const result = checkLoopUnits(loopsDir);
168
+ const exitCode = result.failures.length > 0 ? 1 : 0;
169
+
170
+ if (json) {
171
+ stdout.write(
172
+ `${JSON.stringify(
173
+ {
174
+ kind: 'loop-units-report',
175
+ dir: loopsDir,
176
+ checked: result.files.length,
177
+ failures: result.failures,
178
+ exitCode,
179
+ },
180
+ null,
181
+ 2,
182
+ )}\n`,
183
+ );
184
+ } else {
185
+ const report = renderReport(result);
186
+ if (exitCode === 0) {
187
+ stdout.write(`${report}\n`);
188
+ } else {
189
+ stderr.write(`${report}\n`);
190
+ }
191
+ }
192
+
193
+ return exitCode;
194
+ }
195
+
196
+ async function main() {
197
+ return runCli();
198
+ }
199
+
200
+ runAsCli(import.meta.url, main, {
201
+ source: 'check-loop-units',
202
+ propagateExitCode: true,
203
+ errorPrefix: '[check-loop-units] ❌ Fatal error',
204
+ });
@@ -34,7 +34,7 @@ import { fileURLToPath } from 'node:url';
34
34
  import { parseArgs } from 'node:util';
35
35
  import { runAsCli } from './lib/cli-utils.js';
36
36
  import { Logger } from './lib/Logger.js';
37
- import { buildCatalog } from './lib/mandrel-catalog.js';
37
+ import { buildCatalog, buildLoopCatalog } from './lib/mandrel-catalog.js';
38
38
 
39
39
  const __filename = fileURLToPath(import.meta.url);
40
40
  const __dirname = path.dirname(__filename);
@@ -66,12 +66,14 @@ function cellEscape(description) {
66
66
  }
67
67
 
68
68
  /**
69
- * Render the full generated `workflows.md` content from a catalog.
69
+ * Render the full generated `workflows.md` content from the flat command
70
+ * catalog and the loop-unit catalog.
70
71
  *
71
72
  * @param {Array<{ name: string, description: string | null, vague: boolean }>} catalog
73
+ * @param {Array<{ name: string, description: string | null, vague: boolean }>} [loopCatalog]
72
74
  * @returns {string}
73
75
  */
74
- export function renderWorkflowsDoc(catalog) {
76
+ export function renderWorkflowsDoc(catalog, loopCatalog = []) {
75
77
  const lines = [
76
78
  '<!--',
77
79
  ' GENERATED FILE — do not edit by hand.',
@@ -95,6 +97,13 @@ export function renderWorkflowsDoc(catalog) {
95
97
  '`.claude/commands/<name>.md` — there is no plugin manifest and no',
96
98
  'marketplace listing. The commands load in every Claude Code environment.',
97
99
  '',
100
+ 'Loop units are the one namespaced exception: files under',
101
+ '`.agents/workflows/loops/<name>.md` project to',
102
+ '`.claude/commands/loops/<name>.md` and are invoked as the namespaced',
103
+ '`/loops:<name>` command. On hosts that flatten subdirectory commands the',
104
+ 'same unit surfaces under the flat fallback `/loops-<name>`. They are',
105
+ 'listed separately in the **Loops namespace** section below.',
106
+ '',
98
107
  'This index is regenerated from each workflow’s front-matter `description:`',
99
108
  'by `node .agents/scripts/generate-workflows-doc.js`; `npm run docs:check`',
100
109
  'fails when it drifts from the on-disk workflow set. To change a command’s',
@@ -110,6 +119,29 @@ export function renderWorkflowsDoc(catalog) {
110
119
  lines.push(`| \`/${entry.name}\` | ${cellEscape(entry.description)} |`);
111
120
  }
112
121
 
122
+ lines.push('');
123
+ lines.push(`## Loops namespace (${loopCatalog.length})`);
124
+ lines.push('');
125
+ lines.push(
126
+ 'Loop units project to `.claude/commands/loops/<name>.md` and are invoked',
127
+ );
128
+ lines.push(
129
+ 'as `/loops:<name>` (flat fallback `/loops-<name>` on hosts that flatten',
130
+ );
131
+ lines.push('subdirectory commands).');
132
+ lines.push('');
133
+ if (loopCatalog.length === 0) {
134
+ lines.push('> No loop units are shipped yet.');
135
+ } else {
136
+ lines.push('| Command | Description |');
137
+ lines.push('| --- | --- |');
138
+ for (const entry of loopCatalog) {
139
+ lines.push(
140
+ `| \`/loops:${entry.name}\` | ${cellEscape(entry.description)} |`,
141
+ );
142
+ }
143
+ }
144
+
113
145
  lines.push('');
114
146
  return lines.join('\n');
115
147
  }
@@ -121,7 +153,8 @@ export function renderWorkflowsDoc(catalog) {
121
153
  */
122
154
  export function buildExpected() {
123
155
  const catalog = buildCatalog(WORKFLOWS_DIR);
124
- const generated = renderWorkflowsDoc(catalog);
156
+ const loopCatalog = buildLoopCatalog(WORKFLOWS_DIR);
157
+ const generated = renderWorkflowsDoc(catalog, loopCatalog);
125
158
  const original = fs.existsSync(DOC_PATH)
126
159
  ? fs.readFileSync(DOC_PATH, 'utf8')
127
160
  : null;
@@ -53,6 +53,34 @@ export function gateExitCode(code, sig) {
53
53
  return sig ? 143 : 1;
54
54
  }
55
55
 
56
+ /**
57
+ * Biome's marker for "you handed me a path set, but every one of them is
58
+ * excluded by my own config (`files.includes` allowlist / `files.ignore` /
59
+ * `overrides`), so I processed nothing" — biome exits 1 in that case.
60
+ *
61
+ * The format gate scopes biome to the changed-file subset (Story #3410). When
62
+ * that subset is non-empty by extension but every path is biome-config-ignored,
63
+ * the scoped invocation reports this message and exits 1 even though
64
+ * `biome format .` over the whole tree is clean — a false negative for the
65
+ * gate (Story #4292). Detecting the marker lets the runner treat that exit as
66
+ * a clean skip rather than a formatting failure.
67
+ */
68
+ const BIOME_NO_FILES_PROCESSED =
69
+ 'No files were processed in the specified paths';
70
+
71
+ /**
72
+ * Whether biome's combined gate output carries the "No files were processed"
73
+ * marker. Pure function — no I/O. Exported for unit coverage (Story #4292).
74
+ *
75
+ * @param {string} output - Combined stdout/stderr captured from the gate child.
76
+ * @returns {boolean}
77
+ */
78
+ export function isBiomeNoFilesProcessed(output) {
79
+ return (
80
+ typeof output === 'string' && output.includes(BIOME_NO_FILES_PROCESSED)
81
+ );
82
+ }
83
+
56
84
  /**
57
85
  * Default async gate runner — used by `runCloseValidation` when no `runner`
58
86
  * is injected. Spawns the gate via `child_process.spawn`, prefixes every
@@ -66,13 +94,19 @@ export function gateExitCode(code, sig) {
66
94
  * `runCloseValidation` sees a non-zero status and folds it into the
67
95
  * already-recorded first-failure.
68
96
  *
97
+ * When `opts.tolerateNoFilesProcessed` is set (the biome-scoped format gate —
98
+ * Story #4292), a non-zero exit whose combined output carries biome's
99
+ * "No files were processed" marker is downgraded to a clean `status: 0`,
100
+ * because that exit means every config-included path was already excluded,
101
+ * not that formatting drifted.
102
+ *
69
103
  * @param {string} cmd
70
104
  * @param {string[]} args
71
- * @param {{ cwd: string, signal?: AbortSignal, gateName?: string, log?: (m: string) => void, env?: Record<string, string> }} opts
105
+ * @param {{ cwd: string, signal?: AbortSignal, gateName?: string, log?: (m: string) => void, env?: Record<string, string>, tolerateNoFilesProcessed?: boolean }} opts
72
106
  * @returns {Promise<{ status: number }>}
73
107
  */
74
108
  export function defaultGateRunner(cmd, args, opts = {}) {
75
- const { cwd, signal, gateName, log, env } = opts;
109
+ const { cwd, signal, gateName, log, env, tolerateNoFilesProcessed } = opts;
76
110
  const child = spawn(cmd, args, {
77
111
  cwd,
78
112
  shell: process.platform === 'win32',
@@ -85,13 +119,35 @@ export function defaultGateRunner(cmd, args, opts = {}) {
85
119
  const prefix = gateName ? `[${gateName}] ` : '';
86
120
  const emit =
87
121
  typeof log === 'function' ? log : (m) => process.stdout.write(`${m}\n`);
88
- pipePrefixed(child.stdout, prefix, emit);
89
- pipePrefixed(child.stderr, prefix, emit);
122
+ // Capture the combined output only when we may need to inspect it for the
123
+ // biome "No files were processed" marker — otherwise the stream is purely
124
+ // piped through to the operator (no retained buffer).
125
+ let captured = '';
126
+ const tap = tolerateNoFilesProcessed
127
+ ? (line) => {
128
+ captured += `${line}\n`;
129
+ emit(line);
130
+ }
131
+ : emit;
132
+ pipePrefixed(child.stdout, prefix, tap);
133
+ pipePrefixed(child.stderr, prefix, tap);
90
134
  const detach = attachGateAbortHandler(child, signal);
91
135
  return new Promise((resolve) => {
92
136
  child.on('exit', (code, sig) => {
93
137
  detach();
94
- resolve({ status: gateExitCode(code, sig) });
138
+ const status = gateExitCode(code, sig);
139
+ if (
140
+ status !== 0 &&
141
+ tolerateNoFilesProcessed &&
142
+ isBiomeNoFilesProcessed(captured)
143
+ ) {
144
+ emit(
145
+ `${prefix}↳ biome processed zero files (all changed paths are config-ignored); treating as a clean skip`,
146
+ );
147
+ resolve({ status: 0 });
148
+ return;
149
+ }
150
+ resolve({ status });
95
151
  });
96
152
  child.on('error', () => {
97
153
  detach();
@@ -50,11 +50,18 @@ function applyChangedFileScope({ gate, spawnCwd, log }) {
50
50
  log(
51
51
  `[close-validation] ↳ ${gate.name} scoped to ${eligibleFiles.length} formatter-eligible changed file(s) from ${gate.changedFileScope.baseRef}...HEAD`,
52
52
  );
53
+ // The extension filter cannot see biome's own config-ignore axis
54
+ // (`files.includes` allowlist / `files.ignore` / `overrides`). When every
55
+ // eligible-by-extension path is also config-ignored, the scoped biome
56
+ // invocation exits 1 with "No files were processed" — a false negative for
57
+ // the gate (Story #4292). Flag the scoped run so the runner downgrades that
58
+ // specific exit to a clean skip instead of a formatting failure.
53
59
  return {
54
60
  gate,
55
61
  cmd: gate.cmd,
56
62
  args: [...args, ...eligibleFiles],
57
63
  skip: false,
64
+ tolerateNoFilesProcessed: true,
58
65
  };
59
66
  }
60
67
 
@@ -209,6 +216,9 @@ export async function runCloseValidation({
209
216
  log,
210
217
  signal,
211
218
  ...(gate.env ? { env: gate.env } : {}),
219
+ ...(gate.tolerateNoFilesProcessed
220
+ ? { tolerateNoFilesProcessed: true }
221
+ : {}),
212
222
  });
213
223
  return { status: result?.status ?? 1 };
214
224
  };
@@ -255,7 +265,12 @@ export async function runCloseValidation({
255
265
  let result;
256
266
  try {
257
267
  result = await dispatchGate(
258
- { ...gate, cmd: execution.cmd, args: execution.args },
268
+ {
269
+ ...gate,
270
+ cmd: execution.cmd,
271
+ args: execution.args,
272
+ tolerateNoFilesProcessed: execution.tolerateNoFilesProcessed,
273
+ },
259
274
  ac.signal,
260
275
  );
261
276
  } catch (err) {
@@ -321,6 +336,7 @@ export async function runCloseValidation({
321
336
  ...gate,
322
337
  cmd: execution.cmd,
323
338
  args: execution.args,
339
+ tolerateNoFilesProcessed: execution.tolerateNoFilesProcessed,
324
340
  });
325
341
  if (result.status !== 0) {
326
342
  failed.push({ gate, status: result.status, cwd: spawnCwd });