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,83 @@
1
+ /**
2
+ * lib/audit-to-stories/issue-index.js — a local index of the audit Issues a
3
+ * sweep must dedupe against.
4
+ *
5
+ * Dedup used to answer every finding with a **search** round-trip: one
6
+ * `findIssuesByFingerprint(sha)` per finding, plus a meaning-first semantic
7
+ * search on top. GitHub's search endpoint is rate-limited an order of magnitude
8
+ * harder than the list endpoint, so a full-scope sweep — hundreds of findings —
9
+ * spent its whole budget re-discovering the same few dozen Issues, and then
10
+ * degraded the rest of the run to `create`, which is how a sweep opens
11
+ * duplicates of Issues it already filed.
12
+ *
13
+ * The Issues that can possibly match are exactly those carrying an `audit::*`
14
+ * label, and there are tens of them, not hundreds. Listing them **once per run**
15
+ * and indexing their provenance footers answers every exact-fingerprint lookup
16
+ * locally, for free. The search API is then spent only where it is the only
17
+ * thing that can help: a finding with no exact hit, whose fingerprint may have
18
+ * drifted under a rewording.
19
+ *
20
+ * Pure: the caller injects the list port and this module performs no I/O.
21
+ */
22
+
23
+ import {
24
+ parseFingerprintFooter,
25
+ parseSemanticKeyFooter,
26
+ } from '../findings/route-finding.js';
27
+
28
+ /**
29
+ * Add `record` to the list `map` keys under `key`.
30
+ *
31
+ * @param {Map<string, object[]>} map
32
+ * @param {string} key
33
+ * @param {object} record
34
+ */
35
+ function push(map, key, record) {
36
+ const bucket = map.get(key);
37
+ if (bucket) bucket.push(record);
38
+ else map.set(key, [record]);
39
+ }
40
+
41
+ /**
42
+ * Index issues by both provenance footers the audit filers stamp.
43
+ *
44
+ * An issue carrying neither footer is indexed under nothing — it can never
45
+ * confirm a match, exactly as it could not when it came back from a search.
46
+ *
47
+ * @param {Array<{ number: number, state: string, body?: string }>} issues
48
+ * @returns {{ byFingerprint: Map<string, object[]>, bySemanticKey: Map<string, object[]>, size: number }}
49
+ */
50
+ export function buildIssueIndex(issues) {
51
+ const byFingerprint = new Map();
52
+ const bySemanticKey = new Map();
53
+ const records = (issues ?? []).filter(
54
+ (issue) => typeof issue?.number === 'number',
55
+ );
56
+ for (const issue of records) {
57
+ for (const sha of parseFingerprintFooter(issue.body)) {
58
+ push(byFingerprint, sha, issue);
59
+ }
60
+ for (const key of parseSemanticKeyFooter(issue.body)) {
61
+ push(bySemanticKey, key, issue);
62
+ }
63
+ }
64
+ return { byFingerprint, bySemanticKey, size: records.length };
65
+ }
66
+
67
+ /**
68
+ * The union of the two local lookups for one finding, fingerprint hits first.
69
+ *
70
+ * Order matters downstream: `routeFinding` keeps the first record contributed
71
+ * for an issue number, and the record retrieved by exact identity is the one
72
+ * that should survive into confirmation.
73
+ *
74
+ * @param {{ byFingerprint: Map, bySemanticKey: Map }} index
75
+ * @param {string} sha
76
+ * @param {string} semanticKey
77
+ * @returns {{ exact: object[], pool: object[] }}
78
+ */
79
+ export function lookupLocally(index, sha, semanticKey) {
80
+ const exact = index.byFingerprint.get(sha) ?? [];
81
+ const byKey = semanticKey ? (index.bySemanticKey.get(semanticKey) ?? []) : [];
82
+ return { exact, pool: [...exact, ...byKey] };
83
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * lib/audit-to-stories/issues-file.js — the issue corpus dedup checks against,
3
+ * and the one normaliser every source of it goes through.
4
+ *
5
+ * Dedup needs exactly one thing from GitHub: the list of Issues carrying an
6
+ * `audit::*` label. Until Story #5301 the only way to get it was the provider,
7
+ * which spawns `gh`, so a host without a `gh` CLI — a Claude Code cloud
8
+ * sandbox, where `gh` is absent and direct API access is disabled but the
9
+ * GitHub MCP tools work fine — could not dedup at all. Every group classified
10
+ * `create` and a scheduled sweep re-filed findings it had already filed.
11
+ *
12
+ * `--issues-file <path>` breaks that coupling: the host fetches the list by
13
+ * whatever access path it has and hands over a JSON array. Nothing here knows
14
+ * how it was fetched, so MCP, REST, and a cached dump are all equally valid.
15
+ *
16
+ * A file that cannot be read as an issue array is a **hard error**, never a
17
+ * degrade. Falling back would mean "dedup did not run" on precisely the
18
+ * invocation whose whole purpose is that it does — and a create-only plan the
19
+ * operator reads as checked is how the duplicate-filing loop starts.
20
+ */
21
+
22
+ import fs from 'node:fs';
23
+
24
+ /**
25
+ * Flatten one raw issue onto the `{ number, state, title, body }` shape the
26
+ * dedupe module reads, collapsing every closed-ish state spelling (`CLOSED`,
27
+ * `state_reason: not_planned`, …) onto `'closed'`.
28
+ *
29
+ * Shared by both corpus sources — the provider's `searchIssues` hits and the
30
+ * `--issues-file` array — so a host can hand over a raw `list_issues` result
31
+ * verbatim without knowing which spelling this repo's dedup expects.
32
+ *
33
+ * @param {object} hit
34
+ * @returns {{ number: number, state: 'open'|'closed', title: string, body: string }}
35
+ */
36
+ export function normaliseIssueHit(hit) {
37
+ return {
38
+ number: hit.number,
39
+ state: (hit.state ?? hit.state_reason ?? 'open')
40
+ .toString()
41
+ .toLowerCase()
42
+ .includes('closed')
43
+ ? 'closed'
44
+ : 'open',
45
+ title: hit.title ?? '',
46
+ body: hit.body ?? '',
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Read and normalise a host-supplied issue corpus.
52
+ *
53
+ * `state` is read from either `state` or `state_reason`, so a closed-as-
54
+ * not-planned issue is recognised however the host's API spelled it. Only
55
+ * `number` and `body` are load-bearing: the number identifies the match and
56
+ * the body carries the provenance footers dedup confirms identity against.
57
+ *
58
+ * @param {string} filePath — path to a JSON array of issues.
59
+ * @param {{ readFileSyncImpl?: typeof fs.readFileSync }} [seams]
60
+ * @returns {Array<{ number: number, state: string, title: string, body: string }>}
61
+ * @throws {Error} when the file is missing, unreadable, not JSON, or not an array.
62
+ */
63
+ export function loadIssuesFile(
64
+ filePath,
65
+ { readFileSyncImpl = fs.readFileSync } = {},
66
+ ) {
67
+ let raw;
68
+ try {
69
+ raw = readFileSyncImpl(filePath, 'utf8');
70
+ } catch (err) {
71
+ throw new Error(
72
+ `--issues-file: cannot read "${filePath}" (${err.message}). Dedup needs ` +
73
+ 'the issue corpus to check against; running without it would classify ' +
74
+ 'every group "create" and re-file findings already tracked.',
75
+ );
76
+ }
77
+ let parsed;
78
+ try {
79
+ parsed = JSON.parse(raw);
80
+ } catch (err) {
81
+ throw new Error(
82
+ `--issues-file: "${filePath}" is not valid JSON (${err.message}). ` +
83
+ 'Expected a JSON array of issues, e.g. the result of listing every ' +
84
+ 'issue labelled audit::* with state "all".',
85
+ );
86
+ }
87
+ if (!Array.isArray(parsed)) {
88
+ throw new Error(
89
+ `--issues-file: "${filePath}" holds ${describeShape(parsed)}, not a JSON ` +
90
+ 'array of issues. Pass the issue list itself, not the envelope wrapping it.',
91
+ );
92
+ }
93
+ return parsed.filter(isIssueLike).map(normaliseIssueHit);
94
+ }
95
+
96
+ /**
97
+ * Whether one array entry can possibly identify an issue. An entry without a
98
+ * numeric `number` can never confirm a match — exactly as it could not when it
99
+ * came back from a search — so it is dropped rather than indexed under nothing.
100
+ *
101
+ * @param {unknown} entry
102
+ * @returns {boolean}
103
+ */
104
+ function isIssueLike(entry) {
105
+ return (
106
+ Boolean(entry) &&
107
+ typeof entry === 'object' &&
108
+ typeof entry.number === 'number'
109
+ );
110
+ }
111
+
112
+ /**
113
+ * Name what a non-array payload actually was, so the error points at the fix.
114
+ * @param {unknown} value
115
+ * @returns {string}
116
+ */
117
+ function describeShape(value) {
118
+ if (value === null) return 'null';
119
+ if (Array.isArray(value)) return 'an array';
120
+ return `a JSON ${typeof value}`;
121
+ }
@@ -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
@@ -27,9 +28,10 @@
27
28
  * complexity budget does not absorb a git driver.
28
29
  */
29
30
 
31
+ import { DEFAULT_LEDGER_PATH } from '../findings/audit-ledger.js';
30
32
  import { gh as defaultGh } from '../gh-exec.js';
31
33
  import { gitSync } from '../git-utils.js';
32
- 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
  }