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
@@ -0,0 +1,483 @@
1
+ /**
2
+ * baseline-upward-writeback.js — persist improved maintainability rows on the
3
+ * branch that earned them (Story #5224).
4
+ *
5
+ * The diff-scoped baseline ratchet only ever reds on a REGRESSION. A branch
6
+ * that *improves* a file it touched is therefore waved through with its
7
+ * committed row left describing the worse, older tree — and nothing on the
8
+ * per-PR path ever writes it back. The only thing that notices is the nightly
9
+ * full-scope re-score (`check-baseline-drift.js`), which had filed the same
10
+ * one-command chore seven times before this module existed.
11
+ *
12
+ * The classifier already does the hard half: `kinds/kind-factory.js#classify`
13
+ * partitions every compared row into `regressions` / `improvements` /
14
+ * `unchanged` / `additions`, and the enforcement path forwards `improvements`
15
+ * all the way to the report. Nothing persisted it. This module is that
16
+ * missing half — run from the close's `close-validation` phase, ahead of the
17
+ * gate chain, so the refreshed row lands in the branch's own PR.
18
+ *
19
+ * Shape borrowed from {@link ../story-close/format-autofix.js#runScopedFormatAutofix}:
20
+ * scope to the branch's changed-file set, fold the writes into one dedicated
21
+ * commit ahead of the gates, log the paths touched, and inject every
22
+ * git / baseline / scoring collaborator so the unit tests never spawn git
23
+ * (`.agents/rules/test-seams.md`).
24
+ *
25
+ * Four constraints bind the design, and every one of them is a "must not":
26
+ *
27
+ * 1. **Only `improvements` are written.** A regression must still fail the
28
+ * gate exactly as it does today. A write-back that could launder one
29
+ * makes the baseline actively worse than leaving it stale, so a regressed
30
+ * row is never in the written set — it is not filtered out downstream, it
31
+ * never enters.
32
+ * 2. **`maintainability` only.** CRAP's drift identity is
33
+ * `path::method@startLine`, which re-keys whenever anything above a
34
+ * method moves, so the same treatment there is churn rather than signal.
35
+ * That is exactly why the nightly watches maintainability alone.
36
+ * 3. **Changed files only — never a full-scope regeneration.** A full-scope
37
+ * write at land time would absorb unrelated drift from other branches
38
+ * into whichever PR happened to land next, and would fight the
39
+ * row-identity merge driver that exists to keep concurrent refreshes
40
+ * apart (Story #5215).
41
+ * 4. **Idempotent and silent.** No empty commit; a second close over the
42
+ * same tree finds nothing left to improve and commits nothing.
43
+ *
44
+ * The authored commit carries the `baseline-refresh:` marker
45
+ * `phases/refresh-ack.js` recognises, so the refreshed rows are vouched for
46
+ * rather than read as fresh drift, and its subject is conventional so
47
+ * commitlint accepts it.
48
+ */
49
+
50
+ import path from 'node:path';
51
+
52
+ import {
53
+ compare as compareMaintainability,
54
+ projectRow as projectMaintainabilityRow,
55
+ } from '../../baselines/kinds/maintainability.js';
56
+ import {
57
+ _internals as baselineReaderInternals,
58
+ load as loadBaselineEnvelope,
59
+ } from '../../baselines/reader.js';
60
+ import {
61
+ refreshBaseline as defaultRefreshBaseline,
62
+ resolveDefaultScorer,
63
+ } from '../../baselines/refresh-service.js';
64
+ import { getQuality } from '../../config-resolver.js';
65
+ import { gitSync as defaultGitSync } from '../../git-utils.js';
66
+ import { Logger as DefaultLogger } from '../../Logger.js';
67
+ import { currentBranch, listChangedFiles } from './format-autofix.js';
68
+
69
+ const TAG = '[baseline-writeback]';
70
+
71
+ /** The one kind this module touches. See constraint 2 in the preamble. */
72
+ const KIND = 'maintainability';
73
+
74
+ /**
75
+ * Absolute drift tolerance when the gate configures none. Mirrors
76
+ * `drift-detector.js`'s `KIND_SPECS.maintainability.defaultTolerance`, so the
77
+ * per-PR write-back and the nightly full-scope check agree on what counts as
78
+ * movement rather than float noise.
79
+ */
80
+ const DEFAULT_TOLERANCE = 0.5;
81
+
82
+ /** Files the maintainability scorer can measure at all. */
83
+ const SCORABLE = /\.(?:m?[jt]sx?)$/i;
84
+
85
+ /**
86
+ * Reasons reported before the step scored anything, so `ran: false` means
87
+ * exactly "a guard stopped this before any work happened" rather than the
88
+ * softer "nothing came of it". `no-scored-rows`, `no-improvements` and
89
+ * `unchanged` are deliberately absent: those are outcomes of a run.
90
+ */
91
+ const GUARD_REASONS = new Set([
92
+ 'gate-disabled',
93
+ 'no-changed-files',
94
+ 'wrong-branch',
95
+ 'no-baseline',
96
+ 'no-scorer',
97
+ ]);
98
+
99
+ /**
100
+ * Resolve the gate's absolute tolerance. Anything below it is float noise the
101
+ * ratchet already refuses to red on, so writing it back would be churn — and
102
+ * churn on a file every concurrent branch also touches is the one cost this
103
+ * step must not add.
104
+ *
105
+ * @param {object|undefined} gate resolved `delivery.quality.gates.maintainability`
106
+ * @returns {number}
107
+ */
108
+ function resolveTolerance(gate) {
109
+ const configured = gate?.tolerance;
110
+ if (configured?.kind === 'absolute') {
111
+ const value = Number(configured.value);
112
+ if (Number.isFinite(value)) return Math.abs(value);
113
+ }
114
+ return DEFAULT_TOLERANCE;
115
+ }
116
+
117
+ /**
118
+ * Read the maintainability gate block as DECLARED — `quality.gates[kind]`, not
119
+ * the sibling `quality[kind]` projection. The two differ in exactly the two
120
+ * fields this module reads: the projection drops `enabled` entirely and
121
+ * flattens `tolerance` to a bare number, so reading it would silently make the
122
+ * gate un-disablable and every configured tolerance unreadable. `evaluate.js`
123
+ * receives this same declared block as its `gateBlock`, which is what keeps
124
+ * the write-back's notion of "moved" identical to the gate's.
125
+ *
126
+ * Tolerates a resolver that throws (a malformed config under a tmp cwd). An
127
+ * unresolvable config reads as "framework defaults", which enable the gate —
128
+ * the same reading `projections/advisories.js#isEnabled` applies.
129
+ *
130
+ * @param {object|undefined} config
131
+ * @returns {object|undefined}
132
+ */
133
+ function resolveGate(config) {
134
+ try {
135
+ return getQuality(config)?.gates?.[KIND];
136
+ } catch {
137
+ return undefined;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Normalise scorer output into the on-disk row shape so scored rows and
143
+ * committed rows are directly comparable. `projectRow` is the same projection
144
+ * the writer applies, which is what makes the two sides comparable at all.
145
+ * A row the writer itself would refuse is dropped rather than compared.
146
+ *
147
+ * @param {Array<object>} rows
148
+ * @returns {Array<{ path: string, mi: number }>}
149
+ */
150
+ function projectRows(rows) {
151
+ const out = [];
152
+ for (const row of rows ?? []) {
153
+ try {
154
+ const projected = projectMaintainabilityRow(row);
155
+ if (Number.isFinite(projected.mi)) out.push(projected);
156
+ } catch {
157
+ // Unprojectable row → not evidence of an improvement.
158
+ }
159
+ }
160
+ return out;
161
+ }
162
+
163
+ /**
164
+ * Select the rows this branch has genuinely improved.
165
+ *
166
+ * The base side is deliberately narrowed to the rows the head side actually
167
+ * scored. `compare()` classifies a base row with no head row through the
168
+ * kind's `removedRowPolicy`, which for maintainability pushes an
169
+ * **improvement** ("the file is gone, so its debt is gone too"). That policy
170
+ * is correct for a full-scope compare and catastrophic for a scoped one: every
171
+ * untouched file in the repo would arrive here as an improvement and be
172
+ * rewritten from a score nobody computed. Narrowing the base to the scored
173
+ * keys means every comparison has both sides, so the removed-row arm cannot
174
+ * fire at all — and the `head === null` guard below makes that structural
175
+ * rather than incidental.
176
+ *
177
+ * `additions` — a scored file with no committed row — is likewise excluded:
178
+ * constraint 1 admits only what the classifier calls an improvement, and a new
179
+ * file's row is the ordinary refresh path's business, not this step's.
180
+ *
181
+ * Pure.
182
+ *
183
+ * @param {{ scoredRows: Array<object>, baselineRows: Array<object>, tolerance: number }} opts
184
+ * @returns {Array<{ path: string, mi: number }>} the head rows to persist
185
+ */
186
+ function selectImprovedRows({ scoredRows, baselineRows, tolerance }) {
187
+ const scoredPaths = new Set(scoredRows.map((row) => row.path));
188
+ const baseSubset = (baselineRows ?? []).filter((row) =>
189
+ scoredPaths.has(row?.path),
190
+ );
191
+ if (baseSubset.length === 0) return [];
192
+
193
+ const result = compareMaintainability(
194
+ { rows: scoredRows },
195
+ { rows: baseSubset },
196
+ );
197
+ const improved = [];
198
+ for (const entry of result?.improvements ?? []) {
199
+ const head = entry?.head;
200
+ const base = entry?.base;
201
+ if (!head || !base) continue;
202
+ if (head.mi - base.mi <= tolerance) continue;
203
+ improved.push(head);
204
+ }
205
+ return improved;
206
+ }
207
+
208
+ /**
209
+ * Render the commit body: one line per rewritten row, before → after. The body
210
+ * is the durable record of what the step touched — the `Logger` line scrolls
211
+ * out of a close transcript, this does not — and `check-baseline-drift.js`'s
212
+ * remedy text asks a `baseline-refresh:` commit to carry a non-empty body.
213
+ *
214
+ * @param {Array<{ path: string, mi: number }>} improved
215
+ * @param {Array<object>} baselineRows
216
+ * @returns {string}
217
+ */
218
+ function buildCommitBody(improved, baselineRows) {
219
+ const priorByPath = new Map(
220
+ (baselineRows ?? []).map((row) => [row?.path, row?.mi]),
221
+ );
222
+ const lines = [
223
+ 'Rows the branch improved on files it touched, written back so the',
224
+ 'committed baseline stops falling behind the tree in the upward',
225
+ 'direction. Scoped to the branch changed set; no regression is rewritten.',
226
+ '',
227
+ ];
228
+ for (const row of improved) {
229
+ const before = Number(priorByPath.get(row.path) ?? 0).toFixed(2);
230
+ lines.push(`- ${row.path}: ${before} -> ${row.mi.toFixed(2)}`);
231
+ }
232
+ return lines.join('\n');
233
+ }
234
+
235
+ /**
236
+ * Build the commit subject. Conventional (`chore(baselines): …`) so commitlint
237
+ * accepts it, carrying the `baseline-refresh:` marker as a plain substring so
238
+ * `refresh-ack.js#resolveRefreshTrigger` recognises it, and fixed-length in
239
+ * everything but the Story id so it cannot drift past commitlint's 100-char
240
+ * subject cap.
241
+ *
242
+ * @param {number|string} storyId
243
+ * @returns {string}
244
+ */
245
+ function buildCommitSubject(storyId) {
246
+ return `chore(baselines): baseline-refresh: improved maintainability rows (story #${storyId})`;
247
+ }
248
+
249
+ /**
250
+ * Stage the single baseline file and commit it. Hooks must run; never pass
251
+ * `--no-verify` (project policy).
252
+ *
253
+ * On a commit failure the written file is restored, because everything
254
+ * downstream — base-sync, the push, the gate chain's own reads — assumes the
255
+ * close left the worktree clean. A half-applied write-back that survives as an
256
+ * uncommitted edit would silently change what the gates score without ever
257
+ * reaching the PR.
258
+ *
259
+ * @param {{ cwd: string, git: Function, relPath: string, subject: string, body: string }} opts
260
+ * @returns {{ sha: string }}
261
+ */
262
+ function commitBaseline({ cwd, git, relPath, subject, body }) {
263
+ git(['add', '--', relPath], { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
264
+ try {
265
+ git(['commit', '-m', subject, '-m', body], {
266
+ cwd,
267
+ stdio: ['ignore', 'pipe', 'pipe'],
268
+ });
269
+ } catch (err) {
270
+ git(['checkout', '--', relPath], {
271
+ cwd,
272
+ stdio: ['ignore', 'pipe', 'pipe'],
273
+ });
274
+ throw err;
275
+ }
276
+ const sha = git(['rev-parse', '--short', 'HEAD'], {
277
+ cwd,
278
+ encoding: 'utf8',
279
+ stdio: ['ignore', 'pipe', 'ignore'],
280
+ });
281
+ return { sha: String(sha ?? '').trim() };
282
+ }
283
+
284
+ /**
285
+ * Everything that must hold before the step is allowed to score anything.
286
+ * Returns a skip reason, or `null` to proceed.
287
+ *
288
+ * The branch assertion runs here — before the write, not before the commit —
289
+ * so a mis-wired `worktreePath` can never leave a modified baseline in a tree
290
+ * whose history we then refuse to touch.
291
+ *
292
+ * @param {{ gate: object|undefined, workTree: string, storyBranch: string, git: Function, changed: string[] }} ctx
293
+ * @returns {string|null}
294
+ */
295
+ function precheck({ gate, workTree, storyBranch, git, changed }) {
296
+ if (gate?.enabled === false) return 'gate-disabled';
297
+ if (changed.length === 0) return 'no-changed-files';
298
+ const onBranch = currentBranch(workTree, git);
299
+ if (onBranch !== storyBranch) return 'wrong-branch';
300
+ return null;
301
+ }
302
+
303
+ /**
304
+ * Persist improved maintainability rows for the files this branch changed, and
305
+ * fold them into one `baseline-refresh:` commit on the Story branch.
306
+ *
307
+ * Every no-op is reported by name rather than silently: `gate-disabled`,
308
+ * `no-changed-files`, `wrong-branch`, `no-baseline`, `no-scored-rows`,
309
+ * `no-improvements`, `unchanged`. The caller logs the reason and proceeds —
310
+ * this step is never allowed to fail a close, because `check-baselines` is
311
+ * still the gate and this is only the refresh half of the loop.
312
+ *
313
+ * @param {{
314
+ * cwd: string,
315
+ * worktreePath?: string,
316
+ * storyId: number|string,
317
+ * baseBranch: string,
318
+ * storyBranch: string,
319
+ * config?: object,
320
+ * logger?: object,
321
+ * gitSync?: (cwd: string, ...args: string[]) => string,
322
+ * loadBaselineRows?: (opts: { cwd: string }) => Array<object>|null,
323
+ * scoreFiles?: (files: string[], opts: object) => Promise<Array<object>>|Array<object>,
324
+ * refreshBaseline?: typeof defaultRefreshBaseline,
325
+ * resolveWritePath?: (opts: { cwd: string }) => string,
326
+ * }} opts
327
+ * @returns {Promise<{
328
+ * ran: boolean,
329
+ * committed: boolean,
330
+ * sha?: string,
331
+ * improvedPaths?: string[],
332
+ * reason?: string,
333
+ * }>}
334
+ */
335
+ export async function runBaselineUpwardWriteback({
336
+ cwd,
337
+ worktreePath,
338
+ storyId,
339
+ baseBranch,
340
+ storyBranch,
341
+ config,
342
+ logger = DefaultLogger,
343
+ gitSync = defaultGitSync,
344
+ loadBaselineRows = defaultLoadBaselineRows,
345
+ scoreFiles,
346
+ refreshBaseline = defaultRefreshBaseline,
347
+ resolveWritePath = defaultResolveWritePath,
348
+ } = {}) {
349
+ if (!cwd) throw new Error('runBaselineUpwardWriteback: cwd is required');
350
+ if (!baseBranch)
351
+ throw new Error('runBaselineUpwardWriteback: baseBranch is required');
352
+ if (!storyBranch)
353
+ throw new Error('runBaselineUpwardWriteback: storyBranch is required');
354
+
355
+ const workTree = worktreePath || cwd;
356
+ // The two format-autofix helpers below take git as `(args, opts) => stdout`;
357
+ // `git-utils.gitSync` is `(cwd, ...args) => trimmed stdout` and throws on a
358
+ // non-zero exit. Adapt rather than reach for `node:child_process` directly —
359
+ // the shared surface owns the stdout ceiling, `shell: false` and error
360
+ // normalisation, and `tests/enforcement/child-process-imports.test.js`
361
+ // enforces that.
362
+ const git = (args, opts = {}) => gitSync(opts.cwd ?? workTree, ...args);
363
+ const gate = resolveGate(config);
364
+
365
+ const changed = listChangedFiles({
366
+ cwd: workTree,
367
+ baseBranch,
368
+ storyBranch,
369
+ git,
370
+ }).filter((file) => SCORABLE.test(file));
371
+
372
+ const blocked = precheck({ gate, workTree, storyBranch, git, changed });
373
+ if (blocked) return skip(logger, blocked);
374
+
375
+ const baselineRows = loadBaselineRows({ cwd: workTree });
376
+ if (!Array.isArray(baselineRows) || baselineRows.length === 0) {
377
+ return skip(logger, 'no-baseline');
378
+ }
379
+
380
+ const score = scoreFiles ?? resolveDefaultScorer(KIND, { cwd: workTree });
381
+ if (typeof score !== 'function') return skip(logger, 'no-scorer');
382
+ const scoredRows = projectRows(
383
+ await score(changed, { kind: KIND, fullScope: false, cwd: workTree }),
384
+ );
385
+ if (scoredRows.length === 0) return skip(logger, 'no-scored-rows');
386
+
387
+ const improved = selectImprovedRows({
388
+ scoredRows,
389
+ baselineRows,
390
+ tolerance: resolveTolerance(gate),
391
+ });
392
+ if (improved.length === 0) return skip(logger, 'no-improvements');
393
+
394
+ return await persist({
395
+ improved,
396
+ baselineRows,
397
+ workTree,
398
+ git,
399
+ storyId,
400
+ logger,
401
+ refreshBaseline,
402
+ resolveWritePath,
403
+ });
404
+ }
405
+
406
+ /**
407
+ * Write the selected rows through the one sanctioned write funnel and commit
408
+ * them. The already-computed rows are handed to `refreshBaseline` as its
409
+ * scorer so the files are scored exactly once — the service still owns path
410
+ * canonicalization, `mergeRows` (which preserves every out-of-scope row
411
+ * byte-for-byte), rollup, envelope stamping and the atomic write.
412
+ *
413
+ * @returns {Promise<{ ran: boolean, committed: boolean, sha?: string, improvedPaths?: string[], reason?: string }>}
414
+ */
415
+ async function persist({
416
+ improved,
417
+ baselineRows,
418
+ workTree,
419
+ git,
420
+ storyId,
421
+ logger,
422
+ refreshBaseline,
423
+ resolveWritePath,
424
+ }) {
425
+ const improvedPaths = improved.map((row) => row.path);
426
+ const writePath = resolveWritePath({ cwd: workTree });
427
+ const { wrote } = await refreshBaseline({
428
+ kind: KIND,
429
+ cwd: workTree,
430
+ writePath,
431
+ scopeFiles: improvedPaths,
432
+ scorer: () => improved,
433
+ });
434
+ // The writer short-circuits on structural equality, so `wrote: false` means
435
+ // the committed rows already carried these values — nothing to commit, and
436
+ // nothing worth a log line above debug.
437
+ if (!wrote) return skip(logger, 'unchanged');
438
+
439
+ const relPath = path.relative(workTree, writePath).split(path.sep).join('/');
440
+ const { sha } = commitBaseline({
441
+ cwd: workTree,
442
+ git,
443
+ relPath,
444
+ subject: buildCommitSubject(storyId),
445
+ body: buildCommitBody(improved, baselineRows),
446
+ });
447
+
448
+ logger.warn?.(
449
+ `${TAG} wrote back ${improvedPaths.length} improved ${KIND} row(s) ` +
450
+ `on story #${storyId}: ${improvedPaths.join(', ')}; committed as ${sha}.`,
451
+ );
452
+ return { ran: true, committed: true, sha, improvedPaths };
453
+ }
454
+
455
+ /**
456
+ * Report a no-op by name. Every skip is `info`-level: none of them is a
457
+ * problem, and the close transcript already carries one line per phase.
458
+ *
459
+ * @param {object} logger
460
+ * @param {string} reason
461
+ */
462
+ function skip(logger, reason) {
463
+ logger.info?.(`${TAG} no write-back (${reason}).`);
464
+ return { ran: !GUARD_REASONS.has(reason), committed: false, reason };
465
+ }
466
+
467
+ /**
468
+ * Default committed-row loader — the schema-validating reader, so a baseline
469
+ * this module would refuse to compare against is reported as `no-baseline`
470
+ * rather than half-read.
471
+ */
472
+ function defaultLoadBaselineRows({ cwd }) {
473
+ try {
474
+ return loadBaselineEnvelope(KIND, { cwd })?.rows ?? null;
475
+ } catch {
476
+ return null;
477
+ }
478
+ }
479
+
480
+ /** Default on-disk location of the maintainability baseline. */
481
+ function defaultResolveWritePath({ cwd }) {
482
+ return baselineReaderInternals.resolveBaselinePath(KIND, { cwd });
483
+ }
@@ -333,10 +333,15 @@ export function runFormatAutofix({
333
333
  * `(args: string[], opts: object) => string`. A bridge adapter wraps it into
334
334
  * the `gitSpawn(cwd, ...args)` shape that `diffNameOnly` expects.
335
335
  *
336
+ * Exported since Story #5224: the sibling `baseline-upward-writeback.js` step
337
+ * scopes to the same branch changed-file set, and a second copy of this
338
+ * `(args, opts)` → `gitSpawn` bridge is exactly the kind of near-duplicate the
339
+ * duplication gate exists to refuse.
340
+ *
336
341
  * @param {{ cwd: string, baseBranch: string, storyBranch: string, git: Function }} opts
337
342
  * @returns {string[]}
338
343
  */
339
- function listChangedFiles({ cwd, baseBranch, storyBranch, git }) {
344
+ export function listChangedFiles({ cwd, baseBranch, storyBranch, git }) {
340
345
  // Bridge the (args, opts) → string interface into gitSpawn(cwd, ...args).
341
346
  const gitSpawn = (_cwd, ...args) => {
342
347
  try {