mandrel 2.55.0 → 2.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.agents/docs/agentrc-reference.json +4 -0
  2. package/.agents/docs/configuration.md +3 -0
  3. package/.agents/rules/ci-remediation.md +39 -21
  4. package/.agents/schemas/agentrc.schema.json +19 -0
  5. package/.agents/scripts/audit-to-stories.js +222 -75
  6. package/.agents/scripts/file-ci-gap.js +306 -0
  7. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +25 -1
  8. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +40 -52
  9. package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +5 -1
  10. package/.agents/scripts/lib/audit-to-stories/issue-corpus.js +162 -0
  11. package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
  12. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +1 -1
  13. package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
  14. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
  15. package/.agents/scripts/lib/config-settings-schema.js +33 -0
  16. package/.agents/scripts/lib/feedback-loop/graduator-core.js +53 -13
  17. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +71 -25
  18. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +18 -25
  19. package/.agents/scripts/lib/{audit-to-stories/ledger.js → findings/audit-ledger.js} +131 -24
  20. package/.agents/scripts/lib/findings/route-finding.js +38 -0
  21. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  22. package/.agents/scripts/lib/github/framework-repo.js +148 -2
  23. package/.agents/scripts/lib/label-constants.js +6 -1
  24. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  25. package/.agents/scripts/lib/orchestration/ci-gap-intake.js +605 -0
  26. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +13 -8
  27. package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
  28. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +15 -2
  29. package/.agents/scripts/lib/orchestration/run-epilogue.js +4 -4
  30. package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
  31. package/.agents/scripts/pr-watch-with-update.js +3 -2
  32. package/.agents/workflows/audit-to-stories.md +63 -27
  33. package/.agents/workflows/helpers/deliver-story-reference.md +19 -4
  34. package/.agents/workflows/helpers/plan-reference.md +23 -0
  35. package/.agents/workflows/mandrel-plan.md +6 -6
  36. package/docs/CHANGELOG.md +10 -0
  37. package/package.json +1 -1
@@ -28,6 +28,10 @@
28
28
  "projectOwner": null,
29
29
  "operatorHandle": "@[USERNAME]",
30
30
  "defaultTimeoutMs": 60000,
