mandrel 2.38.0 → 2.40.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 (41) hide show
  1. package/.agents/README.md +51 -11
  2. package/.agents/agents/auditor.md +5 -0
  3. package/.agents/docs/SDLC.md +21 -12
  4. package/.agents/docs/agentrc-reference.json +1 -4
  5. package/.agents/docs/configuration.md +2 -2
  6. package/.agents/instructions.md +17 -16
  7. package/.agents/schemas/agentrc.schema.json +6 -7
  8. package/.agents/scripts/audit-to-stories.js +510 -66
  9. package/.agents/scripts/generate-skills-index.js +158 -75
  10. package/.agents/scripts/lib/audit-to-stories/epic-grouping-directive.js +39 -0
  11. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +290 -0
  12. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +94 -3
  13. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +10 -0
  14. package/.agents/scripts/lib/changed-files.js +100 -9
  15. package/.agents/scripts/lib/config-settings-schema.js +25 -7
  16. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  17. package/.agents/scripts/lib/label-constants.js +18 -0
  18. package/.agents/scripts/lib/label-taxonomy.js +18 -5
  19. package/.agents/scripts/lib/orchestration/epic-container.js +186 -0
  20. package/.agents/scripts/lib/orchestration/epic-expansion.js +148 -0
  21. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +320 -0
  22. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +18 -0
  23. package/.agents/scripts/lib/orchestration/run-epilogue.js +130 -1
  24. package/.agents/scripts/lib/qa/resolve-qa-contract.js +58 -6
  25. package/.agents/scripts/lib/skills/skills-index.js +168 -0
  26. package/.agents/scripts/lib/skills/walk-skill-files.js +133 -9
  27. package/.agents/scripts/plan-persist.js +39 -1
  28. package/.agents/scripts/providers/github/sub-issue-add.js +218 -0
  29. package/.agents/scripts/quality-preview.js +50 -9
  30. package/.agents/scripts/resolve-stories.js +42 -2
  31. package/.agents/scripts/validate-skills.js +53 -66
  32. package/.agents/templates/docs/audit-sweep-runbook.md +169 -0
  33. package/.agents/workflows/audit-to-stories.md +85 -7
  34. package/.agents/workflows/helpers/audit-lens-core.md +24 -4
  35. package/.agents/workflows/helpers/deliver-reference.md +8 -0
  36. package/.agents/workflows/helpers/plan-reference.md +28 -0
  37. package/.agents/workflows/mandrel-deliver.md +47 -43
  38. package/.agents/workflows/mandrel-plan.md +44 -38
  39. package/.agents/workflows/qa-run.md +13 -5
  40. package/docs/CHANGELOG.md +28 -0
  41. package/package.json +1 -1
@@ -8,6 +8,15 @@
8
8
  // generator output (ignoring the volatile `generatedAt` field) and exits
9
9
  // non-zero with a diff-style message if they diverge.
10
10
  //
11
+ // Two indexes, never one (Story #5135). The shipped manifest above is a
12
+ // committed payload file that `mandrel doctor` / `mandrel sync-agents`
13
+ // compare byte-for-byte against the installed package, so consumer-authored
14
+ // skills under the `.agents/local/skills/` zone MUST NOT be folded into it —
15
+ // a merged index would read as payload drift in every consumer that authored
16
+ // a skill, and those commands would refuse. Local skills are therefore
17
+ // indexed into their own `.agents/local/skills/skills.index.json`, inside
18
+ // the zone sync never prunes and drift never walks.
19
+ //
11
20
  // CLI surface:
12
21
  //
13
22
  // node generate-skills-index.js [--check] [--root <dir>] [--out <file>]
@@ -38,7 +47,18 @@ import { runAsCli } from './lib/cli-utils.js';
38
47
  import { formatGeneratedJson } from './lib/format-generated-json.js';
39
48
  import { Logger } from './lib/Logger.js';
