mandrel 2.47.0 → 2.49.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 (32) hide show
  1. package/.agents/agents/story-worker.md +49 -49
  2. package/.agents/docs/configuration.md +1 -0
  3. package/.agents/docs/quality-gates.md +48 -0
  4. package/.agents/scripts/lib/baselines/kernel.js +19 -0
  5. package/.agents/scripts/lib/baselines/kinds/bundle-size.js +12 -0
  6. package/.agents/scripts/lib/baselines/kinds/coverage.js +1 -0
  7. package/.agents/scripts/lib/baselines/kinds/crap.js +21 -5
  8. package/.agents/scripts/lib/baselines/kinds/duplication.js +1 -0
  9. package/.agents/scripts/lib/baselines/kinds/kind-factory.js +26 -1
  10. package/.agents/scripts/lib/baselines/kinds/lighthouse.js +1 -0
  11. package/.agents/scripts/lib/baselines/kinds/lint.js +12 -0
  12. package/.agents/scripts/lib/baselines/kinds/maintainability.js +1 -0
  13. package/.agents/scripts/lib/baselines/kinds/mutation.js +1 -0
  14. package/.agents/scripts/lib/baselines/merge-envelopes.js +272 -0
  15. package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +175 -0
  16. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +8 -2
  17. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  18. package/.agents/scripts/lib/orchestration/epic-container.js +48 -21
  19. package/.agents/scripts/lib/orchestration/epic-expansion.js +28 -6
  20. package/.agents/scripts/lib/orchestration/epic-rollup.js +66 -7
  21. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +32 -61
  22. package/.agents/scripts/lib/orchestration/single-story-close/phases/pre-gate-steps.js +171 -0
  23. package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +483 -0
  24. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
  25. package/.agents/scripts/merge-baseline.js +238 -0
  26. package/.agents/scripts/providers/github/errors.js +66 -10
  27. package/.agents/scripts/providers/github/sub-issues.js +8 -1
  28. package/.agents/workflows/helpers/deliver-digest.md +30 -26
  29. package/.agents/workflows/helpers/parallel-tooling.md +17 -0
  30. package/docs/CHANGELOG.md +25 -0
  31. package/lib/cli/registry.js +63 -0
  32. package/package.json +1 -1
@@ -28,9 +28,18 @@
28
28
  * 3. **Never throws.** Every step degrades with a reason. A stale
29
29
  * container costs tidiness; a delivery failed on a board mutation
30
30
  * costs a landed Story its terminal envelope.
