mandrel 2.54.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 (134) hide show
  1. package/.agents/agents/story-worker.md +24 -23
  2. package/.agents/audit-checklists/accessibility.md +0 -3
  3. package/.agents/audit-checklists/mobile.md +0 -4
  4. package/.agents/docs/agentrc-reference.json +8 -2
  5. package/.agents/docs/configuration.md +5 -0
  6. package/.agents/rules/ci-remediation.md +39 -21
  7. package/.agents/schemas/agentrc.schema.json +34 -1
  8. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  9. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  10. package/.agents/scripts/audit-to-stories.js +374 -76
  11. package/.agents/scripts/check-audit-attribution.js +119 -62
  12. package/.agents/scripts/check-test-portability.js +512 -0
  13. package/.agents/scripts/coverage-capture.js +17 -10
  14. package/.agents/scripts/evidence-gate.js +31 -4
  15. package/.agents/scripts/file-ci-gap.js +306 -0
  16. package/.agents/scripts/generate-workflows-doc.js +65 -14
  17. package/.agents/scripts/git-cleanup.js +4 -0
  18. package/.agents/scripts/lib/ITicketingProvider.js +78 -0
  19. package/.agents/scripts/lib/audit-advisories.js +195 -0
  20. package/.agents/scripts/lib/audit-attribution.js +22 -0
  21. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +25 -1
  22. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +80 -29
  23. package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +5 -1
  24. package/.agents/scripts/lib/audit-to-stories/issue-corpus.js +162 -0
  25. package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
  26. package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
  27. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +61 -115
  28. package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
  29. package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
  30. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
  31. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
  32. package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
  33. package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
  34. package/.agents/scripts/lib/cli-args.js +26 -0
  35. package/.agents/scripts/lib/close-validation/gates.js +113 -7
  36. package/.agents/scripts/lib/close-validation/process.js +7 -3
  37. package/.agents/scripts/lib/close-validation/runner.js +62 -11
  38. package/.agents/scripts/lib/config/ci.js +28 -9
  39. package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
  40. package/.agents/scripts/lib/config-settings-schema.js +52 -1
  41. package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
  42. package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
  43. package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
  44. package/.agents/scripts/lib/coverage-capture.js +77 -3
  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 +42 -2
  50. package/.agents/scripts/lib/full-suite-lock.js +232 -6
  51. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  52. package/.agents/scripts/lib/git/sync-from-base.js +130 -13
  53. package/.agents/scripts/lib/github/framework-repo.js +148 -2
  54. package/.agents/scripts/lib/label-constants.js +6 -1
  55. package/.agents/scripts/lib/observability/source-classifier.js +2 -0
  56. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
  57. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
  58. package/.agents/scripts/lib/orchestration/ci-gap-intake.js +605 -0
  59. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +13 -8
  60. package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
  61. package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
  62. package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
  63. package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
  64. package/.agents/scripts/lib/orchestration/epic-rollup.js +233 -84
  65. package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
  66. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
  67. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
  68. package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
  69. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
  70. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
  71. package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
  72. package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
  73. package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
  74. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
  75. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
  76. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +39 -3
  77. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
  78. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
  79. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -0
  80. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
  81. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
  82. package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
  83. package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
  84. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
  85. package/.agents/scripts/lib/orchestration/run-epilogue.js +63 -42
  86. package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
  87. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
  88. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
  89. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
  90. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
  91. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
  92. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
  93. package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
  94. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
  95. package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
  96. package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
  97. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +30 -0
  98. package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
  99. package/.agents/scripts/lib/pinned-override-notes.js +41 -53
  100. package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
  101. package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
  102. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
  103. package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
  104. package/.agents/scripts/lib/test-temp.js +167 -30
  105. package/.agents/scripts/lib/validation-evidence.js +37 -0
  106. package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
  107. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  108. package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
  109. package/.agents/scripts/merge-baseline.js +175 -21
  110. package/.agents/scripts/pr-watch-with-update.js +3 -2
  111. package/.agents/scripts/providers/github/errors.js +22 -1
  112. package/.agents/scripts/providers/github/issues.js +106 -1
  113. package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
  114. package/.agents/scripts/providers/github.js +6 -0
  115. package/.agents/scripts/resolve-stories.js +44 -34
  116. package/.agents/scripts/single-story-close.js +5 -0
  117. package/.agents/scripts/stories-wave-tick.js +37 -13
  118. package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
  119. package/.agents/workflows/audit-accessibility.md +16 -31
  120. package/.agents/workflows/audit-mobile.md +20 -37
  121. package/.agents/workflows/audit-to-stories.md +63 -27
  122. package/.agents/workflows/git-cleanup.md +17 -3
  123. package/.agents/workflows/helpers/audit-lens-core.md +45 -0
  124. package/.agents/workflows/helpers/deliver-digest.md +7 -6
  125. package/.agents/workflows/helpers/deliver-reference.md +35 -14
  126. package/.agents/workflows/helpers/deliver-story-reference.md +26 -8
  127. package/.agents/workflows/helpers/deliver-story.md +15 -12
  128. package/.agents/workflows/helpers/plan-reference.md +30 -0
  129. package/.agents/workflows/mandrel-plan.md +10 -13
  130. package/.agents/workflows/memory-consolidate.md +14 -9
  131. package/docs/CHANGELOG.md +37 -0
  132. package/lib/cli/registry.js +64 -21
  133. package/lib/cli/sync.js +27 -2
  134. package/package.json +7 -4
