mandrel 2.55.0 → 2.57.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 (131) hide show
  1. package/.agents/agents/plan-critic.md +13 -18
  2. package/.agents/agents/story-worker.md +25 -34
  3. package/.agents/docs/agentrc-reference.json +4 -30
  4. package/.agents/docs/configuration.md +11 -28
  5. package/.agents/docs/execution-reference.md +5 -5
  6. package/.agents/docs/quality-gates.md +8 -7
  7. package/.agents/instructions.md +9 -10
  8. package/.agents/rules/ci-remediation.md +39 -21
  9. package/.agents/schemas/agentrc.schema.json +28 -185
  10. package/.agents/schemas/story-deliver-terminal.schema.json +1 -1
  11. package/.agents/scripts/acceptance-eval.js +107 -17
  12. package/.agents/scripts/audit-to-stories.js +222 -75
  13. package/.agents/scripts/ceremony-derive.js +191 -0
  14. package/.agents/scripts/check-context-budget.js +28 -33
  15. package/.agents/scripts/check-cyclomatic.js +4 -3
  16. package/.agents/scripts/deliver-light.js +31 -94
  17. package/.agents/scripts/file-ci-gap.js +306 -0
  18. package/.agents/scripts/lib/audit-suite/checklist-threading.js +15 -2
  19. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +25 -1
  20. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +40 -52
  21. package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +5 -1
  22. package/.agents/scripts/lib/audit-to-stories/issue-corpus.js +162 -0
  23. package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
  24. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +1 -1
  25. package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
  26. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
  27. package/.agents/scripts/lib/baselines/coverage-updater-cli.js +110 -0
  28. package/.agents/scripts/lib/baselines/crap-preview-scan.js +25 -0
  29. package/.agents/scripts/lib/baselines/crap-updater-cli.js +223 -0
  30. package/.agents/scripts/lib/bdd-scenario-budget.js +21 -3
  31. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +0 -1
  32. package/.agents/scripts/lib/close-validation/gates.js +52 -1
  33. package/.agents/scripts/lib/config/acceptance-eval.js +25 -57
  34. package/.agents/scripts/lib/config/delivery-routing.js +7 -33
  35. package/.agents/scripts/lib/config/explain.js +0 -19
  36. package/.agents/scripts/lib/config/limits.js +18 -78
  37. package/.agents/scripts/lib/config/quality.js +6 -3
  38. package/.agents/scripts/lib/config/runners.js +3 -2
  39. package/.agents/scripts/lib/config-settings-schema-delivery.js +15 -68
  40. package/.agents/scripts/lib/config-settings-schema-quality.js +0 -14
  41. package/.agents/scripts/lib/config-settings-schema.js +49 -143
  42. package/.agents/scripts/lib/crap-engine.js +35 -4
  43. package/.agents/scripts/lib/crap-utils.js +17 -1
  44. package/.agents/scripts/lib/cyclomatic-ceiling.js +19 -7
  45. package/.agents/scripts/lib/feedback-loop/graduator-core.js +53 -13
  46. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +71 -25
  47. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +18 -25
  48. package/.agents/scripts/lib/{audit-to-stories/ledger.js → findings/audit-ledger.js} +131 -24
  49. package/.agents/scripts/lib/findings/route-finding.js +38 -0
  50. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  51. package/.agents/scripts/lib/github/framework-repo.js +148 -2
  52. package/.agents/scripts/lib/label-constants.js +6 -1
  53. package/.agents/scripts/lib/observability/runtime-friction.js +1 -1
  54. package/.agents/scripts/lib/observability/source-classifier.js +2 -0
  55. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +5 -4
  56. package/.agents/scripts/lib/orchestration/ceremony-routing.js +19 -73
  57. package/.agents/scripts/lib/orchestration/ci-gap-intake.js +605 -0
  58. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +13 -8
  59. package/.agents/scripts/lib/orchestration/complexity-gate.js +46 -212
  60. package/.agents/scripts/lib/orchestration/file-assumptions.js +32 -17
  61. package/.agents/scripts/lib/orchestration/light-escalation.js +3 -3
  62. package/.agents/scripts/lib/orchestration/light-suitability.js +66 -233
  63. package/.agents/scripts/lib/orchestration/plan-context.js +181 -387
  64. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +42 -153
  65. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +14 -70
  66. package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
  67. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +300 -0
  68. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +131 -168
  69. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +133 -299
  70. package/.agents/scripts/lib/orchestration/plan-persist/soft-findings.js +55 -0
  71. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +16 -65
  72. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +22 -35
  73. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +30 -139
  74. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +61 -223
  75. package/.agents/scripts/lib/orchestration/run-epilogue.js +4 -4
  76. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -0
  77. package/.agents/scripts/lib/orchestration/single-story-close/phases/pre-gate-steps.js +46 -16
  78. package/.agents/scripts/lib/orchestration/story-close/context-budget-writeback.js +213 -0
  79. package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
  80. package/.agents/scripts/lib/orchestration/task-body-validator.js +10 -63
  81. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +33 -539
  82. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +21 -414
  83. package/.agents/scripts/lib/orchestration/ticket-validator.js +54 -118
  84. package/.agents/scripts/lib/orchestration/verify-credit.js +69 -24
  85. package/.agents/scripts/lib/story-body/body-format-lints.js +15 -85
  86. package/.agents/scripts/lib/story-body/story-body.js +17 -237
  87. package/.agents/scripts/lib/templates/decomposer-prompts.js +84 -121
  88. package/.agents/scripts/lib/test-isolate/cli-options.js +93 -0
  89. package/.agents/scripts/lib/test-isolate/progress-log.js +45 -0
  90. package/.agents/scripts/lib/test-isolate/render-report.js +97 -0
  91. package/.agents/scripts/lib/test-isolate/run-isolate.js +87 -0
  92. package/.agents/scripts/lib/test-run-credit.js +266 -0
  93. package/.agents/scripts/lib/wave-runner/footprint.js +48 -358
  94. package/.agents/scripts/lib/wave-runner/ready-set.js +6 -5
  95. package/.agents/scripts/lib/workers/crap-worker.js +32 -41
  96. package/.agents/scripts/plan-context.js +7 -9
  97. package/.agents/scripts/plan-critics.js +28 -54
  98. package/.agents/scripts/plan-persist.js +25 -68
  99. package/.agents/scripts/pr-watch-with-update.js +3 -2
  100. package/.agents/scripts/quality-preview.js +51 -0
  101. package/.agents/scripts/run-tests.js +12 -0
  102. package/.agents/scripts/stories-wave-tick.js +23 -45
  103. package/.agents/scripts/test-isolate.js +13 -180
  104. package/.agents/scripts/update-coverage-baseline.js +25 -70
  105. package/.agents/scripts/update-crap-baseline.js +19 -123
  106. package/.agents/skills/core/scope-triage/SKILL.md +3 -3
  107. package/.agents/workflows/audit-clean-code.md +4 -3
  108. package/.agents/workflows/audit-to-stories.md +63 -27
  109. package/.agents/workflows/helpers/acceptance-self-eval.md +41 -41
  110. package/.agents/workflows/helpers/code-quality-guardrails.md +4 -4
  111. package/.agents/workflows/helpers/code-review.md +2 -3
  112. package/.agents/workflows/helpers/deliver-digest.md +41 -57
  113. package/.agents/workflows/helpers/deliver-light.md +40 -105
  114. package/.agents/workflows/helpers/deliver-reference.md +1 -1
  115. package/.agents/workflows/helpers/deliver-story-reference.md +56 -62
  116. package/.agents/workflows/helpers/deliver-story.md +9 -13
  117. package/.agents/workflows/helpers/plan-reference.md +132 -196
  118. package/.agents/workflows/mandrel-plan.md +28 -41
  119. package/.agents/workflows/memory-consolidate.md +9 -13
  120. package/docs/CHANGELOG.md +33 -0
  121. package/lib/migrations/index.js +4 -0
  122. package/lib/migrations/steps/2.57.0-retire-delivery-limit-knobs.js +45 -0
  123. package/lib/migrations/steps/2.57.0-retire-planning-limit-knobs.js +59 -0
  124. package/package.json +1 -1
  125. package/.agents/scripts/lib/framework-version.js +0 -39
  126. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +0 -223
  127. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +0 -97
  128. package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +0 -26
  129. package/.agents/scripts/lib/orchestration/spec-budget.js +0 -89
  130. package/.agents/scripts/lib/orchestration/spec-spill.js +0 -74
  131. package/.agents/scripts/lib/orchestration/verify-tier-repair.js +0 -107
