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
|
@@ -23,10 +23,22 @@
|
|
|
23
23
|
* environment (CLI, IDE, GUI, web, SDK). On a machine that previously synced
|
|
24
24
|
* the plugin tree, this script reaps it on the next run (see reapPluginTree).
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
* subdirectory holds path-included modules
|
|
28
|
-
* that parent workflows read by
|
|
29
|
-
* exposed as commands, so
|
|
26
|
+
* Top-level .md files project flat (`/<name>`). The
|
|
27
|
+
* `.agents/workflows/helpers/` subdirectory holds path-included modules
|
|
28
|
+
* (e.g. epic-code-review, epic-retro) that parent workflows read by
|
|
29
|
+
* relative path — they are intentionally **not** exposed as commands, so
|
|
30
|
+
* helpers/ is skipped.
|
|
31
|
+
*
|
|
32
|
+
* The `.agents/workflows/loops/` subdirectory is the **one** exception to
|
|
33
|
+
* the skip-subdirectories rule (Story #4289, Epic #4284). Each loop unit
|
|
34
|
+
* there projects to `.claude/commands/loops/<name>.md`, preserving the
|
|
35
|
+
* subpath so Claude Code namespaces it as `/loops:<name>` (matching Claude
|
|
36
|
+
* Code's subdirectory-command namespacing). Hosts that flatten
|
|
37
|
+
* subdirectory commands surface the same file as the flat fallback
|
|
38
|
+
* `/loops-<name>` (i.e. `loops-<name>` in the command tree) — the
|
|
39
|
+
* projection writes the namespaced path; the flat form is the documented
|
|
40
|
+
* host-side fallback, not a second on-disk copy. No subdirectory other
|
|
41
|
+
* than `loops/` is recursed.
|
|
30
42
|
*
|
|
31
43
|
* Usage: node .agents/scripts/sync-claude-commands.js
|
|
32
44
|
*/
|
|
@@ -127,43 +139,111 @@ function reapPluginTree() {
|
|
|
127
139
|
reapPluginTree();
|
|
128
140
|
fs.mkdirSync(DEST_DIR, { recursive: true });
|
|
129
141
|
|
|
130
|
-
//
|
|
131
|
-
//
|
|
142
|
+
// The only namespaced subdirectory we recurse. Every other subdirectory
|
|
143
|
+
// (notably helpers/) is skipped — those hold path-included modules, not
|
|
144
|
+
// slash commands. Loop units under workflows/loops/ project into
|
|
145
|
+
// .claude/commands/loops/ so Claude Code namespaces them as /loops:<name>
|
|
146
|
+
// (Story #4289).
|
|
147
|
+
const LOOPS_NS = 'loops';
|
|
148
|
+
|
|
149
|
+
// Top-level .md files project flat. Subdirectories are skipped here and the
|
|
150
|
+
// only one re-introduced is loops/ (handled by enumerateLoopUnits below).
|
|
132
151
|
const isTopLevelWorkflow = (entry) =>
|
|
133
152
|
entry.isFile() && entry.name.endsWith('.md');
|
|
134
153
|
|
|
154
|
+
/**
|
|
155
|
+
* `README.md` (any case) under `loops/` is namespace documentation, not a
|
|
156
|
+
* loop unit — it carries no `loop:` frontmatter and must not project as a
|
|
157
|
+
* `/loops:README` command. Exclude it from the loop-unit enumeration (this
|
|
158
|
+
* mirrors `check-loop-units.js#isLoopUnitFile`, which excludes it from the
|
|
159
|
+
* lint gate).
|
|
160
|
+
*
|
|
161
|
+
* @param {import('node:fs').Dirent} entry
|
|
162
|
+
* @returns {boolean}
|
|
163
|
+
*/
|
|
164
|
+
const isLoopUnit = (entry) =>
|
|
165
|
+
isTopLevelWorkflow(entry) && entry.name.toLowerCase() !== 'readme.md';
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Enumerate the loop units under a source dir's `loops/` subdirectory.
|
|
169
|
+
* Returns entries keyed by the namespaced relative path
|
|
170
|
+
* (`loops/<name>.md`) so they never collide with a flat top-level command
|
|
171
|
+
* of the same basename and so the reap can track them distinctly. The
|
|
172
|
+
* directory's `README.md` is skipped — it is documentation, not a command.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} dir — a workflows source root (payload or local).
|
|
175
|
+
* @returns {Array<{dir: string, name: string, rel: string}>}
|
|
176
|
+
*/
|
|
177
|
+
function enumerateLoopUnits(dir) {
|
|
178
|
+
const loopsDir = path.join(dir, LOOPS_NS);
|
|
179
|
+
if (!dirExists(loopsDir)) return [];
|
|
180
|
+
return fs
|
|
181
|
+
.readdirSync(loopsDir, { withFileTypes: true })
|
|
182
|
+
.filter(isLoopUnit)
|
|
183
|
+
.map((e) => ({
|
|
184
|
+
dir,
|
|
185
|
+
name: e.name,
|
|
186
|
+
rel: `${LOOPS_NS}/${e.name}`,
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
|
|
135
190
|
// Enumerate sources: payload first, then local (if it exists). Payload wins
|
|
136
|
-
// on
|
|
191
|
+
// on relative-path collision — a consumer must not silently shadow a core
|
|
192
|
+
// command. Each entry carries its destination-relative path (`rel`): a bare
|
|
193
|
+
// basename for flat top-level commands, `loops/<name>.md` for loop units.
|
|
137
194
|
const SRC_DIRS = [PAYLOAD_SRC, LOCAL_SRC].filter(dirExists);
|
|
138
195
|
|
|
139
|
-
/** @type {Array<{dir: string, name: string}>} */
|
|
140
|
-
const entries = SRC_DIRS.flatMap((dir) =>
|
|
141
|
-
fs
|
|
196
|
+
/** @type {Array<{dir: string, name: string, rel: string}>} */
|
|
197
|
+
const entries = SRC_DIRS.flatMap((dir) => [
|
|
198
|
+
...fs
|
|
142
199
|
.readdirSync(dir, { withFileTypes: true })
|
|
143
200
|
.filter(isTopLevelWorkflow)
|
|
144
|
-
.map((e) => ({ dir, name: e.name })),
|
|
145
|
-
)
|
|
201
|
+
.map((e) => ({ dir, name: e.name, rel: e.name })),
|
|
202
|
+
...enumerateLoopUnits(dir),
|
|
203
|
+
]);
|
|
146
204
|
|
|
147
|
-
// Collision policy: payload wins, warn on a shadowed local file.
|
|
148
|
-
|
|
205
|
+
// Collision policy: payload wins, warn on a shadowed local file. Keyed by the
|
|
206
|
+
// destination-relative path so a flat `foo.md` and a `loops/foo.md` are
|
|
207
|
+
// distinct entries.
|
|
208
|
+
const byRel = new Map();
|
|
149
209
|
for (const e of entries) {
|
|
150
|
-
if (
|
|
151
|
-
Logger.warn(` shadowed ${e.
|
|
210
|
+
if (byRel.has(e.rel)) {
|
|
211
|
+
Logger.warn(` shadowed ${e.rel} (local copy ignored; payload wins)`);
|
|
152
212
|
continue;
|
|
153
213
|
}
|
|
154
|
-
|
|
214
|
+
byRel.set(e.rel, e);
|
|
155
215
|
}
|
|
156
216
|
|
|
157
217
|
// sourceSet drives the orphan-reap: any existing command not in this set is
|
|
158
|
-
// removed.
|
|
159
|
-
|
|
218
|
+
// removed. Keyed by destination-relative path so loop units are reaped from
|
|
219
|
+
// the loops/ namespace and flat commands from the root.
|
|
220
|
+
const sourceSet = new Set(byRel.keys());
|
|
160
221
|
|
|
161
|
-
|
|
222
|
+
/**
|
|
223
|
+
* List the destination-relative paths of every projected command currently
|
|
224
|
+
* on disk: flat `*.md` at the root plus `loops/*.md` in the namespace.
|
|
225
|
+
*
|
|
226
|
+
* @returns {string[]}
|
|
227
|
+
*/
|
|
228
|
+
function listExistingCommands() {
|
|
229
|
+
const flat = fs
|
|
230
|
+
.readdirSync(DEST_DIR)
|
|
231
|
+
.filter((f) => f.endsWith('.md'))
|
|
232
|
+
.map((f) => f);
|
|
233
|
+
const loopsDest = path.join(DEST_DIR, LOOPS_NS);
|
|
234
|
+
const loops = dirExists(loopsDest)
|
|
235
|
+
? fs
|
|
236
|
+
.readdirSync(loopsDest)
|
|
237
|
+
.filter((f) => f.endsWith('.md'))
|
|
238
|
+
.map((f) => `${LOOPS_NS}/${f}`)
|
|
239
|
+
: [];
|
|
240
|
+
return [...flat, ...loops];
|
|
241
|
+
}
|
|
162
242
|
|
|
163
|
-
for (const
|
|
164
|
-
if (!sourceSet.has(
|
|
165
|
-
fs.unlinkSync(path.join(DEST_DIR,
|
|
166
|
-
Logger.info(` removed ${
|
|
243
|
+
for (const rel of listExistingCommands()) {
|
|
244
|
+
if (!sourceSet.has(rel)) {
|
|
245
|
+
fs.unlinkSync(path.join(DEST_DIR, rel));
|
|
246
|
+
Logger.info(` removed ${rel} (no longer in workflows)`);
|
|
167
247
|
}
|
|
168
248
|
}
|
|
169
249
|
|
|
@@ -173,15 +253,18 @@ for (const file of existing) {
|
|
|
173
253
|
// Parallelised so the ~30-file sync doesn't serialise on per-file fs latency
|
|
174
254
|
// (noticeable on Windows where each syscall pays a larger fixed cost).
|
|
175
255
|
let synced = 0;
|
|
176
|
-
const resolvedEntries = Array.from(
|
|
256
|
+
const resolvedEntries = Array.from(byRel.values());
|
|
177
257
|
await Promise.all(
|
|
178
|
-
resolvedEntries.map(async ({ dir,
|
|
258
|
+
resolvedEntries.map(async ({ dir, rel }) => {
|
|
179
259
|
const isLocal = dir === LOCAL_SRC;
|
|
180
260
|
const header = isLocal ? LOCAL_HEADER : HEADER;
|
|
181
|
-
const content = await fs.promises.readFile(path.join(dir,
|
|
182
|
-
const dest = path.join(DEST_DIR,
|
|
261
|
+
const content = await fs.promises.readFile(path.join(dir, rel), 'utf8');
|
|
262
|
+
const dest = path.join(DEST_DIR, rel);
|
|
183
263
|
const target = applyHeader(content, header);
|
|
184
264
|
|
|
265
|
+
// Ensure the namespace subdirectory exists before writing a loop unit.
|
|
266
|
+
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
|
|
267
|
+
|
|
185
268
|
// Skip write if content is already identical (avoid noisy git diffs).
|
|
186
269
|
// Use try/catch over existsSync+readFile so we only pay one syscall.
|
|
187
270
|
try {
|
|
@@ -193,7 +276,7 @@ await Promise.all(
|
|
|
193
276
|
|
|
194
277
|
await fs.promises.writeFile(dest, target, 'utf8');
|
|
195
278
|
synced++;
|
|
196
|
-
Logger.info(` synced ${
|
|
279
|
+
Logger.info(` synced ${rel}`);
|
|
197
280
|
}),
|
|
198
281
|
);
|
|
199
282
|
|
|
@@ -8,6 +8,18 @@
|
|
|
8
8
|
* resolution, envelope assembly, and persistence flows through the unified
|
|
9
9
|
* service.
|
|
10
10
|
*
|
|
11
|
+
* Story #4293: the CLI no longer injects a bespoke maintainability scorer.
|
|
12
|
+
* It now lets `refreshBaseline` resolve the canonical default scorer
|
|
13
|
+
* (`buildDefaultMaintainabilityScorer`) the same way `update-crap-baseline.js`
|
|
14
|
+
* and `update-coverage-baseline.js` route through their canonical defaults.
|
|
15
|
+
* The previously-injected `buildMaintainabilityScorer` was a stale copy of the
|
|
16
|
+
* canonical scorer that never received the `ignoreGlobs` fix on its diff-scope
|
|
17
|
+
* branch, so an ignored-but-changed file (e.g. one matched by
|
|
18
|
+
* `config-settings-schema*.js` or a consumer's `seed.mjs`) leaked into `rows`
|
|
19
|
+
* and dragged `rollup["*"].min` below the maintainability floor. The canonical
|
|
20
|
+
* default scorer applies the ignore filter on BOTH the full-scope walk and the
|
|
21
|
+
* diff-scope branch, eliminating the divergence at the source.
|
|
22
|
+
*
|
|
11
23
|
* Surface:
|
|
12
24
|
*
|
|
13
25
|
* - `--diff-scope <ref>` (or `--diff-scope=<ref>`): explicitly scope the
|
|
@@ -18,13 +30,10 @@
|
|
|
18
30
|
* Operators wanting a full rewrite must pass `--full-scope` (added by
|
|
19
31
|
* Task #2214; see that Task's notes for the cut-over).
|
|
20
32
|
*
|
|
21
|
-
* The scoring step (escomplex / typhonjs maintainability index) is
|
|
22
|
-
* injected as a scorer function via the service's `opts.scorer` seam.
|
|
23
33
|
* Full-scope refreshes (`scope.mode === 'full'`) walk every configured
|
|
24
34
|
* target directory; diff/explicit refreshes score only the files the
|
|
25
|
-
* service hands in.
|
|
26
|
-
*
|
|
27
|
-
* story-close would have produced for the same scope.
|
|
35
|
+
* service hands in. Both paths drop `ignoreGlobs`-listed files via the
|
|
36
|
+
* canonical default scorer.
|
|
28
37
|
*/
|
|
29
38
|
|
|
30
39
|
// Fail-fast if the framework's runtime deps are not installed — must be the
|
|
@@ -33,16 +42,10 @@
|
|
|
33
42
|
import './lib/runtime-deps/ensure-installed.js';
|
|
34
43
|
import path from 'node:path';
|
|
35
44
|
import { parseDiffScopeFlag } from './lib/baselines/diff-scope-cli.js';
|
|
36
|
-
import { filterExcludedRows } from './lib/baselines/kinds/maintainability.js';
|
|
37
45
|
import { refreshBaseline } from './lib/baselines/refresh-service.js';
|
|
38
46
|
import { getBaselineEpsilon } from './lib/config/quality.js';
|
|
39
|
-
import {
|
|
40
|
-
getBaselines,
|
|
41
|
-
getQuality,
|
|
42
|
-
resolveConfig,
|
|
43
|
-
} from './lib/config-resolver.js';
|
|
47
|
+
import { getBaselines, resolveConfig } from './lib/config-resolver.js';
|
|
44
48
|
import { Logger } from './lib/Logger.js';
|
|
45
|
-
import { calculateAll, scanDirectory } from './lib/maintainability-utils.js';
|
|
46
49
|
|
|
47
50
|
/**
|
|
48
51
|
* Parse `--full-scope` (boolean opt-out flag).
|
|
@@ -54,60 +57,6 @@ function parseFullScopeFlag(argv = []) {
|
|
|
54
57
|
return argv.includes('--full-scope');
|
|
55
58
|
}
|
|
56
59
|
|
|
57
|
-
/**
|
|
58
|
-
* Build the per-kind scorer the service will invoke. The scorer receives
|
|
59
|
-
* `(files, { fullScope })`:
|
|
60
|
-
*
|
|
61
|
-
* - `fullScope === true`: ignore `files`, walk every configured target
|
|
62
|
-
* directory, score every supported source file, return rows.
|
|
63
|
-
* - `fullScope === false`: `files` is the resolved (diff or explicit)
|
|
64
|
-
* scope. Score only those that fall under a configured target
|
|
65
|
-
* directory; rows outside that set are dropped (the service / writer
|
|
66
|
-
* preserves their prior-on-disk entries verbatim).
|
|
67
|
-
*
|
|
68
|
-
* The scorer is `cwd`-aware: the service passes its `cwd` through so all
|
|
69
|
-
* path normalisation stays consistent with diff-scope derivation.
|
|
70
|
-
*/
|
|
71
|
-
function buildMaintainabilityScorer({ targetDirs, ignoreGlobs = [], logger }) {
|
|
72
|
-
return async function maintainabilityScorer(files, opts) {
|
|
73
|
-
const cwd = opts?.cwd ?? process.cwd();
|
|
74
|
-
let absPaths;
|
|
75
|
-
if (opts?.fullScope) {
|
|
76
|
-
absPaths = [];
|
|
77
|
-
for (const dir of targetDirs) {
|
|
78
|
-
const abs = path.isAbsolute(dir) ? dir : path.resolve(cwd, dir);
|
|
79
|
-
logger.info(`[Maintainability] Scanning ${dir}...`);
|
|
80
|
-
scanDirectory(abs, absPaths, { cwd, ignoreGlobs });
|
|
81
|
-
}
|
|
82
|
-
} else {
|
|
83
|
-
// Files come in as canonical POSIX repo-relative paths from the
|
|
84
|
-
// service. Resolve to absolute paths for the scorer, but only keep
|
|
85
|
-
// the ones that fall under a configured target dir — rows outside
|
|
86
|
-
// those roots are the gate's responsibility, not the baseline's.
|
|
87
|
-
const targetAbsDirs = targetDirs.map((dir) =>
|
|
88
|
-
path.isAbsolute(dir) ? dir : path.resolve(cwd, dir),
|
|
89
|
-
);
|
|
90
|
-
absPaths = [];
|
|
91
|
-
for (const rel of files ?? []) {
|
|
92
|
-
const abs = path.resolve(cwd, rel);
|
|
93
|
-
const underTarget = targetAbsDirs.some(
|
|
94
|
-
(root) => abs === root || abs.startsWith(`${root}${path.sep}`),
|
|
95
|
-
);
|
|
96
|
-
if (underTarget) absPaths.push(abs);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
logger.info(
|
|
101
|
-
`[Maintainability] Calculating scores for ${absPaths.length} files...`,
|
|
102
|
-
);
|
|
103
|
-
const scores = await calculateAll(absPaths);
|
|
104
|
-
const rows = Object.entries(scores).map(([p, mi]) => ({ path: p, mi }));
|
|
105
|
-
// Story #2467 / Task #2494: drop files the escomplex kernel can't parse
|
|
106
|
-
// so they stop landing as `mi: 0` phantom entries in the baseline.
|
|
107
|
-
return filterExcludedRows(rows);
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
|
|
111
60
|
async function main() {
|
|
112
61
|
const argv = process.argv.slice(2);
|
|
113
62
|
const diffScopeRef = parseDiffScopeFlag(argv);
|
|
@@ -120,9 +69,6 @@ async function main() {
|
|
|
120
69
|
}
|
|
121
70
|
|
|
122
71
|
const config = resolveConfig();
|
|
123
|
-
const miQuality = getQuality(config).maintainability;
|
|
124
|
-
const targetDirs = miQuality.targetDirs;
|
|
125
|
-
const ignoreGlobs = miQuality.ignoreGlobs ?? [];
|
|
126
72
|
const baselinePath = getBaselines(config).maintainability.path;
|
|
127
73
|
const absBaselinePath = path.isAbsolute(baselinePath)
|
|
128
74
|
? baselinePath
|
|
@@ -140,21 +86,18 @@ async function main() {
|
|
|
140
86
|
);
|
|
141
87
|
}
|
|
142
88
|
|
|
143
|
-
const scorer = buildMaintainabilityScorer({
|
|
144
|
-
targetDirs,
|
|
145
|
-
ignoreGlobs,
|
|
146
|
-
logger: Logger,
|
|
147
|
-
});
|
|
148
|
-
|
|
149
89
|
// Task #2214 (Epic #2173, AC-2): flag-omission now defaults to
|
|
150
90
|
// diff-scope. The pre-migration default was a full regenerate; operators
|
|
151
91
|
// wanting that behaviour must now pass `--full-scope` explicitly. This is
|
|
152
92
|
// a deliberate breaking CLI behaviour change — see docs/CHANGELOG.md.
|
|
93
|
+
//
|
|
94
|
+
// Story #4293: no `scorer` is injected — the service resolves the canonical
|
|
95
|
+
// default maintainability scorer, which applies `ignoreGlobs` on both the
|
|
96
|
+
// full-scope walk and the diff-scope branch.
|
|
153
97
|
const refreshOpts = {
|
|
154
98
|
kind: 'maintainability',
|
|
155
99
|
writePath: absBaselinePath,
|
|
156
100
|
epsilon,
|
|
157
|
-
scorer,
|
|
158
101
|
};
|
|
159
102
|
if (fullScope) {
|
|
160
103
|
refreshOpts.fullScope = true;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Loop units (`.agents/workflows/loops/`)
|
|
2
|
+
|
|
3
|
+
A **loop unit** is a markdown file that defines one unit of *recurring* work
|
|
4
|
+
with a checkable definition of done. Each file's leading YAML frontmatter
|
|
5
|
+
carries a `loop:` block — a cadence, a goal, an optional `verify` oracle, a
|
|
6
|
+
round cap, and an exhaustion policy — validated against
|
|
7
|
+
[`.agents/schemas/loop-unit.schema.json`](../../schemas/loop-unit.schema.json)
|
|
8
|
+
by `node .agents/scripts/check-loop-units.js` (wired into `npm run lint`).
|
|
9
|
+
|
|
10
|
+
This directory is the **one** namespaced exception to the flat slash-command
|
|
11
|
+
projection. Files here project to `.claude/commands/loops/<name>.md` and are
|
|
12
|
+
invoked as the namespaced `/loops:<name>` command (flat fallback
|
|
13
|
+
`/loops-<name>` on hosts that flatten subdirectory commands). Every other
|
|
14
|
+
top-level workflow projects flat as `/<name>`; `helpers/` is not projected at
|
|
15
|
+
all.
|
|
16
|
+
|
|
17
|
+
## What a loop unit is — and is not
|
|
18
|
+
|
|
19
|
+
A loop unit ships **content and contract**, not a runner. It declares:
|
|
20
|
+
|
|
21
|
+
- **the action** — what one round does;
|
|
22
|
+
- **the goal** — the standing objective each round works toward;
|
|
23
|
+
- **the `verify` oracle** — the runnable check that proves a round is complete
|
|
24
|
+
(required for `self-paced` cadence, optional for `interval` / `cron`); and
|
|
25
|
+
- **the observability / escalation contract** — the `maxRounds` backstop, the
|
|
26
|
+
`onExhaust` policy, and the explicit "stop & escalate" conditions in the body.
|
|
27
|
+
|
|
28
|
+
It does **not** ship the loop driver. **Cadence and iteration are owned by the
|
|
29
|
+
host** — Claude Code's built-in `/loop` (self-paced or interval) and
|
|
30
|
+
`/schedule` (cron). Mandrel deliberately ships **no** `/goal` or `/loop`
|
|
31
|
+
runner of its own. The full rationale, and why this division exists, is fixed
|
|
32
|
+
in the ADR:
|
|
33
|
+
|
|
34
|
+
> [`docs/decisions/loop-units-division-of-labor.md`](../../../docs/decisions/loop-units-division-of-labor.md)
|
|
35
|
+
> — *Loop units: mandrel owns content + oracle + contract; the host owns
|
|
36
|
+
> cadence + iteration; no runner shipped.*
|
|
37
|
+
|
|
38
|
+
Read that ADR before adding a runner, a scheduler, or a `/goal` command to the
|
|
39
|
+
framework — the decision to **not** build one is deliberate.
|
|
40
|
+
|
|
41
|
+
## Cadence → host mapping
|
|
42
|
+
|
|
43
|
+
| Cadence | `verify` | Driven by | Starter unit |
|
|
44
|
+
| ------------- | -------- | --------------------------------- | -------------------------------------------------------------- |
|
|
45
|
+
| `self-paced` | required | `/loop` (no interval) | [`fix-failing-tests.md`](fix-failing-tests.md) — red → green |
|
|
46
|
+
| `interval` | optional | `/loop <interval>` (e.g. `/loop 5m`) | [`watch-ci.md`](watch-ci.md) — poll a PR's checks |
|
|
47
|
+
| `cron` | optional | `/schedule` (cron-driven) | [`nightly-audit.md`](nightly-audit.md) — nightly audit sweep |
|
|
48
|
+
|
|
49
|
+
A `self-paced` unit **must** carry a `verify` oracle because nothing external
|
|
50
|
+
paces it — the oracle is the only signal that tells the host when to stop.
|
|
51
|
+
`interval` and `cron` units are paced by an external scheduler, so a
|
|
52
|
+
terminating oracle is optional; they observe, report, and yield each tick.
|
|
53
|
+
|
|
54
|
+
## Authoring a new loop unit
|
|
55
|
+
|
|
56
|
+
1. Create `.agents/workflows/loops/<name>.md` with a `loop:` frontmatter block
|
|
57
|
+
(`cadence` + `goal` required; add `verify` for `self-paced`).
|
|
58
|
+
2. Give it a `description:` so it shows up in the generated catalog
|
|
59
|
+
([`.agents/docs/workflows.md`](../../docs/workflows.md), **Loops namespace**).
|
|
60
|
+
3. Body sections: **Action** (what one round does), **Goal & done-signal** (the
|
|
61
|
+
objective and the oracle/stop check), **Stop & escalate** (when to hand back
|
|
62
|
+
rather than loop).
|
|
63
|
+
4. Run `node .agents/scripts/check-loop-units.js` (or `npm run lint`) to
|
|
64
|
+
validate the frontmatter, then `npm run sync:commands` to project it to
|
|
65
|
+
`/loops:<name>` and `npm run docs:gen` to refresh the catalog.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: >-
|
|
3
|
+
Self-paced convergence loop that drives a red test suite to green. Each round
|
|
4
|
+
reads the latest failure, applies the smallest fix, and re-runs the verify
|
|
5
|
+
oracle (`npm test`); the loop terminates when the oracle exits 0. The host
|
|
6
|
+
(`/loop`) owns iteration and pacing — mandrel supplies the action, the goal,
|
|
7
|
+
and the terminating oracle.
|
|
8
|
+
loop:
|
|
9
|
+
cadence: self-paced
|
|
10
|
+
goal: >-
|
|
11
|
+
Drive the project's test suite from red to green by fixing the root cause of
|
|
12
|
+
each failure, one round at a time, until the verify oracle passes.
|
|
13
|
+
verify: npm test
|
|
14
|
+
maxRounds: 10
|
|
15
|
+
onExhaust: hand-back
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# /loops:fix-failing-tests — drive a red suite to green
|
|
19
|
+
|
|
20
|
+
A **self-paced convergence loop**. The host (`/loop` with no interval) decides
|
|
21
|
+
when to run the next round; this unit supplies the action each round performs,
|
|
22
|
+
the standing goal, and the runnable `verify` oracle that tells the host when to
|
|
23
|
+
stop. When `npm test` exits 0, the goal is met and the loop terminates.
|
|
24
|
+
|
|
25
|
+
> **Scope.** This loop fixes the **root cause** of failing tests. It does not
|
|
26
|
+
> delete, skip, `.only`, or weaken assertions to force a green bar — that is an
|
|
27
|
+
> escalation condition, not a round (see **Stop & escalate** below).
|
|
28
|
+
|
|
29
|
+
## Action
|
|
30
|
+
|
|
31
|
+
Each round:
|
|
32
|
+
|
|
33
|
+
1. **Read the latest failure.** Run the verify oracle (`npm test`) and read the
|
|
34
|
+
first failing assertion — name, file, and the expected-vs-actual diff. Fix
|
|
35
|
+
one failure cluster per round; do not fan out across unrelated failures in a
|
|
36
|
+
single round.
|
|
37
|
+
2. **Diagnose the root cause.** Decide whether the failure is in the production
|
|
38
|
+
code under test or in the test's own setup/expectation. Prefer the
|
|
39
|
+
smallest change that makes the assertion honest — fix the code when the test
|
|
40
|
+
encodes the intended contract; fix the test only when it asserts the wrong
|
|
41
|
+
thing and you can state why in one sentence.
|
|
42
|
+
3. **Apply the smallest fix.** Make the minimal edit that addresses the
|
|
43
|
+
diagnosed cause. Avoid speculative refactors — convergence depends on each
|
|
44
|
+
round changing exactly one thing.
|
|
45
|
+
4. **Re-run the oracle.** Run `npm test` again. A reduced failure count is
|
|
46
|
+
progress; a new failure introduced by the fix means the diagnosis was wrong
|
|
47
|
+
— revert and re-diagnose rather than stacking another fix on top.
|
|
48
|
+
|
|
49
|
+
## Goal & done-signal
|
|
50
|
+
|
|
51
|
+
- **Goal:** the test suite passes — every test green, no skipped-to-hide
|
|
52
|
+
failures.
|
|
53
|
+
- **Done-signal (the oracle):** `npm test` exits 0. This is the single
|
|
54
|
+
terminating check the host `/loop` evaluates after each round. When it
|
|
55
|
+
passes, stop — the loop is complete.
|
|
56
|
+
- **Backstop:** `maxRounds: 10`. If the oracle is still red after ten rounds,
|
|
57
|
+
the `onExhaust: hand-back` policy returns control to the caller with a
|
|
58
|
+
summary rather than looping indefinitely.
|
|
59
|
+
|
|
60
|
+
## Stop & escalate
|
|
61
|
+
|
|
62
|
+
Stop the loop and hand back (do **not** keep iterating) when:
|
|
63
|
+
|
|
64
|
+
- **The same failure survives the same class of fix twice.** Per the
|
|
65
|
+
anti-thrashing protocol, a repeated fix against an unchanged failure means
|
|
66
|
+
the diagnosis is wrong — stop and report what you tried.
|
|
67
|
+
- **A fix would weaken the contract.** If the only way to make the bar green is
|
|
68
|
+
to delete a test, add `.skip` / `.only`, or relax an assertion to match buggy
|
|
69
|
+
behaviour, that is a product decision, not a loop round. Stop and surface it.
|
|
70
|
+
- **The failure is environmental, not a code defect** (missing service, absent
|
|
71
|
+
credential, a flaky timing-dependent test). The loop cannot converge on an
|
|
72
|
+
external cause — report the blocker so the operator can resolve it.
|
|
73
|
+
- **`maxRounds` is reached with the oracle still red.** Hand back a summary of
|
|
74
|
+
the remaining failures and the rounds spent.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: >-
|
|
3
|
+
Cron maintenance loop that runs a nightly audit sweep over the repository and
|
|
4
|
+
files actionable findings. Each run executes the audit workflows and routes
|
|
5
|
+
the results; the host (`/schedule` or a cron-driven `/loop`) owns the cadence.
|
|
6
|
+
verify is optional for a cron loop — the scheduler owns iteration, so this
|
|
7
|
+
unit ships the action and goal, not a terminating oracle.
|
|
8
|
+
loop:
|
|
9
|
+
cadence: cron
|
|
10
|
+
goal: >-
|
|
11
|
+
Keep the repository's standing health surfaced by running the audit sweep on
|
|
12
|
+
a nightly schedule and turning each fresh finding into an actionable, deduped
|
|
13
|
+
record so regressions are caught within a day rather than at release time.
|
|
14
|
+
maxRounds: 30
|
|
15
|
+
onExhaust: report
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# /loops:nightly-audit — scheduled maintenance audit sweep
|
|
19
|
+
|
|
20
|
+
A **cron maintenance loop**. The host (`/schedule`, or a cron-driven `/loop`)
|
|
21
|
+
owns the cadence and fires this unit once per scheduled window — typically
|
|
22
|
+
overnight. Because the scheduler owns iteration, this unit carries **no
|
|
23
|
+
`verify` oracle**: per the loop-unit schema, `verify` is required only for
|
|
24
|
+
`self-paced` cadence and optional for `interval` / `cron`. Each run is a single
|
|
25
|
+
sweep that observes, records, and yields until the next scheduled tick.
|
|
26
|
+
|
|
27
|
+
## Action
|
|
28
|
+
|
|
29
|
+
Each scheduled run:
|
|
30
|
+
|
|
31
|
+
1. **Run the audit sweep.** Execute the relevant audit workflows for the repo
|
|
32
|
+
(`/audit-security`, `/audit-clean-code`, `/audit-dependencies`,
|
|
33
|
+
`/audit-quality`, and any others the project relies on). Each audit writes a
|
|
34
|
+
structured `temp/audits/audit-*-results.md` report — that is the canonical
|
|
35
|
+
artifact this loop consumes, not free-form prose.
|
|
36
|
+
2. **Diff against the prior night.** Compare the fresh findings against the last
|
|
37
|
+
sweep's reports and against already-open Issues. A finding seen before is
|
|
38
|
+
not new signal; only genuinely fresh or regressed findings warrant a record.
|
|
39
|
+
3. **Route fresh findings.** Hand the new findings to `/audit-to-stories`, which
|
|
40
|
+
deduplicates against existing Issues by fingerprint and either chains into
|
|
41
|
+
`/plan` or opens standalone Stories. Do not open raw duplicate Issues —
|
|
42
|
+
dedup is the loop's job, not the operator's.
|
|
43
|
+
4. **Report and yield.** Emit a short digest (sweeps run, new findings, Issues
|
|
44
|
+
opened or updated) and return control to the scheduler, which sleeps until
|
|
45
|
+
the next cron window.
|
|
46
|
+
|
|
47
|
+
## Goal & done-signal
|
|
48
|
+
|
|
49
|
+
- **Goal:** the repository's health regressions are caught and turned into
|
|
50
|
+
actionable, deduplicated records within a day, without a human remembering to
|
|
51
|
+
run the audits by hand.
|
|
52
|
+
- **Done-signal:** the nightly sweep completed and every fresh finding has been
|
|
53
|
+
routed to a record (or explicitly judged a non-finding). A cron loop has no
|
|
54
|
+
self-evaluated oracle — the scheduler owns whether the loop runs again; this
|
|
55
|
+
unit simply finishes the night's sweep and yields.
|
|
56
|
+
- **Backstop:** `maxRounds: 30`. Roughly a month of nightly runs;
|
|
57
|
+
`onExhaust: report` emits a final digest and stops so a long-lived schedule
|
|
58
|
+
is renewed deliberately rather than running unbounded.
|
|
59
|
+
|
|
60
|
+
## Stop & escalate
|
|
61
|
+
|
|
62
|
+
- **An audit cannot run** (a required tool is missing, the audit harness errors,
|
|
63
|
+
the working tree is dirty in a way that invalidates the sweep). Report the
|
|
64
|
+
failure for that audit and continue with the others — do not abort the whole
|
|
65
|
+
night because one audit broke.
|
|
66
|
+
- **A finding is high-severity and time-sensitive** (an exposed secret, a
|
|
67
|
+
critical CVE reachable in production). Surface it loudly in the digest rather
|
|
68
|
+
than letting it sit as one row among many — a nightly cadence is too slow for
|
|
69
|
+
an actively-exploitable finding.
|
|
70
|
+
- **`maxRounds` is reached.** Emit a final digest (`onExhaust: report`) so the
|
|
71
|
+
operator can renew or retire the schedule deliberately.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: >-
|
|
3
|
+
Interval watch loop that polls a pull request's CI checks until they settle.
|
|
4
|
+
Each round runs `gh pr checks` and reports the delta; the host (`/loop 5m`)
|
|
5
|
+
owns the cadence and re-invokes the unit on its schedule. verify is optional
|
|
6
|
+
for an interval loop — the externally-scheduled host owns iteration, so this
|
|
7
|
+
unit ships the action and goal, not a terminating oracle.
|
|
8
|
+
loop:
|
|
9
|
+
cadence: interval
|
|
10
|
+
goal: >-
|
|
11
|
+
Keep an eye on the current pull request's required CI checks each interval,
|
|
12
|
+
surfacing the first failed or stuck check the moment it appears so a human
|
|
13
|
+
can act before the checks finish.
|
|
14
|
+
maxRounds: 60
|
|
15
|
+
onExhaust: report
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# /loops:watch-ci — poll a PR's checks until they settle
|
|
19
|
+
|
|
20
|
+
An **interval watch loop**. The host (`/loop <interval> /loops:watch-ci`, e.g.
|
|
21
|
+
`/loop 5m …`) owns the cadence and re-runs this unit on each tick. Because the
|
|
22
|
+
host schedules iteration externally, this unit carries **no `verify` oracle** —
|
|
23
|
+
per the loop-unit schema, `verify` is required only for `self-paced` cadence and
|
|
24
|
+
optional for `interval` / `cron`. The unit's job each round is to observe and
|
|
25
|
+
report, not to converge.
|
|
26
|
+
|
|
27
|
+
## Action
|
|
28
|
+
|
|
29
|
+
Each interval:
|
|
30
|
+
|
|
31
|
+
1. **Read the current check state.** Run `gh pr checks` for the PR under watch
|
|
32
|
+
(the host supplies the PR number, or it is inferred from the current
|
|
33
|
+
branch's open PR). Capture the per-check status: pending, passed, or failed.
|
|
34
|
+
2. **Compute the delta since last round.** Compare against the prior round's
|
|
35
|
+
snapshot. A check that flipped `pending → failed` is the headline; a check
|
|
36
|
+
that flipped `pending → passed` is progress.
|
|
37
|
+
3. **Surface failures immediately.** On the first failed or cancelled required
|
|
38
|
+
check, report it — name the check, link the run, and quote the first error
|
|
39
|
+
line if cheaply available — so a human can act before the rest of the matrix
|
|
40
|
+
finishes. Do not wait for the whole suite to settle to raise a red check.
|
|
41
|
+
4. **Report and yield.** Emit a one-line status summary
|
|
42
|
+
(`N passed, M pending, K failed`) and return control to the host, which
|
|
43
|
+
sleeps until the next interval.
|
|
44
|
+
|
|
45
|
+
## Goal & done-signal
|
|
46
|
+
|
|
47
|
+
- **Goal:** the operator learns about a CI failure on the watched PR as early as
|
|
48
|
+
the polling interval allows, and knows when all required checks have gone
|
|
49
|
+
green.
|
|
50
|
+
- **Done-signal:** all required checks have a terminal status (every check
|
|
51
|
+
passed, or at least one has failed). An interval loop has no self-evaluated
|
|
52
|
+
oracle — the host stops the loop when the operator cancels it, when a failure
|
|
53
|
+
is surfaced and acted on, or when `maxRounds` is reached.
|
|
54
|
+
- **Backstop:** `maxRounds: 60`. At a 5-minute interval that is ~5 hours of
|
|
55
|
+
watching; `onExhaust: report` emits a final status and stops rather than
|
|
56
|
+
polling forever on a wedged check.
|
|
57
|
+
|
|
58
|
+
## Stop & escalate
|
|
59
|
+
|
|
60
|
+
- **A required check failed.** Surface it and let the operator decide whether to
|
|
61
|
+
keep watching the remaining checks or stop. A failed required check is the
|
|
62
|
+
signal the watch existed to catch.
|
|
63
|
+
- **The PR cannot be resolved** (no open PR for the branch, `gh` not
|
|
64
|
+
authenticated, the PR was merged or closed out from under the watch). Report
|
|
65
|
+
the condition and stop — there is nothing left to watch.
|
|
66
|
+
- **`maxRounds` is reached with checks still pending.** Emit a final summary of
|
|
67
|
+
the stuck checks (`onExhaust: report`) so the operator can investigate the
|
|
68
|
+
wedged run.
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.78.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.77.0...mandrel-v1.78.0) (2026-06-24)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
* Epic [#4284](https://github.com/dsj1984/mandrel/issues/4284) ([#4297](https://github.com/dsj1984/mandrel/issues/4297)) ([0f7c543](https://github.com/dsj1984/mandrel/commit/0f7c543e1ceb1c38ac425c388e338e3bfaaec49e))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
* **baselines:** apply ignoreGlobs in the maintainability baseline CLI diff-scope branch (refs [#4293](https://github.com/dsj1984/mandrel/issues/4293)) ([#4294](https://github.com/dsj1984/mandrel/issues/4294)) ([c0c7dd5](https://github.com/dsj1984/mandrel/commit/c0c7dd5948314800e5a9d5808fb5eb0b01b09833))
|
|
16
|
+
* **close-validation:** treat biome "No files were processed" as a clean format-gate skip (refs [#4292](https://github.com/dsj1984/mandrel/issues/4292)) ([#4295](https://github.com/dsj1984/mandrel/issues/4295)) ([bf6840d](https://github.com/dsj1984/mandrel/commit/bf6840dd69abbece4eb221797e6432519b6614a0))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
* **github-provider:** unify the two divergent withTransientRetry into one canonical primitive (refs [#4298](https://github.com/dsj1984/mandrel/issues/4298)) ([#4299](https://github.com/dsj1984/mandrel/issues/4299)) ([e5668dc](https://github.com/dsj1984/mandrel/commit/e5668dcca73b37c37d38e52e1eb8d2930a0e1790))
|
|
22
|
+
|
|
5
23
|
## [1.77.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.76.0...mandrel-v1.77.0) (2026-06-24)
|
|
6
24
|
|
|
7
25
|
|
package/package.json
CHANGED