mandrel 2.54.0 → 2.55.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 (114) hide show
  1. package/.agents/agents/story-worker.md +24 -23
  2. package/.agents/audit-checklists/accessibility.md +0 -3
  3. package/.agents/audit-checklists/mobile.md +0 -4
  4. package/.agents/docs/agentrc-reference.json +4 -2
  5. package/.agents/docs/configuration.md +2 -0
  6. package/.agents/schemas/agentrc.schema.json +15 -1
  7. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  8. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  9. package/.agents/scripts/audit-to-stories.js +158 -7
  10. package/.agents/scripts/check-audit-attribution.js +119 -62
  11. package/.agents/scripts/check-test-portability.js +512 -0
  12. package/.agents/scripts/coverage-capture.js +17 -10
  13. package/.agents/scripts/evidence-gate.js +31 -4
  14. package/.agents/scripts/generate-workflows-doc.js +65 -14
  15. package/.agents/scripts/git-cleanup.js +4 -0
  16. package/.agents/scripts/lib/ITicketingProvider.js +78 -0
  17. package/.agents/scripts/lib/audit-advisories.js +195 -0
  18. package/.agents/scripts/lib/audit-attribution.js +22 -0
  19. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +68 -5
  20. package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
  21. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +60 -114
  22. package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
  23. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
  24. package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
  25. package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
  26. package/.agents/scripts/lib/cli-args.js +26 -0
  27. package/.agents/scripts/lib/close-validation/gates.js +113 -7
  28. package/.agents/scripts/lib/close-validation/process.js +7 -3
  29. package/.agents/scripts/lib/close-validation/runner.js +62 -11
  30. package/.agents/scripts/lib/config/ci.js +28 -9
  31. package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
  32. package/.agents/scripts/lib/config-settings-schema.js +19 -1
  33. package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
  34. package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
  35. package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
  36. package/.agents/scripts/lib/coverage-capture.js +77 -3
  37. package/.agents/scripts/lib/findings/route-finding.js +4 -2
  38. package/.agents/scripts/lib/full-suite-lock.js +232 -6
  39. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  40. package/.agents/scripts/lib/git/sync-from-base.js +130 -13
  41. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  42. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
  43. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
  44. package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
  45. package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
  46. package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
  47. package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
  48. package/.agents/scripts/lib/orchestration/epic-rollup.js +233 -84
  49. package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
  50. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
  51. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
  52. package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
  53. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
  54. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
  55. package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
  56. package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
  57. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
  58. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
  59. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +24 -1
  60. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
  61. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
  62. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -0
  63. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
  64. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
  65. package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
  66. package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
  67. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
  68. package/.agents/scripts/lib/orchestration/run-epilogue.js +59 -38
  69. package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
  70. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
  71. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
  72. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
  73. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
  74. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
  75. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
  76. package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
  77. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
  78. package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
  79. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +30 -0
  80. package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
  81. package/.agents/scripts/lib/pinned-override-notes.js +41 -53
  82. package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
  83. package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
  84. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
  85. package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
  86. package/.agents/scripts/lib/test-temp.js +167 -30
  87. package/.agents/scripts/lib/validation-evidence.js +37 -0
  88. package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
  89. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  90. package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
  91. package/.agents/scripts/merge-baseline.js +175 -21
  92. package/.agents/scripts/providers/github/errors.js +22 -1
  93. package/.agents/scripts/providers/github/issues.js +106 -1
  94. package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
  95. package/.agents/scripts/providers/github.js +6 -0
  96. package/.agents/scripts/resolve-stories.js +44 -34
  97. package/.agents/scripts/single-story-close.js +5 -0
  98. package/.agents/scripts/stories-wave-tick.js +37 -13
  99. package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
  100. package/.agents/workflows/audit-accessibility.md +16 -31
  101. package/.agents/workflows/audit-mobile.md +20 -37
  102. package/.agents/workflows/git-cleanup.md +17 -3
  103. package/.agents/workflows/helpers/audit-lens-core.md +45 -0
  104. package/.agents/workflows/helpers/deliver-digest.md +7 -6
  105. package/.agents/workflows/helpers/deliver-reference.md +35 -14
  106. package/.agents/workflows/helpers/deliver-story-reference.md +7 -4
  107. package/.agents/workflows/helpers/deliver-story.md +15 -12
  108. package/.agents/workflows/helpers/plan-reference.md +7 -0
  109. package/.agents/workflows/mandrel-plan.md +4 -7
  110. package/.agents/workflows/memory-consolidate.md +14 -9
  111. package/docs/CHANGELOG.md +27 -0
  112. package/lib/cli/registry.js +64 -21
  113. package/lib/cli/sync.js +27 -2
  114. package/package.json +7 -4