31
+ "followUpRepos": {
32
+ "framework": "dsj1984/mandrel",
33
+ "platform": null
34
+ },
31
35
  "branchProtection": {
32
36
  "enforce": true,
33
37
  "requiredChecks": [
@@ -103,6 +103,9 @@ GitHub provider identity plus the remote stance the bootstrap enforces. `owner`,
103
103
  | `projectOwner` | No | `string` \| `null` | `null` | Owner of the Projects V2 board when it lives outside `owner` (an org board fed by a user repo). `null` means the board shares `owner`. |
104
104
  | `operatorHandle` | Yes | `string` | `"@[USERNAME]"` | The human the framework escalates to, `@`-prefixed. Used for HITL @-mentions on `agent::blocked`. |
105
105
  | `defaultTimeoutMs` | No | `integer` | `60000` | Default `timeoutMs` applied to every `gh` subprocess the provider facade spawns, so a stalled socket or long-poll cannot hang an orchestration indefinitely. A `GhExecTimeoutError` from a hit ceiling is classified `transient` and retried by `withTransientRetry`. Story #2860. |
106
+ | `followUpRepos` | No | `object` | — | Repository slugs for the non-consumer follow-up ownership buckets, used when a CI gap, retro proposal, or audit finding belongs to someone other than the repo that surfaced it. |
107
+ | `followUpRepos.framework` | No | `string` | `"dsj1984/mandrel"` | `<owner>/<repo>` that owns framework-level defects. Defaults to the Mandrel mirror — the one bucket with a knowable default. |
108
+ | `followUpRepos.platform` | No | `string` \| `null` | `null` | `<owner>/<repo>` for a shared platform or infrastructure tracker (a shared base config, a runner fleet, a cross-repo toolchain). No default — nothing can guess a shared repo. Left unset, platform-owned findings file locally and say so. |
106
109
  | `branchProtection` | No | `object` | — | Branch-protection stance applied to `project.baseBranch` by the GitHub bootstrap, and reproduced locally before every push. |
107
110
  | `branchProtection.enforce` | No | `boolean` | `true` | When true, the GitHub bootstrap writes the required-check ruleset. False leaves the remote stance alone. |
108
111
  | `branchProtection.requiredChecks[]` | No | `array<object>` | `[{"name":"lint","cmd":["npm","run","lint"]},{"name":"test","cmd":["npm","test"]},{"name":"baselines","cmd":["node",".agents/scripts/check-baselines.js"]}]` | Checks that must pass before a Story PR merges. Each entry carries both the remote context name and the local argv. Each item has: name, cmd. |
@@ -27,13 +27,29 @@ exactly one of two ways, and no others:
27
27
  through the fix table in
28
28
  [`deliver-story-reference.md` § Step 4](../workflows/helpers/deliver-story-reference.md#step-4--ci-watch--fix-recovery);
29
29
  refresh a baseline only when the diff demonstrably can't be covered.
30
- 2. **File a `meta::framework-gap` issue** when the root cause is outside this
30
+ 2. **File the CI-gap intake issue** when the root cause is outside this
31
31
  delivery's scope — a pre-existing flaky test, a runner/infra weakness, a
32
- framework-level environment gap. Open the issue with the `meta::framework-gap`
33
- label (see [`git-conventions.md`](git-conventions.md)) carrying **the run
34
- link and the failure signature** so a later `/mandrel-plan` Phase 0 sweep can act on
35
- it. Remediate this delivery only if the pre-existing defect is genuinely
36
- blocking it.
32
+ framework-level environment gap. One command does it, and it is the only
33
+ sanctioned filing surface:
34
+
35
+ ```bash
36
+ node .agents/scripts/file-ci-gap.js --story <id> --verdict <verdict> \
37
+ --owner <consumer|framework|platform> --evidence "<proof reading>" [--block]
38
+ ```
39
+
40
+ It reads the digest for the run link and failure signature, routes the
41
+ filing to the repository that **owns** the fault, dedups by signature so
42
+ the Nth occurrence updates the existing ticket, posts the `friction`
43
+ comment, and (with `--block`) flips the Story. Hand-running `gh issue
44
+ create` is not the fallback: it files an issue no `/mandrel-plan` pass can
45
+ graduate, in whichever repo you happen to be standing in. Remediate this
46
+ delivery only if the pre-existing defect is genuinely blocking it.
47
+
48
+ **`--owner` is the judgement call**, and it is yours to make from the
49
+ evidence: `consumer` for this repository's own code, `framework` for a
50
+ Mandrel defect, `platform` for a shared base config, runner fleet, or
51
+ cross-repo toolchain that neither owns. An unconfigured bucket files
52
+ locally and says so in the issue body — it never pretends to be routed.
37
53
 
38
54
  Infra, transient, and flaky failures are root-cause defects too — a flaky test
39
55
  that passes on a rerun is still a bug that will fail a future run. They route
@@ -49,9 +65,9 @@ the two options above. Name the verdict you reached in the `friction` comment.
49
65
  | Verdict | Evidence | Routes to |
50
66
  | --- | --- | --- |
51
67
  | **defect-in-diff** | The failure reproduces on the branch and not on an unmodified `main` | Option 1 — fix at source |
52
- | **pre-existing** | The same check fails on an unmodified `main` too | Option 2 — file `meta::framework-gap`; remediate here only if it blocks this delivery |
53
- | **capacity** | Proven exhaustion of a runner resource, not a property of the diff (see below) | Option 2 — file `meta::framework-gap` **and** escalate to the operator |
54
- | **unreproducible-tier** | The tier cannot be exercised in this sandbox at all, proven by an attempted attach (see below) | Option 2 — file `meta::framework-gap` **and** escalate on first encounter |
68
+ | **pre-existing** | The same check fails on an unmodified `main` too | Option 2 — `file-ci-gap.js --verdict pre-existing`; remediate here only if it blocks this delivery |
69
+ | **capacity** | Proven exhaustion of a runner resource, not a property of the diff (see below) | Option 2 — `file-ci-gap.js --verdict capacity` (`meta::framework-gap` unless `--owner` routes it elsewhere) **and** escalate to the operator |
70
+ | **unreproducible-tier** | The tier cannot be exercised in this sandbox at all, proven by an attempted attach (see below) | Option 2 — `file-ci-gap.js --verdict unreproducible-tier` (`meta::framework-gap` unless `--owner` routes it elsewhere) **and** escalate on first encounter |
55
71
 
56
72
  Why the verdict set carries these last two is recorded in
57
73
  [`docs/decisions.md` ADR 20260906-5160a](../../docs/decisions.md).
@@ -72,10 +88,12 @@ line naming the exhausted limit (an OOM kill, `ENOSPC`, `EMFILE`,
72
88
  timeout), plus the fact that the failure is not specific to this diff. Absent
73
89
  that reading the verdict is **flaky, not capacity**, and it routes to Option 1.
74
90
 
75
- On a `capacity` verdict: file the `meta::framework-gap` issue with the run link,
76
- the failure signature, and the resource reading; flip the Story to
77
- `agent::blocked` with a `friction` comment naming the verdict; and hand back to
78
- the operator, who owns the runner pool. Do not sit in a retry loop waiting for
91
+ On a `capacity` verdict: run `file-ci-gap.js --verdict capacity --block`, passing
92
+ the resource reading as `--evidence` (the run link and failure signature come
93
+ from the digest). That files the intake issue — `meta::framework-gap`, or
94
+ `meta::platform-gap` when `--owner platform` names a shared runner fleet — posts
95
+ the `friction` comment and flips the Story in one call; then hand back to the
96
+ operator, who owns the runner pool. Do not sit in a retry loop waiting for
79
97
  capacity to return.
80
98
 
81
99
  **Rerunning a failed job to reach green stays forbidden under every verdict,
@@ -106,10 +124,9 @@ both:
106
124
  failure in the app under test.
107
125
 
108
126
  Absent both readings the verdict is unavailable and the failure routes as it did
109
- before. On the verdict: file the `meta::framework-gap` issue with the run link,
110
- the failure signature, and the attach attempt; flip the Story to
111
- `agent::blocked` with a `friction` comment naming the verdict; and hand back to
112
- the operator, who owns the sandbox. Do not author a fix for a tier you could not
127
+ before. On the verdict: run
128
+ `file-ci-gap.js --verdict unreproducible-tier --block`, passing the failed attach
129
+ as `--evidence`; then hand back to the operator, who owns the sandbox. Do not author a fix for a tier you could not
113
130
  run — a blind fix to a suite nobody exercised is how the gap compounds.
114
131
 
115
132
  ## Verifier
@@ -131,8 +148,9 @@ alongside the failing check-run identity. On green it adjudicates:
131
148
 
132
149
  - **Same head SHA** → the green came from re-running the failed job. The
133
150
  watcher exits non-zero, flips the Story to `agent::blocked` with a
134
- `friction` comment, and requires the `meta::framework-gap` issue (run link +
135
- failure signature, both already in the digest) before the delivery proceeds.
151
+ `friction` comment, and requires the CI-gap intake issue
152
+ (`file-ci-gap.js` — run link and failure signature are already in the
153
+ digest) before the delivery proceeds.
136
154
  - **New head SHA** → fix at source. The digest is retired, auto-merge is
137
155
  re-armed, and the delivery continues unobstructed.
138
156
 
@@ -153,8 +171,8 @@ operator under **any** of:
153
171
  - **Clearly-environmental → escalate immediately.** An unambiguously
154
172
  environmental failure outside your control (runner provisioning, a persistent
155
173
  registry/network outage, a branch-protection or CI misconfiguration, an
156
- expired credential) — file the `meta::framework-gap` issue (with run link +
157
- signature) and escalate on the first encounter rather than burning iterations
174
+ expired credential) — run `file-ci-gap.js --block` and escalate on the
175
+ first encounter rather than burning iterations
158
176
  trying to code around it. A proven-capacity failure is this case: reach the
159
177
  `capacity` verdict above and escalate on the first encounter.
160
178
  - **Unrunnable tier → escalate immediately.** A tier the sandbox cannot host at
@@ -169,6 +169,25 @@
169
169
  "description": "Default `timeoutMs` applied to every `gh` subprocess the provider facade spawns, so a stalled socket or long-poll cannot hang an orchestration indefinitely. A `GhExecTimeoutError` from a hit ceiling is classified `transient` and retried by `withTransientRetry`. Story #2860.",
170
170
  "default": 60000
171
171
  },
172
+ "followUpRepos": {
173
+ "type": "object",
174
+ "description": "Repository slugs for the non-consumer follow-up ownership buckets, used when a CI gap, retro proposal, or audit finding belongs to someone other than the repo that surfaced it.",
175
+ "properties": {
176
+ "framework": {
177
+ "type": "string",
178
+ "pattern": "^[^/\\s]+/[^/\\s]+$",
179
+ "description": "`<owner>/<repo>` that owns framework-level defects. Defaults to the Mandrel mirror — the one bucket with a knowable default.",
180
+ "default": "dsj1984/mandrel"
181
+ },
182
+ "platform": {
183
+ "type": ["string", "null"],
184
+ "pattern": "^[^/\\s]+/[^/\\s]+$",
185
+ "description": "`<owner>/<repo>` for a shared platform or infrastructure tracker (a shared base config, a runner fleet, a cross-repo toolchain). No default — nothing can guess a shared repo. Left unset, platform-owned findings file locally and say so.",
186
+ "default": null
187
+ }
188
+ },
189
+ "additionalProperties": false
190
+ },
172
191
  "branchProtection": {
173
192
  "type": "object",
174
193
  "description": "Branch-protection stance applied to `project.baseBranch` by the GitHub bootstrap, and reproduced locally before every push.",
@@ -36,18 +36,20 @@ import { parseArgs } from 'node:util';
36
36
  import { buildStoryBody } from './lib/audit-to-stories/build-story-body.js';
37
37
  import { classifyGroupsAgainstGitHub } from './lib/audit-to-stories/dedupe-against-github.js';
38
38
  import { formatEpicGrouping } from './lib/audit-to-stories/epic-grouping-directive.js';
39
- import { withFingerprints } from './lib/audit-to-stories/finding-adapter.js';
39
+ import {
40
+ toCanonicalFinding,
41
+ withFingerprints,
42
+ } from './lib/audit-to-stories/finding-adapter.js';
40
43
  import { groupFindings } from './lib/audit-to-stories/group-findings.js';
41
44
  import {
42
- DEFAULT_LEDGER_PATH,
43
- readLedger,
44
- reconcileLedger,
45
- writeLedger,
46
- } from './lib/audit-to-stories/ledger.js';
45
+ loadIssuesFile,
46
+ normaliseIssueHit,
47
+ } from './lib/audit-to-stories/issues-file.js';
47
48
  import {
48
49
  resolveLedgerSummary,
49
50
  runLedgerCommit,
50
51
  } from './lib/audit-to-stories/ledger-commit.js';
52
+ import { recordFiledIssues } from './lib/audit-to-stories/ledger-record.js';
51
53
  import {
52
54
  parseAuditReports,
53
55
  readSeverityTally,
@@ -55,6 +57,12 @@ import {
55
57
  import { buildPlanSeedMarkdown } from './lib/audit-to-stories/seed-from-findings.js';
56
58
  import { wireAuditStoryEdges } from './lib/audit-to-stories/wire-dependencies.js';
57
59
  import { runAsCli } from './lib/cli-utils.js';
60
+ import {
61
+ DEFAULT_LEDGER_PATH,
62
+ readLedger,
63
+ reconcileLedger,
64
+ writeLedger,
65
+ } from './lib/findings/audit-ledger.js';
58
66
  import { searchSemanticCandidates } from './lib/findings/semantic-issue-search.js';
59
67
  import {
60
68
  normalizeSeverity,
@@ -366,28 +374,6 @@ class ProviderUnavailableError extends Error {
366
374
  }
367
375
  }
368
376
 
369
- /**
370
- * Flatten one raw `searchIssues` hit onto the `{ number, state, title, body }`
371
- * shape the dedupe module reads, collapsing every closed-ish state spelling
372
- * (`CLOSED`, `state_reason: not_planned`, …) onto `'closed'`.
373
- *
374
- * @param {object} hit
375
- * @returns {{ number: number, state: 'open'|'closed', title: string, body: string }}
376
- */
377
- function normaliseIssueHit(hit) {
378
- return {
379
- number: hit.number,
380
- state: (hit.state ?? hit.state_reason ?? 'open')
381
- .toString()
382
- .toLowerCase()
383
- .includes('closed')
384
- ? 'closed'
385
- : 'open',
386
- title: hit.title ?? '',
387
- body: hit.body ?? '',
388
- };
389
- }
390
-
391
377
  /**
392
378
  * Walk the list endpoint once per label and merge the pages into one
393
379
  * deduplicated, normalised issue list.
@@ -649,6 +635,136 @@ function dedupDegradedWarning(entries) {
649
635
  );
650
636
  }
651
637
 
638
+ /**
639
+ * Render the operator-visible line naming a host-supplied index and its size.
640
+ *
641
+ * Always emitted for a `--issues-file` run, because "how many issues did you
642
+ * actually check against" is the one number that separates a real dedup from a
643
+ * plan that merely looks checked. At zero it is the load-bearing case: an empty
644
+ * corpus is a legitimate first sweep AND exactly what a broken fetch writes, so
645
+ * the operator — not the run — decides which this was. Deliberately distinct in
646
+ * wording from both `dedupSkippedWarning` and `dedupDegradedWarning` so the
647
+ * three are never confused in a scrollback.
648
+ *
649
+ * @param {{ source?: string, size?: number }} dedupIndex
650
+ * @returns {string}
651
+ */
652
+ function dedupIndexWarning({ size = 0 } = {}) {
653
+ if (size === 0) {
654
+ return (
655
+ 'dedup index: 0 issues supplied via --issues-file. Dedup DID run and ' +
656
+ 'every group is correctly "create" — but that is also what a fetch that ' +
657
+ 'returned nothing looks like. If audit issues already exist, the fetch ' +
658
+ 'that wrote this file is broken and this run will re-file them.'
659
+ );
660
+ }
661
+ return `dedup index: ${size} issue(s) supplied via --issues-file; every exact-fingerprint lookup was answered from it.`;
662
+ }
663
+
664
+ /**
665
+ * Render the warning for a pre-fetch of the issue index that could not
666
+ * complete. The run still dedups — it falls back to a per-finding search — but
667
+ * it loses the one-list saving, and until Story #5301 this failure was
668
+ * swallowed whole: the operator saw only the downstream per-group degradation
669
+ * and could not tell that the pre-fetch itself was the cause.
670
+ *
671
+ * @param {string} reason
672
+ * @returns {string}
673
+ */
674
+ function dedupIndexDegradedWarning(reason) {
675
+ return (
676
+ `dedup index unavailable: ${reason}. Dedup fell back to a per-finding ` +
677
+ 'search, which is slower and rate-limited — if those searches also fail, ' +
678
+ 'every affected group is classified "create" WITHOUT a dedup check.'
679
+ );
680
+ }
681
+
682
+ /**
683
+ * Phase 6: classify every group against GitHub, and say loudly whichever way
684
+ * it went.
685
+ *
686
+ * Extracted from `buildPlan` because the gate has three outcomes, not two, and
687
+ * inlining them pushed the caller past its complexity ceiling. The three:
688
+ *
689
+ * - **deduped** — a provider resolved, or the host supplied a corpus, or
690
+ * both. A host-supplied corpus is a dedup source in its own right, which is
691
+ * the whole point: the gate asks "can we dedup at all", not "did a provider
692
+ * resolve". While it asked the latter, `--no-provider --issues-file` — the
693
+ * one invocation a `gh`-less host can run — short-circuited to the seeded
694
+ * all-`create` classifications however well the dedupe module worked.
695
+ * - **skipped, no port** — a provider was wanted but could not be adapted.
696
+ * - **skipped, disabled** — `--no-provider` with no corpus to fall back on.
697
+ *
698
+ * Every outcome warns on stderr, so the `--scan` JSON on stdout stays clean and
699
+ * a create-only plan is never read as "checked, found nothing".
700
+ *
701
+ * @param {{ groups: Array<object>, useProvider?: boolean,
702
+ * issues?: Array<object>|null }} params
703
+ * @param {{ loadProviderImpl: Function, classifyGroupsImpl: Function,
704
+ * logger: { warn: Function } }} deps
705
+ * @returns {Promise<{ classifications: Array<object>, summary: object,
706
+ * dedupApplied: boolean }>}
707
+ */
708
+ async function runDedupPhase(
709
+ { groups, useProvider, issues },
710
+ { loadProviderImpl, classifyGroupsImpl, logger },
711
+ ) {
712
+ const provider = useProvider ? await loadProviderImpl() : null;
713
+ if (!provider && !issues) {
714
+ logger.warn(
715
+ dedupSkippedWarning(useProvider ? 'no-provider-port' : 'disabled'),
716
+ );
717
+ return {
718
+ classifications: groups.map((group) => ({
719
+ group,
720
+ action: 'create',
721
+ matchedIssues: [],
722
+ matchedFingerprints: [],
723
+ })),
724
+ summary: { create: groups.length, skipOpen: 0, skipReoccurring: 0 },
725
+ dedupApplied: false,
726
+ };
727
+ }
728
+
729
+ const { classifications, summary } = await classifyGroupsImpl({
730
+ groups,
731
+ provider,
732
+ searchCandidates: provider?.searchCandidates,
733
+ listAuditIssues: provider?.listAuditIssues,
734
+ issues,
735
+ });
736
+ for (const warning of dedupPhaseWarnings({ issues, summary })) {
737
+ logger.warn(warning);
738
+ }
739
+ return { classifications, summary, dedupApplied: true };
740
+ }
741
+
742
+ /**
743
+ * Every warning a completed dedup pass owes the operator, in order. Pure, so
744
+ * the wording stays unit-testable and `runDedupPhase` keeps one write site.
745
+ *
746
+ * @param {{ issues?: Array<object>|null, summary: object }} params
747
+ * @returns {string[]}
748
+ */
749
+ function dedupPhaseWarnings({ issues, summary }) {
750
+ const warnings = [];
751
+ if (issues) warnings.push(dedupIndexWarning(summary.dedupIndex));
752
+ // The pre-fetch failing is a distinct fact from any group's lookup failing,
753
+ // and used to be invisible: the operator saw only the downstream per-group
754
+ // degradation and could not tell what had caused it.
755
+ if (summary.dedupDegraded?.indexPrefetch) {
756
+ warnings.push(
757
+ dedupIndexDegradedWarning(summary.dedupDegraded.indexPrefetch),
758
+ );
759
+ }
760
+ // A partially-checked plan is a useful result — name the groups that degraded
761
+ // to create because their lookup could not complete (Story #4678).
762
+ if (summary.dedupDegraded?.count > 0) {
763
+ warnings.push(dedupDegradedWarning(summary.dedupDegraded.groups));
764
+ }
765
+ return warnings;
766
+ }
767
+
652
768
  /**
653
769
  * Scan → group → dedup → (optionally) reconcile the cross-run ledger, and
654
770
  * return the plan envelope.
@@ -657,12 +773,14 @@ function dedupDegradedWarning(entries) {
657
773
  * implementation (`.agents/rules/test-seams.md` rules 1-2, 4), so `main`,
658
774
  * `runAuto`, and every production caller are unchanged.
659
775
  *
660
- * @param {{ glob?: string, severity?: string, useProvider?: boolean, ledger?: object }} params
776
+ * @param {{ glob?: string, severity?: string, useProvider?: boolean,
777
+ * issuesFile?: string, ledger?: object }} params
661
778
  * @param {{
662
779
  * collectReportPathsImpl?: typeof collectReportPaths,
663
780
  * readReportsImpl?: typeof readReports,
664
781
  * loadProviderImpl?: typeof loadProviderOrNull,
665
782
  * classifyGroupsImpl?: typeof classifyGroupsAgainstGitHub,
783
+ * loadIssuesFileImpl?: typeof loadIssuesFile,
666
784
  * reconcileScanLedgerImpl?: typeof reconcileScanLedger,
667
785
  * logger?: { warn: Function },
668
786
  * }} [deps]
@@ -673,6 +791,7 @@ async function buildPlan(
673
791
  glob: pattern,
674
792
  severity,
675
793
  useProvider,
794
+ issuesFile,
676
795
  ledger,
677
796
  allowMissingTally,
678
797
  failOnReportFailures,
@@ -684,9 +803,14 @@ async function buildPlan(
684
803
  readReportsImpl = readReports,
685
804
  loadProviderImpl = loadProviderOrNull,
686
805
  classifyGroupsImpl = classifyGroupsAgainstGitHub,
806
+ loadIssuesFileImpl = loadIssuesFile,
687
807
  reconcileScanLedgerImpl = reconcileScanLedger,
688
808
  logger = Logger,
689
809
  } = deps;
810
+ // Deliberately BEFORE the reports are read: an unusable corpus is a usage
811
+ // error, and failing fast costs the operator nothing, where failing late
812
+ // would tempt a fallback that silently dedups nothing.
813
+ const issues = issuesFile ? loadIssuesFileImpl(issuesFile) : null;
690
814
  const reportPaths = await collectReportPathsImpl(pattern ?? DEFAULT_GLOB);
691
815
  if (reportPaths.length === 0) {
692
816
  return {
@@ -724,45 +848,10 @@ async function buildPlan(
724
848
  const stamped = withFingerprints(filtered.filter((f) => Boolean(f.severity)));
725
849
  const { groups, edges } = groupFindings(stamped);
726
850
 
727
- let classifications = groups.map((g) => ({
728
- group: g,
729
- action: 'create',
730
- matchedIssues: [],
731
- matchedFingerprints: [],
732
- }));
733
- let summary = { create: groups.length, skipOpen: 0, skipReoccurring: 0 };
734
- let dedupApplied = false;
735
-
736
- if (useProvider) {
737
- const provider = await loadProviderImpl();
738
- if (provider) {
739
- const result = await classifyGroupsImpl({
740
- groups,
741
- provider,
742
- searchCandidates: provider.searchCandidates,
743
- listAuditIssues: provider.listAuditIssues,
744
- });
745
- classifications = result.classifications;
746
- summary = result.summary;
747
- dedupApplied = true;
748
- // A partially-checked plan is a useful result — warn loudly (stderr, so
749
- // the --scan JSON on stdout stays clean) naming the groups that degraded
750
- // to create because their lookup could not complete (Story #4678).
751
- if (summary.dedupDegraded?.count > 0) {
752
- logger.warn(dedupDegradedWarning(summary.dedupDegraded.groups));
753
- }
754
- } else {
755
- // The provider could not resolve a searchIssues port — the dedup gate
756
- // is silently a no-op without this. Surface it loudly (stderr, so the
757
- // --scan JSON on stdout stays clean) so the operator does not read a
758
- // create-only plan as "no duplicates found".
759
- logger.warn(dedupSkippedWarning('no-provider-port'));
760
- }
761
- } else {
762
- // Operator explicitly opted out via --no-provider. Still warn so a
763
- // duplicate-opening re-run is never a surprise.
764
- logger.warn(dedupSkippedWarning('disabled'));
765
- }
851
+ const { classifications, summary, dedupApplied } = await runDedupPhase(
852
+ { groups, useProvider, issues },
853
+ { loadProviderImpl, classifyGroupsImpl, logger },
854
+ );
766
855
 
767
856
  // Cross-run ledger (Story #4626): fold this scan onto the committed memory,
768
857
  // suppress findings a prior run recorded as accepted-risk, and (unless the
@@ -832,6 +921,9 @@ function reconcileScanLedger({ ledgerPath, findings, classifications, write }) {
832
921
  ledger: prior,
833
922
  findings,
834
923
  issueStates,
924
+ // The ledger lives in the shared findings layer and cannot import the
925
+ // audit adapter without closing a cycle, so the projection is ours to pass.
926
+ toCanonical: toCanonicalFinding,
835
927
  });
836
928
  if (write !== false) writeLedger(ledgerPath, next);
837
929
  return new Set(
@@ -918,6 +1010,7 @@ async function runAuto({
918
1010
  severity,
919
1011
  dryRun,
920
1012
  useProvider,
1013
+ issuesFile,
921
1014
  ledgerPath,
922
1015
  ledgerCommit,
923
1016
  git,
@@ -930,6 +1023,7 @@ async function runAuto({
930
1023
  glob,
931
1024
  severity: floor,
932
1025
  useProvider,
1026
+ issuesFile,
933
1027
  ledger: { path: resolvedLedgerPath, write: !dryRun },
934
1028
  // `--auto` never accepts `--allow-missing-tally`: an unattended sweep has
935
1029
  // no operator to read a warning, so every report failure is fatal here.
@@ -967,6 +1061,11 @@ async function runAuto({
967
1061
  skipReoccurring: byAction.skipReoccurring.length,
968
1062
  suppressedByLedger: byAction.suppressed.length,
969
1063
  },
1064
+ // `--auto` opens no Issues itself — the caller does, from the `--emit-stories`
1065
+ // drafts — so these keys are the only thing standing between its summary and
1066
+ // the `--wire-edges --ids` map. Without them an unattended sweep cannot
1067
+ // record what it filed, and the ledger stays empty however well it works.
1068
+ createGroupKeys: eligible.map((g) => g?.groupKey).filter(Boolean),
970
1069
  // Re-detected open Issues the operator may want a "re-detected" comment on.
971
1070
  reDetected: byAction.skipOpen
972
1071
  .flatMap((c) => c.matchedIssues ?? [])
@@ -1076,20 +1175,50 @@ function wireEdgesPreconditionError(reason, detail) {
1076
1175
  * re-rendered with a canonical `blocked by #N` footer and the same edges are
1077
1176
  * mirrored as native `blocked_by` relations.
1078
1177
  *
1178
+ * The same map is what the cross-run ledger needs to record what this run
1179
+ * filed, so the record rides along here rather than arriving as a second
1180
+ * command an operator must remember (Story #5305).
1181
+ *
1079
1182
  * @param {object} params
1080
1183
  * @param {object} params.plan A `--scan` plan envelope.
1081
1184
  * @param {Record<string, number>} params.issueByGroupKey
1185
+ * @param {string} [params.ledgerPath] — ledger to record into; defaults to
1186
+ * `DEFAULT_LEDGER_PATH` inside the record.
1187
+ * @param {boolean} [params.write] — `false` computes the record without
1188
+ * persisting it (what `--dry-run` passes).
1082
1189
  * @param {object} [deps]
1083
1190
  * @param {Function} [deps.loadProviderImpl]
1084
1191
  * @param {Function} [deps.wireImpl]
1085
- * @returns {Promise<object>} the wiring summary.
1192
+ * @param {Function} [deps.recordFiledIssuesImpl]
1193
+ * @returns {Promise<object>} the wiring summary, with the ledger record on
1194
+ * `ledger`.
1086
1195
  */
1087
- async function wireEdges({ plan, issueByGroupKey }, deps = {}) {
1088
- const { loadProviderImpl = loadProvider, wireImpl = wireAuditStoryEdges } =
1089
- deps;
1196
+ async function wireEdges(
1197
+ { plan, issueByGroupKey, ledgerPath, write },
1198
+ deps = {},
1199
+ ) {
1200
+ const {
1201
+ loadProviderImpl = loadProvider,
1202
+ wireImpl = wireAuditStoryEdges,
1203
+ recordFiledIssuesImpl = recordFiledIssues,
1204
+ } = deps;
1090
1205
  const groups = (plan.classifications ?? [])
1091
1206
  .filter((c) => c.action === 'create')
1092
1207
  .map((c) => c.group);
1208
+
1209
+ // Record BEFORE the provider is loaded. The record is pure local filesystem
1210
+ // work, while the wiring below needs a provider exposing `updateTicket` — and
1211
+ // the host most likely to lack one is the `gh`-less host where the ledger is
1212
+ // the only duplicate protection there is. Recording first means such a run
1213
+ // still remembers what it filed, and the precondition error below still
1214
+ // surfaces unchanged afterwards.
1215
+ const ledger = recordFiledIssuesImpl({
1216
+ ledgerPath,
1217
+ groups,
1218
+ issueByGroupKey,
1219
+ write,
1220
+ });
1221
+
1093
1222
  let provider;
1094
1223
  try {
1095
1224
  provider = await loadProviderImpl();
@@ -1099,7 +1228,7 @@ async function wireEdges({ plan, issueByGroupKey }, deps = {}) {
1099
1228
  if (typeof provider?.updateTicket !== 'function') {
1100
1229
  throw wireEdgesPreconditionError('fixture-no-write-port');
1101
1230
  }
1102
- return wireImpl({
1231
+ const wired = await wireImpl({
1103
1232
  groups,
1104
1233
  edges: plan.edges ?? [],
1105
1234
  issueByGroupKey,
@@ -1107,6 +1236,7 @@ async function wireEdges({ plan, issueByGroupKey }, deps = {}) {
1107
1236
  updateBody: (issueNumber, body) =>
1108
1237
  provider.updateTicket(issueNumber, { body }),
1109
1238
  });
1239
+ return { ...wired, ledger };
1110
1240
  }
1111
1241
 
1112
1242
  /**
@@ -1161,6 +1291,8 @@ export const __testing = {
1161
1291
  loadProviderOrNull,
1162
1292
  dedupSkippedWarning,
1163
1293
  dedupDegradedWarning,
1294
+ dedupIndexWarning,
1295
+ dedupIndexDegradedWarning,
1164
1296
  buildAndGateStories,
1165
1297
  runAuto,
1166
1298
  resolveSeverityFloor,
@@ -1291,6 +1423,7 @@ export async function runAuditToStories(
1291
1423
  plan: { type: 'string' },
1292
1424
  out: { type: 'string' },
1293
1425
  'no-provider': { type: 'boolean' },
1426
+ 'issues-file': { type: 'string' },
1294
1427
  'allow-missing-tally': { type: 'boolean' },
1295
1428
  json: { type: 'boolean' },
1296
1429
  },
@@ -1308,6 +1441,7 @@ export async function runAuditToStories(
1308
1441
  severity: values.severity,
1309
1442
  dryRun: values['dry-run'],
1310
1443
  useProvider: !values['no-provider'],
1444
+ issuesFile: values['issues-file'],
1311
1445
  ledgerPath: values.ledger,
1312
1446
  ledgerCommit: values['ledger-commit'],
1313
1447
  })
@@ -1334,6 +1468,7 @@ export async function runAuditToStories(
1334
1468
  glob: values.glob,
1335
1469
  severity: values.severity,
1336
1470
  useProvider: !values['no-provider'],
1471
+ issuesFile: values['issues-file'],
1337
1472
  allowMissingTally: values['allow-missing-tally'],
1338
1473
  });
1339
1474
 
@@ -1359,6 +1494,11 @@ export async function runAuditToStories(
1359
1494
  wireEdgesImpl({
1360
1495
  plan: loadPlanImpl(values.plan),
1361
1496
  issueByGroupKey: parseIssueMapImpl(values.ids),
1497
+ ledgerPath: values.ledger,
1498
+ // `--scan` still never writes the ledger; `--wire-edges` runs only after
1499
+ // the Issues were really opened, where recording is never wrong — so the
1500
+ // record is on by default here and `--dry-run` is what suppresses it.
1501
+ write: !values['dry-run'],
1362
1502
  });
1363
1503
 
1364
1504
  // One table, not a chain of `if (values.X) { …; return; }`. Each entry
@@ -1435,7 +1575,7 @@ runAsCli(import.meta.url, main, {
1435
1575
  ['--emit-stories', 'Emit the Story drafts as JSON.'],
1436
1576
  [
1437
1577
  '--wire-edges',
1438
- 'Second pass: resolve the detected group edges to blocked by #N footers plus native blocked_by relations. Needs --plan and --ids.',
1578
+ 'Second pass: resolve the detected group edges to blocked by #N footers plus native blocked_by relations, and record the mapped issues in the cross-run ledger as filed. Needs --plan and --ids; --dry-run suppresses the ledger write.',
1439
1579
  ],
1440
1580
  [
1441
1581
  '--ids <json|path>',
@@ -1443,7 +1583,10 @@ runAsCli(import.meta.url, main, {
1443
1583
  ],
1444
1584
  ['--glob <pattern>', 'Override the audit-results glob.'],
1445
1585
  ['--severity <level>', 'Lowest severity to include (high|medium|low).'],
1446
- ['--ledger <path>', 'Path to the dedup ledger.'],
1586
+ [
1587
+ '--ledger <path>',
1588
+ `Path to the cross-run dedup ledger (default ${DEFAULT_LEDGER_PATH}).`,
1589
+ ],
1447
1590
  [
1448
1591
  '--ledger-commit',
1449
1592
  'After the --auto summary prints, commit a changed ledger onto chore/audit-ledger-<date>, push it, and open a PR against the base branch (never auto-merged). Ignored under --dry-run.',
@@ -1454,6 +1597,10 @@ runAsCli(import.meta.url, main, {
1454
1597
  ],
1455
1598
  ['--out <path>', 'Write output to a file instead of stdout.'],
1456
1599
  ['--no-provider', 'Skip live GitHub dedup lookups (offline).'],
1600
+ [
1601
+ '--issues-file <path>',
1602
+ 'Dedup against a JSON array of issues the host already fetched (every issue labelled audit::*, state all) instead of listing them through the provider. Lets dedup run where there is no gh CLI; composes with --no-provider.',
1603
+ ],
1457
1604
  [
1458
1605
  '--allow-missing-tally',
1459
1606
  'Downgrade a missing "Severity tally:" line to a warning (--scan only; --auto ignores it).',