mandrel 1.77.0 → 1.79.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.
Files changed (30) hide show
  1. package/.agents/docs/workflows.md +19 -0
  2. package/.agents/schemas/lifecycle/loop.tick.schema.json +20 -0
  3. package/.agents/schemas/loop-unit.schema.json +70 -0
  4. package/.agents/scripts/check-doc-links.js +24 -1
  5. package/.agents/scripts/check-loop-units.js +204 -0
  6. package/.agents/scripts/generate-workflows-doc.js +37 -4
  7. package/.agents/scripts/lib/close-validation/process.js +61 -5
  8. package/.agents/scripts/lib/close-validation/runner.js +17 -1
  9. package/.agents/scripts/lib/loop-units/validate-loop-unit.js +197 -0
  10. package/.agents/scripts/lib/mandrel-catalog.js +36 -0
  11. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/context.js +7 -2
  12. package/.agents/scripts/lib/orchestration/epic-spec-reconciler-diff.js +15 -36
  13. package/.agents/scripts/lib/orchestration/lifecycle/emit-loop-tick.js +183 -0
  14. package/.agents/scripts/lib/story-lifecycle.js +12 -4
  15. package/.agents/scripts/lib/templates/decomposer-prompts.js +14 -5
  16. package/.agents/scripts/providers/github/branch-protection.js +1 -1
  17. package/.agents/scripts/providers/github/errors.js +53 -2
  18. package/.agents/scripts/providers/github/labels.js +1 -1
  19. package/.agents/scripts/providers/github/projects-v2-graphql.js +1 -1
  20. package/.agents/scripts/run-lint.js +11 -0
  21. package/.agents/scripts/sync-claude-commands.js +112 -29
  22. package/.agents/scripts/update-maintainability-baseline.js +19 -76
  23. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +10 -7
  24. package/.agents/workflows/loops/README.md +65 -0
  25. package/.agents/workflows/loops/fix-failing-tests.md +74 -0
  26. package/.agents/workflows/loops/nightly-audit.md +71 -0
  27. package/.agents/workflows/loops/watch-ci.md +68 -0
  28. package/docs/CHANGELOG.md +26 -0
  29. package/package.json +1 -1
  30. package/.agents/scripts/providers/github/transient-retry.js +0 -62