@@ -12,6 +12,13 @@
12
12
  * `blockOnAdvisoryFailure: false` to restore the pre-#5096 behaviour verbatim,
13
13
  * or list a job name in `advisoryAllowlist` to exempt just that one.
14
14
  *
15
+ * Story #5266 added `rerunAdvisory`, **default `0`**. It is the number of
16
+ * times close may re-run a failed advisory workflow run before blocking on it.
17
+ * Zero is the deliberate default and not a placeholder: a rerun spends the
18
+ * consumer's CI minutes and mutates GitHub state, and close must never do
19
+ * either unasked. Raise it (or pass `--rerun-advisory <n>`) on a repository
20
+ * whose advisory scans time out transiently.
21
+ *
15
22
  * Retired (no production readers on v2 Story-only delivery): `earlyPr`
16
23
  * (Epic early-PR warmup) and `requireChecks` (AutomergePredicate escape hatch
17
24
  * whose listener was never landed).
@@ -21,6 +28,20 @@ export const CI_DELIVERY_DEFAULTS = Object.freeze({
21
28
  autoMerge: 'trust-ci',
22
29
  blockOnAdvisoryFailure: true,
23
30
  advisoryAllowlist: Object.freeze([]),
31
+ rerunAdvisory: 0,
32
+ });
33
+
34
+ /**
35
+ * Per-knob validators for the scalar `delivery.ci` settings. A value that
36
+ * fails its own test is not an instruction to guess — it degrades to the
37
+ * framework default beside it in {@link CI_DELIVERY_DEFAULTS}, which is why
38
+ * the two objects are keyed alike and read as a pair.
39
+ */
40
+ const CI_KNOB_VALIDATORS = Object.freeze({
41
+ autoMerge: (value) => value === 'trust-ci' || value === 'strict',
42
+ blockOnAdvisoryFailure: (value) => typeof value === 'boolean',
43
+ // Story #5266 — a negative or non-integer allowance spends nothing.
44
+ rerunAdvisory: (value) => Number.isInteger(value) && value >= 0,
24
45
  });
25
46
 
26
47
  /**
@@ -31,19 +52,17 @@ export const CI_DELIVERY_DEFAULTS = Object.freeze({
31
52
  * defaults; only the scalar knobs carry framework defaults here.
32
53
  *
33
54
  * @param {object | null | undefined} config
34
- * @returns {{ autoMerge: 'trust-ci' | 'strict', watch: object | undefined }}
55
+ * @returns {{ autoMerge: 'trust-ci' | 'strict', blockOnAdvisoryFailure: boolean,
56
+ * advisoryAllowlist: string[], rerunAdvisory: number, watch: object | undefined }}
35
57
  */
