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.
@@ -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
- * Only top-level .md files are projected. The `.agents/workflows/helpers/`
27
- * subdirectory holds path-included modules (e.g. epic-code-review, epic-retro)
28
- * that parent workflows read by relative path — they are intentionally **not**
29
- * exposed as commands, so helpers/ is skipped.
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
- // Only sync top-level .md files. Subdirectories (notably helpers/) are
131
- // ignoredthey contain path-included modules, not slash commands.
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 basename collision — a consumer must not silently shadow a core command.
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
- const byName = new Map();
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 (byName.has(e.name)) {
151
- Logger.warn(` shadowed ${e.name} (local copy ignored; payload wins)`);
210
+ if (byRel.has(e.rel)) {
211
+ Logger.warn(` shadowed ${e.rel} (local copy ignored; payload wins)`);
152
212
  continue;
153
213
  }
154
- byName.set(e.name, e);
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. Local-projected commands are included, so they survive the reap.
159
- const sourceSet = new Set(byName.keys());
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
- const existing = fs.readdirSync(DEST_DIR).filter((f) => f.endsWith('.md'));
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 file of existing) {
164
- if (!sourceSet.has(file)) {
165
- fs.unlinkSync(path.join(DEST_DIR, file));
166
- Logger.info(` removed ${file} (no longer in workflows)`);
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(byName.values());
256
+ const resolvedEntries = Array.from(byRel.values());
177
257
  await Promise.all(
178
- resolvedEntries.map(async ({ dir, name }) => {
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, name), 'utf8');
182
- const dest = path.join(DEST_DIR, name);
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 ${name}`);
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. This keeps the manual CLI byte-identical (per
26
- * AC-3 see Task #2212's byte-identity test) to whatever code path
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "1.77.0",
3
+ "version": "1.78.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, personas, skills, and SDLC workflows that govern AI coding assistants.",
5
5
  "files": [
6
6
  ".agents/",