@@ -48,6 +48,40 @@ const TRANSIENT_MESSAGES = [
48
48
 
49
49
  const PERMISSION_MESSAGES = ['unauthorized', 'forbidden', 'permission'];
50
50
 
51
+ // Network/connectivity blips that the gh-CLI path surfaces on `err.stderr`
52
+ // (Go HTTP errors, e.g. `dial tcp ...: i/o timeout`) and the direct `fetch`
53
+ // path surfaces as `TypeError: fetch failed` with the real reason on
54
+ // `err.cause` (e.g. `ETIMEDOUT`, `ENOTFOUND`). Folded in from the former
55
+ // `transient-retry.js` predicate (Story #4298) so the single canonical
56
+ // classifier retries the **union** of transient HTTP statuses/codes AND
57
+ // transient network errors. The `\b50[234]\b` alternative also catches a
58
+ // bare 502/503/504 surfaced only in an error message string (no `.status`).
59
+ const TRANSIENT_NETWORK_RE =
60
+ /i\/o timeout|dial tcp|TLS handshake timeout|connection reset|connection refused|temporary failure|could not resolve host|no such host|network is unreachable|socket hang up|fetch failed|ConnectTimeoutError|UND_ERR_CONNECT_TIMEOUT|ETIMEDOUT|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|\b50[234]\b/i;
61
+
62
+ /**
63
+ * True when an error looks like a retryable network/connectivity blip.
64
+ * Scans the union of fields both transport paths populate (`stderr`,
65
+ * `message`, `code`, and the nested `cause.message` / `cause.code` the
66
+ * `fetch` path uses). Module-private — folded in from the former
67
+ * `transient-retry.js` predicate (Story #4298) and consumed only by
68
+ * `classifyGithubError` below; its behavior is exercised through that public
69
+ * classifier rather than as a standalone export (keeps the dead-export gate
70
+ * green — nothing outside this module imports it).
71
+ */
72
+ function isTransientNetworkError(err) {
73
+ const hay = [
74
+ err?.stderr,
75
+ err?.message,
76
+ err?.code,
77
+ err?.cause?.message,
78
+ err?.cause?.code,
79
+ ]
80
+ .filter(Boolean)
81
+ .join(' ');
82
+ return TRANSIENT_NETWORK_RE.test(hay);
83
+ }
84
+
51
85
  function matchesAny(haystack, needles) {
52
86
  for (const n of needles) if (haystack.includes(n)) return true;
53
87
  return false;
@@ -97,19 +131,36 @@ export function classifyGithubError(err) {
97
131
  if (matchesAny(lower, FEATURE_DISABLED_MESSAGES)) return 'feature-disabled';
98
132
  if (isTransientStatus(status)) return 'transient';
99
133
  if (isTransientByCodeOrMessage(code, lower)) return 'transient';
134
+ // Union with the former `transient-retry.js` predicate (Story #4298):
135
+ // retry on network/connectivity blips the status/code checks above miss
136
+ // (e.g. a `dial tcp ... i/o timeout` on `err.stderr` from the gh-CLI path,
137
+ // or `ECONNREFUSED` / `ENETUNREACH`). Checked before the permission rule so
138
+ // a transient network failure never masquerades as a permanent denial.
139
+ if (isTransientNetworkError(err)) return 'transient';
100
140
  if (isPermissionSignal(status, lower)) return 'permission';
101
141
  return 'permanent';
102
142
  }
103
143
 
104
144
  // ---------------------------------------------------------------------------
105
- // Transient-retry helper (Story #2852)
145
+ // Transient-retry helper (Story #2852; unified in Story #4298)
106
146
  // ---------------------------------------------------------------------------
107
147
  //
148
+ // The single canonical `withTransientRetry` for the GitHub provider. Story
149
+ // #4298 collapsed the former two divergent same-named implementations (this
150
+ // one + the network-only one in the deleted `transient-retry.js`) into this
151
+ // one primitive. Its default classifier (`classifyGithubError`) is the
152
+ // **union** predicate — it retries on transient HTTP statuses/codes AND on
153
+ // transient network/connectivity errors — so every former consumer of either
154
+ // module keeps (or gains) its prior retry coverage with no shim.
155
+ //
108
156
  // Mirrors the addSubIssue retry contract in `sub-issues.js` so read-path
109
157
  // callers (paginateRest, getTicket, getNativeSubIssues, …) absorb the same
110
158
  // jittered exponential backoff on transient GitHub errors instead of
111
159
  // bubbling a one-shot 502/429/ECONNRESET that kills a longer pipeline
112
- // (e.g. the /deliver Phase E retro).
160
+ // (e.g. the /deliver Phase E retro). The network consumers repointed here
161
+ // (branch-protection, labels, projects-v2-graphql) call with no opts, so
162
+ // they adopt these defaults; their retry *classes* (the network blips) are
163
+ // preserved via the unified classifier above.
113
164
 
114
165
  export const TRANSIENT_RETRY_DEFAULTS = Object.freeze({
115
166
  maxAttempts: 6,
@@ -16,7 +16,7 @@
16
16
  * @see Story #2462 — Split GitHubProvider god class into seven composed gateways.
17
17
  */
18
18
 
19
- import { withTransientRetry } from './transient-retry.js';
19
+ import { withTransientRetry } from './errors.js';
20
20
 
21
21
  /**
22
22
  * Detect the "label already exists" signal across the surfaces `gh label
@@ -7,7 +7,7 @@
7
7
  * `unavailable:true` envelopes. Wave 3 deletes the old submodules.
8
8
  */
9
9
  import { execSync } from 'node:child_process';
10
- import { withTransientRetry } from './transient-retry.js';
10
+ import { withTransientRetry } from './errors.js';
11
11
 
12
12
  // Resolve an owner node id per-scope. Querying `user` and `organization`
13
13
  // together in one request makes GitHub return a NOT_FOUND error for whichever
@@ -78,6 +78,17 @@ const tasks = [
78
78
  cmd: 'node',
79
79
  args: ['.agents/scripts/check-arch-cycles.js'],
80
80
  },
81
+ {
82
+ // Loop-unit frontmatter gate (Story #4288, Epic #4284). Validates
83
+ // every `.agents/workflows/loops/*.md` loop unit against
84
+ // `.agents/schemas/loop-unit.schema.json`. An absent/empty loops
85
+ // directory is a clean pass; a malformed unit (e.g. a self-paced
86
+ // cadence missing its required `verify`) fails the lint gate with a
87
+ // message naming the offending file + field.
88
+ name: 'loop-units',
89
+ cmd: 'node',
90
+ args: ['.agents/scripts/check-loop-units.js'],
91
+ },
81
92
  ];
82
93
 
83
94
  function runTask({ name, cmd, args }) {
@@ -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;
@@ -290,13 +290,14 @@ When the Acceptance Spec contains **one or more `Disposition: new` rows**, you M
290
290
  - **goal** (in body string): contains the literal token `bdd-scaffold`.
291
291
  - **depends_on**: EMPTY (`[]`) — the scaffold runs first, in wave 0.
292
292
  - **changes** (in body string): one `{ path, assumption: "creates" }` entry per distinct `.feature` file named in a `new` row.
293
- - **acceptance** (top-level array): MUST assert (a) every new `.feature` file exists, and (b) every new scenario within them carries an `@skip` tag. Keep items observable (a command exits 0; a file exists at a path).
294
- - **verify** (top-level array): a grep/validate command (tier `validate`), NOT an e2e runnerverifying that a file exists with a tag needs no browser/playwright run.
293
+ - **acceptance** (top-level array): MUST assert (a) every new `.feature` file exists, (b) every new scenario within them carries an `@skip` tag, AND (c) every new scenario also carries its **namespaced per-Epic AC tag** `@epic-<id>-ac-N` (one tag per AC ID the scenario satisfies). Keep items observable (a command exits 0; a file exists at a path).
294
+ - **The namespaced AC tag is REQUIRED at scaffold time, not only at de-skip time.** Phase 7 finalize's `acceptance-spec-reconciler.js` matches AC IDs only against `@epic-<id>-ac-*` / `@pending` tags under `tests/features/**` — a bare `@ac-N` tag is deliberately ignored to prevent cross-Epic collision (Story #3362). A scaffolded scenario carrying `@skip` but no `@epic-<id>-ac-N` tag reads as `missing[]` at finalize and throws, aborting close, even after the implementation Story de-skips it the tag was never added in either pass. Tag each scenario with both `@skip` AND `@epic-<id>-ac-N` (substituting the Epic's real ID and the scenario's own AC number) in this SAME wave-0 commit; do not defer the AC tag to the later de-skip edit.
295
+ - **verify** (top-level array): a grep/validate command (tier `validate`), NOT an e2e runner — verifying that a file exists with the required tags needs no browser/playwright run. Include a check that each new AC ID's namespaced tag is present in the scaffolded files, alongside the `@skip` check.
295
296
  - Each implementation Story whose `verify[]` references a scaffolded `.feature` path MUST add `depends_on: ["<scaffold-slug>"]` so the scaffold lands in an earlier wave. Omitting the link trips the soft `missing-bdd-scaffold` finding in `ticket-validator-conflicts.js` (advisory, not a hard block).
296
297
 
297
298
  When the Acceptance Spec contains **zero `new`-disposition rows** (every row is `updated` or `unchanged`), do NOT emit a scaffold Story — there is nothing to create.
298
299
 
299
- **Worked example.** Acceptance Spec with two `new` rows (`AC-1` -> `tests/features/billing/invoice.feature`, `AC-2` -> `tests/features/billing/refund.feature`). The scaffold Story below uses a serialized string `body`, top-level `acceptance`/`verify` arrays, and an empty `depends_on`:
300
+ **Worked example.** Epic #42, Acceptance Spec with two `new` rows (`AC-1` -> `tests/features/billing/invoice.feature`, `AC-2` -> `tests/features/billing/refund.feature`). The scaffold Story below uses a serialized string `body`, top-level `acceptance`/`verify` arrays, an empty `depends_on`, and tags each scenario with both `@skip` and its namespaced `@epic-42-ac-N` tag:
300
301
 
301
302
  {
302
303
  "slug": "scaffold-billing-feature-files",
@@ -306,16 +307,18 @@ When the Acceptance Spec contains **zero `new`-disposition rows** (every row is
306
307
  "labels": ["type::story", "persona::qa-engineer"],
307
308
  "acceptance": [
308
309
  "tests/features/billing/invoice.feature and tests/features/billing/refund.feature both exist on the branch",
309
- "every Scenario in the two new feature files is preceded by an @skip tag (grep for un-skipped scenarios returns zero matches)"
310
+ "every Scenario in the two new feature files is preceded by an @skip tag (grep for un-skipped scenarios returns zero matches)",
311
+ "the invoice.feature scenario carries @epic-42-ac-1 and the refund.feature scenario carries @epic-42-ac-2"
310
312
  ],
311
313
  "verify": [
312
314
  "test -f tests/features/billing/invoice.feature && test -f tests/features/billing/refund.feature (validate)",
313
- "test -z \"$(grep -rL '@skip' tests/features/billing/*.feature)\" (validate)"
315
+ "test -z \"$(grep -rL '@skip' tests/features/billing/*.feature)\" (validate)",
316
+ "grep -q '@epic-42-ac-1' tests/features/billing/invoice.feature && grep -q '@epic-42-ac-2' tests/features/billing/refund.feature (validate)"
314
317
  ],
315
- "body": "## Goal\nbdd-scaffold: create the @skip-tagged feature files the billing-flows implementation Stories verify against, so wave-0 lands them before any implementation Story runs.\n\n## Changes\n- {\"path\": \"tests/features/billing/invoice.feature\", \"assumption\": \"creates\"}\n- {\"path\": \"tests/features/billing/refund.feature\", \"assumption\": \"creates\"}\n\n## Acceptance\n- [ ] tests/features/billing/invoice.feature and tests/features/billing/refund.feature both exist on the branch\n- [ ] every Scenario in the two new feature files is preceded by an @skip tag\n\n## Verify\n- test -f tests/features/billing/invoice.feature && test -f tests/features/billing/refund.feature (validate)\n- test -z \"$(grep -rL '@skip' tests/features/billing/*.feature)\" (validate)\n"
318
+ "body": "## Goal\nbdd-scaffold: create the @skip-tagged, @epic-42-ac-N-tagged feature files the billing-flows implementation Stories verify against, so wave-0 lands them before any implementation Story runs.\n\n## Changes\n- {\"path\": \"tests/features/billing/invoice.feature\", \"assumption\": \"creates\"}\n- {\"path\": \"tests/features/billing/refund.feature\", \"assumption\": \"creates\"}\n\n## Acceptance\n- [ ] tests/features/billing/invoice.feature and tests/features/billing/refund.feature both exist on the branch\n- [ ] every Scenario in the two new feature files is preceded by an @skip tag\n- [ ] the invoice.feature scenario carries @epic-42-ac-1 and the refund.feature scenario carries @epic-42-ac-2\n\n## Verify\n- test -f tests/features/billing/invoice.feature && test -f tests/features/billing/refund.feature (validate)\n- test -z \"$(grep -rL '@skip' tests/features/billing/*.feature)\" (validate)\n- grep -q '@epic-42-ac-1' tests/features/billing/invoice.feature && grep -q '@epic-42-ac-2' tests/features/billing/refund.feature (validate)\n"
316
319
  }
317
320
 
318
- The implementation Stories that later un-skip and flesh out these scenarios each carry `depends_on: ["scaffold-billing-feature-files"]`, placing them in a later wave than the scaffold.
321
+ The implementation Stories that later un-skip and flesh out these scenarios each carry `depends_on: ["scaffold-billing-feature-files"]`, placing them in a later wave than the scaffold. They MUST NOT add the `@epic-42-ac-N` tag themselves — it is already present from the scaffold pass; their job is to remove `@skip` once the scenario passes.
319
322
 
320
323
  ### SCOPE-OVERLAP FLAGGING (docs/runbook downstream of config work)
321
324
 
@@ -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.