36
58
  export function getCiDelivery(config) {
37
59
  const ci = config?.delivery?.ci ?? config?.ci ?? config ?? {};
60
+ const knobs = {};
61
+ for (const [key, isValid] of Object.entries(CI_KNOB_VALIDATORS)) {
62
+ knobs[key] = isValid(ci[key]) ? ci[key] : CI_DELIVERY_DEFAULTS[key];
63
+ }
38
64
  return {
39
- autoMerge:
40
- ci.autoMerge === 'trust-ci' || ci.autoMerge === 'strict'
41
- ? ci.autoMerge
42
- : CI_DELIVERY_DEFAULTS.autoMerge,
43
- blockOnAdvisoryFailure:
44
- typeof ci.blockOnAdvisoryFailure === 'boolean'
45
- ? ci.blockOnAdvisoryFailure
46
- : CI_DELIVERY_DEFAULTS.blockOnAdvisoryFailure,
65
+ ...knobs,
47
66
  advisoryAllowlist: Array.isArray(ci.advisoryAllowlist)
48
67
  ? ci.advisoryAllowlist.filter(
49
68
  (entry) => typeof entry === 'string' && entry,
@@ -414,6 +414,13 @@ const CI_DELIVERY_SCHEMA = {
414
414
  'Story #5096. Check-run names exempt from blockOnAdvisoryFailure — a red run whose name matches exactly never blocks arming. Matching is exact; an unnamed run can never match and always blocks.',
415
415
  default: [...CI_DELIVERY_DEFAULTS.advisoryAllowlist],
416
416
  },
417
+ rerunAdvisory: {
418
+ type: 'integer',
419
+ minimum: 0,
420
+ description:
421
+ 'Story #5266. How many times close may re-run a failed advisory workflow run before blocking on it, per close invocation. Default 0: close spends no CI minutes and issues no GitHub mutation on an advisory red unless asked. At n > 0 the failed run(s) are re-run within that allowance and the merge wait re-polls inside its existing budget, landing or blocking on the re-run verdict. Overridden per invocation by --rerun-advisory <n>.',
422
+ default: CI_DELIVERY_DEFAULTS.rerunAdvisory,
423
+ },
417
424
  },
418
425
  additionalProperties: false,
419
426
  };
@@ -16,6 +16,8 @@ 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';
20
+ import { SKILL_ID_RE } from './skills/walk-skill-files.js';
19
21
 
20
22
  /**
21
23
  * Annotation contract (Story #5007). These schema literals are the SINGLE
@@ -385,6 +387,37 @@ const MERGE_METHODS_SCHEMA = {
385
387
  additionalProperties: false,
386
388
  };
387
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
+
388
421
  const GITHUB_SCHEMA = {
389
422
  type: 'object',
390
423
  description:
@@ -431,6 +464,7 @@ const GITHUB_SCHEMA = {
431
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.',
432
465
  default: 60000,
433
466
  },
467
+ followUpRepos: FOLLOW_UP_REPOS_SCHEMA,
434
468
  branchProtection: BRANCH_PROTECTION_SCHEMA,
435
469
  mergeMethods: MERGE_METHODS_SCHEMA,
436
470
  notifications: NOTIFICATIONS_SCHEMA,
@@ -529,6 +563,13 @@ const PLANNING_SCHEMA = {
529
563
  'Recommend a consolidation pass once this many entries have been written since the last one. Measured against the entry count the last pass stamped, so a stamp predating that field leaves growth unmeasured and only the age threshold applies. Default 25.',
530
564
  default: 25,
531
565
  },
566
+ indexByteCeiling: {
567
+ type: 'integer',
568
+ minimum: 1,
569
+ description:
570
+ "Recommend a consolidation pass once the pool's `MEMORY.md` index exceeds this many bytes. Independent of the age and growth thresholds: the harness truncates the index it loads into each session at its own byte cap, so an oversized index is a loss already happening — every entry listed after the cut is invisible — rather than a hygiene forecast. Default 24576, the harness cap itself.",
571
+ default: 24576,
572
+ },
532
573
  },
533
574
  additionalProperties: false,
534
575
  },
@@ -681,7 +722,17 @@ const QA_SIGN_IN_SEAM_SCHEMA = {
681
722
  {
682
723
  type: 'object',
683
724
  properties: {
684
- skill: { ...SAFE_STRING, minLength: 1 },
725
+ // The id is joined onto a skills root to reach a `SKILL.md`, so the
726
+ // shape is validated here rather than at the path join (Story #5285).
727
+ // `SKILL_ID_RE` is imported, never restated: one regex, two
728
+ // enforcement points — this schema and `resolveSkillFile`.
729
+ skill: {
730
+ ...SAFE_STRING,
731
+ minLength: 1,
732
+ pattern: SKILL_ID_RE.source,
733
+ description:
734
+ 'Tier-relative skill id, e.g. `stack/qa/acme-sso`: lowercase segments of letters, digits, `.`, `_` or `-`, at least two of them, separated by `/`. A traversal (`../..`), an absolute path, a backslash or an uppercase segment is rejected here rather than normalized.',
735
+ },
685
736
  },
686
737
  required: ['skill'],
687
738
  additionalProperties: false,
@@ -11,6 +11,7 @@ import path from 'node:path';
11
11
  import {
12
12
  anyChangedUnderTargets,
13
13
  describeFreshness,
14
+ stampCapturedTree,
14
15
  } from './coverage-capture.js';
15
16
 
16
17
  /**
@@ -77,10 +78,23 @@ export function runFullScopeCapture({
77
78
  logger.info(
78
79
  `[coverage-capture] Coverage at ${crap.coveragePath} is ${describeFreshness(freshness, crap.targetDirs)}; running npm run test:coverage…`,
79
80
  );
81
+ // Story #5278 — the digest of the tree the suite is about to measure, taken
82
+ // BEFORE the spawn. That is the value the stamp claims; see
83
+ // `stampCapturedTree`.
84
+ const preDigest = computeContentDigestImpl(args.cwd, crap.targetDirs);
80
85
  const code = runCaptureImpl({
81
86
  cwd: args.cwd,
82
87
  timeoutMs: coverage?.timeoutMs,
83
88
  log: (m) => logger.info(m),
89
+ // Story #5278 — consulted only if this capture had to queue behind
90
+ // another full suite on this host. Whoever we waited for may have just
91
+ // stamped this exact tree.
92
+ recheckFresh: () =>
93
+ isCoverageFreshImpl({
94
+ coveragePath: crap.coveragePath,
95
+ targetDirs: crap.targetDirs,
96
+ cwd: args.cwd,
97
+ }).fresh === true,
84
98
  });
85
99
  if (code !== 0) {
86
100
  logger.error(
@@ -93,16 +107,14 @@ export function runFullScopeCapture({
93
107
  // freshness checks are content-aware (mtime churn from branch switches no
94
108
  // longer invalidates). Best-effort — a missing stamp just means the next
95
109
  // check falls back to the mtime heuristic.
96
- const digest = computeContentDigestImpl(args.cwd, crap.targetDirs);
97
- if (
98
- digest &&
99
- writeCaptureStampImpl({
100
- cwd: args.cwd,
101
- coveragePath: crap.coveragePath,
102
- digest,
103
- })
104
- ) {
105
- logger.info('[coverage-capture] Wrote content-digest capture stamp.');
106
- }
110
+ stampCapturedTree({
111
+ preDigest,
112
+ cwd: args.cwd,
113
+ targetDirs: crap.targetDirs,
114
+ coveragePath: crap.coveragePath,
115
+ computeContentDigestImpl,
116
+ writeCaptureStampImpl,
117
+ logger,
118
+ });
107
119
  return code;
108
120
  }
@@ -9,6 +9,7 @@
9
9
  * parameter (`.agents/rules/test-seams.md` rules 1-2, 4).
10
10
  */
11
11
  import path from 'node:path';
12
+ import { stampCapturedTree } from './coverage-capture.js';
12
13
 
13
14
  /**
14
15
  * Run the skip-aware capture path when
@@ -97,10 +98,19 @@ export function tryIncrementalCapture({
97
98
  logger.info(
98
99
  `[coverage-capture] Incremental mode: ${scopedFiles.length} changed file(s) under [${crap.targetDirs.join(', ')}] — capturing…`,
99
100
  );
101
+ // Story #5278 — pre-spawn digest; see `stampCapturedTree`.
102
+ const preDigest = computeContentDigestImpl(args.cwd, crap.targetDirs);
100
103
  const code = runCaptureImpl({
101
104
  cwd: args.cwd,
102
105
  timeoutMs: coverage?.timeoutMs,
103
106
  log: (m) => logger.info(m),
107
+ recheckFresh: () =>
108
+ isCoverageFreshImpl({
109
+ coveragePath: crap.coveragePath,
110
+ targetDirs: crap.targetDirs,
111
+ cwd: args.cwd,
112
+ requireScope: 'incremental',
113
+ }).fresh === true,
104
114
  });
105
115
  if (code !== 0) {
106
116
  logger.error(
@@ -109,21 +119,17 @@ export function tryIncrementalCapture({
109
119
  return code;
110
120
  }
111
121
 
112
- const digest = computeContentDigestImpl(args.cwd, crap.targetDirs);
113
- if (
114
- digest &&
115
- writeCaptureStampImpl({
116
- cwd: args.cwd,
117
- coveragePath: crap.coveragePath,
118
- digest,
119
- scope: 'incremental',
120
- files: scopedFiles,
121
- ref,
122
- })
123
- ) {
124
- logger.info(
125
- '[coverage-capture] Wrote content-digest capture stamp (incremental scope).',
126
- );
127
- }
122
+ stampCapturedTree({
123
+ preDigest,
124
+ cwd: args.cwd,
125
+ targetDirs: crap.targetDirs,
126
+ coveragePath: crap.coveragePath,
127
+ scope: 'incremental',
128
+ files: scopedFiles,
129
+ ref,
130
+ computeContentDigestImpl,
131
+ writeCaptureStampImpl,
132
+ logger,
133
+ });
128
134
  return code;
129
135
  }
@@ -27,7 +27,7 @@ import { respondToHelp } from './cli-usage.js';
27
27
  */
28
28
  const COVERAGE_CAPTURE_USAGE = {
29
29
  invocation:
30
- 'node .agents/scripts/coverage-capture.js [--skip-when-no-crap-files] [--ref <git-ref>] [--cwd <path>]',
30
+ 'node .agents/scripts/coverage-capture.js [--skip-when-no-crap-files] [--require-credited] [--ref <git-ref>] [--cwd <path>]',
31
31
  summary:
32
32
  'Ensure coverage/coverage-final.json is present and fresh before the CRAP gate fires, spawning `npm run test:coverage` only when it is stale. Writes a content-digest capture stamp that close-validation reads to skip a redundant re-run.',
33
33
  flags: [
@@ -35,6 +35,10 @@ const COVERAGE_CAPTURE_USAGE = {
35
35
  '--skip-when-no-crap-files',
36
36
  'Exit 0 without capturing when no changed file under the CRAP target dirs differs from --ref.',
37
37
  ],
38
+ [
39
+ '--require-credited',
40
+ 'Refuse (exit 1) instead of spawning when no credited capture stamp covers this tree. Passed by the close gate when delivery.execution.requireCreditedCapture is set; a bare invocation always runs, so the deposit path stays open.',
41
+ ],
38
42
  ['--ref <git-ref>', 'Git ref the changed-file set is computed against.'],
39
43
  ['--cwd <path>', 'Repository root the capture runs in.'],
40
44
  ],
@@ -400,8 +400,12 @@ const CREDITING_INVOCATION =
400
400
  * @param {{
401
401
  * requireCredited?: boolean,
402
402
  * logger: { info: Function, warn: Function, error: Function },
403
- * }} opts `requireCredited` mirrors `delivery.execution.requireCreditedCapture`
404
- * (default false → announce and run).
403
+ * }} opts `requireCredited` comes from the CLI's `--require-credited`
404
+ * argument, which the close gate passes when
405
+ * `delivery.execution.requireCreditedCapture` is set (default false →
406
+ * announce and run). Story #5278: it is deliberately NOT read from config
407
+ * here — that made the refusal cover the depositing invocation too, leaving
408
+ * no way to earn the credit it demanded.
405
409
  * @returns {number | null} A non-zero exit code the caller MUST return
