mandrel 1.57.0 → 1.59.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 (131) hide show
  1. package/.agents/README.md +89 -87
  2. package/.agents/docs/SDLC.md +11 -7
  3. package/.agents/docs/workflows.md +2 -1
  4. package/.agents/schemas/audit-rules.json +20 -0
  5. package/.agents/scripts/acceptance-eval.js +20 -3
  6. package/.agents/scripts/assert-branch.js +1 -3
  7. package/.agents/scripts/bootstrap.js +1 -1
  8. package/.agents/scripts/check-arch-cycles.js +360 -0
  9. package/.agents/scripts/coverage-capture.js +24 -3
  10. package/.agents/scripts/epic-deliver-preflight.js +5 -3
  11. package/.agents/scripts/epic-deliver-prepare.js +12 -4
  12. package/.agents/scripts/epic-execute-record-wave.js +1 -1
  13. package/.agents/scripts/evidence-gate.js +1 -1
  14. package/.agents/scripts/git-rebase-and-resolve.js +1 -1
  15. package/.agents/scripts/hierarchy-gate.js +34 -14
  16. package/.agents/scripts/lib/baselines/kinds/coverage.js +33 -149
  17. package/.agents/scripts/lib/baselines/kinds/duplication.js +27 -116
  18. package/.agents/scripts/lib/baselines/kinds/kind-factory.js +192 -0
  19. package/.agents/scripts/lib/baselines/kinds/lighthouse.js +34 -133
  20. package/.agents/scripts/lib/baselines/kinds/maintainability.js +31 -124
  21. package/.agents/scripts/lib/baselines/kinds/mutation.js +25 -111
  22. package/.agents/scripts/lib/baselines/maintainability-baseline-io.js +59 -0
  23. package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +37 -0
  24. package/.agents/scripts/lib/baselines/writer.js +1 -1
  25. package/.agents/scripts/lib/close-validation/commands.js +188 -0
  26. package/.agents/scripts/lib/close-validation/gates.js +235 -0
  27. package/.agents/scripts/lib/close-validation/process.js +101 -0
  28. package/.agents/scripts/lib/close-validation/projections/maintainability.js +1 -1
  29. package/.agents/scripts/lib/close-validation/runner.js +325 -0
  30. package/.agents/scripts/lib/close-validation/telemetry.js +70 -0
  31. package/.agents/scripts/lib/config/quality.js +6 -6
  32. package/.agents/scripts/lib/config-resolver.js +2 -5
  33. package/.agents/scripts/lib/coverage-capture.js +147 -4
  34. package/.agents/scripts/lib/cpu-pool.js +14 -0
  35. package/.agents/scripts/lib/crap-utils.js +6 -11
  36. package/.agents/scripts/lib/dynamic-workflow/documentation-report-contract.js +87 -0
  37. package/.agents/scripts/lib/git-utils.js +24 -22
  38. package/.agents/scripts/lib/maintainability-engine.js +1 -1
  39. package/.agents/scripts/lib/maintainability-utils.js +4 -187
  40. package/.agents/scripts/lib/observability/perf-report-readers.js +32 -23
  41. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +80 -6
  42. package/.agents/scripts/lib/orchestration/code-review.js +90 -77
  43. package/.agents/scripts/lib/orchestration/dispatch-pipeline.js +5 -12
  44. package/.agents/scripts/lib/orchestration/epic-deliver-lease-guard.js +14 -14
  45. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/planning-artifacts.js +2 -2
  46. package/.agents/scripts/lib/orchestration/epic-plan-lease-guard.js +184 -49
  47. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/drain.js +1 -1
  48. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/plan-epic.js +26 -2
  49. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +26 -6
  50. package/.agents/scripts/lib/orchestration/epic-runner/phases/build-wave-dag.js +7 -20
  51. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +1 -2
  52. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +0 -6
  53. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +103 -0
  54. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +22 -64
  55. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +38 -76
  56. package/.agents/scripts/lib/orchestration/epic-runner/story-run-progress-writer.js +2 -2
  57. package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +4 -16
  58. package/.agents/scripts/lib/orchestration/file-assumptions.js +4 -3
  59. package/.agents/scripts/lib/orchestration/lease-guard-shared.js +144 -0
  60. package/.agents/scripts/lib/orchestration/lifecycle/emit-story-heartbeat.js +2 -2
  61. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +7 -7
  62. package/.agents/scripts/lib/orchestration/post-merge/phases/notification.js +3 -3
  63. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +7 -7
  64. package/.agents/scripts/lib/orchestration/preflight-cache.js +35 -12
  65. package/.agents/scripts/lib/orchestration/review-providers/codex.js +5 -60
  66. package/.agents/scripts/lib/orchestration/review-providers/native.js +7 -6
  67. package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +105 -0
  68. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +7 -59
  69. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +2 -4
  70. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +241 -0
  71. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +1 -1
  72. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +16 -3
  73. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -4
  74. package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +32 -35
  75. package/.agents/scripts/lib/orchestration/skill-capsule-loader.js +1 -2
  76. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +451 -503
  77. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/pre-merge-attribution.js +8 -2
  78. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/refresh-commit.js +47 -2
  79. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +2 -2
  80. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +358 -54
  81. package/.agents/scripts/lib/orchestration/story-close/phases/close.js +1 -1
  82. package/.agents/scripts/lib/orchestration/story-close/phases/gates.js +3 -2
  83. package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +30 -3
  84. package/.agents/scripts/lib/orchestration/story-close/post-merge-close.js +5 -18
  85. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +3 -3
  86. package/.agents/scripts/lib/orchestration/story-close-recovery.js +33 -16
  87. package/.agents/scripts/lib/orchestration/story-reachability.js +47 -0
  88. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +2 -33
  89. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +42 -64
  90. package/.agents/scripts/lib/orchestration/ticketing/reads.js +9 -0
  91. package/.agents/scripts/lib/orchestration/ticketing/state.js +50 -436
  92. package/.agents/scripts/lib/orchestration/ticketing/transition.js +471 -0
  93. package/.agents/scripts/lib/orchestration/ticketing.js +0 -1
  94. package/.agents/scripts/lib/orchestration/wave-record-notifications.js +1 -1
  95. package/.agents/scripts/lib/orchestration/wave-record-projection.js +1 -7
  96. package/.agents/scripts/lib/project-root.js +17 -0
  97. package/.agents/scripts/lib/story-adjacency.js +76 -0
  98. package/.agents/scripts/lib/story-lifecycle.js +1 -1
  99. package/.agents/scripts/lib/transpile.js +93 -0
  100. package/.agents/scripts/lib/wave-runner/tick.js +4 -153
  101. package/.agents/scripts/lib/workers/crap-worker.js +1 -1
  102. package/.agents/scripts/lib/workers/maintainability-report-worker.js +1 -1
  103. package/.agents/scripts/lib/worktree/lifecycle/creation.js +20 -2
  104. package/.agents/scripts/lib/worktree/lifecycle/force-drain.js +90 -0
  105. package/.agents/scripts/lib/worktree/lifecycle/reap.js +26 -8
  106. package/.agents/scripts/lib/worktree/node-modules-strategy.js +74 -0
  107. package/.agents/scripts/providers/github/tickets.js +110 -6
  108. package/.agents/scripts/run-lint.js +9 -0
  109. package/.agents/scripts/run-tests.js +24 -4
  110. package/.agents/scripts/stories-wave-tick.js +8 -5
  111. package/.agents/scripts/story-init.js +149 -10
  112. package/.agents/scripts/sync-branch-from-base.js +1 -1
  113. package/.agents/skills/stack/qa/lighthouse-baseline/SKILL.md +1 -1
  114. package/.agents/workflows/audit-documentation.md +226 -0
  115. package/.agents/workflows/epic-deliver.md +16 -23
  116. package/.agents/workflows/epic-plan.md +1 -1
  117. package/.agents/workflows/helpers/epic-deliver-story.md +17 -28
  118. package/.agents/workflows/helpers/single-story-deliver.md +2 -1
  119. package/.agents/workflows/onboard.md +4 -3
  120. package/.agents/workflows/story-deliver.md +1 -1
  121. package/README.md +21 -8
  122. package/lib/cli/init.js +336 -0
  123. package/package.json +2 -1
  124. package/.agents/scripts/lib/auto-refresh-baselines.js +0 -308
  125. package/.agents/scripts/lib/close-validation.js +0 -897
  126. package/.agents/scripts/lib/orchestration/cascade-grouping.js +0 -275
  127. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter.js +0 -69
  128. package/.agents/scripts/lib/orchestration/story-close/format-autofix-scoped.js +0 -221
  129. package/.agents/scripts/lib/orchestration/story-close/format-autofix-shared.js +0 -123
  130. package/.agents/scripts/lib/task-utils.js +0 -26
  131. package/.agents/scripts/story-deliver-prepare.js +0 -267
