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.
- 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/epic-plan-decompose/phases/context.js +7 -2
- package/.agents/scripts/lib/orchestration/epic-spec-reconciler-diff.js +15 -36
- package/.agents/scripts/lib/orchestration/lifecycle/emit-loop-tick.js +183 -0
- package/.agents/scripts/lib/story-lifecycle.js +12 -4
- package/.agents/scripts/lib/templates/decomposer-prompts.js +14 -5
- 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/skills/core/epic-plan-decompose-author/SKILL.md +10 -7
- 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 +26 -0
- package/package.json +1 -1
- 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 './
|
|
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 './
|
|
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
|
-
*
|
|
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;
|
|
@@ -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,
|
|
294
|
-
- **
|
|
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,
|
|
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.
|