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
@@ -0,0 +1,197 @@
1
+ /**
2
+ * lib/orchestration/plan-persist/audit-provenance.js — what an audit-seeded
3
+ * plan leaves behind for the next sweep.
4
+ *
5
+ * The chained `/mandrel-plan` path files Stories from an audit seed, and until
6
+ * Story #5307 those Stories were invisible to the sweep that proposed them:
7
+ * they carried no `audit::*` label, so they were absent from the label-listed
8
+ * corpus dedup indexes, and nothing recorded them in the cross-run ledger. The
9
+ * provenance footers `carryProvenanceFooters` stamps could not cover for
10
+ * either — with an index in play the exact lookup is answered from that pool
11
+ * and never reaches the provider.
12
+ *
13
+ * Both halves ride the create loop in `story-ops.js` because that is the only
14
+ * seam on this path that cannot be forgotten: there is no second required pass
15
+ * here the way `--wire-edges` is one for the standalone filer.
16
+ */
17
+
18
+ import {
19
+ DEFAULT_LEDGER_PATH,
20
+ readLedger,
21
+ recordFiledIdentities,
22
+ writeLedger,
23
+ } from '../../findings/audit-ledger.js';
24
+ import {
25
+ parseAuditLabelFooter,
26
+ parseFingerprintFooter,
27
+ parseSemanticKeyFooter,
28
+ } from '../../findings/route-finding.js';
29
+ import { Logger } from '../../Logger.js';
30
+
31
+ /**
32
+ * A fresh accumulator for one persist run's audit filings.
33
+ *
34
+ * The run collects into this and flushes once, so a plan that files N Stories
35
+ * leaves one reviewable ledger diff rather than N.
36
+ *
37
+ * @param {string} [ledgerPath]
38
+ * @returns {{ path: string, ledger: object|null, recorded: number, ambiguous: number }}
39
+ */
40
+ function newAuditLedgerRecord(ledgerPath) {
41
+ return {
42
+ path: ledgerPath ?? DEFAULT_LEDGER_PATH,
43
+ ledger: null,
44
+ recorded: 0,
45
+ ambiguous: 0,
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Merge the seed's `audit::*` labels into an audit-seeded Story's labels.
51
+ *
52
+ * Taken from the **seed union**, not from the attributed provenance source: a
53
+ * label only scopes which issues the next sweep's corpus fetch returns, and
54
+ * matching inside that corpus is by fingerprint and semantic key. A superset
55
+ * corpus can therefore only ever find more, never less — so erring wide is the
56
+ * safe direction, and it keeps lens-to-label knowledge in the audit filer that
57
+ * owns it rather than re-deriving a dimension here (the junk-derivation trap
58
+ * Story #4195 closed).
59
+ *
60
+ * A non-audit seed carries no such footer, so this is a no-op there.
61
+ *
62
+ * @param {Array<object>} stories — the assembled Stories.
63
+ * @param {string} [provenanceSource] — the seed this plan was authored from.
64
+ * @returns {Array<object>}
65
+ */
66
+ export function withAuditLabels(stories, provenanceSource) {
67
+ const fromSeed = parseAuditLabelFooter(provenanceSource ?? '');
68
+ if (fromSeed.length === 0) return stories;
69
+ return stories.map((story) => ({
70
+ ...story,
71
+ labels: [...new Set([...story.labels, ...fromSeed])],
72
+ }));
73
+ }
74
+
75
+ /**
76
+ * Record one just-created Story in the cross-run audit ledger.
77
+ *
78
+ * The identities are read back off the body this run assembled, so what is
79
+ * recorded is exactly what was stamped — attribution already applied. A Story
80
+ * from a non-audit plan carries no footers and records nothing, which is why
81
+ * a `--seed` or `--tickets` run never touches the ledger file at all.
82
+ *
83
+ * **The union fallback is deliberately NOT recorded.** When the seed carried
84
+ * footers but the plan attributed none per-Story, every sibling carries every
85
+ * fingerprint; binding a finding to one of them would be a coin flip, and a
86
+ * wrong binding is worse than none — the finding would be suppressed against an
87
+ * Issue that never tracked it, or resurrected when an unrelated Story closed.
88
+ * The audit path stamps `provenance` mechanically, so this is the exception.
89
+ *
90
+ * @param {{ story: object, id: number, ledgerRecord: object, attributed: boolean }} args
91
+ */
92
+ function recordAuditFiling({ story, id, ledgerRecord, attributed }) {
93
+ const fingerprints = parseFingerprintFooter(story.body);
94
+ if (fingerprints.length === 0) return;
95
+ if (!attributed) {
96
+ ledgerRecord.ambiguous += fingerprints.length;
97
+ return;
98
+ }
99
+ const semanticKeys = parseSemanticKeyFooter(story.body);
100
+ const identities = fingerprints.map((fingerprint, i) => ({
101
+ fingerprint,
102
+ semanticKey: semanticKeys[i] ?? '',
103
+ title: story.title,
104
+ }));
105
+ const { ledger, recorded } = recordFiledIdentities({
106
+ ledger: ledgerRecord.ledger ?? readLedger(ledgerRecord.path),
107
+ identities,
108
+ issue: { number: id },
109
+ });
110
+ ledgerRecord.ledger = ledger;
111
+ ledgerRecord.recorded += recorded;
112
+ }
113
+
114
+ /**
115
+ * Persist the run's audit filings, once, after every Story exists.
116
+ *
117
+ * Writing per-Story would rewrite a committed baseline N times for one plan;
118
+ * writing once keeps the diff to a single reviewable change. A run that
119
+ * recorded nothing writes nothing.
120
+ *
121
+ * @param {object} ledgerRecord
122
+ * @param {{ warn: Function }} logger
123
+ */
124
+ function flushAuditLedger(ledgerRecord, logger) {
125
+ if (ledgerRecord.ambiguous > 0) {
126
+ logger.warn(
127
+ `[plan-persist] audit ledger: ${ledgerRecord.ambiguous} identity(ies) not recorded — ` +
128
+ 'the seed carried provenance footers but no Story attributed them, so ownership is ' +
129
+ 'ambiguous. Author per-Story `provenance` to record them.',
130
+ );
131
+ }
132
+ if (!ledgerRecord.ledger || ledgerRecord.recorded === 0) return;
133
+ writeLedger(ledgerRecord.path, ledgerRecord.ledger);
134
+ logger.warn(
135
+ `[plan-persist] audit ledger: recorded ${ledgerRecord.recorded} filed finding(s) ` +
136
+ `to ${ledgerRecord.path}.`,
137
+ );
138
+ }
139
+
140
+ /**
141
+ * Record everything one persist run filed, once, after every Story exists.
142
+ *
143
+ * Driven from the persist orchestrator rather than from inside the create loop:
144
+ * the loop's job is to create issues, and this is the only other place that
145
+ * always runs after it. Either seam is unforgettable — there is no second
146
+ * required pass on this path the way `--wire-edges` is one for the standalone
147
+ * filer — and keeping the side effect at the orchestration layer leaves the
148
+ * create loop doing one thing.
149
+ *
150
+ * One write per plan, not per Story: the ledger is committed state, and N
151
+ * rewrites of it for one plan is a diff nobody can review. A run that recorded
152
+ * nothing — a non-audit plan, or one whose identities were all union-carried —
153
+ * writes nothing at all.
154
+ *
155
+ * @param {object} params
156
+ * @param {Array<object>} params.stories — the assembled Stories.
157
+ * @param {Array<{ slug: string, id: number }>} params.created — persist receipts.
158
+ * @param {Array<object>} params.tickets — the raw authored tickets.
159
+ * @param {string} [params.ledgerPath]
160
+ * @param {{ warn: Function }} [params.logger]
161
+ * @param {boolean} [params.dryRun] — a dry run records nothing at all.
162
+ * @returns {{ recorded: number, ambiguous: number }}
163
+ */
164
+ export function recordAuditFilings({
165
+ stories,
166
+ created,
167
+ tickets,
168
+ ledgerPath,
169
+ logger,
170
+ dryRun = false,
171
+ }) {
172
+ // A dry run created no issue to record against, and must not touch committed
173
+ // state. Owned here rather than at the call site so the contract is testable.
174
+ if (dryRun) return { recorded: 0, ambiguous: 0 };
175
+ const record = newAuditLedgerRecord(ledgerPath);
176
+ const idBySlug = new Map((created ?? []).map((c) => [c.slug, c.id]));
177
+ // Attribution is a property of what the plan AUTHORED, so it is read off the
178
+ // raw tickets rather than threaded through assembly: a Story that declared no
179
+ // `provenance` inherited the seed union, where ownership is a coin flip.
180
+ const attributedSlugs = new Set(
181
+ (tickets ?? [])
182
+ .filter((t) => t?.provenance !== undefined && t?.provenance !== null)
183
+ .map((t) => t?.slug),
184
+ );
185
+ for (const story of stories ?? []) {
186
+ const id = idBySlug.get(story.slug);
187
+ if (typeof id !== 'number') continue;
188
+ recordAuditFiling({
189
+ story,
190
+ id,
191
+ ledgerRecord: record,
192
+ attributed: attributedSlugs.has(story.slug),
193
+ });
194
+ }
195
+ flushAuditLedger(record, logger ?? Logger);
196
+ return { recorded: record.recorded, ambiguous: record.ambiguous };
197
+ }
@@ -68,6 +68,7 @@ import {
68
68
  renderHardConflictError,
69
69
  } from '../ticket-validator-conflicts.js';
70
70
  import { upsertStructuredComment } from '../ticketing.js';
71
+ import { recordAuditFilings, withAuditLabels } from './audit-provenance.js';
71
72
  import {
72
73
  resolveContainerEpic,
73
74
  resolveCrossPlanLinks,
@@ -780,7 +781,8 @@ export async function runPlanPersist({
780
781
  });
781
782
 
782
783
  // Split policy + inline Spec fold (over-budget Specs fail closed — no docs/).
783
- const { stories } = assemblePlanStories(rawStories, {
784
+ const seedContent = planContextEnvelope?.seed?.content ?? '';
785
+ const { stories: assembled } = assemblePlanStories(rawStories, {
784
786
  sharedSpec: techSpecContent,
785
787
  planAcceptance: planAcceptance ?? undefined,
786
788
  sourceTicketIds,
@@ -791,9 +793,16 @@ export async function runPlanPersist({
791
793
  // is the **fallback** — it is carried onto every Story that did not
792
794
  // attribute its own `provenance`, which keeps an un-attributed plan exactly
793
795
  // as recall-safe as it was. Empty for a `--tickets` run, a no-op there.
794
- provenanceSource: planContextEnvelope?.seed?.content ?? '',
796
+ provenanceSource: seedContent,
795
797
  });
796
798
 
799
+ // Stamp the `audit::*` labels the dedup corpus is listed by. Without them a
800
+ // Story this path files is absent from the pool an indexed sweep matches
801
+ // against, and an indexed run answers exact lookups from that pool without
802
+ // ever reaching the provider — so the provenance footers alone leave it
803
+ // invisible (Story #5307). A non-audit seed carries none: a no-op there.
804
+ const stories = withAuditLabels(assembled, seedContent);
805
+
797
806
  // Story #5045: the cross-Story conflict passes re-run over the assembled,
798
807
  // footer-stamped bodies — the artifact persist actually writes — before any
799
808
  // GitHub call, so a policy upgrade still refuses the plan pre-creation.
@@ -824,6 +833,10 @@ export async function runPlanPersist({
824
833
  opts: { dryRun, routeLabel: isLiteRoute ? LITE_ROUTE_LABEL : null },
825
834
  });
826
835
 
836
+ // What this run filed, recorded where the next audit sweep reads it
837
+ // (Story #5307). A dry run created nothing, and the call knows it.
838
+ recordAuditFilings({ stories, created, tickets: rawStories, dryRun });
839
+
827
840
  const primary = created[0];
828
841
  const waveTable = buildWaveTable(
829
842
  stories.map((s) => ({
@@ -683,10 +683,10 @@ async function executeFollowUpRollup({
683
683
  provider,
684
684
  config,
685
685
  currentRepo: repos.currentRepo,
686
- frameworkRepo: (() => {
687
- const [owner, repo] = repos.frameworkRepo.split('/');
688
- return { owner, repo };
689
- })(),
686
+ // The resolved bucket object, not a re-split of the slug: routing is
687
+ // decided once in `github/framework-repo.js`.
688
+ frameworkRepo: repos.repos.framework,
689
+ platformRepo: repos.repos.platform,
690
690
  routedProposals: proposals,
691
691
  cwd,
692
692
  });
@@ -11,7 +11,11 @@
11
11
 
12
12
  import { signalsFile } from '../config/temp-paths.js';
13
13
  import { graduateRetroProposals } from '../feedback-loop/retro-proposals-graduator.js';
14
- import { DEFAULT_FRAMEWORK_REPO } from '../github/framework-repo.js';
14
+ import {
15
+ DEFAULT_FRAMEWORK_REPO,
16
+ formatRepoSlug,
17
+ resolveOwnershipRepos,
18
+ } from '../github/framework-repo.js';
15
19
  import { Logger } from '../Logger.js';
16
20
  import { normalizeGatheredSignal } from '../observability/runtime-friction.js';
17
21
  import {
@@ -54,28 +58,36 @@ function resolveFrictionWindowDays(config) {
54
58
  }
55
59
 
56
60
  /**
61
+ * Resolve the follow-up ownership buckets for the retro composer and the
62
+ * graduator walk.
63
+ *
64
+ * Routing itself lives in `github/framework-repo.js` — this is the slug-shaped
65
+ * adapter the composer wants (`composeRoutedProposals` renders slugs in prose)
66
+ * plus the `{owner, repo}` map `graduate()` routes on. The `platform` bucket
67
+ * has **no default**: an unconfigured shared-infra repo stays `null` so a
68
+ * caller reports it rather than filing platform-owned work somewhere plausible.
69
+ *
57
70
  * @param {object} [config]
58
- * @returns {{ frameworkRepo: string, consumerRepo: string, currentRepo: { owner: string, repo: string } }}
71
+ * @returns {{ frameworkRepo: string, consumerRepo: string, platform: (string|null), currentRepo: { owner: string, repo: string }, repos: { consumer: object|null, framework: object|null, platform: object|null } }}
59
72
  */
60
73
  export function resolveFollowUpRepos(config) {
61
- const owner =
62
- typeof config?.github?.owner === 'string' ? config.github.owner.trim() : '';
63
- const repo =
64
- typeof config?.github?.repo === 'string' ? config.github.repo.trim() : '';
65
- const consumerRepo =
66
- owner && repo ? `${owner}/${repo}` : DEFAULT_FRAMEWORK_REPO;
67
- const frameworkRepo =
68
- typeof config?.github?.frameworkRepo === 'string' &&
69
- config.github.frameworkRepo.trim()
70
- ? config.github.frameworkRepo.trim()
71
- : DEFAULT_FRAMEWORK_REPO;
74
+ const repos = resolveOwnershipRepos(config);
75
+ const consumerRepo = formatRepoSlug(repos.consumer) ?? DEFAULT_FRAMEWORK_REPO;
76
+ // `resolveOwnershipRepos` always resolves the framework bucket (it defaults
77
+ // to the mirror constant), and both slugs above are well-formed by
78
+ // construction — so neither the render nor the split can yield a blank half.
79
+ const frameworkRepo = formatRepoSlug(repos.framework);
72
80
  const [cOwner, cRepo] = consumerRepo.split('/');
81
+ const currentRepo = { owner: cOwner, repo: cRepo };
73
82
  return {
74
83
  frameworkRepo,
75
84
  consumerRepo,
76
- currentRepo: {
77
- owner: cOwner || 'unknown',
78
- repo: cRepo || 'unknown',
85
+ platform: formatRepoSlug(repos.platform),
86
+ currentRepo,
87
+ repos: {
88
+ consumer: currentRepo,
89
+ framework: repos.framework,
90
+ platform: repos.platform,
79
91
  },
80
92
  };
81
93
  }
@@ -749,10 +761,10 @@ export async function captureStoryFollowUps({
749
761
  provider,
750
762
  config,
751
763
  currentRepo: repos.currentRepo,
752
- frameworkRepo: (() => {
753
- const [owner, repo] = repos.frameworkRepo.split('/');
754
- return { owner, repo };
755
- })(),
764
+ // The resolved bucket object, not a re-split of the slug: routing is
765
+ // decided once in `github/framework-repo.js`.
766
+ frameworkRepo: repos.repos.framework,
767
+ platformRepo: repos.repos.platform,
756
768
  routedProposals: proposals,
757
769
  cwd,
758
770
  });
@@ -66,7 +66,8 @@
66
66
  * green can already have merged by the time it is observed. On red the
67
67
  * watcher disarms auto-merge and records the head SHA; on green it reads
68
68
  * the digest and adjudicates: a green on the SAME head SHA is a forbidden
69
- * re-run (exit 1, `agent::blocked`, `meta::framework-gap` required), while
69
+ * re-run (exit 1, `agent::blocked`, a `file-ci-gap.js` intake filing
70
+ * required), while
70
71
  * a green on a NEW head SHA is a fix at source — the digest is retired,
71
72
  * auto-merge is re-armed, and the delivery proceeds. A delivery that never
72
73
  * went red has no digest and is untouched. Mechanism:
@@ -475,7 +476,7 @@ async function evaluateGreenWatch({
475
476
  `[pr-watch] run link: ${digest.runUrl ?? `run id ${digest.runId ?? 'unresolved'}`} — classification: ${digest.classification ?? 'unknown'}`,
476
477
  );
477
478
  logger.error?.(
478
- '[pr-watch] fix the root cause and push a new commit, or file a `meta::framework-gap` issue carrying the run link and failure signature.',
479
+ '[pr-watch] fix the root cause and push a new commit, or — when the root cause is outside this delivery — run `node .agents/scripts/file-ci-gap.js --story <id> --verdict <verdict> --owner <bucket> --block` to file the routed, deduped intake issue.',
479
480
  );
480
481
  const outcome = await blockFn({ storyId, body });
481
482
  return {
@@ -181,6 +181,14 @@ persists, via `carryProvenanceFooters`
181
181
  The carry is additive, union-preserving and idempotent, so a resumed persist
182
182
  cannot stack footers and a hand-authored fingerprint is never dropped.
183
183
 
184
+ **Persist records the ledger and the labels too.** It stamps the seed's
185
+ `audit::<dimension>` labels — the dedup corpus is listed by them, and an indexed
186
+ run answers lookups from that pool without reaching the provider, so a footer
187
+ alone leaves a plan-path Story invisible — and records each Story's
188
+ **attributed** identities. Union-only identities are not recorded: every sibling
189
+ carries every fingerprint, so an owner would be a coin flip; persist says so on
190
+ stderr. Author per-Story `provenance` to record them.
191
+
184
192
  This is deliberately not an authoring step. It used to be: the footers reached
185
193
  the seed and stopped there, leaving the authoring agent to notice HTML comments
186
194
  in a one-pager and copy them forward — a remembered step, which is to say no
@@ -243,6 +251,14 @@ every Story that has a resolvable blocker with a canonical
243
251
  GitHub `blocked_by` relations. An edge whose target was never opened (deduped,
244
252
  ledger-suppressed) drops rather than becoming a `blocked by #undefined`.
245
253
 
254
+ **This pass also records the ledger.** The `--ids` map is the only artifact
255
+ carrying the numbers just opened, so `--wire-edges` folds in the cross-run
256
+ record: each mapped group's findings are written `filed` against its Issue
257
+ (`--ledger <path>`, default `baselines/audit-ledger.json`; `--dry-run`
258
+ suppresses the write). The record runs **before** the provider loads, so a host
259
+ with no `gh` still remembers what it filed. Without it nothing ever writes
260
+ `filed` and the ledger suppresses nothing.
261
+
246
262
  **Do not skip this.** `/mandrel-deliver` has no other source for this cohort's order:
247
263
  its footprint guard ignores the shared provenance footers, so an unwired cohort
248
264
  is genuinely unordered and `/mandrel-deliver` will co-dispatch Stories the edges say
@@ -277,31 +293,45 @@ the `classifications` array:
277
293
  is skipped by default; flag in the Phase 7 summary so the operator
278
294
  can decide whether to reopen.
279
295
 
280
- `routeFinding` is handed a `searchIssues` port adapted from the
281
- project's existing GitHub provider — the actual search runs against the
282
- repo's open + closed issues for each sha in the group, and the helper's
283
- footer-confirmation step filters out false-positive search hits whose
284
- body mentions the sha in prose without the canonical marker. The
285
- workflow owns **no** parallel dedup or footer-parsing code: the
286
- fingerprint, footer round-trip, and routing all live in that one shared
287
- module.
288
-
289
- Dedup runs in **two stages** when a provider resolves: a
290
- meaning-first **semantic candidate** pass (`searchCandidates`, wired to
291
- [`lib/findings/semantic-issue-search.js`](../scripts/lib/findings/semantic-issue-search.js))
292
- runs FIRST and widens the net across open + closed issues; the exact
293
- **fingerprint / semantic-key** confirmation runs SECOND. A finding whose title
294
- was reworded but whose *location* is unchanged still confirms against the Issue
295
- that already tracks that location, because the audit filers stamp a
296
- location-based `audit-semantic-keys` footer alongside the `audit-fingerprints`
297
- footer. Filings from the
296
+ `routeFinding` reads open + closed issues through two ports, which **both run
297
+ and union their pools** — the exact `searchIssues` lookup, and the meaning-first
298
+ `searchCandidates` pass wired to
299
+ [`lib/findings/semantic-issue-search.js`](../scripts/lib/findings/semantic-issue-search.js).
300
+ Confirmation then filters that union by footer, dropping hits whose body
301
+ mentions a sha in prose without the canonical marker. A finding reworded but
302
+ unmoved still confirms, because the filers stamp a location-based
303
+ `audit-semantic-keys` footer
304
+ beside `audit-fingerprints`; so do
298
305
  [`retro-proposals-graduator`](../scripts/lib/feedback-loop/retro-proposals-graduator.js)
299
- carry the same canonical `audit-fingerprints` footer, so a sweep recognizes a
300
- graduator-filed issue and never re-files it.
306
+ filings, which a sweep therefore never re-files.
307
+
308
+ Where the corpus is pre-fetched — either off the list endpoint or from
309
+ `--issues-file` — the exact lookup is answered from that local index and the
310
+ search API is spent only on findings with no exact hit. The workflow owns **no**
311
+ parallel dedup or footer-parsing code: fingerprint, footer round-trip, and
312
+ routing all live in that one shared module.
313
+
314
+ ### When there is no `gh` CLI
315
+
316
+ **No GitHub access** (air-gapped): pass `--no-provider` to `--scan`. Every
317
+ group is classified `create` and the operator is told dedupe was skipped — a
318
+ re-run opens duplicates.
319
+
320
+ **Reachable, but not through `gh`** (a cloud sandbox: no `gh`, no direct API,
321
+ MCP fine): fetch the corpus yourself. List every issue labelled `audit::*` at
322
+ state `all` — `mcp__github__list_issues` or any other path — write the raw
323
+ result as a JSON array, and pass it:
324
+
325
+ ```bash
326
+ node .agents/scripts/audit-to-stories.js --scan --no-provider \
327
+ --issues-file temp/audits/issues.json --glob "temp/audits/audit-*-results.md"
328
+ ```
301
329
 
302
- When no provider is available (e.g. air-gapped dev environment), pass
303
- `--no-provider` to the `--scan` step — every group is classified
304
- `create` and the operator is informed that dedupe was skipped.
330
+ Real `skip-open` / `skip-reoccurring` classifications come back. The corpus is
331
+ normalised on load, so a raw list result works as-is; only `number` and `body`
332
+ are read. An unreadable file is a hard error, never a silent fall-back to an
333
+ unchecked run; an **empty** array — a valid first sweep — is reported with its
334
+ count so it cannot pass for a failed fetch.
305
335
 
306
336
  ### Cross-run ledger
307
337
 
@@ -314,8 +344,12 @@ shape). Each entry is keyed by the finding's fingerprint plus a location-based
314
344
  (`new | filed | fixed | accepted-risk | regressed`). A finding whose tracking
315
345
  Issue was closed as `not_planned` becomes `accepted-risk` and is **suppressed**
316
346
  on every later scan; a `fixed` finding that re-appears becomes `regressed`. The
317
- ledger is written by the unattended `--auto` sweep and by any `--scan --ledger`
318
- run; the plain `--scan` path leaves it untouched.
347
+ ledger is written by the unattended `--auto` sweep, by any `--scan --ledger`
348
+ run, by the Phase 5c `--wire-edges` pass, and by `plan-persist` on the Phase 5a
349
+ chained path — the two filing paths both record what they filed; the
350
+ plain `--scan` path leaves it untouched. A finding whose resolved Issue is open
351
+ is recorded `filed` and is known on re-detection; a closed Issue still outranks
352
+ that.
319
353
 
320
354
  ## Phase 7 — Summary & cleanup
321
355
 
@@ -391,8 +425,10 @@ The routine shape is **lenses full-scope → dry-run → live with a ledger PR**
391
425
  from `delivery.auditToStories.severityFloor` (default `high`, overridable with
392
426
  `--severity`), applies the two-stage dedup, reconciles the cross-run ledger,
393
427
  and prints a run-summary JSON (create / skip-open / skip-reoccurring /
394
- suppressed-by-ledger tallies, plus the re-detected open Issue numbers an
395
- operator may want a "re-detected" comment on). `--dry-run` performs zero GitHub
428
+ suppressed-by-ledger tallies, the `create`-classified group keys the `--ids`
429
+ map is built from, plus the re-detected open Issue numbers an operator may want
430
+ a "re-detected" comment on). It opens no Issues itself, so run Phase 5c after
431
+ filing or the sweep's memory stays empty. `--dry-run` performs zero GitHub
396
432
  writes and skips the ledger write, emitting only the summary.
397
433
 
398
434
  `--auto` **fails closed on any `summary.reportFailures[]` entry** (Phase 1): an
@@ -682,13 +682,28 @@ When the watch exits, branch on the exit code:
682
682
 
683
683
  **Triage authority.** How to classify and remediate a red (or repeatedly slow)
684
684
  check — the root-cause-only decision tree for infra/transient and flaky failures
685
- (reproduce → check `main` → bisect env vs code → fix in-scope or file a
686
- `meta::framework-gap` issue), the never-rerun / never-quarantine prohibitions,
687
- and the escalation criteria (three-strikes, the 30-minute wall-clock timebox,
688
- and the clearly-environmental fast path) — is defined once in
685
+ (reproduce → check `main` → bisect env vs code → fix in-scope, or reach an
686
+ Option-2 verdict and file the intake issue), the never-rerun / never-quarantine
687
+ prohibitions, and the escalation criteria (three-strikes, the 30-minute
688
+ wall-clock timebox, and the clearly-environmental fast path) — is defined once in
689
689
  [`.agents/rules/ci-remediation.md`](../../rules/ci-remediation.md). Read it
690
690
  before remediating a red check.
691
691
 
692
+ **Filing an out-of-scope root cause is one command, never a hand-run `gh issue
693
+ create`:**
694
+
695
+ ```bash
696
+ node <agentRoot>/scripts/file-ci-gap.js --story <storyId> \
697
+ --verdict <pre-existing|capacity|unreproducible-tier> \
698
+ --owner <consumer|framework|platform> --evidence "<proof reading>" [--block]
699
+ ```
700
+
701
+ It reads the digest, routes the filing to the repo that owns the fault, updates
702
+ the existing ticket when the signature is a repeat, posts the `friction`
703
+ comment, and with `--block` flips the Story. What it files is an **intake**
704
+ issue, not a Story — `/mandrel-plan <issue number>` graduates it on the next
705
+ planning pass, so the delivery never waits on planning.
706
+
692
707
  ### The auto-merge wait is an internally-blocking step
693
708
 
694
709
  This is the single most important contract of this workflow, and the seam
@@ -99,6 +99,29 @@ On a truthy `memoryPoolAdvisory.recommend`, name
99
99
  `reasons[]`. Purely advisory: a stale pool degrades recall, it does not make
100
100
  the plan wrong, so it never blocks and never reroutes.
101
101
 
102
+ ## Gate #1 → graduating a CI-gap intake filing (`intake`)
103
+
104
+ The envelope's `priorFeedback` arrays carry the open `meta::*` feedback issues.
105
+ A row flagged `intake: true` is a **CI-gap intake filing** — written by
106
+ [`file-ci-gap.js`](../../scripts/file-ci-gap.js) when a delivery reached an
107
+ Option-2 verdict in [`ci-remediation.md`](../../rules/ci-remediation.md) and the
108
+ root cause was outside its scope. It carries evidence (failure signature, run
109
+ link, occurrence history, ownership routing) but **no `## Spec`, no
110
+ `acceptance[]` / `verify[]` and no `agent::*` label**, so `/mandrel-deliver`
111
+ cannot take it: it is intake awaiting graduation, by design. Delivery files it
112
+ and moves on rather than blocking on a planning pass nobody is present for.
113
+
114
+ Graduating one is exactly **tickets mode**: `/mandrel-plan <issue number>`
115
+ rewrites it into a Story, `supersedes[]` claims it, and persist closes it.
116
+
117
+ At Gate #1, in **ask** and **seed** mode, name any open intake rows and offer
118
+ that instead of the seed in front of you — a filing that keeps recurring
119
+ (its `## Occurrences` table is the count) is usually the better next Story than
120
+ whatever prompted this run. It is **advisory**: never reroute automatically, and
121
+ skip the offer entirely under `--yes`, where nobody is at the keyboard to take
122
+ it. A `platformGaps[]` row is the same shape with a different owner — the
123
+ Story it graduates into may well be a config or runbook change rather than code.
124
+
102
125
  ## Gate #1 → the light path (in-session handoff)
103
126
 
104
127
  On a confirmed `deliverLightSuggestion`, `/mandrel-plan` routes into
@@ -58,10 +58,9 @@ and derives source ids from its `sourceTickets[]`; it also writes
58
58
 
59
59
  The envelope carries docs context, the story-author prompt, `sourceTickets[]`,
60
60
  `duplicates[]` (open **Stories**, never Epics), `epicCandidates[]` +
61
- `dependencyCandidates[]` (Gate #3; path collisions) and advisory
62
- `complexitySignals` (**no routing authority**). A trivial scope can claim the
63
- lite route at persist — shape-validated, failing closed to `full`
64
- ([ref](helpers/plan-reference.md)).
61
+ `dependencyCandidates[]` (Gate #3; path collisions), `priorFeedback` and
62
+ advisory `complexitySignals` (**no routing authority**). A trivial scope claims
63
+ the lite route at persist, failing closed to `full`.
65
64
 
66
65
  **Triage each unknown by resolver** ([ref](helpers/plan-reference.md)): an
67
66
  **AFK** unknown (research settles it) is resolved before authoring, never
@@ -69,8 +68,9 @@ assumed; a **HITL** unknown goes to Gate #1. Under `--yes` do not ask free-form
69
68
  operator questions — AFK unknowns are still researched; only HITL unknowns land
70
69
  in Key Assumptions, each a decision-made-by-default.
71
70
 
72
- **Gate #1** — STOP to confirm the sharpened plan intent and any
73
- duplicate-candidate review. Under `--yes`, auto-proceed.
71
+ **Gate #1** — STOP to confirm the sharpened plan intent, any
72
+ duplicate-candidate review, and any `intake` row worth graduating
73
+ ([ref](helpers/plan-reference.md)). Under `--yes`, auto-proceed.
74
74
 
75
75
  On a truthy `deliverLightSuggestion.suggested`, offer — advisory, never an
76
76
  automatic reroute — to deliver the seed instead; on confirm route **in this
package/docs/CHANGELOG.md CHANGED
@@ -15,6 +15,16 @@ All notable changes to this project will be documented in this file.
15
15
  -->
16
16
  <!-- markdownlint-disable-file MD004 MD012 MD037 -->
17
17
 
18
+ ## [2.56.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.55.0...mandrel-v2.56.0) (2026-09-11)
19
+
20
+
21
+ ### Added
22
+
23
+ * audit-to-stories: dedup against a host-supplied issue index, so a sweep on a gh-less host stops re-filing what it already filed ([#5301](https://github.com/dsj1984/mandrel/issues/5301)) ([#5304](https://github.com/dsj1984/mandrel/issues/5304)) ([1b8abf0](https://github.com/dsj1984/mandrel/commit/1b8abf072a05db65e54a63cb8ec6cf6f06bc80ab))
24
+ * audit-to-stories: make Phase 5a filings visible to the next sweep — record the ledger from plan-persist and stamp the audit labels the corpus is listed by ([#5307](https://github.com/dsj1984/mandrel/issues/5307)) ([#5308](https://github.com/dsj1984/mandrel/issues/5308)) ([094ba8a](https://github.com/dsj1984/mandrel/commit/094ba8ae6e2640ba3ac7cb5a4aeb9309aa72e42d))
25
+ * audit-to-stories: record filed Issues in the cross-run ledger, so its suppression branch stops being unreachable ([#5305](https://github.com/dsj1984/mandrel/issues/5305)) ([#5306](https://github.com/dsj1984/mandrel/issues/5306)) ([d5e36ab](https://github.com/dsj1984/mandrel/commit/d5e36ab796f6946bf93d7161efa1470cad8a0a2b))
26
+ * cI-gap intake: script-backed, repo-routed, deduped meta filings that /mandrel-plan can graduate ([#5300](https://github.com/dsj1984/mandrel/issues/5300)) ([#5302](https://github.com/dsj1984/mandrel/issues/5302)) ([37bc287](https://github.com/dsj1984/mandrel/commit/37bc2875ed8c98298afd90c72ece4f318e2c233d))
27
+
18
28
  ## [2.55.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.54.0...mandrel-v2.55.0) (2026-09-11)
19
29
 
20
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "2.55.0",
3
+ "version": "2.56.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, skills, rules, and SDLC workflows that govern AI coding assistants.",
5
5
  "files": [
6
6
  ".agents/",