40
49
  import { parseSkill } from './lib/skills/parse-skill.js';
41
- import { collectSkillFiles } from './lib/skills/walk-skill-files.js';
50
+ import {
51
+ diffManifests,
52
+ INDEX_FILENAME,
53
+ indexPathFor,
54
+ readManifest,
55
+ } from './lib/skills/skills-index.js';
56
+ import {
57
+ collectLocalSkillFiles,
58
+ collectSkillFiles,
59
+ LOCAL_SKILLS_SEGMENTS,
60
+ PAYLOAD_SKILLS_SEGMENTS,
61
+ } from './lib/skills/walk-skill-files.js';
42
62
 
43
63
  const GENERATOR_ID = 'generate-skills-index.js@1';
44
64
 
@@ -103,8 +123,8 @@ function projectEntry(parsed) {
103
123
  * Build the manifest object (without `generatedAt`) by walking the tree
104
124
  * and projecting each parsed SKILL.md into an index entry.
105
125
  */
106
- export function buildManifestBody(repoRoot) {
107
- const skillFiles = collectSkillFiles(repoRoot);
126
+ export function buildManifestBody(repoRoot, collect = collectSkillFiles) {
127
+ const skillFiles = collect(repoRoot);
108
128
  const skills = skillFiles.map((absPath) =>
109
129
  projectEntry(parseSkill(absPath, { repoRoot })),
110
130
  );
@@ -118,8 +138,8 @@ export function buildManifestBody(repoRoot) {
118
138
  * Build the full manifest with `generatedAt`. `nowIso` is injected so
119
139
  * tests can pin the timestamp deterministically.
120
140
  */
121
- export function buildManifest(repoRoot, { nowIso } = {}) {
122
- const body = buildManifestBody(repoRoot);
141
+ export function buildManifest(repoRoot, { nowIso, collect } = {}) {
142
+ const body = buildManifestBody(repoRoot, collect);
123
143
  return {
124
144
  generatedAt: nowIso ?? new Date().toISOString(),
125
145
  generator: body.generator,
@@ -143,66 +163,147 @@ export function serializeManifest(manifest) {
143
163
  }
144
164
 
145
165
  /**
146
- * Read the on-disk manifest as a parsed object, or null when missing /
147
- * unparseable. The --check pipeline distinguishes "missing" (drift) from
148
- * "unparseable" (drift) via the returned `reason` channel.
166
+ * Resolve the manifest output path given (root, optional explicit
167
+ * override).
168
+ */
169
+ function resolveOutPath(root, override) {
170
+ return override
171
+ ? path.resolve(override)
172
+ : indexPathFor(root, PAYLOAD_SKILLS_SEGMENTS);
173
+ }
174
+
175
+ /**
176
+ * Resolve the local-zone manifest path. Deliberately NOT overridable by
177
+ * `--out`: that flag redirects the payload manifest (tests stage fixture
178
+ * trees with it), and letting it also move the local manifest would let one
179
+ * invocation write both indexes to the same file.
180
+ */
181
+ function resolveLocalOutPath(root) {
182
+ return indexPathFor(root, LOCAL_SKILLS_SEGMENTS);
183
+ }
184
+
185
+ /**
186
+ * Write one manifest through the project formatter so a regeneration on a
187
+ * clean tree leaves no format drift behind.
149
188
  */
150
- function readOnDiskManifest(outPath) {
151
- if (!fs.existsSync(outPath)) {
152
- return { manifest: null, reason: 'missing' };
189
+ function writeManifest(manifest, outPath, root) {
190
+ const serialized = serializeManifest(manifest);
191
+ const opts = { cwd: root, filename: INDEX_FILENAME };
192
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
193
+ fs.writeFileSync(
194
+ outPath,
195
+ formatGeneratedJson(serialized, opts) ?? serialized,
196
+ );
197
+ }
198
+
199
+ /**
200
+ * Write (or reap) the local-zone manifest. A consumer who deletes their last
201
+ * local skill would otherwise be left with a stale index reporting skills
202
+ * that no longer exist, so an emptied zone removes the artifact rather than
203
+ * leaving it behind.
204
+ */
205
+ function writeLocalManifest(localFresh, localOutPath, root) {
206
+ const rel = path.relative(root, localOutPath).split(path.sep).join('/');
207
+ if (localFresh === null) {
208
+ if (fs.existsSync(localOutPath)) {
209
+ fs.rmSync(localOutPath);
210
+ Logger.info(`removed ${rel} (no local skills remain)`);
211
+ }
212
+ return;
153
213
  }
154
- let src;
155
- try {
156
- src = fs.readFileSync(outPath, 'utf8');
157
- } catch (err) {
158
- return { manifest: null, reason: `read-error: ${err.message}` };
214
+ writeManifest(localFresh, localOutPath, root);
215
+ Logger.info(`wrote ${rel} (${localFresh.skills.length} entries)`);
216
+ }
217
+
218
+ /**
219
+ * Compare the local-zone manifest against fresh generator output. Returns
220
+ * null when in sync (including the common case of no local skills and no
221
+ * artifact), or a diff-style message.
222
+ */
223
+ function checkLocalManifest(localFresh, localOutPath) {
224
+ const exists = fs.existsSync(localOutPath);
225
+ if (localFresh === null) {
226
+ return exists
227
+ ? 'local skills.index.json drift detected: the local skills zone is ' +
228
+ 'empty but .agents/local/skills/skills.index.json still exists — ' +
229
+ "run 'node .agents/scripts/generate-skills-index.js' to reap it"
230
+ : null;
159
231
  }
160
- try {
161
- return { manifest: JSON.parse(src), reason: null };
162
- } catch (err) {
163
- return { manifest: null, reason: `parse-error: ${err.message}` };
232
+ if (!exists) {
233
+ return (
234
+ 'local skills.index.json drift detected: missing — run ' +
235
+ "'node .agents/scripts/generate-skills-index.js' to write it"
236
+ );
164
237
  }
238
+ const { manifest: disk } = readManifest(localOutPath);
239
+ return diffManifests(disk, localFresh, 'local skills.index.json');
165
240
  }
166
241
 
167
242
  /**
168
- * Compare two manifests ignoring `generatedAt`. Returns null when they
169
- * match, or a short diff-style message when they diverge.
243
+ * Build the local zone's manifest plan for this invocation: its output path,
244
+ * and a fresh manifest when the consumer has authored any local skill (null
245
+ * otherwise, which is the signal to reap a stale artifact).
246
+ *
247
+ * Split out of `run` so the payload path and the local path each read as one
248
+ * step there rather than interleaving.
249
+ *
250
+ * @param {string} root
251
+ * @param {Date} now
252
+ * @returns {{ localFresh: object | null, localOutPath: string }}
170
253
  */
171
- function diffManifestsIgnoringTimestamp(diskManifest, freshManifest) {
172
- if (diskManifest === null) {
173
- return 'on-disk manifest is missing or unreadable';
174
- }
175
- const a = { ...diskManifest };
176
- const b = { ...freshManifest };
177
- delete a.generatedAt;
178
- delete b.generatedAt;
179
- const sa = JSON.stringify(a);
180
- const sb = JSON.stringify(b);
181
- if (sa === sb) return null;
182
- // Surface a structural summary rather than a full JSON dump.
183
- const diskCount = Array.isArray(diskManifest.skills)
184
- ? diskManifest.skills.length
185
- : 'n/a';
186
- const freshCount = Array.isArray(freshManifest.skills)
187
- ? freshManifest.skills.length
188
- : 'n/a';
189
- const summary = [
190
- 'skills.index.json drift detected:',
191
- ` on-disk entries: ${diskCount}`,
192
- ` generated entries: ${freshCount}`,
193
- " run 'node .agents/scripts/generate-skills-index.js' to refresh",
194
- ].join('\n');
195
- return summary;
254
+ function buildLocalPlan(root, now) {
255
+ const localOutPath = resolveLocalOutPath(root);
256
+ const localFresh =
257
+ collectLocalSkillFiles(root).length > 0
258
+ ? buildManifest(root, {
259
+ nowIso: now.toISOString(),
260
+ collect: collectLocalSkillFiles,
261
+ })
262
+ : null;
263
+ return { localFresh, localOutPath };
196
264
  }
197
265
 
198
266
  /**
199
- * Resolve the manifest output path given (root, optional explicit
200
- * override).
267
+ * Render the freshness line's entry counts, naming the local zone only when
268
+ * one exists.
269
+ *
270
+ * @param {object} fresh
271
+ * @param {object | null} localFresh
272
+ * @returns {string}
201
273
  */
202
- function resolveOutPath(root, override) {
203
- return override
204
- ? path.resolve(override)
205
- : path.join(root, '.agents', 'skills', 'skills.index.json');
274
+ function describeCounts(fresh, localFresh) {
275
+ const base = `${fresh.skills.length} entries`;
276
+ return localFresh === null
277
+ ? base
278
+ : `${base}, ${localFresh.skills.length} local`;
279
+ }
280
+
281
+ /**
282
+ * `--check` mode: compare both manifests against fresh generator output and
283
+ * report the first drift found, payload first.
284
+ *
285
+ * Lives outside `run` so the entry point reads as "resolve inputs, then check
286
+ * or write" — and so the check path's branches are not charged to a function
287
+ * that also owns argument resolution.
288
+ *
289
+ * @param {{ outPath: string, fresh: object, localOutPath: string, localFresh: object | null }} plan
290
+ * @returns {{ status: number, output: string }}
291
+ */
292
+ function checkBothManifests({ outPath, fresh, localOutPath, localFresh }) {
293
+ const { manifest: disk, reason } = readManifest(outPath);
294
+ if (disk === null) {
295
+ return { status: 1, output: `${INDEX_FILENAME} drift detected: ${reason}` };
296
+ }
297
+ const drift =
298
+ diffManifests(disk, fresh, INDEX_FILENAME) ??
299
+ checkLocalManifest(localFresh, localOutPath);
300
+ if (drift !== null) {
301
+ return { status: 1, output: drift };
302
+ }
303
+ Logger.info(
304
+ `${INDEX_FILENAME} is fresh (${describeCounts(fresh, localFresh)})`,
305
+ );
306
+ return { status: 0, output: '' };
206
307
  }
207
308
 
208
309
  /**
@@ -225,35 +326,17 @@ export function run({ argv = [], now = new Date(), repoRoot } = {}) {
225
326
  : (repoRoot ?? defaultRepoRoot());
226
327
  const outPath = resolveOutPath(root, parsed.out);
227
328
  const fresh = buildManifest(root, { nowIso: now.toISOString() });
329
+ const { localFresh, localOutPath } = buildLocalPlan(root, now);
228
330
 
229
331
  if (parsed.check) {
230
- const { manifest: disk, reason } = readOnDiskManifest(outPath);
231
- if (disk === null) {
232
- return {
233
- status: 1,
234
- output: `skills.index.json drift detected: ${reason}`,
235
- };
236
- }
237
- const diff = diffManifestsIgnoringTimestamp(disk, fresh);
238
- if (diff === null) {
239
- Logger.info(
240
- `skills.index.json is fresh (${fresh.skills.length} entries)`,
241
- );
242
- return { status: 0, output: '' };
243
- }
244
- return { status: 1, output: diff };
332
+ return checkBothManifests({ outPath, fresh, localOutPath, localFresh });
245
333
  }
246
334
 
247
- const serialized = serializeManifest(fresh);
248
- const opts = { cwd: root, filename: 'skills.index.json' };
249
- fs.mkdirSync(path.dirname(outPath), { recursive: true });
250
- fs.writeFileSync(
251
- outPath,
252
- formatGeneratedJson(serialized, opts) ?? serialized,
253
- );
335
+ writeManifest(fresh, outPath, root);
254
336
  Logger.info(
255
337
  `wrote ${path.relative(root, outPath).split(path.sep).join('/')} (${fresh.skills.length} entries)`,
256
338
  );
339
+ writeLocalManifest(localFresh, localOutPath, root);
257
340
  return { status: 0, output: '' };
258
341
  }
259
342
 
@@ -0,0 +1,39 @@
1
+ /**
2
+ * epic-grouping-directive.js — the container-Epic directive an audit sweep
3
+ * emits (Story #5139).
4
+ *
5
+ * Lives on its own because **both** `/audit-to-stories` output paths carry it:
6
+ * the `/mandrel-plan` seed one-pager (`seed-from-findings.js`) and the
7
+ * standalone Story-draft transcript (`audit-to-stories.js`). A sweep is the
8
+ * clearest case for a container — every Story shares a provenance and the
9
+ * operator almost always wants them delivered together — so the Epic is the
10
+ * **default** here, unlike the offer `/mandrel-plan` makes on an ad-hoc plan.
11
+ *
12
+ * It stays a directive in the text rather than an automatic write: the
13
+ * workflow's Phase 4 HITL stop is where an operator declines it.
14
+ *
15
+ * @module lib/audit-to-stories/epic-grouping-directive
16
+ */
17
+
18
+ import { EPIC_SUGGESTION_THRESHOLD } from '../orchestration/plan-persist/epic-ops.js';
19
+
20
+ /**
21
+ * Render the grouping directive for a proposed Story set.
22
+ *
23
+ * @param {unknown[]} groups The proposed Stories (only the count is read).
24
+ * @returns {string} Markdown paragraph(s).
25
+ */
26
+ export function formatEpicGrouping(groups) {
27
+ const count = Array.isArray(groups) ? groups.length : 0;
28
+ if (count < EPIC_SUGGESTION_THRESHOLD) {
29
+ const noun = count === 1 ? 'Story' : 'Stories';
30
+ return `This sweep proposes ${count} ${noun} — below the ${EPIC_SUGGESTION_THRESHOLD}-Story threshold, so no container Epic is needed.`;
31
+ }
32
+ return [
33
+ `**Group these under a container Epic.** This sweep proposes ${count} Stories from one audit pass, which is exactly the case a container earns: they share a provenance and an operator will want to deliver them as a unit.`,
34
+ '',
35
+ 'The Epic is a **pure container** — a title, a one-paragraph goal, and the child checklist. It must carry no finding, no path and no rationale that is not already in a child Story, or that information ends up somewhere no delivering agent reads.',
36
+ '',
37
+ 'Decline it and file the Stories flat if the operator prefers.',
38
+ ].join('\n');
39
+ }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * lib/audit-to-stories/ledger-commit.js — persist the cross-run audit ledger.
3
+ *
4
+ * The `--auto` sweep's whole value is memory: `baselines/audit-ledger.json`
5
+ * is what lets the next run tell a re-detection from a fresh finding and an
6
+ * accepted risk from an unseen one. A scheduled sweep, though, typically runs
7
+ * on an ephemeral checkout — a fresh clone that is deleted when the job ends —
8
+ * so the ledger `--auto` writes is discarded and every later sweep starts from
9
+ * an empty memory. The sweep is then permanently amnesiac, and the ledger's
10
+ * suppression and regression signals never fire.
11
+ *
12
+ * This module closes that hole from both ends:
13
+ *
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.
17
+ * Auto-merge is never requested: a ledger PR records machine-derived state
18
+ * a human should glance at, so landing it stays an operator decision.
19
+ * - {@link resolveLedgerSummary} answers the question the *unflagged* sweep
20
+ * needs — "would this ledger survive?" — so a run that cannot persist (no
21
+ * `origin`, or HEAD parked off the base branch) says so in its summary,
22
+ * and on stderr, instead of silently discarding the state.
23
+ *
24
+ * Both take injectable `git` / `gh` seams (`.agents/rules/test-seams.md`) so
25
+ * the branch/commit/push/PR argv shape is assertable without a live remote.
26
+ * The logic lives here rather than in `audit-to-stories.js` so the CLI file's
27
+ * complexity budget does not absorb a git driver.
28
+ */
29
+
30
+ import { gh as defaultGh } from '../gh-exec.js';
31
+ import { gitSync } from '../git-utils.js';
32
+ import { DEFAULT_LEDGER_PATH } from './ledger.js';
33
+
34
+ /** Fallback base branch when config carries no `project.baseBranch`. */
35
+ const DEFAULT_BASE_BRANCH = 'main';
36
+
37
+ /**
38
+ * Render the `YYYY-MM-DD` stamp both the branch name and the commit subject
39
+ * carry, so one sweep produces one identifiable ledger branch per day.
40
+ * @param {Date|string|number} [now]
41
+ * @returns {string}
42
+ */
43
+ function isoDate(now) {
44
+ const date = now instanceof Date ? now : new Date(now ?? Date.now());
45
+ return date.toISOString().slice(0, 10);
46
+ }
47
+
48
+ /**
49
+ * Resolve `project.baseBranch` defensively: an explicit value wins, then
50
+ * config, then `main`. A failed config resolve must never break a sweep that
51
+ * has already done its real work.
52
+ * @param {string} [explicit]
53
+ * @returns {Promise<string>}
54
+ */
55
+ async function resolveBaseBranch(explicit) {
56
+ if (typeof explicit === 'string' && explicit.length > 0) return explicit;
57
+ try {
58
+ const { resolveConfig } = await import('../config-resolver.js');
59
+ const branch = resolveConfig()?.project?.baseBranch;
60
+ if (typeof branch === 'string' && branch.length > 0) return branch;
61
+ } catch (_) {
62
+ // fall through to the default
63
+ }
64
+ return DEFAULT_BASE_BRANCH;
65
+ }
66
+
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
+ /**
105
+ * Inspect whether the ledger changed and whether this checkout could persist
106
+ * it at all. Module-local: the two exported entry points below are the whole
107
+ * public surface, so a probe helper never becomes a second way in.
108
+ *
109
+ * `unpersisted` is the signal the unflagged `--auto` summary carries: the
110
+ * sweep produced new memory, and this checkout has nowhere to put it — either
111
+ * there is no `origin` to push to or HEAD is not on the base branch, so a
112
+ * commit here would not reach the repository's shared state.
113
+ *
114
+ * @param {object} [params]
115
+ * @param {string} [params.ledgerPath] — defaults to `baselines/audit-ledger.json`.
116
+ * @param {string} [params.baseBranch] — defaults to resolved `project.baseBranch`.
117
+ * @param {string} [params.cwd]
118
+ * @param {(cwd: string, ...args: string[]) => string} [params.git]
119
+ * @returns {Promise<{ ledgerPath: string, baseBranch: string, changed: boolean,
120
+ * hasOrigin: boolean, headBranch: string, onBaseBranch: boolean,
121
+ * unpersisted: boolean }>}
122
+ */
123
+ async function assessLedgerPersistence({
124
+ ledgerPath = DEFAULT_LEDGER_PATH,
125
+ baseBranch,
126
+ cwd = process.cwd(),
127
+ git = gitSync,
128
+ } = {}) {
129
+ 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;
138
+
139
+ return {
140
+ ledgerPath,
141
+ baseBranch: base,
142
+ changed,
143
+ hasOrigin,
144
+ headBranch,
145
+ onBaseBranch,
146
+ unpersisted: changed && (!hasOrigin || !onBaseBranch),
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Warn that the reconciled ledger has nowhere to go. Names the file, because
152
+ * "state will be lost" is unactionable without knowing which state.
153
+ * @param {{ ledgerPath: string, baseBranch: string, hasOrigin: boolean, headBranch: string }} state
154
+ * @returns {string}
155
+ */
156
+ function unpersistedWarning(state) {
157
+ const cause = state.hasOrigin
158
+ ? `HEAD is on "${state.headBranch || '(detached)'}", not the base branch "${state.baseBranch}"`
159
+ : 'this checkout has no "origin" remote';
160
+ return `ledger not persisted: ${state.ledgerPath} changed but ${cause}, so this sweep's memory will be lost when the checkout goes away. Re-run with --ledger-commit to open a PR for it, or commit ${state.ledgerPath} by hand.`;
161
+ }
162
+
163
+ /**
164
+ * Resolve the `--auto` summary's `ledger` field, annotating it with
165
+ * `unpersisted: true` (and warning on stderr) when the sweep produced memory
166
+ * this checkout cannot keep.
167
+ *
168
+ * The whole decision lives here rather than in the CLI so `runAuto` stays a
169
+ * straight-line assembly of its summary: `dryRun` and `ledgerCommit` are
170
+ * passed through raw and branched on once, in one place.
171
+ *
172
+ * @param {object} [params]
173
+ * @param {object|null} [params.ledger] — the plan's ledger summary, or null.
174
+ * @param {string} [params.ledgerPath]
175
+ * @param {boolean} [params.dryRun] — nothing was written, so nothing is at risk.
176
+ * @param {boolean} [params.ledgerCommit] — a PR is about to persist it.
177
+ * @param {string} [params.cwd]
178
+ * @param {(cwd: string, ...args: string[]) => string} [params.git]
179
+ * @param {{ warn: Function }} [params.logger]
180
+ * @returns {Promise<object|null>} the (possibly annotated) ledger summary.
181
+ */
182
+ export async function resolveLedgerSummary({
183
+ ledger = null,
184
+ ledgerPath = DEFAULT_LEDGER_PATH,
185
+ dryRun,
186
+ ledgerCommit,
187
+ cwd,
188
+ git,
189
+ logger,
190
+ } = {}) {
191
+ if (dryRun || ledgerCommit) return ledger;
192
+ const state = await assessLedgerPersistence({ ledgerPath, cwd, git });
193
+ if (!state.unpersisted) return ledger;
194
+ logger?.warn?.(unpersistedWarning(state));
195
+ return { ...(ledger ?? { path: ledgerPath }), unpersisted: true };
196
+ }
197
+
198
+ /**
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.
222
+ *
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.
227
+ *
228
+ * @param {object} [params]
229
+ * @param {string} [params.ledgerPath]
230
+ * @param {string} [params.baseBranch]
231
+ * @param {string} [params.cwd]
232
+ * @param {(cwd: string, ...args: string[]) => string} [params.git]
233
+ * @param {{ pr: { create: (flags: string[]) => Promise<unknown> } }} [params.gh]
234
+ * @param {Date|string|number} [params.now]
235
+ * @returns {Promise<{ committed: boolean, reason?: string, branch?: string,
236
+ * subject?: string, baseBranch?: string, ledgerPath: string }>}
237
+ */
238
+ export async function runLedgerCommit({
239
+ ledgerPath = DEFAULT_LEDGER_PATH,
240
+ baseBranch,
241
+ cwd = process.cwd(),
242
+ git = gitSync,
243
+ gh = defaultGh,
244
+ now,
245
+ } = {}) {
246
+ const state = await assessLedgerPersistence({
247
+ ledgerPath,
248
+ baseBranch,
249
+ cwd,
250
+ git,
251
+ });
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,
288
+ ledgerPath,
289
+ };
290
+ }