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
@@ -12,9 +12,18 @@
12
12
  * 2. `npm run quality:watch` — chokidar wrapper re-emits on save.
13
13
  * 3. `.husky/pre-commit` — block the commit on threshold violations.
14
14
  *
15
- * Story #1394 (Epic #1386) flipped the default scope of both gates to
16
- * diff-against-`main`, so passing `--changed-since HEAD` here mirrors what the
17
- * pre-commit hook actually wants: the delta the operator is about to commit.
15
+ * The pre-commit hook passes `--staged` and nothing else the index is
16
+ * already the exact delta the operator is about to commit, and
17
+ * `tests/pre-commit-hook.test.js` pins that `--changed-since` stays off it.
18
+ * (This docblock previously claimed the hook passed `--changed-since HEAD`,
19
+ * describing a wiring the hook has not used for some time; the stale prose
20
+ * sent at least one bug report at the wrong flag — Story #5131.)
21
+ *
22
+ * `--staged` is merge-aware: while a merge is in progress the index is read
23
+ * against `MERGE_HEAD` rather than `HEAD`, so a base-sync merge commit is
24
+ * scored for the merging branch's own work and its conflict resolutions, not
25
+ * for everything the base branch landed. See `resolveMergeHead` in
26
+ * `lib/changed-files.js`.
18
27
  *
19
28
  * The CLI exits 0 when both envelopes report zero violations and the script
20
29
  * could not surface a regression. Any violation in either envelope, or any
@@ -29,6 +38,7 @@ import {
29
38
  runCrapPreview,
30
39
  runMaintainabilityPreview,
31
40
  } from './lib/baselines/preview-gates.js';
41
+ import { resolveMergeHead } from './lib/changed-files.js';
32
42
  import { respondToHelp } from './lib/cli-usage.js';
33
43
  import { getQuality, resolveConfig } from './lib/config-resolver.js';
34
44
  import { resolveCyclomaticPolicy } from './lib/cyclomatic-ceiling.js';
@@ -39,7 +49,10 @@ const USAGE = {
39
49
  summary:
40
50
  'Preview the per-file maintainability and CRAP deltas for the change set, and exit non-zero on any threshold violation.',
41
51
  flags: [
42
- ['--staged', 'Score the git index only (the pre-commit-hook scope).'],
52
+ [
53
+ '--staged',
54
+ 'Score the git index only (the pre-commit-hook scope). During a merge the index is read against MERGE_HEAD.',
55
+ ],
43
56
  [
44
57
  '--changed-since <ref>',
45
58
  'Score the diff against <ref> (default: HEAD). Last occurrence wins.',
@@ -388,6 +401,35 @@ function runGateSafely(runner, args, label, stderr) {
388
401
  });
389
402
  }
390
403
 
404
+ /**
405
+ * Render the scope header line.
406
+ *
407
+ * Story #5131 — when `--staged` runs during a merge the scope is re-based to
408
+ * `MERGE_HEAD`, and the header says so. Without that line the operator sees a
409
+ * table whose row count does not match `git diff --cached` with no way to tell
410
+ * the narrowing was deliberate.
411
+ *
412
+ * The merge state is resolved here rather than read back off a gate envelope's
413
+ * `summary.diffRef`: that field means "the ref this scope was resolved
414
+ * against" for every scope kind, so anything that populates it — a future
415
+ * scope mode, a test stub — would render a merge banner over a repo that is
416
+ * not merging. Resolving it after the `!staged` early return also keeps the
417
+ * probe off the `--changed-since` path, which has no use for it.
418
+ *
419
+ * @param {{ staged: boolean, ref: string|null, cwd: string }} args
420
+ * @returns {string}
421
+ */
422
+ function stagedScopeLine({ staged, ref, cwd }) {
423
+ if (!staged) return `scope=diff ref=${ref}\n\n`;
424
+ const mergeHead = resolveMergeHead({ cwd });
425
+ if (!mergeHead) return 'scope=staged (git diff --cached)\n\n';
426
+ return (
427
+ `scope=staged (git diff --cached ${mergeHead.slice(0, 12)}) — merge in ` +
428
+ "progress: scored against MERGE_HEAD, not HEAD, so the base branch's " +
429
+ 'incoming files are excluded\n\n'
430
+ );
431
+ }
432
+
391
433
  /**
392
434
  * Write the run's report — the `--json` envelope, or the human-readable
393
435
  * table plus any gate diagnostics and the non-zero-exit summary.
@@ -401,6 +443,7 @@ function runGateSafely(runner, args, label, stderr) {
401
443
  * json: boolean,
402
444
  * staged: boolean,
403
445
  * ref: string|null,
446
+ * cwd: string,
404
447
  * miResult: {exitCode: number, envelope: object|null},
405
448
  * crapResult: {exitCode: number, envelope: object|null},
406
449
  * merged: ReturnType<typeof mergeEnvelopes>,
@@ -413,6 +456,7 @@ function emitReport({
413
456
  json,
414
457
  staged,
415
458
  ref,
459
+ cwd,
416
460
  miResult,
417
461
  crapResult,
418
462
  merged,
@@ -438,11 +482,7 @@ function emitReport({
438
482
  return;
439
483
  }
440
484
  stdout.write('\n--- quality:preview ---\n');
441
- stdout.write(
442
- staged
443
- ? 'scope=staged (git diff --cached)\n\n'
444
- : `scope=diff ref=${ref}\n\n`,
445
- );
485
+ stdout.write(stagedScopeLine({ staged, ref, cwd }));
446
486
  stdout.write(`${renderTable(merged)}\n`);
447
487
  const diagnostics = renderDiagnostics([miResult, crapResult]);
448
488
  if (diagnostics) stdout.write(`\n${diagnostics}\n`);
@@ -518,6 +558,7 @@ export async function runCli({
518
558
  json,
519
559
  staged,
520
560
  ref,
561
+ cwd,
521
562
  miResult,
522
563
  crapResult,
523
564
  merged,
@@ -39,6 +39,7 @@ import { parseArgs } from 'node:util';
39
39
  import { runAsCli } from './lib/cli-utils.js';
40
40
  import { resolveConfig } from './lib/config-resolver.js';
41
41
  import { Logger, routeAllOutputToStderr } from './lib/Logger.js';
42
+ import { expandEpicIds } from './lib/orchestration/epic-expansion.js';
42
43
  import {
43
44
  buildStoriesEnvelope,
44
45
  isSatisfiedBlocker,
@@ -71,7 +72,9 @@ real issue state.
71
72
  Options:
72
73
  --ids <csv> Comma-separated Story issue numbers. Required. A token may be
73
74
  a single id (4922) or an inclusive dash range (4922-4926);
74
- ranges expand in place and dedupe against the rest.
75
+ ranges expand in place and dedupe against the rest. A
76
+ container Epic id expands to its open child Stories, and may
77
+ be mixed with Story ids.
75
78
  --pretty Pretty-print the JSON envelope.
76
79
  --no-native Skip the native blocked_by read (body edges only).
77
80
  --help Show this help.
@@ -89,17 +92,54 @@ export function resolveStoriesProvider({
89
92
  return { provider: createProviderFn(config), config };
90
93
  }
91
94
 
95
+ /**
96
+ * Read an Epic's native sub-issue children as issue numbers.
97
+ *
98
+ * Injected into `expandEpicIds` so the lib layer stays provider-agnostic,
99
+ * exactly as `paginate` is injected into `readNativeBlockedBy`. A provider
100
+ * without the GraphQL surface yields `[]`, and the Epic body's checklist
101
+ * carries the children on its own.
102
+ *
103
+ * @param {object} provider
104
+ * @returns {(epic: object) => Promise<number[]>}
105
+ */
106
+ export function nativeChildReader(provider) {
107
+ return async (epic) => {
108
+ if (typeof provider?._getNativeSubIssues !== 'function') return [];
109
+ return provider._getNativeSubIssues(epic?.nodeId, epic?.number ?? epic?.id);
110
+ };
111
+ }
112
+
92
113
  /**
93
114
  * Fetch every requested id and map it to a Story record, failing on the first
94
115
  * id that is not a deliverable Story.
95
116
  *
117
+ * Container Epics are expanded to their open child Stories **first**, so
118
+ * everything downstream sees a plain Story-id list (Story #5139). The
119
+ * expansion walk is sequential because it is id-by-id conditional; the Story
120
+ * fetch that follows stays under the bounded concurrency.
121
+ *
96
122
  * @param {object} provider
97
123
  * @param {number[]} ids
98
124
  * @returns {Promise<object[]>}
99
125
  */
100
126
  export async function fetchStories(provider, ids) {
101
- return concurrentMap(
127
+ const { ids: resolvedIds, expansions } = await expandEpicIds({
102
128
  ids,
129
+ getTicket: (id) => provider.getTicket(id),
130
+ readNativeChildIds: nativeChildReader(provider),
131
+ warn: (m) => Logger.warn(m),
132
+ });
133
+
134
+ for (const { epicId, childIds } of expansions) {
135
+ Logger.info(
136
+ `[resolve-stories] Epic #${epicId} → ${childIds.length} open Story(ies): ` +
137
+ childIds.map((c) => `#${c}`).join(', '),
138
+ );
139
+ }
140
+
141
+ return concurrentMap(
142
+ resolvedIds,
103
143
  async (id) => {
104
144
  const issue = await provider.getTicket(id);
105
145
  if (!issue) {
@@ -1,11 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  // .agents/scripts/validate-skills.js
3
3
  //
4
- // Walk `.agents/skills/{core,stack}/**/SKILL.md` via the shared parser
4
+ // Walk `SKILL.md` under both skills roots — the package payload
5
+ // (`.agents/skills/{core,stack}/`) and the consumer-writable local zone
6
+ // (`.agents/local/skills/{core,stack}/`, Story #5135) — via the shared parser
5
7
  // helper, validate each frontmatter block against
6
8
  // `.agents/schemas/skill.schema.json`, enforce Policy Capsule presence
7
- // (5–12 bullets), and verify membership in `.agents/skills/skills.index.json`
8
- // when that manifest exists. All findings are batched into a single
9
+ // (5–12 bullets), and verify membership in each root's own manifest when it
10
+ // exists. A consumer-authored skill is held to exactly the same bar as a
11
+ // shipped one; the roots are validated separately because each carries its
12
+ // own index (the shipped manifest is a payload file and must stay
13
+ // payload-only — see generate-skills-index.js). All findings are batched into a single
9
14
  // human-readable report; the process exits non-zero when any finding is
10
15
  // surfaced.
11
16
  //
@@ -30,7 +35,17 @@ import { parseStandardCliArgs } from './lib/cli/standard-args.js';
30
35
  import { runAsCli } from './lib/cli-utils.js';
31
36
  import { Logger } from './lib/Logger.js';
32
37
  import { parseSkill } from './lib/skills/parse-skill.js';
33
- import { collectSkillFiles } from './lib/skills/walk-skill-files.js';
38
+ import {
39
+ auditIndex,
40
+ indexPathFor,
41
+ readIndexPaths,
42
+ } from './lib/skills/skills-index.js';
43
+ import {
44
+ collectLocalSkillFiles,
45
+ collectSkillFiles,
46
+ LOCAL_SKILLS_SEGMENTS,
47
+ PAYLOAD_SKILLS_SEGMENTS,
48
+ } from './lib/skills/walk-skill-files.js';
34
49
 
35
50
  const MIN_CAPSULE_BULLETS = 5;
36
51
  const MAX_CAPSULE_BULLETS = 12;
@@ -118,56 +133,11 @@ function buildManifestValidator(repoRoot) {
118
133
  }
119
134
 
120
135
  /**
121
- * Read the on-disk skills.index.json manifest, returning `{ exists, paths,
122
- * manifest }` where `paths` is the Set of `entry.path` values when the file
123
- * is present and parseable, or null otherwise.
136
+ * Read one root's manifest into the `{ exists, paths, manifest, indexPath }`
137
+ * shape the findings pass consumes.
124
138
  */
125
- function readIndex(repoRoot) {
126
- const indexPath = path.join(
127
- repoRoot,
128
- '.agents',
129
- 'skills',
130
- 'skills.index.json',
131
- );
132
- if (!fs.existsSync(indexPath)) {
133
- return { exists: false, paths: null, manifest: null, indexPath };
134
- }
135
- try {
136
- const manifest = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
137
- const paths = new Set(
138
- Array.isArray(manifest.skills)
139
- ? manifest.skills
140
- .map((s) => s.path)
141
- .filter((p) => typeof p === 'string')
142
- : [],
143
- );
144
- return { exists: true, paths, manifest, indexPath };
145
- } catch (err) {
146
- return {
147
- exists: true,
148
- paths: null,
149
- manifest: null,
150
- indexPath,
151
- parseError: err.message,
152
- };
153
- }
154
- }
155
-
156
- /**
157
- * Validate a parsed manifest against skills-index.schema.json. Returns
158
- * finding strings tagged with the `manifest-schema` pillar.
159
- */
160
- function validateManifestSchema(manifest, indexRelPath, validateManifest) {
161
- const findings = [];
162
- if (!validateManifest(manifest)) {
163
- for (const err of validateManifest.errors ?? []) {
164
- const where = err.instancePath || '(root)';
165
- findings.push(
166
- `${indexRelPath}: manifest-schema: schema violation at ${where}: ${err.message}`,
167
- );
168
- }
169
- }
170
- return findings;
139
+ function readIndex(repoRoot, rootSegments = PAYLOAD_SKILLS_SEGMENTS) {
140
+ return readIndexPaths(indexPathFor(repoRoot, rootSegments));
171
141
  }
172
142
 
173
143
  /**
@@ -216,6 +186,7 @@ function rel(absPath, repoRoot) {
216
186
  /**
217
187
  * Pure entry point used by tests. Returns `{ status, output, findings }`.
218
188
  */
189
+
219
190
  export function run({ argv = [], repoRoot } = {}) {
220
191
  const parsed = parseArgs(argv);
221
192
  if (parsed.help) {
@@ -234,25 +205,41 @@ export function run({ argv = [], repoRoot } = {}) {
234
205
  const indexRel = rel(indexInfo.indexPath, root);
235
206
 
236
207
  const findings = [];
237
- if (!indexInfo.exists) {
238
- findings.push(
239
- `index missing: ${indexRel} not found — run 'node .agents/scripts/generate-skills-index.js'`,
240
- );
241
- } else if (indexInfo.paths === null) {
242
- findings.push(`index unparseable: ${indexRel} — ${indexInfo.parseError}`);
243
- } else if (indexInfo.manifest !== null) {
244
- findings.push(
245
- ...validateManifestSchema(indexInfo.manifest, indexRel, validateManifest),
246
- );
247
- }
208
+ findings.push(
209
+ ...auditIndex(indexInfo, indexRel, validateManifest, { required: true }),
210
+ );
248
211
 
249
- const skillFiles = collectSkillFiles(root);
212
+ const payloadFiles = collectSkillFiles(root);
250
213
  const indexPaths =
251
214
  indexInfo.exists && indexInfo.paths !== null ? indexInfo.paths : null;
252
- for (const file of skillFiles) {
215
+ for (const file of payloadFiles) {
253
216
  findings.push(...validateOne(file, root, validateFrontmatter, indexPaths));
254
217
  }
255
218
 
219
+ // The local zone is optional: a repo with no consumer-authored skills has
220
+ // no local root and no local index, and that is a clean run, not a finding.
221
+ const localFiles = collectLocalSkillFiles(root);
222
+ if (localFiles.length > 0) {
223
+ const localIndexInfo = readIndex(root, LOCAL_SKILLS_SEGMENTS);
224
+ const localIndexRel = rel(localIndexInfo.indexPath, root);
225
+ findings.push(
226
+ ...auditIndex(localIndexInfo, localIndexRel, validateManifest, {
227
+ required: true,
228
+ }),
229
+ );
230
+ const localIndexPaths =
231
+ localIndexInfo.exists && localIndexInfo.paths !== null
232
+ ? localIndexInfo.paths
233
+ : null;
234
+ for (const file of localFiles) {
235
+ findings.push(
236
+ ...validateOne(file, root, validateFrontmatter, localIndexPaths),
237
+ );
238
+ }
239
+ }
240
+
241
+ const skillFiles = [...payloadFiles, ...localFiles];
242
+
256
243
  if (findings.length === 0) {
257
244
  Logger.info(`validate-skills: ${skillFiles.length} skill(s) passed`);
258
245
  return { status: 0, output: '', findings };
@@ -0,0 +1,169 @@
1
+ # Audit sweep runbook
2
+
3
+ > **Template generated by Mandrel.** Copy it into your own docs tree (e.g.
4
+ > `docs/audit-sweep-runbook.md`), then localise every bracketed placeholder:
5
+ > the cadence, the lens list, who reviews the ledger PR, and the label
6
+ > conventions your repository actually uses. The steps themselves are the
7
+ > contract — the values around them are yours.
8
+ >
9
+ > The workflow this runbook drives is
10
+ > [`/audit-to-stories`](../../workflows/audit-to-stories.md); the CLI it calls
11
+ > is [`audit-to-stories.js`](../../scripts/audit-to-stories.js). Run the CLI
12
+ > with `--help` for the authoritative flag list.
13
+
14
+ ## What this runbook is for
15
+
16
+ A maintenance **sweep** runs the `audit-*` lenses full-scope, folds their
17
+ findings onto the cross-run ledger, and turns what is genuinely new into
18
+ Stories. It is the unattended sibling of an interactive `/audit-to-stories`
19
+ run: no HITL gates, so every judgement call the interactive path asks a human
20
+ has to be settled here instead.
21
+
22
+ | Setting | Value for this repository |
23
+ | --- | --- |
24
+ | Cadence | _e.g. weekly, Sunday 02:00_ |
25
+ | Lenses in scope | _e.g. security, clean-code, quality, dependencies_ |
26
+ | Severity floor | _`delivery.auditToStories.severityFloor`, default `high`_ |
27
+ | Ledger reviewer | _e.g. @your-handle_ |
28
+ | Story triage owner | _e.g. the on-call maintainer_ |
29
+
30
+ ## Step 1 — Run the lenses full-scope
31
+
32
+ Run each `audit-*` workflow with **no** `--paths` and no change-set filter, so
33
+ the whole target-set union is audited rather than whatever a recent branch
34
+ happened to touch. Each lens writes its report to
35
+ `temp/audits/audit-<lens>-results.md`.
36
+
37
+ A sweep scoped to a change set is not a sweep: it re-reports the same recent
38
+ files every cycle and never reaches the code nobody has touched in a year,
39
+ which is exactly where audit findings accumulate.
40
+
41
+ ## Step 2 — Cross-check the severity tally
42
+
43
+ Every lens report ends its executive summary with a machine-readable line:
44
+
45
+ ```text
46
+ Severity tally: Critical 0 / High 3 / Medium 7 / Low 2
47
+ ```
48
+
49
+ The CLI re-counts the `### Finding` blocks it actually parsed and compares them
50
+ to that declared tally. A mismatch — or a missing tally line — means the report
51
+ is not trustworthy: a finding was malformed, a severity did not resolve onto the
52
+ canonical scale, or the lens truncated its own output.
53
+
54
+ `--auto` **fails closed** on any such failure. It exits non-zero having opened
55
+ no Issue and written no ledger, and names the offending report in
56
+ `summary.reportFailures[]`. `--allow-missing-tally` is a `--scan` affordance
57
+ only; `--auto` ignores it by design, because an unattended run has no operator
58
+ to read a warning.
59
+
60
+ **When the sweep goes red here, re-run the lens.** Do not reach for
61
+ `--allow-missing-tally` and do not hand-edit the report to make the numbers
62
+ agree — the tally is the only signal that the parse saw what the lens wrote.
63
+
64
+ ## Step 3 — Dry-run the first cycles
65
+
66
+ Start every new sweep in report-only mode, and stay there until the tallies
67
+ stop surprising you:
68
+
69
+ ```bash
70
+ node .agents/scripts/audit-to-stories.js --auto --dry-run
71
+ ```
72
+
73
+ `--dry-run` performs zero GitHub writes and skips the ledger write, printing
74
+ only the run summary. Read `totals.create` before you let the sweep file
75
+ anything: a first full-scope run over an un-audited repository can propose more
76
+ Stories than your team can triage in a quarter. Raise `--severity` (or
77
+ `delivery.auditToStories.severityFloor`) until the create count is a batch you
78
+ would actually take on, then go live.
79
+
80
+ ## Step 4 — Go live, and persist the ledger
81
+
82
+ ```bash
83
+ node .agents/scripts/audit-to-stories.js --auto --ledger-commit
84
+ ```
85
+
86
+ The cross-run ledger (`baselines/audit-ledger.json`) is **consumer state, not
87
+ scratch output**. It is what lets the next sweep tell a re-detection from a
88
+ fresh finding, and a deliberately-rejected finding from an unseen one. A
89
+ scheduled job normally runs on an ephemeral checkout, so unless the ledger is
90
+ committed back it dies with the clone and every later sweep starts amnesiac —
91
+ re-proposing findings already filed and re-surfacing findings a human already
92
+ rejected.
93
+
94
+ `--ledger-commit` closes that loop. After the run summary has printed, and only
95
+ when the ledger actually changed, it:
96
+
97
+ 1. creates `chore/audit-ledger-<YYYY-MM-DD>` from the current HEAD,
98
+ 2. commits **only** the ledger file, subject
99
+ `chore(audit): reconcile audit ledger <date>`,
100
+ 3. pushes the branch, and
101
+ 4. opens a PR against your base branch.
102
+
103
+ **Auto-merge is never requested.** The ledger records machine-derived lifecycle
104
+ state, so a human glance before it lands is the point — nominate that reviewer
105
+ in the table above. Review the PR for entries flipping to `accepted-risk` or
106
+ `regressed`; those two are the ledger telling you something about your backlog,
107
+ not about itself.
108
+
109
+ Any git or `gh` failure in that sequence is fatal and names the step it broke
110
+ on — but it happens _after_ the summary is printed, so a broken remote never
111
+ costs you the sweep's findings.
112
+
113
+ Without the flag, a run whose ledger changed on a checkout that cannot persist
114
+ it — no `origin` remote, or HEAD parked off the base branch — sets
115
+ `ledger.unpersisted: true` in the summary and warns on stderr naming the file.
116
+ Treat that warning as a red sweep: the findings are fine, but the memory is
117
+ about to be thrown away.
118
+
119
+ ## Step 5 — Enrich before you deliver
120
+
121
+ `--emit-stories` renders `{ title, body, labels }` payloads from audit findings.
122
+ Those bodies are **audit prose**, not delivery-ready Specs: they describe a
123
+ symptom and a recommendation, not a scoped change with acceptance criteria a
124
+ worker can verify against.
125
+
126
+ Do not point `/mandrel-deliver` at a freshly-filed audit Story. Route it through
127
+ `/mandrel-plan` first — the planning pass is where the finding becomes a
128
+ capability slice with a `## Spec`, real `acceptance[]` items and runnable
129
+ `verify[]` lines. Planning is deliberately not automated here: deciding what a
130
+ finding is worth, and how far the fix should reach, is the judgement the sweep
131
+ exists to surface rather than to make.
132
+
133
+ The wiring pass is the exception — it _is_ mechanical and it is **required**.
134
+ After opening the Issues, replay their numbers through `--wire-edges` so the
135
+ cohort's declared ordering exists as `blocked by #N` footers and native
136
+ `blocked_by` relations. An unwired cohort is genuinely unordered, and
137
+ `/mandrel-deliver` will co-dispatch Stories the edges say must follow one
138
+ another.
139
+
140
+ ## Step 6 — Label convention
141
+
142
+ Audit-sourced Stories carry a closed label set, so they can be filtered out of
143
+ (or into) ordinary planning at a glance:
144
+
145
+ | Label | Meaning |
146
+ | --- | --- |
147
+ | `type::story` | Every emitted Story — the sweep never opens Epics or tasks. |
148
+ | `agent::ready` | Filed and available for pickup. |
149
+ | `audit::<lens>` | One per lens represented in the group; a cross-audit merge carries several. |
150
+ | `risk::high` | Added when any finding in the group is Critical. |
151
+
152
+ The lens labels are a **closed taxonomy**: only the canonical `audit::<lens>`
153
+ names are valid, and the filer refuses to emit a label the repository has never
154
+ created. Create them once with `audit-labels-bootstrap.js` before the first live
155
+ sweep — a generated label that does not exist makes every Issue create fail.
156
+
157
+ Do not invent per-finding labels. If you need another axis (a team, a
158
+ component), add it in triage on top of this set rather than teaching the sweep
159
+ to mint labels no taxonomy defines.
160
+
161
+ ## Failure triage
162
+
163
+ | Symptom | Cause | Action |
164
+ | --- | --- | --- |
165
+ | Non-zero exit, `summary.reportFailures[]` populated | A lens report's tally is missing or disagrees with the parse | Re-run that lens; never downgrade with `--allow-missing-tally` |
166
+ | `ledger.unpersisted: true` in the summary | No `origin`, or HEAD off the base branch | Re-run with `--ledger-commit`, or commit the ledger by hand |
167
+ | `--ledger-commit failed at step "..."` | git or `gh` failed at the named step | Fix the remote/auth and re-run; the summary above it is still valid |
168
+ | Same findings re-proposed every cycle | The ledger is not being committed | Adopt Step 4 |
169
+ | `totals.create` far larger than the team can absorb | Severity floor too low for a first full-scope run | Raise `--severity` and re-dry-run |
@@ -44,10 +44,12 @@ They remain read-only emitters of audit reports.
44
44
  ## Phase 1 — Discover & parse
45
45
 
46
46
  Run the CLI in `--scan` mode against the resolved glob. It parses every
47
- `### Finding` block, normalises the fields (`Severity` / `Impact` are
47
+ finding block, normalises the fields (`Severity` / `Impact` are
48
48
  both recognised; `Dimension` / `Category` likewise), and extracts file
49
- paths mentioned in the body. It then stamps each finding with a stable
50
- sha1 fingerprint via the shared
49
+ paths mentioned in the body. A `###` heading that carries no severity axis and
50
+ holds `####` blocks is read as a **grouping header**: its `####` children are
51
+ the findings, and the header itself never becomes one. It then stamps each
52
+ finding with a stable sha1 fingerprint via the shared
51
53
  [`lib/findings/route-finding.js`](../scripts/lib/findings/route-finding.js)
52
54
  helper (`fingerprintFinding`) — the single dedup/route implementation
53
55
  shared with `qa-explore`. The workflow carries **no** separate inline
@@ -64,6 +66,19 @@ The emitted plan envelope carries `findings`, `groups`, `edges`,
64
66
  `classifications`, and `summary`. Subsequent phases consume the file
65
67
  rather than re-parsing the reports.
66
68
 
69
+ **The tally cross-check is automatic.** Every report declares
70
+ `Severity tally: Critical <n> / High <n> / Medium <n> / Low <n>` in its
71
+ Executive Summary; the scan compares that line with what it parsed and carries
72
+ each disagreement on `summary.reportFailures[]` as
73
+ `{ sourceReport, kind, reported, parsed }`. The kinds are `missing-tally` (no
74
+ line), `tally-mismatch` (line and parse disagree), and `unresolved-severity` (a
75
+ finding whose severity did not resolve — dropped from grouping, never filed as
76
+ an `unknown` group). They print to stderr before `--scan` returns its plan, so
77
+ a mis-parsed report is never read as a clean audit: re-run the lens rather than
78
+ file from it. Over older reports predating the mandate,
79
+ `--scan --allow-missing-tally` downgrades **only** `missing-tally` to a
80
+ warning.
81
+
67
82
  ## Phase 2 — HITL: severity gate
68
83
 
69
84
  Read the plan envelope's `summary.tally`. Present the operator with the
@@ -117,6 +132,11 @@ Ask:
117
132
  > default-single policy.
118
133
  > - **Individual standalone Stories** — opens one GitHub Issue per
119
134
  > group directly (no plan ceremony).
135
+ >
136
+ > Either way, if the sweep proposes **more than 2** Stories they are grouped
137
+ > under a **container Epic** by default — a title, a one-paragraph goal and a
138
+ > child checklist, carrying nothing a child does not already carry. Say so if
139
+ > you would rather file them flat.
120
140
 
121
141
  **STOP** until the operator picks.
122
142
 
@@ -133,7 +153,14 @@ node .agents/scripts/audit-to-stories.js --emit-plan-seed \
133
153
  The seed renders the canonical one-pager sections — Problem Statement,
134
154
  Recommended Direction, Key Assumptions (with links to every source
135
155
  report), MVP Scope (the M proposed Stories), Key Files (so `/mandrel-plan`'s
136
- authoring step has concrete anchors), Not Doing.
156
+ authoring step has concrete anchors), Grouping, Not Doing.
157
+
158
+ **Grouping is the container-Epic directive.** Above 2 proposed Stories the
159
+ seed instructs `/mandrel-plan` to group them under one Epic — a sweep is the
160
+ clearest case for a container, since every Story shares a provenance and an
161
+ operator usually delivers them together. It is a directive in the text, not an
162
+ automatic write: Phase 4 above is where an operator declines it. Below the
163
+ threshold the section says so and asks for nothing.
137
164
 
138
165
  Chain into the existing planning entrypoint:
139
166
 
@@ -215,6 +242,14 @@ its footprint guard ignores the shared provenance footers, so an unwired cohort
215
242
  is genuinely unordered and `/mandrel-deliver` will co-dispatch Stories the edges say
216
243
  must follow one another.
217
244
 
245
+ **Preconditions.** The pass writes through the configured provider, so it needs
246
+ `github.owner` **and** `github.repo` in `.agentrc.json` plus working `gh` auth
247
+ (`GH_TOKEN`/`gh auth status`) — the same two things Phase 1's dedup needs. When
248
+ either is missing the command refuses and names which one; fix that and re-run
249
+ the exact command above. Do not transcribe the footers by hand: `/mandrel-deliver`
250
+ reads them, but the native `blocked_by` relations only exist if this pass wrote
251
+ them.
252
+
218
253
  ## Phase 6 — Idempotency (folded into Phase 1 scan)
219
254
 
220
255
  The `--scan` step routes each group's findings through the shared
@@ -289,6 +324,8 @@ summarising the run:
289
324
 
290
325
  When the single-plan path ran, link the Story (or plan-run) the chained
291
326
  `/mandrel-plan` opened. When the Standalone-Stories path ran, list every Issue URL.
327
+ Either way, name the container Epic if one was created — it is the single id
328
+ that delivers the whole sweep (`/mandrel-deliver <epicId>`).
292
329
 
293
330
  ## Constraints
294
331
 
@@ -330,18 +367,59 @@ writing their `temp/audits/audit-*-results.md` reports, then (2) invokes the
330
367
  CLI's **`--auto` mode** over those results:
331
368
 
332
369
  ```bash
333
- node .agents/scripts/audit-to-stories.js --auto [--dry-run] \
370
+ node .agents/scripts/audit-to-stories.js --auto [--dry-run] [--ledger-commit] \
334
371
  [--glob "temp/audits/audit-*-results.md"] [--severity <floor>]
335
372
  ```
336
373
 
374
+ The routine shape is **lenses full-scope → dry-run → live with a ledger PR**:
375
+
376
+ 1. Run the `audit-*` lenses with no `--paths` and no change-set filter. A
377
+ sweep scoped to a change set re-reports the same recent files every cycle
378
+ and never reaches the untouched code where findings accumulate.
379
+ 2. `--auto --dry-run` for the first cycles — zero writes, summary only. Read
380
+ `totals.create` and raise the severity floor until it is a batch the team
381
+ would actually take on.
382
+ 3. `--auto --ledger-commit` once the tallies stop surprising you.
383
+
337
384
  `--auto` runs with **no interactive gates**: it resolves the severity floor
338
385
  from `delivery.auditToStories.severityFloor` (default `high`, overridable with
339
386
  `--severity`), applies the two-stage dedup, reconciles the cross-run ledger,
340
387
  and prints a run-summary JSON (create / skip-open / skip-reoccurring /
341
388
  suppressed-by-ledger tallies, plus the re-detected open Issue numbers an
342
389
  operator may want a "re-detected" comment on). `--dry-run` performs zero GitHub
343
- writes and skips the ledger write, emitting only the summary. The host
344
- scheduler owns the cadence; this workflow owns the routing.
390
+ writes and skips the ledger write, emitting only the summary.
391
+
392
+ `--auto` **fails closed on any `summary.reportFailures[]` entry** (Phase 1): an
393
+ unattended sweep has no operator to read a warning, so a missing or mismatched
394
+ `Severity tally:` line — or a finding whose severity did not resolve — exits
395
+ non-zero having opened no Issue and written no ledger. `--allow-missing-tally`
396
+ is a `--scan` affordance that `--auto` ignores. A red sweep means the report is
397
+ untrustworthy: re-run the lens. The host scheduler owns the cadence; this
398
+ workflow owns the routing.
399
+
400
+ ### The ledger is consumer state — commit it
401
+
402
+ `baselines/audit-ledger.json` is **committed consumer state, not scratch
403
+ output**. A scheduled sweep normally runs on an ephemeral checkout, so unless
404
+ the reconciled ledger is committed back it dies with the clone: every later
405
+ sweep starts amnesiac, re-proposing findings already filed and re-surfacing
406
+ findings a human already rejected.
407
+
408
+ `--ledger-commit` closes that loop. After the summary prints — and only when
409
+ the ledger changed — it creates `chore/audit-ledger-<YYYY-MM-DD>` from HEAD,
410
+ commits **only** the ledger file, pushes it, and opens a PR against
411
+ `project.baseBranch`. **Auto-merge is never requested**: a human glance at the
412
+ `accepted-risk` / `regressed` flips before it lands is the point. A git or `gh`
413
+ failure is fatal and names its step, but only after the summary is printed, so
414
+ a broken remote never costs the operator the run's findings. `--dry-run` skips
415
+ the tail. Without the flag, a changed ledger on a checkout that cannot persist
416
+ it — no `origin`, or HEAD off the base branch — sets `ledger.unpersisted: true`
417
+ in the summary and warns on stderr naming the file.
418
+
419
+ The full sweep procedure — tally cross-check, the ledger PR, the
420
+ enrich-before-deliver step and the label convention — ships as a
421
+ consumer-copyable template at
422
+ [`templates/docs/audit-sweep-runbook.md`](../templates/docs/audit-sweep-runbook.md).
345
423
 
346
424
  ## See also
347
425