@@ -55,13 +55,19 @@ export async function runPreMergeGatesWithAttribution({
55
55
  projectRegressionsFn = projectRegressionsForGate,
56
56
  logger = DefaultLogger,
57
57
  maxAttempts = 2,
58
+ cycleState: cycleStateParam = null,
58
59
  } = {}) {
59
60
  let attempt = 0;
60
61
  const gateCwd = worktreePath || cwd;
61
62
  // Story #2205: single mutable cycle state object — `refreshedKinds`
62
63
  // gates the idempotency token enforcing AC-9 (one refresh commit per
63
- // kind per close cycle).
64
- const cycleState = { refreshedKinds: new Set(), lastRefreshSha: null };
64
+ // kind per close cycle). Story #4017: the caller may thread the close
65
+ // cycle's shared object so the post-gates auto-refresh sees the kinds
66
+ // already refreshed by this retry loop and never re-scores them.
67
+ const cycleState = cycleStateParam ?? {
68
+ refreshedKinds: new Set(),
69
+ lastRefreshSha: null,
70
+ };
65
71
  while (attempt < maxAttempts) {
66
72
  attempt += 1;
67
73
  try {
@@ -261,8 +261,17 @@ export function stageAndCheckBaselineDrift({
261
261
  * Story branch — this helper does NOT re-check the branch. story-close.js
262
262
  * holds that invariant via `withEpicMergeLock`.
263
263
  *
264
+ * Story #4017 — this helper is the **single refresh→stage→commit funnel**
265
+ * for story-close. Both call paths route through it: the gate-failure
266
+ * attribution retry (`gate-failure.js`, no `capCheck`) and the post-gates
267
+ * bounded auto-refresh (`auto-refresh-runner.js`, which injects a
268
+ * `capCheck` evaluating the refreshed envelope against the configured
269
+ * delta caps). When `capCheck` returns `{ canAutoRefresh: false }`, the
270
+ * staged refresh is rolled back to HEAD and the helper returns
271
+ * `{ ok: true, refused: true, verdict }` without committing.
272
+ *
264
273
  * @returns {Promise<
265
- * | { ok: true, sha: string, skipped?: boolean, reason?: string }
274
+ * | { ok: true, sha: string, skipped?: boolean, reason?: string, refused?: boolean, verdict?: object }
266
275
  * | { ok: false, error: string }
267
276
  * >}
268
277
  */
@@ -274,9 +283,11 @@ export async function runRefreshCommit({
274
283
  storyBranch,
275
284
  config,
276
285
  cycleState = null,
286
+ capCheck = null,
277
287
  refreshBaseline = defaultRefreshBaseline,
278
288
  scorerBuilder = buildKindScorer,
279
289
  getBaselines: getBaselinesImpl = defaultGetBaselines,
290
+ getQuality: getQualityImpl = defaultGetQuality,
280
291
  fsImpl = fs,
281
292
  gitRunner = { gitSpawn: defaultGitSpawn },
282
293
  logger = DefaultLogger,
@@ -326,8 +337,9 @@ export async function runRefreshCommit({
326
337
  };
327
338
  }
328
339
 
340
+ let refreshResult;
329
341
  try {
330
- await refreshBaseline({
342
+ refreshResult = await refreshBaseline({
331
343
  kind,
332
344
  baseRef,
333
345
  headRef,
@@ -341,6 +353,7 @@ export async function runRefreshCommit({
341
353
  requiredScopeFilePredicate: buildRequiredScopeFilePredicate({
342
354
  kind,
343
355
  config,
356
+ getQuality: getQualityImpl,
344
357
  }),
345
358
  });
346
359
  } catch (err) {
@@ -350,6 +363,18 @@ export async function runRefreshCommit({
350
363
  };
351
364
  }
352
365
 
366
+ // Story #4017 — when the service reports it persisted nothing, skip the
367
+ // stage/diff round-trip entirely: there is no drift to fold in.
368
+ if (refreshResult?.wrote === false) {
369
+ if (cycleState?.refreshedKinds instanceof Set) {
370
+ cycleState.refreshedKinds.add(kind);
371
+ }
372
+ logger?.info?.(
373
+ `[baseline-attribution-wiring] refresh wrote nothing for kind=${kind} (story-${storyId}); skipping.`,
374
+ );
375
+ return { ok: true, sha: '', skipped: true, reason: 'no-baseline-drift' };
376
+ }
377
+
353
378
  const drift = stageAndCheckBaselineDrift({
354
379
  cwd,
355
380
  baselineFile: writePath,
@@ -366,6 +391,26 @@ export async function runRefreshCommit({
366
391
  return { ok: true, sha: '', skipped: true, reason: 'no-baseline-drift' };
367
392
  }
368
393
 
394
+ // Story #4017 — optional bounded-delta cap check (injected by the
395
+ // post-gates auto-refresh path). A refusal rolls the staged refresh back
396
+ // to HEAD so the merge consumes the pre-refresh baseline unchanged.
397
+ if (typeof capCheck === 'function') {
398
+ const verdict = await capCheck({ kind, writePath });
399
+ if (verdict && verdict.canAutoRefresh === false) {
400
+ const rel = path.isAbsolute(writePath)
401
+ ? path.relative(cwd, writePath)
402
+ : writePath;
403
+ const posixRel = rel.split(path.sep).join('/');
404
+ const res = gitRunner.gitSpawn(cwd, 'checkout', 'HEAD', '--', posixRel);
405
+ if (res.status !== 0) {
406
+ logger?.warn?.(
407
+ `[baseline-attribution-wiring] failed to restore ${rel} after cap refusal: ${res.stderr || res.stdout}`,
408
+ );
409
+ }
410
+ return { ok: true, sha: '', refused: true, verdict };
411
+ }
412
+ }
413
+
369
414
  const subject = `chore(baselines): refresh ${kind} for story-${storyId}`;
370
415
  const commitRes = gitRunner.gitSpawn(cwd, 'commit', '-m', subject);
371
416
  if (commitRes.status !== 0) {
@@ -10,14 +10,14 @@
10
10
  * Two projectors live here:
11
11
  *
12
12
  * - `projectMaintainabilityForGate` — reuses the MI projection from
13
- * `close-validation.js` (Story #874).
13
+ * `close-validation/projections/maintainability.js` (Story #874).
14
14
  * - `projectCrapForGate` / `projectCrapRegressions` — diff CRAP envelopes
15
15
  * at `origin/<epicBranch>` vs `storyBranch`, optionally scoped to the
16
16
  * Story's touched files (Story #1124).
17
17
  */
18
18
 
19
19
  import { readBaselineAtRef as defaultReadBaselineAtRef } from '../../../../baseline-loader.js';
20
- import { projectMaintainabilityRegressions as defaultProjectMaintainabilityRegressions } from '../../../../close-validation.js';
20
+ import { projectMaintainabilityRegressions as defaultProjectMaintainabilityRegressions } from '../../../../close-validation/projections/maintainability.js';
21
21
  import { getBaselines as defaultGetBaselines } from '../../../../config-resolver.js';
22
22
  import {
23
23
  computeStoryDiffPaths,
@@ -1,6 +1,12 @@
1
1
  /**
2
2
  * format-autofix.js — self-healing biome-format step for story-close.
3
3
  *
4
+ * Story #4017 collapsed the historical three-module split (a whole-tree
5
+ * fork, a scoped changed-file fork, and a shared plumbing module) into
6
+ * this single module. The two entry points differ only in file-scope,
7
+ * commit subject, and log level; the git/formatter plumbing is shared
8
+ * below.
9
+ *
4
10
  * Background. The pre-merge `biome format` gate is check-only — it fails
5
11
  * the close when the working tree has any format drift. In practice
6
12
  * upstream waves frequently leave drift in files that lint-staged does
@@ -9,35 +15,31 @@
9
15
  * `style:` commit before the close can resume. That manual loop is
10
16
  * trivially automatable.
11
17
  *
12
- * This module runs `npx biome format --write .` *before* the pre-merge
13
- * gate chain. If it rewrites files, we stage and commit them on the
14
- * Story branch with a `style:` subject so the merge into `epic/<id>`
15
- * carries the fix forward atomically. The subsequent `biome format`
16
- * check gate then passes deterministically.
18
+ * Entry points:
17
19
  *
18
- * The step is a no-op when:
19
- * - biome rewrites nothing (clean tree),
20
- * - the working tree is dirty for unrelated reasons (we refuse to
21
- * opportunistically commit those — operator intent is unclear), or
22
- * - `npx biome format --write` exits non-zero (we surface the error
23
- * and let the existing format gate report it with the canonical
24
- * hint).
20
+ * - {@link runFormatAutofix} whole-tree heal (`biome format --write .`)
21
+ * before the pre-merge gate chain, for resume/legacy callers that have
22
+ * no Epic→Story diff anchor. Bounded by a wall-clock timeout
23
+ * (Story #2165).
24
+ * - {@link runScopedFormatAutofix} Story #2533: scopes the formatter to
25
+ * the changed-file set between the Epic branch and the Story branch and
26
+ * folds auto-fixed paths into a dedicated `fix(story-close):` commit,
27
+ * emitting `Logger.warn` naming the files. Carries the worktree-cwd fix
28
+ * and branch assert from Story #3907.
25
29
  *
26
- * Dependencies are injected so unit tests pin behaviour without
27
- * spawning git or biome.
30
+ * Dependencies are injected so unit tests pin behaviour without spawning
31
+ * git or biome.
28
32
  */
29
33
 
30
34
  import { execFileSync } from 'node:child_process';
31
35
 
36
+ import { diffNameOnly } from '../../changed-files.js';
37
+ import { resolveFormatWriteCommand } from '../../close-validation/commands.js';
32
38
  import { getQuality } from '../../config-resolver.js';
33
39
  import { Logger as DefaultLogger } from '../../Logger.js';
34
- import {
35
- commitDirtyPaths,
36
- listDirtyPaths,
37
- resolveFormatterCmd,
38
- } from './format-autofix-shared.js';
39
40
 
40
41
  const TAG = '[format-autofix]';
42
+ const SCOPED_TAG = '[format-autofix-scoped]';
41
43
 
42
44
  /**
43
45
  * Story #2165 — exit code surfaced when the bounded `npx biome format
@@ -49,17 +51,161 @@ const TAG = '[format-autofix]';
49
51
  */
50
52
  export const FORMAT_AUTOFIX_TIMEOUT_EXIT_CODE = 124;
51
53
 
54
+ /**
55
+ * Run `git status --porcelain` and return the list of changed paths.
56
+ *
57
+ * Porcelain lines are `XY <path>` — exactly two status chars, one space,
58
+ * then the path. Leading whitespace inside the status pair is significant
59
+ * (e.g. ` M file` for unstaged-modified) so we slice a fixed 3 chars off
60
+ * the front rather than trimming.
61
+ *
62
+ * @param {string} cwd
63
+ * @param {(args: string[], opts: object) => string} git
64
+ * @returns {string[]}
65
+ */
66
+ export function listDirtyPaths(cwd, git) {
67
+ const out = git(['status', '--porcelain'], {
68
+ cwd,
69
+ encoding: 'utf8',
70
+ stdio: ['ignore', 'pipe', 'ignore'],
71
+ });
72
+ return out
73
+ .split('\n')
74
+ .filter((line) => line.length >= 4)
75
+ .map((line) => line.slice(3));
76
+ }
77
+
78
+ /**
79
+ * Resolve the formatter write command from `project.commands.formatWrite`
80
+ * (falling back to the historical `npx biome format --write .`) and split
81
+ * it into an executable + argv pair ready for `execFileSync`.
82
+ *
83
+ * The whole-tree entry point runs the command verbatim (keeping the
84
+ * trailing `.` so biome formats the entire tree). The scoped entry point
85
+ * appends an explicit changed-file set, so it passes
86
+ * `dropTrailingDot: true` to strip the `.` before its file list.
87
+ *
88
+ * @param {{
89
+ * commands?: object,
90
+ * dropTrailingDot?: boolean,
91
+ * }} [opts]
92
+ * @returns {{ writeCmdString: string, writeCmd: string, writeArgs: string[] }}
93
+ */
94
+ export function resolveFormatterCmd({
95
+ commands,
96
+ dropTrailingDot = false,
97
+ } = {}) {
98
+ // `resolveFormatWriteCommand` reads `config.project.commands`; wrap the
99
+ // caller-supplied `commands` map into that canonical shape.
100
+ const writeCmdString = resolveFormatWriteCommand({ project: { commands } });
101
+ const parts = writeCmdString.split(/\s+/).filter(Boolean);
102
+ if (dropTrailingDot && parts[parts.length - 1] === '.') parts.pop();
103
+ const [writeCmd, ...writeArgs] = parts;
104
+ return { writeCmdString, writeCmd, writeArgs };
105
+ }
106
+
107
+ /**
108
+ * Resolve the branch currently checked out at `cwd` via
109
+ * `git rev-parse --abbrev-ref HEAD`. Returns the trimmed branch name, or
110
+ * `null` when the call fails or the tree is in a detached-HEAD state
111
+ * (`HEAD`). Used as the commit-target guard before
112
+ * {@link commitDirtyPaths} writes a scoped-autofix commit, so the commit
113
+ * can never land on the wrong branch (e.g. the main checkout's `main`).
114
+ *
115
+ * @param {string} cwd
116
+ * @param {(args: string[], opts: object) => string} git
117
+ * @returns {string|null}
118
+ */
119
+ export function currentBranch(cwd, git) {
120
+ try {
121
+ const out = git(['rev-parse', '--abbrev-ref', 'HEAD'], {
122
+ cwd,
123
+ encoding: 'utf8',
124
+ stdio: ['ignore', 'pipe', 'ignore'],
125
+ });
126
+ const branch = (out ?? '').toString().trim();
127
+ if (!branch || branch === 'HEAD') return null;
128
+ return branch;
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Stage every modified path (`git add -u`), commit with the caller-supplied
136
+ * `subject`, and return the short HEAD SHA. Hooks must run; we never pass
137
+ * `--no-verify` (project policy: never skip git hooks).
138
+ *
139
+ * @param {{
140
+ * cwd: string,
141
+ * git: (args: string[], opts: object) => string,
142
+ * subject: string,
143
+ * }} opts
144
+ * @returns {string} short HEAD SHA of the new commit
145
+ */
146
+ export function commitDirtyPaths({ cwd, git, subject }) {
147
+ git(['add', '-u'], { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
148
+ git(['commit', '-m', subject], {
149
+ cwd,
150
+ stdio: ['ignore', 'pipe', 'pipe'],
151
+ });
152
+ return git(['rev-parse', '--short', 'HEAD'], {
153
+ cwd,
154
+ encoding: 'utf8',
155
+ stdio: ['ignore', 'pipe', 'ignore'],
156
+ }).trim();
157
+ }
158
+
159
+ /**
160
+ * Story #2165 — resolve the format-autofix spawn timeout. An explicit
161
+ * caller-supplied positive integer wins over both
162
+ * `delivery.quality.formatAutofix.timeoutMs` and the framework default
163
+ * (60 s). Any resolver failure surfaces as `null`; the caller treats that
164
+ * as "no timeout" and the spawn runs unbounded — same fail-open contract
165
+ * coverage-capture uses.
166
+ */
167
+ function resolveFormatTimeoutMs({ timeoutMs, config }) {
168
+ if (
169
+ typeof timeoutMs === 'number' &&
170
+ Number.isInteger(timeoutMs) &&
171
+ timeoutMs > 0
172
+ ) {
173
+ return timeoutMs;
174
+ }
175
+ try {
176
+ const resolved = getQuality(config)?.formatAutofix?.timeoutMs;
177
+ if (
178
+ typeof resolved === 'number' &&
179
+ Number.isInteger(resolved) &&
180
+ resolved > 0
181
+ ) {
182
+ return resolved;
183
+ }
184
+ } catch {
185
+ // resolver failure → fall through to "no timeout"
186
+ }
187
+ return null;
188
+ }
189
+
52
190
  /**
53
191
  * Run `npx biome format --write .` then, if anything changed, commit
54
192
  * the result on the Story branch with a `style:` subject. Returns a
55
193
  * structured envelope so callers can log a single line.
56
194
  *
57
- * Story #2165: the formatter spawn is now bounded by a wall-clock
58
- * timeout (resolved from `delivery.quality.formatAutofix.timeoutMs`,
59
- * default 60 s). A SIGKILL fired at the budget boundary is translated
60
- * to the `timedOut: true` envelope below so the close orchestrator can
61
- * flip the Story to `agent::blocked` with a friction comment naming the
62
- * spawn, mirroring the coverage-capture pattern from Story #2142.
195
+ * Story #2165: the formatter spawn is bounded by a wall-clock timeout
196
+ * (resolved from `delivery.quality.formatAutofix.timeoutMs`, default
197
+ * 60 s). A SIGKILL fired at the budget boundary is translated to the
198
+ * `timedOut: true` envelope below so the close orchestrator can flip the
199
+ * Story to `agent::blocked` with a friction comment naming the spawn,
200
+ * mirroring the coverage-capture pattern from Story #2142.
201
+ *
202
+ * The step is a no-op when:
203
+ * - biome rewrites nothing (clean tree),
204
+ * - the working tree is dirty for unrelated reasons (we refuse to
205
+ * opportunistically commit those — operator intent is unclear), or
206
+ * - `npx biome format --write` exits non-zero (we surface the error
207
+ * and let the existing format gate report it with the canonical
208
+ * hint).
63
209
  *
64
210
  * @param {{
65
211
  * cwd: string,
@@ -96,7 +242,7 @@ export function runFormatAutofix({
96
242
  // Resolve the formatter command from `project.commands.formatWrite` so
97
243
  // Prettier / dprint repos use their own formatter. Falls back to the
98
244
  // historical `npx biome format --write .` for repos that haven't opted in.
99
- // The whole-tree fork keeps the trailing `.` (formats the entire tree).
245
+ // The whole-tree entry point keeps the trailing `.` (formats the tree).
100
246
  const { writeCmdString, writeCmd, writeArgs } = resolveFormatterCmd({
101
247
  commands: config?.project?.commands,
102
248
  });
@@ -112,10 +258,7 @@ export function runFormatAutofix({
112
258
  return { ran: false, committed: false, dirtyPathsBefore: dirtyBefore };
113
259
  }
114
260
 
115
- // Story #2165 — bounded wall-clock for the formatter spawn. Resolve
116
- // through `getQuality` so consumers can tune via
117
- // `delivery.quality.formatAutofix.timeoutMs`; an explicit caller-supplied
118
- // positive integer wins over both the config and the framework default.
261
+ // Story #2165 — bounded wall-clock for the formatter spawn.
119
262
  // execFileSync's contract: on a SIGKILL trip the thrown error carries
120
263
  // `err.signal === 'SIGKILL'` and `err.status === null`, so we branch on
121
264
  // that to surface the 124 envelope below — same shape coverage-capture
@@ -185,32 +328,193 @@ export function runFormatAutofix({
185
328
  }
186
329
 
187
330
  /**
188
- * Story #2165 resolve the format-autofix spawn timeout. An explicit
189
- * caller-supplied positive integer wins over both
190
- * `delivery.quality.formatAutofix.timeoutMs` and the framework default
191
- * (60 s). Any resolver failure surfaces as `null`; the caller treats that
192
- * as "no timeout" and the spawn runs unbounded — same fail-open contract
193
- * coverage-capture uses.
331
+ * List the files changed between `epicBranch` and `storyBranch` using the
332
+ * three-dot merge-base diff. Delegates parsing to `diffNameOnly` from
333
+ * `changed-files.js` so the stdout → path-list conversion lives in one place.
334
+ *
335
+ * The `git` parameter uses the caller's local interface:
336
+ * `(args: string[], opts: object) => string`. A bridge adapter wraps it into
337
+ * the `gitSpawn(cwd, ...args)` shape that `diffNameOnly` expects.
338
+ *
339
+ * @param {{ cwd: string, epicBranch: string, storyBranch: string, git: Function }} opts
340
+ * @returns {string[]}
194
341
  */
195
- function resolveFormatTimeoutMs({ timeoutMs, config }) {
196
- if (
197
- typeof timeoutMs === 'number' &&
198
- Number.isInteger(timeoutMs) &&
199
- timeoutMs > 0
200
- ) {
201
- return timeoutMs;
342
+ function listChangedFiles({ cwd, epicBranch, storyBranch, git }) {
343
+ // Bridge the (args, opts) → string interface into gitSpawn(cwd, ...args).
344
+ const gitSpawn = (_cwd, ...args) => {
345
+ try {
346
+ const stdout = git(args, {
347
+ cwd: _cwd,
348
+ encoding: 'utf8',
349
+ stdio: ['ignore', 'pipe', 'ignore'],
350
+ });
351
+ return { status: 0, stdout: stdout ?? '', stderr: '' };
352
+ } catch (err) {
353
+ return {
354
+ status: err.status ?? 1,
355
+ stdout: err.stdout ?? '',
356
+ stderr: err.stderr ?? err.message,
357
+ };
358
+ }
359
+ };
360
+ return diffNameOnly({
361
+ range: `${epicBranch}...${storyBranch}`,
362
+ cwd,
363
+ gitSpawn,
364
+ });
365
+ }
366
+
367
+ /**
368
+ * Story #2533 — run `biome format --write <changedFiles>` on the Epic→Story
369
+ * diff. If any file is modified, stage and commit the changes on the Story
370
+ * branch with a conventional `fix(story-close):` subject and emit a
371
+ * `Logger.warn` naming the auto-fixed files. Returns a structured
372
+ * envelope so callers can log a single line.
373
+ *
374
+ * Why scoped + warn-level. The Tech Spec (Epic #2527, Story 5) calls out
375
+ * that format diffs introduced by Story commits should never surface to
376
+ * Phase 3 close-validation. The whole-tree autofix already covers that,
377
+ * but emits `info` so operators routinely miss it. This entry point emits
378
+ * `Logger.warn` naming the auto-fixed files so the signal is visible in
379
+ * the close transcript and downstream ledger.
380
+ *
381
+ * No-op envelopes:
382
+ * - `{ ran: false, reason: 'no-changed-files' }` — empty diff.
383
+ * - `{ ran: false, reason: 'dirty-tree' }` — refused to
384
+ * absorb pre-existing edits.
385
+ * - `{ ran: true, committed: false }` — formatter
386
+ * was clean.
387
+ *
388
+ * **Worktree scope (Story #3907).** All git + formatter operations run in
389
+ * `worktreePath` (the Story worktree where `story-<id>` is checked out), not
390
+ * `cwd` (the main checkout). The earlier implementation ran every step
391
+ * against `cwd`, so the `git add -u` + `git commit` could land an unreviewed
392
+ * `fix(story-close):` commit on whatever branch the main checkout happened to
393
+ * have out — including `main`. Before committing, the worktree's checked-out
394
+ * branch is asserted to equal `storyBranch`; a mismatch refuses to commit and
395
+ * returns `{ ran: true, committed: false, reason: 'wrong-branch' }` so a
396
+ * stale-state checkout can never absorb the autofix into the wrong history.
397
+ * `worktreePath` defaults to `cwd` for the resume/legacy callers that have no
398
+ * separate worktree.
399
+ *
400
+ * @param {{
401
+ * cwd: string,
402
+ * worktreePath?: string,
403
+ * storyId: number|string,
404
+ * epicBranch: string,
405
+ * storyBranch: string,
406
+ * config?: object,
407
+ * logger?: object,
408
+ * spawnSync?: typeof execFileSync,
409
+ * gitSync?: (args: string[], opts: object) => string,
410
+ * }} opts
411
+ * @returns {{
412
+ * ran: boolean,
413
+ * committed: boolean,
414
+ * sha?: string,
415
+ * modifiedPaths?: string[],
416
+ * reason?: string,
417
+ * }}
418
+ */
419
+ export function runScopedFormatAutofix({
420
+ cwd,
421
+ worktreePath,
422
+ storyId,
423
+ epicBranch,
424
+ storyBranch,
425
+ config,
426
+ logger = DefaultLogger,
427
+ spawnSync = execFileSync,
428
+ gitSync,
429
+ } = {}) {
430
+ if (!cwd) throw new Error('runScopedFormatAutofix: cwd is required');
431
+ if (!epicBranch)
432
+ throw new Error('runScopedFormatAutofix: epicBranch is required');
433
+ if (!storyBranch)
434
+ throw new Error('runScopedFormatAutofix: storyBranch is required');
435
+
436
+ // Story #3907 — the formatter writes + the commit must land in the Story
437
+ // worktree, never the main checkout. Fall back to `cwd` only for callers
438
+ // that do not run under worktree isolation.
439
+ const workTree = worktreePath || cwd;
440
+
441
+ const git = gitSync ?? ((args, opts) => spawnSync('git', args, opts));
442
+
443
+ // Resolve the formatter base command (e.g. `npx biome format --write`).
444
+ // We drop a trailing `.` so we can append the changed-file set explicitly.
445
+ const { writeCmdString, writeCmd, writeArgs } = resolveFormatterCmd({
446
+ commands: config?.project?.commands,
447
+ dropTrailingDot: true,
448
+ });
449
+
450
+ const changed = listChangedFiles({
451
+ cwd: workTree,
452
+ epicBranch,
453
+ storyBranch,
454
+ git,
455
+ });
456
+ if (changed.length === 0) {
457
+ logger.info?.(
458
+ `${SCOPED_TAG} skipped — no changed files between ${epicBranch} and ${storyBranch}.`,
459
+ );
460
+ return { ran: false, committed: false, reason: 'no-changed-files' };
202
461
  }
462
+
463
+ const dirtyBefore = listDirtyPaths(workTree, git);
464
+ if (dirtyBefore.length) {
465
+ logger.info?.(
466
+ `${SCOPED_TAG} skipped — working tree dirty before scoped autofix (${dirtyBefore.length} paths).`,
467
+ );
468
+ return { ran: false, committed: false, reason: 'dirty-tree' };
469
+ }
470
+
471
+ // Run the formatter against the changed-file set. We tolerate non-zero
472
+ // exit because the downstream check gate is the source of truth for
473
+ // "did formatting succeed".
203
474
  try {
204
- const resolved = getQuality(config)?.formatAutofix?.timeoutMs;
205
- if (
206
- typeof resolved === 'number' &&
207
- Number.isInteger(resolved) &&
208
- resolved > 0
209
- ) {
210
- return resolved;
211
- }
212
- } catch {
213
- // resolver failure → fall through to "no timeout"
475
+ spawnSync(writeCmd, [...writeArgs, ...changed], {
476
+ cwd: workTree,
477
+ stdio: ['ignore', 'pipe', 'pipe'],
478
+ encoding: 'utf8',
479
+ });
480
+ } catch (err) {
481
+ logger.warn?.(
482
+ `${SCOPED_TAG} \`${writeCmdString}\` on ${changed.length} changed file(s) exited non-zero (${err?.status ?? 'unknown'}); falling through to the format check gate.`,
483
+ );
214
484
  }
215
- return null;
485
+
486
+ const dirtyAfter = listDirtyPaths(workTree, git);
487
+ if (!dirtyAfter.length) {
488
+ logger.info?.(
489
+ `${SCOPED_TAG} no format drift on ${changed.length} changed file(s).`,
490
+ );
491
+ return { ran: true, committed: false };
492
+ }
493
+
494
+ // Story #3907 — assert the worktree is actually on `storyBranch` before we
495
+ // stage + commit. Without this guard a stale-state checkout (or a
496
+ // mis-wired `cwd`) could absorb the autofix onto the wrong branch (incl.
497
+ // `main`). A mismatch refuses to commit and leaves the format drift for the
498
+ // downstream check gate to surface.
499
+ const onBranch = currentBranch(workTree, git);
500
+ if (onBranch !== storyBranch) {
501
+ logger.warn?.(
502
+ `${SCOPED_TAG} refusing to commit — worktree ${workTree} is on "${onBranch ?? 'unknown'}", expected "${storyBranch}". ` +
503
+ `${dirtyAfter.length} format-drift path(s) left for the check gate.`,
504
+ );
505
+ return { ran: true, committed: false, reason: 'wrong-branch' };
506
+ }
507
+
508
+ // Stage every modified path and commit. Hooks must run; do not pass
509
+ // --no-verify (project policy: never skip git hooks).
510
+ const subject = `fix(story-close): auto-apply biome format in scoped lint (story #${storyId})`;
511
+ const sha = commitDirtyPaths({ cwd: workTree, git, subject });
512
+
513
+ // The warn-level emission is the Tech Spec contract — operators read
514
+ // this line in the close transcript to know auto-fix landed in the
515
+ // close commit, and downstream ledger inspectors filter on it.
516
+ logger.warn?.(
517
+ `${SCOPED_TAG} auto-applied biome format to ${dirtyAfter.length} path(s) on story #${storyId}: ${dirtyAfter.join(', ')}; committed as ${sha}.`,
518
+ );
519
+ return { ran: true, committed: true, sha, modifiedPaths: dirtyAfter };
216
520
  }
@@ -20,8 +20,8 @@
20
20
  * CC < 12 phase-file budget).
21
21
  */
22
22
 
23
- import { PROJECT_ROOT } from '../../../config-resolver.js';
24
23
  import { Logger } from '../../../Logger.js';
24
+ import { PROJECT_ROOT } from '../../../project-root.js';
25
25
  import { STATE_LABELS } from '../../ticketing.js';
26
26
  import { runFinalizeMerge, runResumeMerge } from '../merge-runner.js';
27
27
  import { runPostMergeClose } from '../post-merge-close.js';
@@ -30,8 +30,7 @@
30
30
 
31
31
  import { Logger } from '../../../Logger.js';
32
32
  import { runPreMergeGatesWithAttribution } from '../baseline-attribution-wiring.js';
33
- import { runFormatAutofix } from '../format-autofix.js';
34
- import { runScopedFormatAutofix } from '../format-autofix-scoped.js';
33
+ import { runFormatAutofix, runScopedFormatAutofix } from '../format-autofix.js';
35
34
  import { emitBlockedCloseResult } from '../merge-runner.js';
36
35
  import { emitMaintainabilityProjection } from '../pre-merge-validation.js';
37
36
 
@@ -223,6 +222,7 @@ export async function runPreMergeValidation({
223
222
  phaseTimer,
224
223
  provider,
225
224
  bus = null,
225
+ cycleState = null,
226
226
  }) {
227
227
  // Story #2533: scope-narrowed biome-format auto-apply on the Epic→Story
228
228
  // diff. The scoped step folds format drift introduced by Story commits
@@ -273,6 +273,7 @@ export async function runPreMergeValidation({
273
273
  config,
274
274
  storyId,
275
275
  epicId,
276
+ cycleState,
276
277
  useEvidence: !noEvidenceFlag,
277
278
  phaseTimer,
278
279
  provider,