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.
- package/.agents/README.md +51 -11
- package/.agents/agents/auditor.md +5 -0
- package/.agents/docs/SDLC.md +21 -12
- package/.agents/docs/agentrc-reference.json +1 -4
- package/.agents/docs/configuration.md +2 -2
- package/.agents/instructions.md +17 -16
- package/.agents/schemas/agentrc.schema.json +6 -7
- package/.agents/scripts/audit-to-stories.js +510 -66
- package/.agents/scripts/generate-skills-index.js +158 -75
- package/.agents/scripts/lib/audit-to-stories/epic-grouping-directive.js +39 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +290 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +94 -3
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +10 -0
- package/.agents/scripts/lib/changed-files.js +100 -9
- package/.agents/scripts/lib/config-settings-schema.js +25 -7
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/label-constants.js +18 -0
- package/.agents/scripts/lib/label-taxonomy.js +18 -5
- package/.agents/scripts/lib/orchestration/epic-container.js +186 -0
- package/.agents/scripts/lib/orchestration/epic-expansion.js +148 -0
- package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +320 -0
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +18 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +130 -1
- package/.agents/scripts/lib/qa/resolve-qa-contract.js +58 -6
- package/.agents/scripts/lib/skills/skills-index.js +168 -0
- package/.agents/scripts/lib/skills/walk-skill-files.js +133 -9
- package/.agents/scripts/plan-persist.js +39 -1
- package/.agents/scripts/providers/github/sub-issue-add.js +218 -0
- package/.agents/scripts/quality-preview.js +50 -9
- package/.agents/scripts/resolve-stories.js +42 -2
- package/.agents/scripts/validate-skills.js +53 -66
- package/.agents/templates/docs/audit-sweep-runbook.md +169 -0
- package/.agents/workflows/audit-to-stories.md +85 -7
- package/.agents/workflows/helpers/audit-lens-core.md +24 -4
- package/.agents/workflows/helpers/deliver-reference.md +8 -0
- package/.agents/workflows/helpers/plan-reference.md +28 -0
- package/.agents/workflows/mandrel-deliver.md +47 -43
- package/.agents/workflows/mandrel-plan.md +44 -38
- package/.agents/workflows/qa-run.md +13 -5
- package/docs/CHANGELOG.md +28 -0
- 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 {
|
|
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 =
|
|
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
|
-
*
|
|
147
|
-
*
|
|
148
|
-
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
161
|
-
return
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
*
|
|
169
|
-
*
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
*
|
|
200
|
-
*
|
|
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
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|