@@ -14,9 +14,35 @@ import {
14
14
  crapFormula,
15
15
  } from './crap-coordinates.js';
16
16
  import { deriveMethodIdentities } from './crap-method-identity.js';
17
+ import { install as installAstCompat } from './escomplex-ast-compat.js';
17
18
 
18
19
  export { COORDINATE_ORIGINAL, COORDINATE_TRANSPILED, crapFormula };
19
20
 
21
+ /**
22
+ * Sentinel returned by {@link calculateCrapForSource} for a source the kernel
23
+ * cannot parse. Deliberately **not** `[]`: a caller receiving an empty array
24
+ * cannot tell an unscorable file from one with no methods, which is how a
25
+ * parse failure used to reach the baseline as a silent zero (Story #5311).
26
+ */
27
+ export const UNSCORABLE = null;
28
+
29
+ // The kernel's code generator predates the Babel AST its own parser emits, so
30
+ // ordinary modern syntax (`?.`, `await` or a regex in a loop head, object
31
+ // spread in a default parameter) aborts `analyzeModule` for the WHOLE file —
32
+ // see `escomplex-ast-compat.js` for the defect and the upstream status.
33
+ //
34
+ // Story #5311: the install belongs here, at the scoring kernel, because this
35
+ // is where both CRAP scorers converge — `calculateCrapForSource` (the worker
36
+ // path) and `crap-utils.js#analyzeOnce` (the serial path, which reaches this
37
+ // module for `methodRowsFromReport`). It used to be reached only as a side
38
+ // effect of `maintainability-engine.js` sitting somewhere in the serial path's
39
+ // import graph, which the worker's graph never included: 362 methods across
40
+ // 21 files scored zero via workers and scored fine serially, and
41
+ // `POOL_SERIAL_THRESHOLD` makes the worker path the only one a real repo
42
+ // takes. Anchoring it at the kernel makes the next worker entrypoint correct
43
+ // by construction rather than by an import nobody would guess is load-bearing.
44
+ installAstCompat();
45
+
20
46
  /**
21
47
  * Derive the raw per-method CRAP rows from an escomplex report.
22
48
  *
@@ -199,8 +225,12 @@ export { finalizeMethodRowsWithBaseline } from './crap-baseline-join.js';
199
225
  * produce `coverage: null` and `crap: null`. Callers apply their own
200
226
  * `requireCoverage` policy at the scanner level (`finalizeMethodRows`);
201
227
  * this kernel never decides to skip.
202
- * - A parse error returns an empty array — the file is unscorable, not
203
- * zero-complexity.
228
+ * - A parse error returns {@link UNSCORABLE} (`null`) — the file could not
229
+ * be scored at all, which is a different fact from "it has no methods"
230
+ * (`[]`). Story #5311: returning `[]` for both collapsed them, and every
231
+ * caller's drop path for an unscorable file became unreachable — the
232
+ * whole parse-failure class landed in the baseline as a clean zero.
233
+ * Callers MUST branch on `rows === null` before iterating.
204
234
  *
205
235
  * @param {string} source JavaScript source text (possibly transpiled).
206
236
  * @param {object|null} coverageForFile The inner value from a
@@ -216,7 +246,8 @@ export { finalizeMethodRowsWithBaseline } from './crap-baseline-join.js';
216
246
  * coverage: number|null,
217
247
  * crap: number|null,
218
248
  * coordinateSystem: 'original'|'transpiled',
219
- * }>}
249
+ * }>|null} The method rows, or {@link UNSCORABLE} when the source did not
250
+ * parse.
220
251
  */
