mandrel 2.54.0 → 2.56.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 (134) 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 +8 -2
  5. package/.agents/docs/configuration.md +5 -0
  6. package/.agents/rules/ci-remediation.md +39 -21
  7. package/.agents/schemas/agentrc.schema.json +34 -1
  8. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  9. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  10. package/.agents/scripts/audit-to-stories.js +374 -76
  11. package/.agents/scripts/check-audit-attribution.js +119 -62
  12. package/.agents/scripts/check-test-portability.js +512 -0
  13. package/.agents/scripts/coverage-capture.js +17 -10
  14. package/.agents/scripts/evidence-gate.js +31 -4
  15. package/.agents/scripts/file-ci-gap.js +306 -0
  16. package/.agents/scripts/generate-workflows-doc.js +65 -14
  17. package/.agents/scripts/git-cleanup.js +4 -0
  18. package/.agents/scripts/lib/ITicketingProvider.js +78 -0
  19. package/.agents/scripts/lib/audit-advisories.js +195 -0
  20. package/.agents/scripts/lib/audit-attribution.js +22 -0
  21. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +25 -1
  22. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +80 -29
  23. package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +5 -1
  24. package/.agents/scripts/lib/audit-to-stories/issue-corpus.js +162 -0
  25. package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
  26. package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
  27. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +61 -115
  28. package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
  29. package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
  30. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
  31. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
  32. package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
  33. package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
  34. package/.agents/scripts/lib/cli-args.js +26 -0
  35. package/.agents/scripts/lib/close-validation/gates.js +113 -7
  36. package/.agents/scripts/lib/close-validation/process.js +7 -3
  37. package/.agents/scripts/lib/close-validation/runner.js +62 -11
  38. package/.agents/scripts/lib/config/ci.js +28 -9
  39. package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
  40. package/.agents/scripts/lib/config-settings-schema.js +52 -1
  41. package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
  42. package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
  43. package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
  44. package/.agents/scripts/lib/coverage-capture.js +77 -3
  45. package/.agents/scripts/lib/feedback-loop/graduator-core.js +53 -13
  46. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +71 -25
  47. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +18 -25
  48. package/.agents/scripts/lib/{audit-to-stories/ledger.js → findings/audit-ledger.js} +131 -24
  49. package/.agents/scripts/lib/findings/route-finding.js +42 -2
  50. package/.agents/scripts/lib/full-suite-lock.js +232 -6
  51. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  52. package/.agents/scripts/lib/git/sync-from-base.js +130 -13
  53. package/.agents/scripts/lib/github/framework-repo.js +148 -2
  54. package/.agents/scripts/lib/label-constants.js +6 -1
  55. package/.agents/scripts/lib/observability/source-classifier.js +2 -0
  56. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
  57. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
  58. package/.agents/scripts/lib/orchestration/ci-gap-intake.js +605 -0
  59. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +13 -8
  60. package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
  61. package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
  62. package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
  63. package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
  64. package/.agents/scripts/lib/orchestration/epic-rollup.js +233 -84
  65. package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
  66. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
  67. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
  68. package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
  69. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
  70. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
  71. package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
  72. package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
  73. package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
  74. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
  75. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
  76. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +39 -3
  77. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
  78. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
  79. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -0
  80. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
  81. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
  82. package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
  83. package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
  84. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
  85. package/.agents/scripts/lib/orchestration/run-epilogue.js +63 -42
  86. package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
  87. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
  88. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
  89. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
  90. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
  91. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
  92. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
  93. package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
  94. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
  95. package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
  96. package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
  97. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +30 -0
  98. package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
  99. package/.agents/scripts/lib/pinned-override-notes.js +41 -53
  100. package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
  101. package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
  102. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
  103. package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
  104. package/.agents/scripts/lib/test-temp.js +167 -30
  105. package/.agents/scripts/lib/validation-evidence.js +37 -0
  106. package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
  107. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  108. package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
  109. package/.agents/scripts/merge-baseline.js +175 -21
  110. package/.agents/scripts/pr-watch-with-update.js +3 -2
  111. package/.agents/scripts/providers/github/errors.js +22 -1
  112. package/.agents/scripts/providers/github/issues.js +106 -1
  113. package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
  114. package/.agents/scripts/providers/github.js +6 -0
  115. package/.agents/scripts/resolve-stories.js +44 -34
  116. package/.agents/scripts/single-story-close.js +5 -0
  117. package/.agents/scripts/stories-wave-tick.js +37 -13
  118. package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
  119. package/.agents/workflows/audit-accessibility.md +16 -31
  120. package/.agents/workflows/audit-mobile.md +20 -37
  121. package/.agents/workflows/audit-to-stories.md +63 -27
  122. package/.agents/workflows/git-cleanup.md +17 -3
  123. package/.agents/workflows/helpers/audit-lens-core.md +45 -0
  124. package/.agents/workflows/helpers/deliver-digest.md +7 -6
  125. package/.agents/workflows/helpers/deliver-reference.md +35 -14
  126. package/.agents/workflows/helpers/deliver-story-reference.md +26 -8
  127. package/.agents/workflows/helpers/deliver-story.md +15 -12
  128. package/.agents/workflows/helpers/plan-reference.md +30 -0
  129. package/.agents/workflows/mandrel-plan.md +10 -13
  130. package/.agents/workflows/memory-consolidate.md +14 -9
  131. package/docs/CHANGELOG.md +37 -0
  132. package/lib/cli/registry.js +64 -21
  133. package/lib/cli/sync.js +27 -2
  134. package/package.json +7 -4