@@ -12,8 +12,9 @@
12
12
  * This module closes that hole from both ends:
13
13
  *
14
14
  * - {@link runLedgerCommit} (`--auto --ledger-commit`) commits the changed
15
- * ledger onto a dated `chore/audit-ledger-<YYYY-MM-DD>` branch, pushes it,
16
- * and opens a PR against `project.baseBranch` through the `gh` wrapper.
15
+ * ledger onto a `chore/audit-ledger-<YYYY-MM-DD>-<shortsha>` branch cut
16
+ * from `origin/<base>`, pushes it, and opens a PR against
17
+ * `project.baseBranch` through the `gh` wrapper.
17
18
  * Auto-merge is never requested: a ledger PR records machine-derived state
18
19
  * a human should glance at, so landing it stays an operator decision.
19
20
  * - {@link resolveLedgerSummary} answers the question the *unflagged* sweep
@@ -30,6 +31,7 @@
30
31
  import { gh as defaultGh } from '../gh-exec.js';
31
32
  import { gitSync } from '../git-utils.js';
32
33
  import { DEFAULT_LEDGER_PATH } from './ledger.js';
34
+ import { openLedgerPullRequest, probeGit } from './ledger-pr.js';
33
35
 
34
36
  /** Fallback base branch when config carries no `project.baseBranch`. */
35
37
  const DEFAULT_BASE_BRANCH = 'main';
@@ -64,43 +66,6 @@ async function resolveBaseBranch(explicit) {
64
66
  return DEFAULT_BASE_BRANCH;
65
67
  }
66
68
 