406
410
  * without spawning the suite, or `null` to proceed with the capture.
407
411
  */
@@ -411,7 +415,7 @@ function announceUncreditedCapture({ requireCredited = false, logger }) {
411
415
  `the full suite is about to run. Deposit credit before the push with: ${CREDITING_INVOCATION}`;
412
416
  if (requireCredited) {
413
417
  logger.error(
414
- `[coverage-capture] ✖ ${preamble} (delivery.execution.requireCreditedCapture is set, so this run is refused instead of paid for).`,
418
+ `[coverage-capture] ✖ ${preamble} (--require-credited was passed, so this run is refused instead of paid for).`,
415
419
  );
416
420
  return 1;
417
421
  }
@@ -445,6 +449,76 @@ export function creditedCapture(runCaptureFn, { requireCredited, logger }) {
445
449
  };
446
450
  }
447
451
 
452
+ /**
453
+ * Write the capture stamp for a run that has just finished — but only when
454
+ * the tree it measured is still the tree on disk (Story #5278).
455
+ *
456
+ * The stamp is a claim about content: "coverage/coverage-final.json reflects
457
+ * sources digesting to X". Computing X *after* the suite finishes makes that
458
+ * claim false whenever anything moved while the suite ran — a sibling
459
+ * worktree's write, a rebase, an editor save minutes into a ten-minute run.
460
+ * The digest taken **before** the spawn is the one the run actually measured,
461
+ * so that is the value written, and a post-run digest that disagrees means
462
+ * the artifact describes a tree nobody has any more: no stamp is written at
463
+ * all, and the next reader captures rather than crediting a run against
464
+ * sources it never saw.
465
+ *
466
+ * A `null` on either digest is "unavailable", not "changed" — the same
467
+ * fail-open the pre-#5278 code had, since without a digest there is nothing
468
+ * to stamp.
469
+ *
470
+ * @param {{
471
+ * preDigest: string|null,
472
+ * cwd: string,
473
+ * targetDirs: string[],
474
+ * coveragePath: string,
475
+ * scope?: 'full' | 'incremental',
476
+ * files?: string[],
477
+ * ref?: string,
478
+ * computeContentDigestImpl: typeof computeContentDigest,
479
+ * writeCaptureStampImpl: typeof writeCaptureStamp,
480
+ * logger: { info: Function, warn: Function, error: Function },
481
+ * }} opts
482
+ * @returns {boolean} Whether a stamp was written.
483
+ */
484
+ export function stampCapturedTree({
485
+ preDigest,
486
+ cwd,
487
+ targetDirs,
488
+ coveragePath,
489
+ scope,
490
+ files,
491
+ ref,
492
+ computeContentDigestImpl,
493
+ writeCaptureStampImpl,
494
+ logger,
495
+ }) {
496
+ if (!preDigest) return false;
497
+ const postDigest = computeContentDigestImpl(cwd, targetDirs);
498
+ if (postDigest && postDigest !== preDigest) {
499
+ logger.warn(
500
+ '[coverage-capture] ⚠ the tree moved while the suite ran — the coverage ' +
501
+ 'artifact measures sources that are no longer on disk, so no capture ' +
502
+ 'stamp was written. The next capture will re-run against the current tree.',
503
+ );
504
+ return false;
505
+ }
506
+ const written = writeCaptureStampImpl({
507
+ cwd,
508
+ coveragePath,
509
+ digest: preDigest,
510
+ ...(scope === undefined ? {} : { scope }),
511
+ ...(files === undefined ? {} : { files }),
512
+ ...(ref === undefined ? {} : { ref }),
513
+ });
514
+ if (written) {
515
+ logger.info(
516
+ `[coverage-capture] Wrote content-digest capture stamp${scope ? ` (${scope} scope)` : ''}.`,
517
+ );
518
+ }
519
+ return written;
520
+ }
521
+
448
522
  /**
449
523
  * Narrow `changedFiles` to the subset that lives under one of `targetDirs`.
450
524
  *
@@ -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,