31
+ * 4. **Closure requires an authoritative child list.** Invariant 3 makes
32
+ * every read degrade rather than fail, which is right for the writes
33
+ * that recompute next tick and wrong for the one that does not. When
34
+ * the native sub-issue read fails, the body checklist still answers
35
+ * "who are the children" — but no longer "are these *all* of them",
36
+ * and closing on that difference shut an Epic over 23 open children
37
+ * (Story #5210). Degraded reads keep the Status and assignee writes
38
+ * and lose only the close.
31
39
  *
32
40
  * @module lib/orchestration/epic-rollup
33
41
  * @see Story #5205
42
+ * @see Story #5210 — fail closed on a degraded child read.
34
43
  */
35
44
 
36
45
  import { Logger } from '../Logger.js';
@@ -120,6 +129,11 @@ function isClosed(issue) {
120
129
  * the list it is handed, so a silently dropped child could close a container
121
130
  * with work still open under it.
122
131
  *
132
+ * Note the scope: this validates the **readability of the ids it was given**,
133
+ * never the **completeness of the id list**. Completeness is
134
+ * `nativeReadFailed`'s job in {@link rollUpOneEpic} — checking only this one
135
+ * is what let three readable ids stand in for 58 (Story #5210).
136
+ *
123
137
  * @param {{ epicId: number, childIds: number[], provider: object }} opts
124
138
  * @returns {Promise<object[]|null>}
125
139
  */
@@ -214,10 +228,24 @@ async function applyClosure({ epicId, provider }) {
214
228
  /**
215
229
  * Roll one Epic up from the children it lists.
216
230
  *
217
- * @param {{ epic: object, childIds: number[], provider: object, columnSync: object, owner: string|null }} opts
231
+ * `nativeReadFailed` splits the writes by reversibility. Column and assignee
232
+ * are recomputed from scratch on every later tick, so applying them to a
233
+ * possibly-truncated list costs at most a stale board cell that self-corrects.
234
+ * Closure does not: it is the one write no subsequent tick undoes (invariant 2
235
+ * — a reopened child pulls Status back but MUST NOT reopen the issue), so it
236
+ * requires a child list we know to be complete.
237
+ *
238
+ * @param {{ epic: object, childIds: number[], nativeReadFailed?: boolean, provider: object, columnSync: object, owner: string|null }} opts
218
239
  * @returns {Promise<object>} Per-Epic outcome record.
219
240
  */
220
- async function rollUpOneEpic({ epic, childIds, provider, columnSync, owner }) {
241
+ async function rollUpOneEpic({
242
+ epic,
243
+ childIds,
244
+ nativeReadFailed = false,
245
+ provider,
246
+ columnSync,
247
+ owner,
248
+ }) {
221
249
  const epicId = Number(epic?.number ?? epic?.id);
222
250
  const outcome = {
223
251
  epicId,
@@ -261,6 +289,24 @@ async function rollUpOneEpic({ epic, childIds, provider, columnSync, owner }) {
261
289
 
262
290
  if (isClosed(epic)) return outcome;
263
291
 
292
+ if (nativeReadFailed) {
293
+ // Every child we could see has landed — but the authoritative read threw,
294
+ // so "every child" is exactly the claim we cannot make. `readChildren`
295
+ // above validates that the ids we were handed are *readable*; nothing
296
+ // there validates that the list is *complete*, which is how an Epic with
297
+ // 23 open children closed off the three its body happened to spell in the
298
+ // bare `- [ ] #N` form (Story #5210). Overwrites any column/owner detail
299
+ // deliberately: this is the reason the Epic is still pending.
300
+ outcome.pending = true;
301
+ outcome.detail = 'child-read-degraded';
302
+ Logger.warn(
303
+ `[epic-rollup] Epic #${epicId}: every child read looks done, but the ` +
304
+ 'native sub-issue read degraded — refusing to close on a possibly ' +
305
+ 'incomplete child list. Re-run once the API read succeeds.',
306
+ );
307
+ return outcome;
308
+ }
309
+
264
310
  const closure = await applyClosure({ epicId, provider });
265
311
  outcome.closed = closure.closed;
266
312
  outcome.pending = !closure.closed;
@@ -277,7 +323,7 @@ async function rollUpOneEpic({ epic, childIds, provider, columnSync, owner }) {
277
323
  * containers are few, and only one listing this Story is ever read further.
278
324
  *
279
325
  * @param {{ storyId: number, provider: object, skipEpicIds: Set<number> }} opts
280
- * @returns {Promise<Array<{ epic: object, childIds: number[] }>>}
326
+ * @returns {Promise<Array<{ epic: object, childIds: number[], nativeReadFailed: boolean }>>}
281
327
  */
282
328
  async function findEpicsForStory({ storyId, provider, skipEpicIds }) {
283
329
  let epics;
@@ -304,13 +350,25 @@ async function findEpicsForStory({ storyId, provider, skipEpicIds }) {
304
350
  // delivery expansion uses. Reading the body alone here is what made an
305
351
  // Epic whose children were linked in the GitHub UI expandable but
306
352
  // permanently unclosable.
307
- const childIds = await readEpicChildIdsFrom({
353
+ const { ids: childIds, nativeReadFailed } = await readEpicChildIdsFrom({
308
354
  epic,
309
355
  readNativeChildIds: nativeChildReader(provider),
310
356
  onWarn: (message) => Logger.warn(message),
311
357
  });
312
- if (!childIds.includes(storyId)) continue;
313
- matches.push({ epic, childIds });
358
+ if (!childIds.includes(storyId)) {
359
+ // A degraded read can truncate this Story out of its own container's
360
+ // child list, which drops the Epic from the run entirely rather than
361
+ // rolling it up wrongly. Non-destructive, but silent — say so, since it
362
+ // is the same root cause as the refusal in `rollUpOneEpic`.
363
+ if (nativeReadFailed) {
364
+ Logger.warn(
365
+ `[epic-rollup] Epic #${epicId}: skipped for Story #${storyId} on a ` +
366
+ 'degraded child read — the Story may in fact be linked to it.',
367
+ );
368
+ }
369
+ continue;
370
+ }
371
+ matches.push({ epic, childIds, nativeReadFailed });
314
372
  }
315
373
  return matches;
316
374
  }
@@ -374,11 +432,12 @@ export async function rollUpEpicForStory({
374
432
  owner === undefined ? resolveEpicOwner(config) : owner;
375
433
 
376
434
  const epics = [];
377
- for (const { epic, childIds } of matches) {
435
+ for (const { epic, childIds, nativeReadFailed } of matches) {
378
436
  epics.push(
379
437
  await rollUpOneEpic({
380
438
  epic,
381
439
  childIds,
440
+ nativeReadFailed,
382
441
  provider,
383
442
  columnSync: sync,
384
443
  owner: resolvedOwner,
@@ -17,12 +17,12 @@
17
17
  * `temp/standalone/stories/story-<id>/validation-evidence.json`, so a
18
18
  * second close at unchanged HEAD short-circuits the already-passed gates.
19
19
  *
20
- * Format-autofix self-heal (Story #4250). The Epic path runs
21
- * `runScopedFormatAutofix` before the check-only gates so benign JSON/YAML
22
- * drift the formatter can fix is folded into a `fix(story-close):` commit
23
- * rather than hard-failing the format gate. The standalone path now does
24
- * the same, with `baseBranch` as the diff anchor and the Story worktree as
25
- * the commit target.
20
+ * Pre-gate self-heal (Stories #4250, #5224). Two best-effort steps run on the
21
+ * Story branch before the check-only gates score it — the scoped format
22
+ * autofix and the upward maintainability write-back — so whatever they commit
23
+ * is part of what the gates see and part of the branch's own PR. Both live in
24
+ * [`pre-gate-steps.js`](pre-gate-steps.js); neither can fail a close, because
25
+ * an authoritative gate for each runs immediately below.
26
26
  *
27
27
  * Bounded gate output (Story #4736). Every gate line goes to the run's
28
28
  * `gate-log.js` sink — an artifact under the gitignored temp tree — instead
@@ -40,25 +40,24 @@
40
40
  * `*:update` + `baseline-refresh:` remedy — advisory only, so the close
41
41
  * verdict is unchanged.
42
42
  *
43
- * `runCloseValidation`, `buildDefaultGates`, and `runScopedFormatAutofix`
44
- * are accepted as injected dependencies so the parent CLI's cache-busted
45
- * bindings win in tests that mock the upstream module URLs.
43
+ * `runCloseValidation`, `buildDefaultGates` and the pre-gate steps (whole, or
44
+ * their two individual collaborators) are accepted as injected dependencies so
45
+ * the parent CLI's cache-busted bindings win in tests that mock the upstream
46
+ * module URLs.
46
47
  */
47
48
 
48
49
  import { buildDefaultGates as defaultBuildDefaultGates } from '../../../close-validation/gates.js';
49
50
  import { runCloseValidation as defaultRunCloseValidation } from '../../../close-validation/runner.js';
50
- import { Logger } from '../../../Logger.js';
51
- import { runScopedFormatAutofix as defaultRunScopedFormatAutofix } from '../../story-close/format-autofix.js';
52
51
  import { createGateLogSink as defaultCreateGateLogSink } from '../gate-log.js';
52
+ import { runPreGateSteps as defaultRunPreGateSteps } from './pre-gate-steps.js';
53
53
 
54
54
  /**
55
55
  * Run the close-validation gate chain. Throws on first gate failure.
56
56
  *
57
- * Order (Story #4250): format-autofix self-heal → close-validation gates.
58
- * The autofix step scopes the formatter to the `baseBranch...storyBranch`
59
- * diff, commits any fix on the Story branch inside the Story worktree, and
60
- * is best-effort — a missing `storyBranch` (resume/legacy callers) skips it
61
- * with a log line rather than failing.
57
+ * Order: pre-gate self-heal steps → close-validation gates. The steps scope
58
+ * to the `baseBranch...storyBranch` diff, commit inside the Story worktree,
59
+ * and are best-effort — a missing `storyBranch` (resume/legacy callers) skips
60
+ * them with a log line rather than failing.
62
61
  *
63
62
  * Gates are built from the canonical resolved config (`buildDefaultGates`
64
63
  * reads `project.commands` and `delivery.quality.gates.crap.enabled`); the
@@ -76,7 +75,9 @@ import { createGateLogSink as defaultCreateGateLogSink } from '../gate-log.js';
76
75
  * progress: (tag: string, msg: string) => void,
77
76
  * runCloseValidation?: typeof defaultRunCloseValidation,
78
77
  * buildDefaultGates?: typeof defaultBuildDefaultGates,
79
- * runScopedFormatAutofix?: typeof defaultRunScopedFormatAutofix,
78
+ * runPreGateSteps?: typeof defaultRunPreGateSteps,
79
+ * runScopedFormatAutofix?: Function,
80
+ * runBaselineUpwardWriteback?: Function,
80
81
  * createGateLogSink?: typeof defaultCreateGateLogSink,
81
82
  * }} args
82
83
  * @returns {Promise<{ gates: Record<string, 'passed'|'skipped'> }>} Per-gate
@@ -94,52 +95,22 @@ export async function runCloseValidationPhase({
94
95
  progress,
95
96
  runCloseValidation = defaultRunCloseValidation,
96
97
  buildDefaultGates = defaultBuildDefaultGates,
97
- runScopedFormatAutofix = defaultRunScopedFormatAutofix,
98
+ runPreGateSteps = defaultRunPreGateSteps,
99
+ runScopedFormatAutofix,
100
+ runBaselineUpwardWriteback,
98
101
  createGateLogSink = defaultCreateGateLogSink,
99
102
  }) {
100
- // Story #4250 — format-autofix self-heal before the check-only gates.
101
- // Mirrors the Epic path (story-close/phases/gates.js): the formatter is
102
- // scoped to the baseBranch...storyBranch diff, and any fix is committed on
103
- // the Story branch in the Story worktree. Skipped (with a log) when no
104
- // storyBranch is available so resume/legacy callers don't trip a throw.
105
- if (storyBranch) {
106
- progress(
107
- 'FORMAT',
108
- `Running scoped format-autofix on ${baseBranch}...${storyBranch}${worktreePath ? ` in ${worktreePath}` : ''}...`,
109
- );
110
- // Best-effort self-heal: a failure to even compute the diff (e.g. a
111
- // missing ref) must never abort close — the format check gate downstream
112
- // is the source of truth for "is the tree formatted". We log and proceed.
113
- try {
114
- const autofix = runScopedFormatAutofix({
115
- cwd,
116
- worktreePath,
117
- storyId,
118
- baseBranch,
119
- storyBranch,
120
- config,
121
- logger: Logger,
122
- });
123
- if (autofix?.committed) {
124
- progress(
125
- 'FORMAT',
126
- `✅ Auto-applied format fix committed as ${autofix.sha} on ${storyBranch}.`,
127
- );
128
- } else {
129
- progress(
130
- 'FORMAT',
131
- `⏭ No format-autofix commit (${autofix?.reason ?? 'clean'}).`,
132
- );
133
- }
134
- } catch (err) {
135
- progress(
136
- 'FORMAT',
137
- `⚠️ scoped format-autofix failed (close continues; format gate is authoritative): ${err?.message ?? err}`,
138
- );
139
- }
140
- } else {
141
- progress('FORMAT', '⏭ Skipped scoped format-autofix (no story branch).');
142
- }
103
+ await runPreGateSteps({
104
+ cwd,
105
+ worktreePath,
106
+ storyId,
107
+ baseBranch,
108
+ storyBranch,
109
+ config,
110
+ progress,
111
+ runScopedFormatAutofix,
112
+ runBaselineUpwardWriteback,
113
+ });
143
114
 
144
115
  progress(
145
116
  'VALIDATE',
@@ -0,0 +1,171 @@
1
+ /**
2
+ * phases/pre-gate-steps.js — the self-heal steps the standalone close runs on
3
+ * the Story branch *before* the check-only gate chain scores it.
4
+ *
5
+ * Two steps live here, and they share one contract that is the reason they
6
+ * share a module: each may author a commit on `story-<id>`, each must run
7
+ * ahead of the gates so its commit is part of what the gates score and part of
8
+ * the branch's own PR, and **neither may ever fail the close**. Downstream
9
+ * there is always an authoritative gate — `biome ci` for formatting,
10
+ * `check-baselines` for the ratchet — so a failure here is a missed
11
+ * opportunity to self-heal, never a verdict.
12
+ *
13
+ * 1. **Scoped format-autofix** (Story #4250) — run the formatter over the
14
+ * `baseBranch...storyBranch` diff and fold any rewrite into a
15
+ * `fix(story-close):` commit, so benign JSON/YAML drift that lint-staged
16
+ * does not glob never reaches the check-only format gate.
17
+ * 2. **Upward baseline write-back** (Story #5224) — persist the
18
+ * maintainability rows this branch improved on files it touched, as a
19
+ * `baseline-refresh:` commit, so the committed baseline stops falling
20
+ * behind the tree in the upward direction.
21
+ *
22
+ * Extracted from `close-validation.js` when the second step landed. The phase
23
+ * module's job is the gate chain, the evidence keyspace and the gate-log sink;
24
+ * carrying two multi-branch best-effort wrappers inline alongside that was the
25
+ * mass that made it the file it was. Both collaborators stay injectable — the
26
+ * parent CLI's cache-busted bindings must win in tests that mock the upstream
27
+ * module URLs — and are threaded through from the phase unchanged.
28
+ */
29
+
30
+ import { Logger } from '../../../Logger.js';
31
+ import { runBaselineUpwardWriteback as defaultRunBaselineUpwardWriteback } from '../../story-close/baseline-upward-writeback.js';
32
+ import { runScopedFormatAutofix as defaultRunScopedFormatAutofix } from '../../story-close/format-autofix.js';
33
+
34
+ /**
35
+ * Run the scoped formatter self-heal.
36
+ *
37
+ * @param {object} ctx the shared step context (see {@link runPreGateSteps})
38
+ * @returns {void}
39
+ */
40
+ function formatAutofixStep({
41
+ cwd,
42
+ worktreePath,
43
+ storyId,
44
+ baseBranch,
45
+ storyBranch,
46
+ config,
47
+ progress,
48
+ runScopedFormatAutofix,
49
+ }) {
50
+ progress(
51
+ 'FORMAT',
52
+ `Running scoped format-autofix on ${baseBranch}...${storyBranch}${worktreePath ? ` in ${worktreePath}` : ''}...`,
53
+ );
54
+ const autofix = runScopedFormatAutofix({
55
+ cwd,
56
+ worktreePath,
57
+ storyId,
58
+ baseBranch,
59
+ storyBranch,
60
+ config,
61
+ logger: Logger,
62
+ });
63
+ progress(
64
+ 'FORMAT',
65
+ autofix?.committed
66
+ ? `✅ Auto-applied format fix committed as ${autofix.sha} on ${storyBranch}.`
67
+ : `⏭ No format-autofix commit (${autofix?.reason ?? 'clean'}).`,
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Run the upward maintainability write-back.
73
+ *
74
+ * @param {object} ctx the shared step context (see {@link runPreGateSteps})
75
+ * @returns {Promise<void>}
76
+ */
77
+ async function baselineWritebackStep({
78
+ cwd,
79
+ worktreePath,
80
+ storyId,
81
+ baseBranch,
82
+ storyBranch,
83
+ config,
84
+ progress,
85
+ runBaselineUpwardWriteback,
86
+ }) {
87
+ const writeback = await runBaselineUpwardWriteback({
88
+ cwd,
89
+ worktreePath,
90
+ storyId,
91
+ baseBranch,
92
+ storyBranch,
93
+ config,
94
+ logger: Logger,
95
+ });
96
+ progress(
97
+ 'BASELINE',
98
+ writeback?.committed
99
+ ? `✅ Wrote back ${writeback.improvedPaths?.length ?? 0} improved maintainability row(s) as ${writeback.sha} on ${storyBranch}.`
100
+ : `⏭ No baseline write-back (${writeback?.reason ?? 'nothing to write'}).`,
101
+ );
102
+ }
103
+
104
+ /**
105
+ * Run one step, absorbing any throw into a progress line.
106
+ *
107
+ * The absorption is the point, not laziness about error handling: every step
108
+ * here is the *refresh* half of a loop whose *enforcement* half runs
109
+ * immediately afterwards. A step that could abort the close would convert a
110
+ * self-heal opportunity into an outage, and would do it on the path with the
111
+ * least operator attention.
112
+ *
113
+ * @param {{ tag: string, label: string, progress: Function, run: () => Promise<void>|void }} opts
114
+ * @returns {Promise<void>}
115
+ */
116
+ async function bestEffort({ tag, label, progress, run }) {
117
+ try {
118
+ await run();
119
+ } catch (err) {
120
+ progress(
121
+ tag,
122
+ `⚠️ ${label} failed (close continues; the gate chain is authoritative): ${err?.message ?? err}`,
123
+ );
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Run every pre-gate self-heal step for a standalone Story close.
129
+ *
130
+ * Both steps commit to `story-<id>`, so both are skipped — with a log line, not
131
+ * a throw — when the caller has no `storyBranch`. That is the resume/legacy
132
+ * path, which has no branch to commit onto and must not trip an exception for
133
+ * saying so.
134
+ *
135
+ * @param {{
136
+ * cwd: string,
137
+ * worktreePath: string|null,
138
+ * storyId: number,
139
+ * baseBranch: string,
140
+ * storyBranch?: string,
141
+ * config: object,
142
+ * progress: (tag: string, msg: string) => void,
143
+ * runScopedFormatAutofix?: typeof defaultRunScopedFormatAutofix,
144
+ * runBaselineUpwardWriteback?: typeof defaultRunBaselineUpwardWriteback,
145
+ * }} args
146
+ * @returns {Promise<void>}
147
+ */
148
+ export async function runPreGateSteps({
149
+ runScopedFormatAutofix = defaultRunScopedFormatAutofix,
150
+ runBaselineUpwardWriteback = defaultRunBaselineUpwardWriteback,
151
+ ...ctx
152
+ }) {
153
+ const { storyBranch, progress } = ctx;
154
+ if (!storyBranch) {
155
+ progress('FORMAT', '⏭ Skipped scoped format-autofix (no story branch).');
156
+ progress('BASELINE', '⏭ Skipped baseline write-back (no story branch).');
157
+ return;
158
+ }
159
+ await bestEffort({
160
+ tag: 'FORMAT',
161
+ label: 'scoped format-autofix',
162
+ progress,
163
+ run: () => formatAutofixStep({ ...ctx, runScopedFormatAutofix }),
164
+ });
165
+ await bestEffort({
166
+ tag: 'BASELINE',
167
+ label: 'baseline write-back',
168
+ progress,
169
+ run: () => baselineWritebackStep({ ...ctx, runBaselineUpwardWriteback }),
170
+ });
171
+ }