67
- /**
68
- * Run a read-only git probe that must never throw: a checkout with no commits
69
- * (or no repository at all) is a legitimate answer of "nothing to report",
70
- * not a crash. The write path below uses {@link runStep} instead, where a
71
- * failure IS fatal.
72
- * @param {(cwd: string, ...args: string[]) => string} git
73
- * @param {string} cwd
74
- * @param {string[]} args
75
- * @returns {string} trimmed stdout, or `''` when git failed.
76
- */
77
- function probeGit(git, cwd, args) {
78
- try {
79
- const out = git(cwd, ...args);
80
- return typeof out === 'string' ? out.trim() : '';
81
- } catch (_) {
82
- return '';
83
- }
84
- }
85
-
86
- /**
87
- * Wrap one write step so a git or `gh` failure surfaces as a fatal error that
88
- * names the step that broke. Accepts sync and async steps alike.
89
- * @param {string} name
90
- * @param {() => unknown} fn
91
- * @returns {Promise<unknown>}
92
- */
93
- async function runStep(name, fn) {
94
- try {
95
- return await fn();
96
- } catch (error) {
97
- throw new Error(
98
- `--ledger-commit failed at step "${name}": ${error?.message ?? error}`,
99
- { cause: error },
100
- );
101
- }
102
- }
103
-
104
69
  /**
105
70
  * Inspect whether the ledger changed and whether this checkout could persist
106
71
  * it at all. Module-local: the two exported entry points below are the whole
@@ -127,14 +92,10 @@ async function assessLedgerPersistence({
127
92
  git = gitSync,
128
93
  } = {}) {
129
94
  const base = await resolveBaseBranch(baseBranch);
130
- const changed =
131
- probeGit(git, cwd, ['status', '--porcelain', '--', ledgerPath]).length > 0;
132
- const hasOrigin = probeGit(git, cwd, ['remote'])
133
- .split('\n')
134
- .map((line) => line.trim())
135
- .includes('origin');
136
- const headBranch = probeGit(git, cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
137
- const onBaseBranch = headBranch === base;
95
+ const probe = (args) => probeGit(git, cwd, args);
96
+ const changed = ledgerIsDirty(probe, ledgerPath);
97
+ const hasOrigin = hasOriginRemote(probe);
98
+ const headBranch = headBranchOf(probe);
138
99
 
139
100
  return {
140
101
  ledgerPath,
@@ -142,11 +103,46 @@ async function assessLedgerPersistence({
142
103
  changed,
143
104
  hasOrigin,
144
105
  headBranch,
145
- onBaseBranch,
146
- unpersisted: changed && (!hasOrigin || !onBaseBranch),
106
+ onBaseBranch: headBranch === base,
107
+ unpersisted: changed && (!hasOrigin || headBranch !== base),
147
108
  };
148
109
  }
149
110
 
111
+ /**
112
+ * Has the sweep actually written new memory? Scoped to the ledger pathspec, so
113
+ * unrelated dirt in the checkout is never mistaken for it.
114
+ * @param {(args: string[]) => string} probe
115
+ * @param {string} ledgerPath
116
+ * @returns {boolean}
117
+ */
118
+ function ledgerIsDirty(probe, ledgerPath) {
119
+ return probe(['status', '--porcelain', '--', ledgerPath]).length > 0;
120
+ }
121
+
122
+ /**
123
+ * Is there an `origin` to push to at all? The ephemeral-clone shape that makes
124
+ * a sweep amnesiac usually has none.
125
+ * @param {(args: string[]) => string} probe
126
+ * @returns {boolean}
127
+ */
128
+ function hasOriginRemote(probe) {
129
+ return probe(['remote'])
130
+ .split('\n')
131
+ .map((line) => line.trim())
132
+ .includes('origin');
133
+ }
134
+
135
+ /**
136
+ * The branch HEAD is on, or `''` when the checkout is detached or has no
137
+ * commits — both of which read as "not the base branch", which is the answer
138
+ * the callers need.
139
+ * @param {(args: string[]) => string} probe
140
+ * @returns {string}
141
+ */
142
+ function headBranchOf(probe) {
143
+ return probe(['rev-parse', '--abbrev-ref', 'HEAD']);
144
+ }
145
+
150
146
  /**
151
147
  * Warn that the reconciled ledger has nowhere to go. Names the file, because
152
148
  * "state will be lost" is unactionable without knowing which state.
@@ -196,34 +192,13 @@ export async function resolveLedgerSummary({
196
192
  }
197
193
 
198
194
  /**
199
- * Compose the ledger PR body. Kept separate so the step sequence below reads
200
- * as a sequence and not as a string-building exercise.
201
- * @param {string} ledgerPath
202
- * @param {string} date
203
- * @returns {string}
204
- */
205
- function pullRequestBody(ledgerPath, date) {
206
- return [
207
- `Reconciles the cross-run audit ledger (\`${ledgerPath}\`) written by the`,
208
- `unattended \`audit-to-stories --auto\` sweep on ${date}.`,
209
- '',
210
- 'Ledger-only change — no source, workflow or documentation file is touched.',
211
- 'Merging it is what gives the next sweep a memory: without it the ledger',
212
- 'dies with the checkout and every later run re-proposes findings this one',
213
- 'already filed, and re-surfaces findings a human already rejected.',
214
- '',
215
- 'Auto-merge is deliberately not requested: the ledger records machine-derived',
216
- 'lifecycle state, and a human glance before it lands is the point.',
217
- ].join('\n');
218
- }
219
-
220
- /**
221
- * Commit the changed ledger onto a dated branch and open a PR for it.
195
+ * Commit the changed ledger onto a unique branch cut from the remote base and
196
+ * open a PR for it.
222
197
  *
223
- * Skipped — returning `{ committed: false }` with a `reason` — when the ledger
224
- * did not change. Every git/`gh` failure is fatal and names its step; the
225
- * caller runs this *after* printing the run summary, so a broken remote never
226
- * costs the operator the sweep's findings.
198
+ * Assesses the checkout, then hands the whole write sequence to
199
+ * {@link openLedgerPullRequest}. Every git/`gh` failure is fatal and names its
200
+ * step; the caller runs this *after* printing the run summary, so a broken
201
+ * remote never costs the operator the sweep's findings.
227
202
  *
228
203
  * @param {object} [params]
229
204
  * @param {string} [params.ledgerPath]
@@ -233,7 +208,8 @@ function pullRequestBody(ledgerPath, date) {
233
208
  * @param {{ pr: { create: (flags: string[]) => Promise<unknown> } }} [params.gh]
234
209
  * @param {Date|string|number} [params.now]
235
210
  * @returns {Promise<{ committed: boolean, reason?: string, branch?: string,
236
- * subject?: string, baseBranch?: string, ledgerPath: string }>}
211
+ * subject?: string, baseBranch?: string, prUrl?: string|null,
212
+ * resumed?: boolean, ledgerPath: string }>}
237
213
  */