@@ -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
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * lib/audit-to-stories/ledger-record.js — record what a run actually filed.
3
+ *
4
+ * The cross-run ledger can only suppress an already-filed finding if something
5
+ * tells it the finding was filed. Until Story #5305 nothing did: the reconcile
6
+ * ran *during* the scan, before any Issue existed, and its `issueStates` were
7
+ * derived from `matchedIssues` — the Issues dedup FOUND. A group classified
8
+ * `create` has none, so a freshly opened Issue contributed nothing and its
9
+ * entry persisted as `status: "new", issue: null` forever. On a host where
10
+ * GitHub-search dedup works that is invisible; on one where it cannot run,
11
+ * nothing suppresses anything and every sweep re-files everything.
12
+ *
13
+ * The missing input is the `groupKey → issueNumber` map, which exists only
14
+ * after the Issues are opened. `--wire-edges` already takes exactly that map
15
+ * and is already a required pass, which is why the record rides along with it
16
+ * rather than arriving as a second command an operator must remember.
17
+ *
18
+ * Nothing here reaches the network: the caller hands over the map it already
19
+ * holds, and the only I/O is the ledger read/write this module delegates to
20
+ * `ledger.js`. That is what lets the record run BEFORE the provider is loaded,
21
+ * so a host whose provider cannot be constructed still records what it filed.
22
+ */
23
+
24
+ import {
25
+ DEFAULT_LEDGER_PATH,
26
+ readLedger,
27
+ reconcileLedger,
28
+ writeLedger,
29
+ } from '../findings/audit-ledger.js';
30
+ import {
31
+ fingerprintAuditFinding,
32
+ toCanonicalFinding,
33
+ } from './finding-adapter.js';
34
+
35
+ /**
36
+ * Project the opened-issue map onto the `{ fingerprint → issueState }` shape
37
+ * `reconcileLedger` reads, and collect the findings those groups carry.
38
+ *
39
+ * Only groups present in the map contribute. A group the map does not mention
40
+ * was not opened — deduped, ledger-suppressed, or simply skipped — and
41
+ * inventing an Issue state for it is how a finding gets suppressed against an
42
+ * Issue that does not exist.
43
+ *
44
+ * Every recorded state is `open`: the map names Issues this run just created,
45
+ * and a just-created Issue is open. A later close is learned from the live
46
+ * lookup on the next run, where it outranks this record (`decideStatus` reads
47
+ * the closed-Issue branch first).
48
+ *
49
+ * @param {object} params
50
+ * @param {Array<object>} params.groups — the `create`-eligible groups.
51
+ * @param {Record<string, number>} params.issueByGroupKey — group key → issue number.
52
+ * Module-internal: `recordFiledIssues` is the only production entrypoint, and
53
+ * exporting this for tests alone trips the CI-only `dead-exports:production`
54
+ * gate. It is covered through `recordFiledIssues`, which is the seam that
55
+ * actually ships.
56
+ *
57
+ * @returns {{ findings: Array<object>, issueStates: Record<string, { state: string, number: number }>, groupsRecorded: number }}
58
+ */
59
+ function issueStatesFromIssueMap({ groups, issueByGroupKey }) {
60
+ const findings = [];
61
+ const issueStates = {};
62
+ let groupsRecorded = 0;
63
+
64
+ for (const group of groups ?? []) {
65
+ const number = issueByGroupKey?.[group?.groupKey];
66
+ if (typeof number !== 'number') continue;
67
+ groupsRecorded += 1;
68
+ for (const finding of group.findings ?? []) {
69
+ findings.push(finding);
70
+ issueStates[fingerprintAuditFinding(finding).full] = {
71
+ state: 'open',
72
+ number,
73
+ };
74
+ }
75
+ }
76
+
77
+ return { findings, issueStates, groupsRecorded };
78
+ }
79
+
80
+ /**
81
+ * Fold the just-opened Issues onto the committed ledger.
82
+ *
83
+ * Only the mapped groups' findings are passed to `reconcileLedger`, so every
84
+ * other entry survives untouched — `reconcileLedger` copies the prior index
85
+ * forward and rewrites only what this scan hands it.
86
+ *
87
+ * @param {object} params
88
+ * @param {string} [params.ledgerPath]
89
+ * @param {Array<object>} params.groups — the `create`-eligible groups.
90
+ * @param {Record<string, number>} params.issueByGroupKey
91
+ * @param {boolean} [params.write=true] — `false` computes the record without
92
+ * persisting it, which is what `--dry-run` needs.
93
+ * @param {{ readLedgerImpl?: Function, writeLedgerImpl?: Function }} [seams]
94
+ * @returns {{ path: string, written: boolean, groupsRecorded: number, findingsRecorded: number, filed: number }}
95
+ */
96
+ export function recordFiledIssues(
97
+ { ledgerPath, groups, issueByGroupKey, write = true },
98
+ { readLedgerImpl = readLedger, writeLedgerImpl = writeLedger } = {},
99
+ ) {
100
+ const path = ledgerPath ?? DEFAULT_LEDGER_PATH;
101
+ const { findings, issueStates, groupsRecorded } = issueStatesFromIssueMap({
102
+ groups,
103
+ issueByGroupKey,
104
+ });
105
+
106
+ const { ledger: next, classifications } = reconcileLedger({
107
+ ledger: readLedgerImpl(path),
108
+ findings,
109
+ issueStates,
110
+ toCanonical: toCanonicalFinding,
111
+ });
112
+ // Nothing to record means nothing to write. The ledger is committed consumer
113
+ // state and `--ledger-commit` opens a PR only when it changed, so rewriting
114
+ // it with a fresh `generatedAt` and no new memory would manufacture a diff
115
+ // that says nothing.
116
+ const wrote = Boolean(write) && findings.length > 0;
117
+ if (wrote) writeLedgerImpl(path, next);
118
+
119
+ return {
120
+ path,
121
+ written: wrote,
122
+ groupsRecorded,
123
+ findingsRecorded: findings.length,
124
+ filed: classifications.filter((c) => c.status === 'filed').length,
125
+ };
126
+ }