mandrel 2.53.0 → 2.55.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 (114) 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 +4 -2
  5. package/.agents/docs/configuration.md +2 -0
  6. package/.agents/schemas/agentrc.schema.json +15 -1
  7. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  8. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  9. package/.agents/scripts/audit-to-stories.js +158 -7
  10. package/.agents/scripts/check-audit-attribution.js +119 -62
  11. package/.agents/scripts/check-test-portability.js +512 -0
  12. package/.agents/scripts/coverage-capture.js +17 -10
  13. package/.agents/scripts/evidence-gate.js +31 -4
  14. package/.agents/scripts/generate-workflows-doc.js +65 -14
  15. package/.agents/scripts/git-cleanup.js +4 -0
  16. package/.agents/scripts/lib/ITicketingProvider.js +78 -0
  17. package/.agents/scripts/lib/audit-advisories.js +195 -0
  18. package/.agents/scripts/lib/audit-attribution.js +22 -0
  19. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +68 -5
  20. package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
  21. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +60 -114
  22. package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
  23. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
  24. package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
  25. package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
  26. package/.agents/scripts/lib/cli-args.js +26 -0
  27. package/.agents/scripts/lib/close-validation/gates.js +113 -7
  28. package/.agents/scripts/lib/close-validation/process.js +7 -3
  29. package/.agents/scripts/lib/close-validation/runner.js +62 -11
  30. package/.agents/scripts/lib/config/ci.js +28 -9
  31. package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
  32. package/.agents/scripts/lib/config-settings-schema.js +19 -1
  33. package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
  34. package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
  35. package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
  36. package/.agents/scripts/lib/coverage-capture.js +77 -3
  37. package/.agents/scripts/lib/findings/route-finding.js +4 -2
  38. package/.agents/scripts/lib/full-suite-lock.js +232 -6
  39. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  40. package/.agents/scripts/lib/git/sync-from-base.js +130 -13
  41. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  42. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
  43. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
  44. package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
  45. package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
  46. package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
  47. package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
  48. package/.agents/scripts/lib/orchestration/epic-rollup.js +241 -84
  49. package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
  50. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
  51. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
  52. package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
  53. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
  54. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
  55. package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
  56. package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
  57. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
  58. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
  59. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +24 -1
  60. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
  61. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
  62. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +119 -6
  63. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
  64. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
  65. package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
  66. package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
  67. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
  68. package/.agents/scripts/lib/orchestration/run-epilogue.js +59 -38
  69. package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
  70. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
  71. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
  72. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
  73. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
  74. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
  75. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
  76. package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
  77. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
  78. package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
  79. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +70 -6
  80. package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
  81. package/.agents/scripts/lib/pinned-override-notes.js +41 -53
  82. package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
  83. package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
  84. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
  85. package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
  86. package/.agents/scripts/lib/test-temp.js +167 -30
  87. package/.agents/scripts/lib/validation-evidence.js +37 -0
  88. package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
  89. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  90. package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
  91. package/.agents/scripts/merge-baseline.js +175 -21
  92. package/.agents/scripts/providers/github/errors.js +22 -1
  93. package/.agents/scripts/providers/github/issues.js +106 -1
  94. package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
  95. package/.agents/scripts/providers/github.js +6 -0
  96. package/.agents/scripts/resolve-stories.js +44 -34
  97. package/.agents/scripts/single-story-close.js +5 -0
  98. package/.agents/scripts/stories-wave-tick.js +37 -13
  99. package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
  100. package/.agents/workflows/audit-accessibility.md +16 -31
  101. package/.agents/workflows/audit-mobile.md +20 -37
  102. package/.agents/workflows/git-cleanup.md +17 -3
  103. package/.agents/workflows/helpers/audit-lens-core.md +45 -0
  104. package/.agents/workflows/helpers/deliver-digest.md +7 -6
  105. package/.agents/workflows/helpers/deliver-reference.md +40 -16
  106. package/.agents/workflows/helpers/deliver-story-reference.md +7 -4
  107. package/.agents/workflows/helpers/deliver-story.md +15 -12
  108. package/.agents/workflows/helpers/plan-reference.md +8 -1
  109. package/.agents/workflows/mandrel-plan.md +4 -7
  110. package/.agents/workflows/memory-consolidate.md +14 -9
  111. package/docs/CHANGELOG.md +34 -0
  112. package/lib/cli/registry.js +64 -21
  113. package/lib/cli/sync.js +27 -2
  114. 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,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 { SKILL_ID_RE } from './skills/walk-skill-files.js';
19
20
 
20
21
  /**
21
22
  * Annotation contract (Story #5007). These schema literals are the SINGLE
@@ -529,6 +530,13 @@ const PLANNING_SCHEMA = {
529
530
  '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
531
  default: 25,
531
532
  },
533
+ indexByteCeiling: {
534
+ type: 'integer',
535
+ minimum: 1,
536
+ description:
537
+ "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.",
538
+ default: 24576,
539
+ },
532
540
  },
533
541
  additionalProperties: false,
534
542
  },
@@ -681,7 +689,17 @@ const QA_SIGN_IN_SEAM_SCHEMA = {
681
689
  {
682
690
  type: 'object',
683
691
  properties: {
684
- skill: { ...SAFE_STRING, minLength: 1 },
692
+ // The id is joined onto a skills root to reach a `SKILL.md`, so the
693
+ // shape is validated here rather than at the path join (Story #5285).
694
+ // `SKILL_ID_RE` is imported, never restated: one regex, two
695
+ // enforcement points — this schema and `resolveSkillFile`.
696
+ skill: {
697
+ ...SAFE_STRING,
698
+ minLength: 1,
699
+ pattern: SKILL_ID_RE.source,
700
+ description:
701
+ '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.',
702
+ },
685
703
  },
686
704
  required: ['skill'],
687
705
  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
  *
@@ -160,12 +160,14 @@ export function semanticKeyFooter(keys) {
160
160
  /**
161
161
  * Extract semantic keys from an Issue body carrying the semantic-key footer.
162
162
  * The audit filers stamp the footer via {@link semanticKeyFooter}; the
163
- * confirmation path here and {@link carryProvenanceFooters} read it back.
163
+ * confirmation path here, {@link carryProvenanceFooters} and the audit dedup's
164
+ * local issue index read it back. Exported alongside its writer so an indexer
165
+ * cannot drift into a second parse of the same footer.
164
166
  *
165
167
  * @param {string} body
166
168
  * @returns {string[]}
167
169
  */
168
- function parseSemanticKeyFooter(body) {
170
+ export function parseSemanticKeyFooter(body) {
169
171
  return parseAllFooterValues(
170
172
  body,
171
173
  /<!--\s*audit-semantic-keys:\s*([^>]*?)\s*-->/g,