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.
- package/.agents/docs/workflows.md +19 -0
- package/.agents/schemas/lifecycle/loop.tick.schema.json +20 -0
- package/.agents/schemas/loop-unit.schema.json +70 -0
- package/.agents/scripts/check-doc-links.js +24 -1
- package/.agents/scripts/check-loop-units.js +204 -0
- package/.agents/scripts/generate-workflows-doc.js +37 -4
- package/.agents/scripts/lib/close-validation/process.js +61 -5
- package/.agents/scripts/lib/close-validation/runner.js +17 -1
- package/.agents/scripts/lib/loop-units/validate-loop-unit.js +197 -0
- package/.agents/scripts/lib/mandrel-catalog.js +36 -0
- package/.agents/scripts/lib/orchestration/lifecycle/emit-loop-tick.js +183 -0
- package/.agents/scripts/providers/github/branch-protection.js +1 -1
- package/.agents/scripts/providers/github/errors.js +53 -2
- package/.agents/scripts/providers/github/labels.js +1 -1
- package/.agents/scripts/providers/github/projects-v2-graphql.js +1 -1
- package/.agents/scripts/run-lint.js +11 -0
- package/.agents/scripts/sync-claude-commands.js +112 -29
- package/.agents/scripts/update-maintainability-baseline.js +19 -76
- package/.agents/workflows/loops/README.md +65 -0
- package/.agents/workflows/loops/fix-failing-tests.md +74 -0
- package/.agents/workflows/loops/nightly-audit.md +71 -0
- package/.agents/workflows/loops/watch-ci.md +68 -0
- package/docs/CHANGELOG.md +18 -0
- package/package.json +1 -1
- package/.agents/scripts/providers/github/transient-retry.js +0 -62
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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 });
|