238
214
  export async function runLedgerCommit({
239
215
  ledgerPath = DEFAULT_LEDGER_PATH,
@@ -249,42 +225,12 @@ export async function runLedgerCommit({
249
225
  cwd,
250
226
  git,
251
227
  });
252
- if (!state.changed) {
253
- return { committed: false, reason: 'ledger-unchanged', ledgerPath };
254
- }
255
-
256
- const date = isoDate(now);
257
- const branch = `chore/audit-ledger-${date}`;
258
- const subject = `chore(audit): reconcile audit ledger ${date}`;
259
-
260
- await runStep('create-branch', () => git(cwd, 'checkout', '-b', branch));
261
- await runStep('stage-ledger', () => git(cwd, 'add', '--', ledgerPath));
262
- // The `-- <path>` pathspec is what keeps the commit ledger-only even when
263
- // the sweep's checkout carries unrelated dirt.
264
- await runStep('commit-ledger', () =>
265
- git(cwd, 'commit', '-m', subject, '--', ledgerPath),
266
- );
267
- await runStep('push-branch', () =>
268
- git(cwd, 'push', '--set-upstream', 'origin', branch),
269
- );
270
- await runStep('open-pull-request', () =>
271
- gh.pr.create([
272
- '--base',
273
- state.baseBranch,
274
- '--head',
275
- branch,
276
- '--title',
277
- subject,
278
- '--body',
279
- pullRequestBody(ledgerPath, date),
280
- ]),
281
- );
282
-
283
- return {
284
- committed: true,
285
- branch,
286
- subject,
287
- baseBranch: state.baseBranch,
228
+ return openLedgerPullRequest({
229
+ state,
288
230
  ledgerPath,
289
- };
231
+ cwd,
232
+ git,
233
+ gh,
234
+ date: isoDate(now),
235
+ });
290
236
  }
@@ -0,0 +1,347 @@
1
+ /**
2
+ * lib/audit-to-stories/ledger-pr.js — everything the ledger PR is made of.
3
+ *
4
+ * The mechanical half of `--ledger-commit`: how the branch is named, when the
5
+ * checkout is refused, the commit sequence itself, the push, and the PR body.
6
+ * `ledger-commit.js` next door keeps only the two entry points and the
7
+ * persistence assessment they share, so the run sequence there reads as a
8
+ * sequence rather than as a git driver.
9
+ *
10
+ * Every refusal in this module happens **before** its first write, so a refused
11
+ * run cannot have left a branch or a commit behind. The git and `gh` seams are
12
+ * injected (`.agents/rules/test-seams.md`), so the whole retry matrix is
13
+ * assertable without a live remote.
14
+ */
15
+
16
+ /**
17
+ * Run a read-only git probe that must never throw: a checkout with no commits
18
+ * (or no repository at all) is a legitimate answer of "nothing to report", not
19
+ * a crash. The write path uses `runStep` instead, where a failure IS fatal.
20
+ *
21
+ * @param {(cwd: string, ...args: string[]) => string} git
22
+ * @param {string} cwd
23
+ * @param {string[]} args
24
+ * @returns {string} trimmed stdout, or `''` when git failed.
25
+ */
26
+ export function probeGit(git, cwd, args) {
27
+ try {
28
+ const out = git(cwd, ...args);
29
+ return typeof out === 'string' ? out.trim() : '';
30
+ } catch (_) {
31
+ return '';
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Does a ref resolve in this checkout? Read-only, and never fatal — an absent
37
+ * ref is the answer, not an error.
38
+ *
39
+ * @param {(cwd: string, ...args: string[]) => string} git
40
+ * @param {string} cwd
41
+ * @param {string} ref
42
+ * @returns {boolean}
43
+ */
44
+ function refExists(git, cwd, ref) {
45
+ return (
46
+ probeGit(git, cwd, ['rev-parse', '--verify', '--quiet', ref]).length > 0
47
+ );
48
+ }
49
+
50
+ /**
51
+ * The short sha the branch name carries.
52
+ *
53
+ * Dating the branch alone was not enough to make a retry safe: a second run on
54
+ * the same day found `chore/audit-ledger-<date>` already present and failed at
55
+ * `create-branch`, so the *first* failure (usually a push) permanently poisoned
56
+ * every retry that day. Qualifying the name with the base commit makes it
57
+ * unique across bases while staying **deterministic** for the same base — which
58
+ * is exactly what lets a retry recognise its own half-finished branch.
59
+ *
60
+ * @param {(cwd: string, ...args: string[]) => string} git
61
+ * @param {string} cwd
62
+ * @param {string} baseRef — `origin/<base>`.
63
+ * @returns {string}
64
+ */
65
+ function shortSha(git, cwd, baseRef) {
66
+ for (const ref of [baseRef, 'HEAD']) {
67
+ const sha = probeGit(git, cwd, ['rev-parse', '--short', ref]);
68
+ if (sha) return sha;
69
+ }
70
+ return 'initial';
71
+ }
72
+
73
+ /**
74
+ * Resolve the ledger branch for this run, and whether it is a **resume**.
75
+ *
76
+ * A ledger branch that exists locally and has never been pushed is the wreckage
77
+ * of a failed run, not a landed one: its commit is already made, so the work
78
+ * left is the push and the PR. Recognising it is what turns a failed push plus
79
+ * its retry into exactly one PR instead of a stranded branch and a run
80
+ * reporting `ledger-unchanged` — which is what the ledger file honestly is once
81
+ * its change has been committed onto that branch.
82
+ *
83
+ * @param {{ git: Function, cwd: string, base: string, date: string }} params
84
+ * @returns {{ branch: string, resuming: boolean }}
85
+ */
86
+ function resolveLedgerBranch({ git, cwd, base, date }) {
87
+ const branch = `chore/audit-ledger-${date}-${shortSha(git, cwd, `origin/${base}`)}`;
88
+ return {
89
+ branch,
90
+ resuming:
91
+ refExists(git, cwd, `refs/heads/${branch}`) &&
92
+ !refExists(git, cwd, `refs/remotes/origin/${branch}`),
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Refuse, naming the step, when the checkout cannot legitimately produce a
98
+ * ledger PR. Both refusals happen **before** any write, so a refused run leaves
99
+ * no branch and no commit behind.
100
+ *
101
+ * HEAD parked off the base branch is the one an unattended sweep actually
102
+ * meets: a job that has already checked out a feature branch would otherwise
103
+ * cut its ledger branch from that branch's tip and open a PR carrying every
104
+ * unrelated commit on it.
105
+ *
106
+ * @param {{ hasOrigin: boolean, onBaseBranch: boolean, headBranch: string,
107
+ * baseBranch: string }} state
108
+ * @param {string} branch — the ledger branch name, allowed as a resume HEAD.
109
+ */
110
+ function assertCommittable(state, branch) {
111
+ if (!state.hasOrigin) {
112
+ throw new Error(
113
+ '--ledger-commit failed at step "verify-origin": this checkout has no "origin" remote, ' +
114
+ 'so the ledger branch could never be pushed. Add the remote, or commit the ledger by hand.',
115
+ );
116
+ }
117
+ if (!state.onBaseBranch && state.headBranch !== branch) {
118
+ throw new Error(
119
+ `--ledger-commit failed at step "verify-base-branch": HEAD is on "${state.headBranch || '(detached)'}", ` +
120
+ `not the base branch "${state.baseBranch}". Nothing was committed — the ledger branch is cut from ` +
121
+ `origin/${state.baseBranch}, and running from a feature branch would carry its commits into the ledger PR. ` +
122
+ `Check out ${state.baseBranch} and re-run.`,
123
+ );
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Extract the PR URL `gh pr create` prints, so the caller can name it in the
129
+ * run summary. A wrapper that returns something else yields `null` rather than
130
+ * a fabricated link.
131
+ *
132
+ * @param {unknown} result
133
+ * @returns {string|null}
134
+ */
135
+ function pullRequestUrl(result) {
136
+ const text = typeof result === 'string' ? result : (result?.stdout ?? '');
137
+ const match = /https?:\/\/\S+/.exec(String(text ?? ''));
138
+ return match ? match[0] : null;
139
+ }
140
+
141
+ /**
142
+ * Wrap one write step so a git or `gh` failure surfaces as a fatal error that
143
+ * names the step that broke. Accepts sync and async steps alike.
144
+ * @param {string} name
145
+ * @param {() => unknown} fn
146
+ * @returns {Promise<unknown>}
147
+ */
148
+ async function runStep(name, fn) {
149
+ try {
150
+ return await fn();
151
+ } catch (error) {
152
+ throw new Error(
153
+ `--ledger-commit failed at step "${name}": ${error?.message ?? error}`,
154
+ { cause: error },
155
+ );
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Compose the ledger PR body. Kept separate so the step sequence below reads
161
+ * as a sequence and not as a string-building exercise.
162
+ * @param {string} ledgerPath
163
+ * @param {string} date
164
+ * @returns {string}
165
+ */
166
+ function pullRequestBody(ledgerPath, date) {
167
+ return [
168
+ `Reconciles the cross-run audit ledger (\`${ledgerPath}\`) written by the`,
169
+ `unattended \`audit-to-stories --auto\` sweep on ${date}.`,
170
+ '',
171
+ 'Ledger-only change — no source, workflow or documentation file is touched.',
172
+ 'Merging it is what gives the next sweep a memory: without it the ledger',
173
+ 'dies with the checkout and every later run re-proposes findings this one',
174
+ 'already filed, and re-surfaces findings a human already rejected.',
175
+ '',
176
+ 'Auto-merge is deliberately not requested: the ledger records machine-derived',
177
+ 'lifecycle state, and a human glance before it lands is the point.',
178
+ ].join('\n');
179
+ }
180
+
181
+ /**
182
+ * Cut the ledger branch from `origin/<base>` and commit the ledger onto it —
183
+ * or, when `resuming`, simply check out the branch a failed run already
184
+ * committed onto, because those steps have already succeeded.
185
+ *
186
+ * @param {object} ctx
187
+ * @returns {Promise<void>}
188
+ */
189
+ async function commitLedgerOnto({
190
+ git,
191
+ cwd,
192
+ branch,
193
+ base,
194
+ ledgerPath,
195
+ subject,
196
+ resuming = false,
197
+ }) {
198
+ if (resuming) {
199
+ // The commit already exists on that branch; all it is missing is a push.
200
+ await runStep('resume-branch', () => git(cwd, 'checkout', branch));
201
+ return;
202
+ }
203
+ await runStep('fetch-base', () => git(cwd, 'fetch', 'origin', base));
204
+ await runStep('create-branch', () =>
205
+ git(cwd, 'checkout', '-b', branch, `origin/${base}`),
206
+ );
207
+ await runStep('stage-ledger', () => git(cwd, 'add', '--', ledgerPath));
208
+ // The `-- <path>` pathspec is what keeps the commit ledger-only even when
209
+ // the sweep's checkout carries unrelated dirt.
210
+ await runStep('commit-ledger', () =>
211
+ git(cwd, 'commit', '-m', subject, '--', ledgerPath),
212
+ );
213
+ }
214
+
215
+ /**
216
+ * Push the ledger branch and open its PR, returning the PR URL.
217
+ *
218
+ * Auto-merge is never requested: the ledger records machine-derived lifecycle
219
+ * state a human should glance at, so landing it stays an operator decision.
220
+ *
221
+ * @param {object} ctx
222
+ * @returns {Promise<string|null>}
223
+ */
224
+ async function pushAndOpenPullRequest({
225
+ git,
226
+ cwd,
227
+ gh,
228
+ branch,
229
+ base,
230
+ subject,
231
+ ledgerPath,
232
+ date,
233
+ }) {
234
+ await runStep('push-branch', () =>
235
+ git(cwd, 'push', '--set-upstream', 'origin', branch),
236
+ );
237
+ return pullRequestUrl(
238
+ await runStep('open-pull-request', () =>
239
+ gh.pr.create([
240
+ '--base',
241
+ base,
242
+ '--head',
243
+ branch,
244
+ '--title',
245
+ subject,
246
+ '--body',
247
+ pullRequestBody(ledgerPath, date),
248
+ ]),
249
+ ),
250
+ );
251
+ }
252
+
253
+ /**
254
+ * Put the checkout back on the branch the run started on.
255
+ *
256
+ * Best-effort by design, and called from a `finally`: on the failure path
257
+ * especially — where the next thing the operator runs is the retry — leaving
258
+ * them parked on a half-finished ledger branch is its own defect, but a failure
259
+ * to restore must never mask the failure that caused it.
260
+ *
261
+ * @param {{ git: Function, cwd: string, startBranch: string, branch: string }} params
262
+ */
263
+ function restoreBranch({ git, cwd, startBranch, branch }) {
264
+ if (!startBranch || startBranch === branch) return;
265
+ try {
266
+ git(cwd, 'checkout', startBranch);
267
+ } catch (_) {
268
+ // Deliberately swallowed — see the contract above.
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Run the whole `--ledger-commit` write sequence against an assessed checkout:
274
+ * refuse or skip, cut (or resume) the branch, push, open the PR, and put the
275
+ * checkout back where it started.
276
+ *
277
+ * **Re-runnable**, which is the property an unattended sweep needs. The two
278
+ * ways a retry used to misbehave are both closed here:
279
+ *
280
+ * - The branch name is qualified by the base commit, so a same-day retry no
281
+ * longer collides with the branch a failed run left behind.
282
+ * - A ledger already committed on an **unpushed** ledger branch resumes at
283
+ * the push rather than reporting `ledger-unchanged` (the ledger file is
284
+ * clean — it is committed, just not pushed) and abandoning the work.
285
+ * Across a failed push and its retry that yields exactly one PR.
286
+ *
287
+ * The branch the run started on is restored in a `finally`, so a failure
288
+ * anywhere in the sequence — and success alike — leaves the operator's checkout
289
+ * where they left it rather than parked on a ledger branch.
290
+ *
291
+ * @param {{ state: object, ledgerPath: string, cwd: string, git: Function,
292
+ * gh: object, date: string }} params
293
+ * @returns {Promise<object>} the result the CLI summarises.
294
+ */
295
+ export async function openLedgerPullRequest({
296
+ state,
297
+ ledgerPath,
298
+ cwd,
299
+ git,
300
+ gh,
301
+ date,
302
+ }) {
303
+ const base = state.baseBranch;
304
+ const subject = `chore(audit): reconcile audit ledger ${date}`;
305
+ const { branch, resuming } = resolveLedgerBranch({ git, cwd, base, date });
306
+
307
+ if (!state.changed && !resuming) {
308
+ return { committed: false, reason: 'ledger-unchanged', ledgerPath };
309
+ }
310
+ assertCommittable(state, branch);
311
+
312
+ const startBranch = state.headBranch;
313
+ let prUrl = null;
314
+ try {
315
+ await commitLedgerOnto({
316
+ git,
317
+ cwd,
318
+ branch,
319
+ base,
320
+ ledgerPath,
321
+ subject,
322
+ resuming,
323
+ });
324
+ prUrl = await pushAndOpenPullRequest({
325
+ git,
326
+ cwd,
327
+ gh,
328
+ branch,
329
+ base,
330
+ subject,
331
+ ledgerPath,
332
+ date,
333
+ });
334
+ } finally {
335
+ restoreBranch({ git, cwd, startBranch, branch });
336
+ }
337
+
338
+ return {
339
+ committed: true,
340
+ resumed: resuming,
341
+ branch,
342
+ subject,
343
+ baseBranch: base,
344
+ prUrl,
345
+ ledgerPath,
346
+ };
347
+ }