221
252
  export function calculateCrapForSource(
222
253
  source,
@@ -227,7 +258,7 @@ export function calculateCrapForSource(
227
258
  try {
228
259
  report = escomplex.analyzeModule(source);
229
260
  } catch {
230
- return [];
261
+ return UNSCORABLE;
231
262
  }
232
263
  return methodRowsFromReport(report, coverageForFile, mapLine);
233
264
  }
@@ -348,6 +348,12 @@ function projectScanRow(relPath, mr) {
348
348
  * skipped from the returned rows so the baseline never contains
349
349
  * partially-scored entries. Both counters surface for reporting.
350
350
  *
351
+ * A file neither path could score at all — unreadable, untranspilable, or one
352
+ * the kernel could not parse — is dropped and counted in `unscorableFiles`
353
+ * (Story #5311). Unscorable is not scored-as-nothing: without this counter a
354
+ * dropped file is indistinguishable from a file with no methods, which is what
355
+ * let a whole parse-failure class leave the baseline silently.
356
+ *
351
357
  * When `scopeFiles` is provided (the `--changed-since` code path) files
352
358
  * discovered via directory walking are filtered against that set before any
353
359
  * I/O or scoring happens — so pre-push / PR-CI runs never pay the
@@ -385,6 +391,7 @@ function projectScanRow(relPath, mr) {
385
391
  * scannedFiles: number,
386
392
  * skippedFilesNoCoverage: number,
387
393
  * skippedMethodsNoCoverage: number,
394
+ * unscorableFiles: number,
388
395
  * }}
389
396
  */
390
397
  export async function scanAndScore({
@@ -442,9 +449,16 @@ export async function scanAndScore({
442
449
  const rows = [];
443
450
  let skippedFilesNoCoverage = 0;
444
451
  let skippedMethodsNoCoverage = 0;
452
+ let unscorableFiles = 0;
445
453
  const resolution = newResolutionAccumulator();
446
454
  for (const { item, result } of perFile) {
447
- if (!result) continue; // unrecoverable per-file failure: drop silently to match pre-pool semantics
455
+ if (!result) {
456
+ // Unrecoverable per-file failure (a pool-level error the worker never
457
+ // answered). Dropped like any other unscorable file, and counted — the
458
+ // point of the counter is that no drop is silent.
459
+ unscorableFiles += 1;
460
+ continue;
461
+ }
448
462
  if (result.skippedFileNoCoverage) {
449
463
  skippedFilesNoCoverage += 1;
450
464
  continue;
@@ -453,6 +467,7 @@ export async function scanAndScore({
453
467
  // read/transpile/parse failure: drop and move on, but if the worker
454
468
  // attached an error message (calculateCrapForSource throw) surface it
455
469
  // so the run isn't silent on the ops side.
470
+ unscorableFiles += 1;
456
471
  if (result.error) {
457
472
  Logger.warn(
458
473
  `[crap-utils] failed to score ${item.relPath}: ${result.error}`,
@@ -479,6 +494,7 @@ export async function scanAndScore({
479
494
  scannedFiles,
480
495
  skippedFilesNoCoverage,
481
496
  skippedMethodsNoCoverage,
497
+ unscorableFiles,
482
498
  resolution: summarizeResolution(resolution),
483
499
  };
484
500
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * cyclomatic-ceiling.js — the enforcing core behind
3
- * `delivery.quality.codingGuardrails.cyclomaticMustFix` (Story #4923).
2
+ * cyclomatic-ceiling.js — the enforcing core behind the cyclomatic ceiling
3
+ * ratchet (Story #4923; fixed ceiling since Story #5313).
4
4
  *
5
5
  * The two `codingGuardrails` cyclomatic knobs shipped schema-validated,
6
6
  * bootstrap-defaulted and resolver-resolved, and were then read by nothing:
@@ -9,6 +9,12 @@
9
9
  * nothing enforces is worse than no ceiling, because the workflow docs promise
10
10
  * the merge will be refused.
11
11
  *
12
+ * Story #5313 retired the `cyclomaticMustFix` config key: the ratchet's
13
+ * ceiling is the fixed {@link CYCLOMATIC_CEILING} (12), so a consumer cannot
14
+ * bound this gate by tuning a number, and `cyclomaticFlag` is the one
15
+ * advisory knob — `quality-preview.js` reports over-flag methods and exits 0
16
+ * on them.
17
+ *
12
18
  * This module is that enforcement, shaped as a **ratchet** rather than a
13
19
  * cliff. The repository already carries dozens of functions above the
14
20
  * must-fix ceiling; failing every one of them at once would have made the
@@ -37,6 +43,13 @@ import { scanDirectory } from './maintainability-utils.js';
37
43
  /** Default location of the committed breach baseline. */
38
44
  export const DEFAULT_CYCLOMATIC_BASELINE = 'baselines/cyclomatic.json';
39
45
 
46
+ /**
47
+ * The per-function cyclomatic ceiling the ratchet enforces. Fixed — not a
48
+ * config key — since Story #5313.
49
+ * @type {number}
50
+ */
51
+ export const CYCLOMATIC_CEILING = 12;
52
+
40
53
  /** Baseline `$schema` marker, matching the sibling ratchet baselines. */
41
54
  const CYCLOMATIC_BASELINE_SCHEMA =
42
55
  'https://mandrel.dev/baselines/cyclomatic.schema.json';
@@ -44,10 +57,9 @@ const CYCLOMATIC_BASELINE_SCHEMA =
44
57
  /**
45
58
  * Resolve the enforcement policy from a resolved `delivery.quality` block.
46
59
  *
47
- * `mustFix` and `flag` come straight from `resolveCodingGuardrails`, so a
48
- * consumer that tunes either knob tunes this gate — which is the whole point
49
- * of the Story. `targetDirs` / `ignoreGlobs` are borrowed from the
50
- * maintainability gate (see the module note).
60
+ * `mustFix` is the fixed {@link CYCLOMATIC_CEILING}; `flag` comes from
61
+ * `resolveCodingGuardrails` (advisory only). `targetDirs` / `ignoreGlobs`
62
+ * are borrowed from the maintainability gate (see the module note).
51
63
  *
52
64
  * @param {object | null | undefined} quality resolved `delivery.quality`
53
65
  * @returns {{ mustFix: number, flag: number, targetDirs: string[], ignoreGlobs: string[] }}
@@ -56,7 +68,7 @@ export function resolveCyclomaticPolicy(quality) {
56
68
  const guardrails = quality?.codingGuardrails ?? {};
57
69
  const mi = quality?.maintainability ?? {};
58
70
  return {
59
- mustFix: Number(guardrails.cyclomaticMustFix ?? 12),
71
+ mustFix: CYCLOMATIC_CEILING,
60
72
  flag: Number(guardrails.cyclomaticFlag ?? 8),
61
73
  targetDirs: Array.isArray(mi.targetDirs) ? mi.targetDirs : [],
62
74
  ignoreGlobs: Array.isArray(mi.ignoreGlobs) ? mi.ignoreGlobs : [],
@@ -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;
@@ -22,7 +22,10 @@
22
22
  * Routing correctness: a routed item already knows its source
23
23
  * (`framework` / `consumer`), so we file each source bucket with its own
24
24
  * constant classifier and thread the graduator's per-run filing cap across
25
- * the two buckets. The `meta::<framework-gap|consumer-improvement>` +
25
+ * the two buckets. Which repository a source routes to is decided once, in
26
+ * `github/framework-repo.js` — the silent consumer-repo fallback that used
27
+ * to live here (and mis-filed framework work into the consumer's tracker)
28
+ * is gone from the whole path, not just from this module. The `meta::<framework-gap|consumer-improvement>` +
26
29
  * `friction::<category>` labels are lifted verbatim from the routed item.
27
30
  *
28
31
  * Behind the `delivery.feedbackLoop.retroProposals` toggle (default ON,
@@ -30,7 +33,10 @@
30
33
  * failure path is captured in `errors[]`.
31
34
  */
32
35
 
33
- import { DEFAULT_FRAMEWORK_REPO } from '../github/framework-repo.js';
36
+ import {
37
+ DEFAULT_FRAMEWORK_REPO,
38
+ parseRepoSlug,
39
+ } from '../github/framework-repo.js';
34
40
  import { META_LABELS } from '../label-constants.js';
35
41
  import {
36
42
  contentFingerprint,
@@ -218,6 +224,8 @@ function toFinding(item, source, index) {
218
224
  * @param {{owner: string, repo: string}} opts.currentRepo — the repo the
219
225
  * retro is running inside (the consumer's own repo); the cross-repo guard's
220
226
  * anchor.
227
+ * @param {{owner: string, repo: string}} [opts.platformRepo] — the shared
228
+ * platform/infra bucket, forwarded to the walk's routing SSOT.
221
229
  * @param {{owner: string, repo: string}} [opts.frameworkRepo] — where
222
230
  * framework-tagged proposals route.
223
231
  * @param {{ framework?: object[], consumer?: object[] }} [opts.routedProposals]
@@ -239,6 +247,7 @@ export async function graduateRetroProposals({
239
247
  config,
240
248
  currentRepo,
241
249
  frameworkRepo,
250
+ platformRepo,
242
251
  routedProposals,
243
252
  ghPath,
244
253
  spawnImpl,
@@ -294,6 +303,7 @@ export async function graduateRetroProposals({
294
303
  config,
295
304
  currentRepo,
296
305
  frameworkRepo,
306
+ platformRepo,
297
307
  // Each bucket's source is known — a constant classifier routes the
298
308
  // whole bucket to the correct repo and stamps the correct label.
299
309
  classifier: () => source,
@@ -380,22 +390,6 @@ export function enrichRoutedProposalsWithFilings(routedProposals, filed) {
380
390
  };
381
391
  }
382
392
 
383
- /**
384
- * Parse an `"<owner>/<repo>"` slug into `{ owner, repo }`, or `null` when
385
- * the slug is empty / malformed.
386
- *
387
- * @param {string|null|undefined} slug
388
- * @returns {{ owner: string, repo: string } | null}
389
- */
390
- function parseRepoSlug(slug) {
391
- if (typeof slug !== 'string') return null;
392
- const parts = slug.split('/');
393
- if (parts.length !== 2) return null;
394
- const [owner, repo] = parts;
395
- if (!owner || !repo) return null;
396
- return { owner, repo };
397
- }
398
-
399
393
  /**
400
394
  * Orchestrating seam invoked by the retro post-and-mirror phase: gate the
401
395
  * toggle, file the routed proposals, and return the routed proposals
@@ -452,13 +446,12 @@ export async function fileRetroProposals({
452
446
  );
453
447
  return passthrough('no-current-repo');
454
448
  }
455
- // Framework-repo fallback parity with `gatherRetroSignals`
456
- // (gather-signals.js): an unconfigured `github.frameworkRepo` falls
457
- // back to the Mandrel mirror constant, NEVER to the consumer's own
458
- // repo — the prior `?? currentRepo` fallback silently auto-filed
459
- // framework-tagged proposals into the consumer's repo while the retro
460
- // body rendered them under "framework repo" (masked in this repo only
461
- // because consumer === framework here).
449
+ // An unconfigured framework slug falls back to the Mandrel mirror
450
+ // constant, NEVER to the consumer's own repo: the retired consumer-repo
451
+ // fallback silently auto-filed framework-tagged proposals into the
452
+ // consumer's tracker while the retro body rendered them under "framework
453
+ // repo" (masked in this repo only because consumer === framework here).
454
+ // `github/framework-repo.js` is the SSOT for that rule now.
462
455
  const frameworkRepoObj =
463
456
  parseRepoSlug(frameworkRepo) ?? parseRepoSlug(DEFAULT_FRAMEWORK_REPO);
464
457