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,121 @@
1
+ /**
2
+ * lib/audit-to-stories/issues-file.js — the issue corpus dedup checks against,
3
+ * and the one normaliser every source of it goes through.
4
+ *
5
+ * Dedup needs exactly one thing from GitHub: the list of Issues carrying an
6
+ * `audit::*` label. Until Story #5301 the only way to get it was the provider,
7
+ * which spawns `gh`, so a host without a `gh` CLI — a Claude Code cloud
8
+ * sandbox, where `gh` is absent and direct API access is disabled but the
9
+ * GitHub MCP tools work fine — could not dedup at all. Every group classified
10
+ * `create` and a scheduled sweep re-filed findings it had already filed.
11
+ *
12
+ * `--issues-file <path>` breaks that coupling: the host fetches the list by
13
+ * whatever access path it has and hands over a JSON array. Nothing here knows
14
+ * how it was fetched, so MCP, REST, and a cached dump are all equally valid.
15
+ *
16
+ * A file that cannot be read as an issue array is a **hard error**, never a
17
+ * degrade. Falling back would mean "dedup did not run" on precisely the
18
+ * invocation whose whole purpose is that it does — and a create-only plan the
19
+ * operator reads as checked is how the duplicate-filing loop starts.
20
+ */
21
+
22
+ import fs from 'node:fs';
23
+
24
+ /**
25
+ * Flatten one raw issue onto the `{ number, state, title, body }` shape the
26
+ * dedupe module reads, collapsing every closed-ish state spelling (`CLOSED`,
27
+ * `state_reason: not_planned`, …) onto `'closed'`.
28
+ *
29
+ * Shared by both corpus sources — the provider's `searchIssues` hits and the
30
+ * `--issues-file` array — so a host can hand over a raw `list_issues` result
31
+ * verbatim without knowing which spelling this repo's dedup expects.
32
+ *
33
+ * @param {object} hit
34
+ * @returns {{ number: number, state: 'open'|'closed', title: string, body: string }}
35
+ */
36
+ export function normaliseIssueHit(hit) {
37
+ return {
38
+ number: hit.number,
39
+ state: (hit.state ?? hit.state_reason ?? 'open')
40
+ .toString()
41
+ .toLowerCase()
42
+ .includes('closed')
43
+ ? 'closed'
44
+ : 'open',
45
+ title: hit.title ?? '',
46
+ body: hit.body ?? '',
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Read and normalise a host-supplied issue corpus.
52
+ *
53
+ * `state` is read from either `state` or `state_reason`, so a closed-as-
54
+ * not-planned issue is recognised however the host's API spelled it. Only
55
+ * `number` and `body` are load-bearing: the number identifies the match and
56
+ * the body carries the provenance footers dedup confirms identity against.
57
+ *
58
+ * @param {string} filePath — path to a JSON array of issues.
59
+ * @param {{ readFileSyncImpl?: typeof fs.readFileSync }} [seams]
60
+ * @returns {Array<{ number: number, state: string, title: string, body: string }>}
61
+ * @throws {Error} when the file is missing, unreadable, not JSON, or not an array.
62
+ */
63
+ export function loadIssuesFile(
64
+ filePath,
65
+ { readFileSyncImpl = fs.readFileSync } = {},
66
+ ) {
67
+ let raw;
68
+ try {
69
+ raw = readFileSyncImpl(filePath, 'utf8');
70
+ } catch (err) {
71
+ throw new Error(
72
+ `--issues-file: cannot read "${filePath}" (${err.message}). Dedup needs ` +
73
+ 'the issue corpus to check against; running without it would classify ' +
74
+ 'every group "create" and re-file findings already tracked.',
75
+ );
76
+ }
77
+ let parsed;
78
+ try {
79
+ parsed = JSON.parse(raw);
80
+ } catch (err) {
81
+ throw new Error(
82
+ `--issues-file: "${filePath}" is not valid JSON (${err.message}). ` +
83
+ 'Expected a JSON array of issues, e.g. the result of listing every ' +
84
+ 'issue labelled audit::* with state "all".',
85
+ );
86
+ }
87
+ if (!Array.isArray(parsed)) {
88
+ throw new Error(
89
+ `--issues-file: "${filePath}" holds ${describeShape(parsed)}, not a JSON ` +
90
+ 'array of issues. Pass the issue list itself, not the envelope wrapping it.',
91
+ );
92
+ }
93
+ return parsed.filter(isIssueLike).map(normaliseIssueHit);
94
+ }
95
+
96
+ /**
97
+ * Whether one array entry can possibly identify an issue. An entry without a
98
+ * numeric `number` can never confirm a match — exactly as it could not when it
99
+ * came back from a search — so it is dropped rather than indexed under nothing.
100
+ *
101
+ * @param {unknown} entry
102
+ * @returns {boolean}
103
+ */
104
+ function isIssueLike(entry) {
105
+ return (
106
+ Boolean(entry) &&
107
+ typeof entry === 'object' &&
108
+ typeof entry.number === 'number'
109
+ );
110
+ }
111
+
112
+ /**
113
+ * Name what a non-array payload actually was, so the error points at the fix.
114
+ * @param {unknown} value
115
+ * @returns {string}
116
+ */
117
+ function describeShape(value) {
118
+ if (value === null) return 'null';
119
+ if (Array.isArray(value)) return 'an array';
120
+ return `a JSON ${typeof value}`;
121
+ }
@@ -28,9 +28,9 @@
28
28
  * complexity budget does not absorb a git driver.
29
29
  */
30
30
 
31
+ import { DEFAULT_LEDGER_PATH } from '../findings/audit-ledger.js';
31
32
  import { gh as defaultGh } from '../gh-exec.js';
32
33
  import { gitSync } from '../git-utils.js';
33
- import { DEFAULT_LEDGER_PATH } from './ledger.js';
34
34
  import { openLedgerPullRequest, probeGit } from './ledger-pr.js';
35
35
 
36
36
  /** Fallback base branch when config carries no `project.baseBranch`. */
@@ -0,0 +1,126 @@
1
+ /**
2
+ * lib/audit-to-stories/ledger-record.js — record what a run actually filed.
3
+ *
4
+ * The cross-run ledger can only suppress an already-filed finding if something
5
+ * tells it the finding was filed. Until Story #5305 nothing did: the reconcile
6
+ * ran *during* the scan, before any Issue existed, and its `issueStates` were
7
+ * derived from `matchedIssues` — the Issues dedup FOUND. A group classified
8
+ * `create` has none, so a freshly opened Issue contributed nothing and its
9
+ * entry persisted as `status: "new", issue: null` forever. On a host where
10
+ * GitHub-search dedup works that is invisible; on one where it cannot run,
11
+ * nothing suppresses anything and every sweep re-files everything.
12
+ *
13
+ * The missing input is the `groupKey → issueNumber` map, which exists only
14
+ * after the Issues are opened. `--wire-edges` already takes exactly that map
15
+ * and is already a required pass, which is why the record rides along with it
16
+ * rather than arriving as a second command an operator must remember.
17
+ *
18
+ * Nothing here reaches the network: the caller hands over the map it already
19
+ * holds, and the only I/O is the ledger read/write this module delegates to
20
+ * `ledger.js`. That is what lets the record run BEFORE the provider is loaded,
21
+ * so a host whose provider cannot be constructed still records what it filed.
22
+ */
23
+
24
+ import {
25
+ DEFAULT_LEDGER_PATH,
26
+ readLedger,
27
+ reconcileLedger,
28
+ writeLedger,
29
+ } from '../findings/audit-ledger.js';
30
+ import {
31
+ fingerprintAuditFinding,
32
+ toCanonicalFinding,
33
+ } from './finding-adapter.js';
34
+
35
+ /**
36
+ * Project the opened-issue map onto the `{ fingerprint → issueState }` shape
37
+ * `reconcileLedger` reads, and collect the findings those groups carry.
38
+ *
39
+ * Only groups present in the map contribute. A group the map does not mention
40
+ * was not opened — deduped, ledger-suppressed, or simply skipped — and
41
+ * inventing an Issue state for it is how a finding gets suppressed against an
42
+ * Issue that does not exist.
43
+ *
44
+ * Every recorded state is `open`: the map names Issues this run just created,
45
+ * and a just-created Issue is open. A later close is learned from the live
46
+ * lookup on the next run, where it outranks this record (`decideStatus` reads
47
+ * the closed-Issue branch first).
48
+ *
49
+ * @param {object} params
50
+ * @param {Array<object>} params.groups — the `create`-eligible groups.
51
+ * @param {Record<string, number>} params.issueByGroupKey — group key → issue number.
52
+ * Module-internal: `recordFiledIssues` is the only production entrypoint, and
53
+ * exporting this for tests alone trips the CI-only `dead-exports:production`
54
+ * gate. It is covered through `recordFiledIssues`, which is the seam that
55
+ * actually ships.
56
+ *
57
+ * @returns {{ findings: Array<object>, issueStates: Record<string, { state: string, number: number }>, groupsRecorded: number }}
58
+ */
59
+ function issueStatesFromIssueMap({ groups, issueByGroupKey }) {
60
+ const findings = [];
61
+ const issueStates = {};
62
+ let groupsRecorded = 0;
63
+
64
+ for (const group of groups ?? []) {
65
+ const number = issueByGroupKey?.[group?.groupKey];
66
+ if (typeof number !== 'number') continue;
67
+ groupsRecorded += 1;
68
+ for (const finding of group.findings ?? []) {
69
+ findings.push(finding);
70
+ issueStates[fingerprintAuditFinding(finding).full] = {
71
+ state: 'open',
72
+ number,
73
+ };
74
+ }
75
+ }
76
+
77
+ return { findings, issueStates, groupsRecorded };
78
+ }
79
+
80
+ /**
81
+ * Fold the just-opened Issues onto the committed ledger.
82
+ *
83
+ * Only the mapped groups' findings are passed to `reconcileLedger`, so every
84
+ * other entry survives untouched — `reconcileLedger` copies the prior index
85
+ * forward and rewrites only what this scan hands it.
86
+ *
87
+ * @param {object} params
88
+ * @param {string} [params.ledgerPath]
89
+ * @param {Array<object>} params.groups — the `create`-eligible groups.
90
+ * @param {Record<string, number>} params.issueByGroupKey
91
+ * @param {boolean} [params.write=true] — `false` computes the record without
92
+ * persisting it, which is what `--dry-run` needs.
93
+ * @param {{ readLedgerImpl?: Function, writeLedgerImpl?: Function }} [seams]
94
+ * @returns {{ path: string, written: boolean, groupsRecorded: number, findingsRecorded: number, filed: number }}
95
+ */
96
+ export function recordFiledIssues(
97
+ { ledgerPath, groups, issueByGroupKey, write = true },
98
+ { readLedgerImpl = readLedger, writeLedgerImpl = writeLedger } = {},
99
+ ) {
100
+ const path = ledgerPath ?? DEFAULT_LEDGER_PATH;
101
+ const { findings, issueStates, groupsRecorded } = issueStatesFromIssueMap({
102
+ groups,
103
+ issueByGroupKey,
104
+ });
105
+
106
+ const { ledger: next, classifications } = reconcileLedger({
107
+ ledger: readLedgerImpl(path),
108
+ findings,
109
+ issueStates,
110
+ toCanonical: toCanonicalFinding,
111
+ });
112
+ // Nothing to record means nothing to write. The ledger is committed consumer
113
+ // state and `--ledger-commit` opens a PR only when it changed, so rewriting
114
+ // it with a fresh `generatedAt` and no new memory would manufacture a diff
115
+ // that says nothing.
116
+ const wrote = Boolean(write) && findings.length > 0;
117
+ if (wrote) writeLedgerImpl(path, next);
118
+
119
+ return {
120
+ path,
121
+ written: wrote,
122
+ groupsRecorded,
123
+ findingsRecorded: findings.length,
124
+ filed: classifications.filter((c) => c.status === 'filed').length,
125
+ };
126
+ }
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import { SEVERITIES } from '../findings/severity.js';
21
+ import { auditLabelFooterForFindings } from './audit-label-taxonomy.js';
21
22
  import { formatEpicGrouping } from './epic-grouping-directive.js';
22
23
  import {
23
24
  renderFingerprintFooter,
@@ -107,6 +108,16 @@ function formatMVPScope(groups) {
107
108
  const footers = [
108
109
  renderFingerprintFooter(findings),
109
110
  renderSemanticKeyFooter(findings),
111
+ // ...and the `audit::*` labels the dedup corpus is listed by.
112
+ //
113
+ // Without them a Story the chained planning path files is absent from
114
+ // the pool an indexed sweep matches against, and with an index in play
115
+ // the exact lookup is answered from that pool without ever reaching
116
+ // the provider — so the fingerprint footer above cannot rescue it. The
117
+ // two footers are therefore a pair: one carries the identity, the
118
+ // other carries the reason the next sweep ever looks at this issue
119
+ // (Story #5307).
120
+ auditLabelFooterForFindings(findings),
110
121
  ]
111
122
  .map((f) => ` ${f}`)
112
123
  .join('\n');
@@ -16,6 +16,7 @@ import { SHELL_INJECTION_PATTERN_STRING } from './config-schema-shared.js';
16
16
  // resolved AGENTRC_SCHEMA is unchanged.
17
17
  import { DELIVERY_SCHEMA } from './config-settings-schema-delivery.js';
18
18
  import compiledAgentrcValidator from './generated/agentrc-validator.js';
19
+ import { DEFAULT_FRAMEWORK_REPO } from './github/framework-repo.js';
19
20
  import { SKILL_ID_RE } from './skills/walk-skill-files.js';
20
21
 
21
22
  /**
@@ -386,6 +387,37 @@ const MERGE_METHODS_SCHEMA = {
386
387
  additionalProperties: false,
387
388
  };
388
389
 
390
+ /**
391
+ * Where follow-up work is filed when the repository that surfaced it does not
392
+ * own it. Ownership splits three ways — consumer / framework / platform — and
393
+ * `github.owner`/`github.repo` already carry the consumer bucket, so only the
394
+ * other two are configured here. Routing itself lives in
395
+ * `lib/github/framework-repo.js`; an unset bucket is reported as unroutable
396
+ * rather than silently re-pointed at the consumer's own tracker.
397
+ */
398
+ const FOLLOW_UP_REPOS_SCHEMA = {
399
+ type: 'object',
400
+ description:
401
+ '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.',
402
+ properties: {
403
+ framework: {
404
+ type: 'string',
405
+ pattern: '^[^/\\s]+/[^/\\s]+$',
406
+ description:
407
+ '`<owner>/<repo>` that owns framework-level defects. Defaults to the Mandrel mirror — the one bucket with a knowable default.',
408
+ default: DEFAULT_FRAMEWORK_REPO,
409
+ },
410
+ platform: {
411
+ type: ['string', 'null'],
412
+ pattern: '^[^/\\s]+/[^/\\s]+$',
413
+ description:
414
+ '`<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.',
415
+ default: null,
416
+ },
417
+ },
418
+ additionalProperties: false,
419
+ };
420
+
389
421
  const GITHUB_SCHEMA = {
390
422
  type: 'object',
391
423
  description:
@@ -432,6 +464,7 @@ const GITHUB_SCHEMA = {
432
464
  '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.',
433
465
  default: 60000,
434
466
  },
467
+ followUpRepos: FOLLOW_UP_REPOS_SCHEMA,
435
468
  branchProtection: BRANCH_PROTECTION_SCHEMA,
436
469
  mergeMethods: MERGE_METHODS_SCHEMA,
437
470
  notifications: NOTIFICATIONS_SCHEMA,
@@ -45,12 +45,18 @@
45
45
  * - **Durable cross-repo deferral.** Cross-repo-deferred findings are
46
46
  * upserted into a structured comment on the Epic instead of only a
47
47
  * log line.
48
+ * - **Routed, never re-pointed.** Ownership routing is delegated to
49
+ * `github/framework-repo.js#routeOwnership`; an unroutable bucket is
50
+ * skipped `unroutable` and named in that same durable comment. The
51
+ * predecessor resolved an absent framework slug to the consumer's own
52
+ * repo, which silently mis-filed framework-owned work.
48
53
  */
49
54
 
50
55
  import { spawn as defaultSpawn } from 'node:child_process';
51
56
  import { createHash } from 'node:crypto';
52
57
 
53
58
  import { inNodeTestContext } from '../config/temp-paths.js';
59
+ import { routeOwnership } from '../github/framework-repo.js';
54
60
  import { LABEL_COLORS } from '../label-constants.js';
55
61
  import { classifyPathSource as defaultClassifier } from '../observability/source-classifier.js';
56
62
  import { upsertStructuredComment } from '../orchestration/ticketing.js';
@@ -531,7 +537,7 @@ async function findExistingFollowUp({
531
537
  *
532
538
  * @returns {Promise<{ url: string|null, error: string|null }>}
533
539
  */
534
- async function updateFollowUpIssue({
540
+ export async function updateFollowUpIssue({
535
541
  owner,
536
542
  repo,
537
543
  number,
@@ -675,7 +681,7 @@ async function readLiveLabelNames({
675
681
  * @param {number} [opts.timeoutMs]
676
682
  * @returns {Promise<{ created: string[], missing: string[], errors: string[] }>}
677
683
  */
678
- async function ensureIssueLabels({
684
+ export async function ensureIssueLabels({
679
685
  owner,
680
686
  repo,
681
687
  labels,
@@ -947,7 +953,7 @@ async function processGraduateFinding({
947
953
  decorate,
948
954
  epicId,
949
955
  currentRepo,
950
- frameworkRepo,
956
+ repos,
951
957
  classifier,
952
958
  gitRef,
953
959
  ghPath,
@@ -994,12 +1000,26 @@ async function processGraduateFinding({
994
1000
  }
995
1001
 
996
1002
  const source = classifier(finding.path, null);
997
- const routedRepo =
998
- source === 'framework' && frameworkRepo ? frameworkRepo : currentRepo;
999
- const isCrossRepo =
1000
- routedRepo.owner !== currentRepo.owner ||
1001
- routedRepo.repo !== currentRepo.repo;
1002
- if (isCrossRepo) {
1003
+ // Ownership routing is the shared SSOT's call, and an unroutable bucket is
1004
+ // an outcome rather than a fallback: the predecessor resolved an absent
1005
+ // framework slug to the CONSUMER's repo, silently filing framework-owned
1006
+ // work in the wrong place (see `github/framework-repo.js`). Unroutable
1007
+ // findings are deferred and named, never re-pointed.
1008
+ const routing = routeOwnership({ bucket: source, repos, currentRepo });
1009
+ if (!routing.routable) {
1010
+ const logLine = `[${spec.fnName}] unroutable ${source} finding (${routing.missingKey} is unset) — not filed: ${finding.title ?? finding.path ?? `finding ${finding.index}`}`;
1011
+ logger?.warn?.(logLine);
1012
+ crossRepoDeferred.push({
1013
+ finding,
1014
+ routedRepo: null,
1015
+ source,
1016
+ logLine,
1017
+ missingKey: routing.missingKey,
1018
+ });
1019
+ return skip('unroutable');
1020
+ }
1021
+ const routedRepo = routing.routedRepo;
1022
+ if (routing.crossRepo) {
1003
1023
  const logLine = spec.buildCrossRepoLog({ finding, routedRepo, source });
1004
1024
  logger?.info?.(logLine);
1005
1025
  crossRepoDeferred.push({ finding, routedRepo, source, logLine });
@@ -1165,13 +1185,18 @@ function renderCrossRepoDeferredBody(deferred, spec) {
1165
1185
  const header =
1166
1186
  spec.crossRepoCommentHeader ??
1167
1187
  '### Cross-repo-deferred findings\n\nThese findings route to a different repository and were **not** filed here. They are recorded for a cross-repo follow-up pass.';
1168
- const rows = deferred.map(({ finding, routedRepo, logLine }) => {
1188
+ const rows = deferred.map(({ finding, routedRepo, logLine, missingKey }) => {
1169
1189
  const path =
1170
1190
  typeof finding.path === 'string' && finding.path.length > 0
1171
1191
  ? `\`${finding.path}\``
1172
1192
  : '_(no path)_';
1193
+ // An unroutable finding has no destination to name — say which config
1194
+ // key would give it one instead of inventing a repo for the row.
1195
+ const destination = routedRepo
1196
+ ? `${routedRepo.owner}/${routedRepo.repo}`
1197
+ : `**unroutable** (\`${missingKey}\` is unset)`;
1173
1198
  return [
1174
- `- ${path} (severity: ${finding.severity ?? 'n/a'}) → ${routedRepo.owner}/${routedRepo.repo}`,
1199
+ `- ${path} (severity: ${finding.severity ?? 'n/a'}) → ${destination}`,
1175
1200
  ` - ${logLine}`,
1176
1201
  ].join('\n');
1177
1202
  });
@@ -1244,7 +1269,12 @@ async function persistCrossRepoDeferred({
1244
1269
  * the durable cross-repo-deferred persistence
1245
1270
  * @param {object} [opts.config]
1246
1271
  * @param {{owner: string, repo: string}} opts.currentRepo
1247
- * @param {{owner: string, repo: string}} [opts.frameworkRepo]
1272
+ * @param {{owner: string, repo: string}} [opts.frameworkRepo] — the
1273
+ * `framework` ownership bucket. Absent means **unroutable**, never the
1274
+ * consumer's repo: a framework-classified finding is then deferred and
1275
+ * named rather than filed in the wrong tracker.
1276
+ * @param {{owner: string, repo: string}} [opts.platformRepo] — the shared
1277
+ * platform/infra bucket, for a caller whose classifier can reach it.
1248
1278
  * @param {string} [opts.gitRef='HEAD']
1249
1279
  * @param {Function} [opts.classifier=classifyPathSource]
1250
1280
  * @param {string} [opts.ghPath='gh']
@@ -1279,6 +1309,7 @@ export async function graduate({
1279
1309
  config,
1280
1310
  currentRepo,
1281
1311
  frameworkRepo,
1312
+ platformRepo,
1282
1313
  gitRef = 'HEAD',
1283
1314
  classifier = defaultClassifier,
1284
1315
  ghPath = 'gh',
@@ -1347,6 +1378,15 @@ export async function graduate({
1347
1378
  return envelope;
1348
1379
  }
1349
1380
 
1381
+ // The ownership map the routing SSOT resolves against. `platform` is
1382
+ // absent for both graduators today (their classifier is binary) and is
1383
+ // threaded so a caller that does know a shared-infra repo routes there
1384
+ // rather than into the nearest plausible tracker.
1385
+ const repos = {
1386
+ consumer: currentRepo,
1387
+ framework: frameworkRepo ?? null,
1388
+ platform: platformRepo ?? null,
1389
+ };
1350
1390
  const crossRepoDeferred = [];
1351
1391
  for (const finding of findings) {
1352
1392
  await processGraduateFinding({
@@ -1355,7 +1395,7 @@ export async function graduate({
1355
1395
  decorate,
1356
1396
  epicId,
1357
1397
  currentRepo,
1358
- frameworkRepo,
1398
+ repos,
1359
1399
  classifier,
1360
1400
  gitRef,
1361
1401
  ghPath,
@@ -25,6 +25,7 @@
25
25
  */
26
26
 
27
27
  import { META_LABELS } from '../label-constants.js';
28
+ import { CI_GAP_INTAKE_MARKER } from '../orchestration/ci-gap-intake.js';
28
29
  import { runChild } from './graduator-core.js';
29
30
 
30
31
  const DEFAULT_LIMIT = 50;
@@ -135,8 +136,15 @@ function formatGhError(label, { code, stderr, spawnError }) {
135
136
  * already-budgeted envelope, and trimming early avoids any ambient assumption
136
137
  * that downstream consumers can rely on extra fields.
137
138
  *
139
+ * `intake` is the one field derived rather than copied: an issue whose body
140
+ * carries the CI-gap intake marker is a filing awaiting graduation, not a
141
+ * finished report, and `/mandrel-plan` offers those a `/mandrel-plan <id>`
142
+ * rewrite. The body itself is NOT carried onto the envelope — the marker
143
+ * check is the whole reason it was fetched, and a planner payload does not
144
+ * need every intake issue's full text.
145
+ *
138
146
  * @param {object} raw
139
- * @returns {{ number: number, title: string, url: string, labels: string[] }|null}
147
+ * @returns {{ number: number, title: string, url: string, labels: string[], intake: boolean }|null}
140
148
  */
141
149
  function normalizeIssue(raw) {
142
150
  if (!raw || typeof raw !== 'object') return null;
@@ -144,12 +152,23 @@ function normalizeIssue(raw) {
144
152
  if (number === null) return null;
145
153
  const title = typeof raw.title === 'string' ? raw.title : '';
146
154
  const url = typeof raw.url === 'string' ? raw.url : '';
147
- const labels = Array.isArray(raw.labels)
148
- ? raw.labels
149
- .map((l) => (l && typeof l === 'object' ? l.name : l))
150
- .filter((name) => typeof name === 'string')
151
- : [];
152
- return { number, title, url, labels };
155
+ const intake =
156
+ typeof raw.body === 'string' && raw.body.includes(CI_GAP_INTAKE_MARKER);
157
+ return { number, title, url, labels: normalizeLabels(raw.labels), intake };
158
+ }
159
+
160
+ /**
161
+ * Flatten `gh issue list --json labels` into plain names. `gh` returns label
162
+ * objects; a hand-built fixture may return strings. Pure.
163
+ *
164
+ * @param {unknown} raw
165
+ * @returns {string[]}
166
+ */
167
+ function normalizeLabels(raw) {
168
+ if (!Array.isArray(raw)) return [];
169
+ return raw
170
+ .map((l) => (l && typeof l === 'object' ? l.name : l))
171
+ .filter((name) => typeof name === 'string');
153
172
  }
154
173
 
155
174
  /**
@@ -176,7 +195,7 @@ async function fetchByLabel({ owner, repo, label, ghPath, limit, spawnImpl }) {
176
195
  '--label',
177
196
  label,
178
197
  '--json',
179
- 'number,title,labels,url',
198
+ 'number,title,labels,url,body',
180
199
  '--limit',
181
200
  String(limit),
182
201
  ];
@@ -209,10 +228,32 @@ async function fetchByLabel({ owner, repo, label, ghPath, limit, spawnImpl }) {
209
228
  }
210
229
 
211
230
  /**
212
- * Fetch the union of open issues carrying either `meta::framework-gap` or
213
- * `meta::consumer-improvement` and split them into two arrays. Issues that
214
- * carry **both** labels appear in `frameworkGaps` only — dedupe-by-number
215
- * runs across both arrays so the planner sees each issue exactly once.
231
+ * Append every not-yet-seen issue to one bucket, marking it seen.
232
+ *
233
+ * An issue carrying more than one meta label must reach the planner exactly
234
+ * once, so the `seen` set spans all three buckets and the first bucket to
235
+ * claim a number keeps it. Mutates both arguments — one walk, three buckets.
236
+ *
237
+ * @param {object[]} bucket
238
+ * @param {object[]} issues
239
+ * @param {Set<number>} seen
240
+ * @returns {void}
241
+ */
242
+ function dedupeInto(bucket, issues, seen) {
243
+ for (const issue of issues) {
244
+ if (seen.has(issue.number)) continue;
245
+ seen.add(issue.number);
246
+ bucket.push(issue);
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Fetch the union of open issues carrying `meta::framework-gap`,
252
+ * `meta::consumer-improvement` or `meta::platform-gap` and split them into
253
+ * three arrays — one per ownership bucket in `github/framework-repo.js`, so
254
+ * a filing's bucket survives all the way to the planner. An issue carrying
255
+ * more than one label appears once, in that precedence order; dedupe-by-number
256
+ * runs across all three arrays so the planner sees each issue exactly once.
216
257
  *
217
258
  * The returned envelope is best-effort: every failure mode (gh missing, repo
218
259
  * not found, non-zero exit, malformed JSON) is captured as a string in
@@ -227,6 +268,7 @@ async function fetchByLabel({ owner, repo, label, ghPath, limit, spawnImpl }) {
227
268
  * @returns {Promise<{
228
269
  * frameworkGaps: object[],
229
270
  * consumerImprovements: object[],
271
+ * platformGaps: object[],
230
272
  * recurringDefectClasses: Array<{ class: string, count: number, issues: number[] }>,
231
273
  * fetchedAt: string,
232
274
  * errors: string[],
@@ -251,6 +293,7 @@ export async function fetchPriorFeedback({
251
293
  const envelope = {
252
294
  frameworkGaps: [],
253
295
  consumerImprovements: [],
296
+ platformGaps: [],
254
297
  recurringDefectClasses: [],
255
298
  fetchedAt: new Date().toISOString(),
256
299
  errors,
@@ -258,7 +301,7 @@ export async function fetchPriorFeedback({
258
301
 
259
302
  if (errors.length > 0) return envelope;
260
303
 
261
- const [gapsResult, improvementsResult] = await Promise.all([
304
+ const [gapsResult, improvementsResult, platformResult] = await Promise.all([
262
305
  fetchByLabel({
263
306
  owner,
264
307
  repo,
@@ -275,25 +318,27 @@ export async function fetchPriorFeedback({
275
318
  limit,
276
319
  spawnImpl,
277
320
  }),
321
+ fetchByLabel({
322
+ owner,
323
+ repo,
324
+ label: META_LABELS.PLATFORM_GAP,
325
+ ghPath,
326
+ limit,
327
+ spawnImpl,
328
+ }),
278
329
  ]);
279
330
 
280
- if (gapsResult.error) errors.push(gapsResult.error);
281
- if (improvementsResult.error) errors.push(improvementsResult.error);
331
+ for (const { error } of [gapsResult, improvementsResult, platformResult]) {
332
+ if (error) errors.push(error);
333
+ }
282
334
 
283
335
  // Dedupe by issue number across both arrays. Issues that carry both labels
284
336
  // land in frameworkGaps first (deterministic) and are filtered out of
285
337
  // consumerImprovements.
286
338
  const seen = new Set();
287
- for (const issue of gapsResult.issues) {
288
- if (seen.has(issue.number)) continue;
289
- seen.add(issue.number);
290
- envelope.frameworkGaps.push(issue);
291
- }
292
- for (const issue of improvementsResult.issues) {
293
- if (seen.has(issue.number)) continue;
294
- seen.add(issue.number);
295
- envelope.consumerImprovements.push(issue);
296
- }
339
+ dedupeInto(envelope.frameworkGaps, gapsResult.issues, seen);
340
+ dedupeInto(envelope.consumerImprovements, improvementsResult.issues, seen);
341
+ dedupeInto(envelope.platformGaps, platformResult.issues, seen);
297
342
 
298
343
  // Story #4135 (Epic #4131, F11) — close the retro→planner loop: derive the
299
344
  // recurring defect classes from the `friction::<class>` labels carried by
@@ -303,6 +348,7 @@ export async function fetchPriorFeedback({
303
348
  envelope.recurringDefectClasses = extractRecurringDefectClasses([
304
349
  ...envelope.frameworkGaps,
305
350
  ...envelope.consumerImprovements,
351
+ ...envelope.platformGaps,
306
352
  ]);
307
353
 
308
354
  return envelope;