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
@@ -48,8 +48,15 @@ export const LOCAL_SKILLS_SEGMENTS = Object.freeze([
48
48
  * The pattern is deliberately strict: ids resolve to filesystem paths, so
49
49
  * anything that could escape a root (`..`, absolute paths, backslashes) or
50
50
  * smuggle a shell metacharacter is rejected rather than normalized.
51
+ *
52
+ * Exported because `config-settings-schema.js` mirrors it as the `pattern`
53
+ * on `qa.environments.*.signInSeam.skill`, so a malformed id is rejected at
54
+ * config-validation time rather than surviving to a path join (Story #5285).
55
+ * It is one regex with two enforcement points, never two regexes: the AJV
56
+ * `pattern` keyword takes the source string, so the schema must import this
57
+ * value rather than restate it.
51
58
  */
52
- const SKILL_ID_RE = /^[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._-]*)+$/;
59
+ export const SKILL_ID_RE = /^[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._-]*)+$/;
53
60
 
54
61
  /**
55
62
  * Recursively enumerate `SKILL.md` paths under a directory.
@@ -145,17 +152,27 @@ export function collectLocalSkillFiles(repoRoot) {
145
152
  * first and the local zone second (payload-wins, matching
146
153
  * {@link collectAllSkillFiles}).
147
154
  *
148
- * Returns `null` rather than throwing so callers own the error message —
149
- * a config resolver wants to name the offending config key, a workflow
150
- * wants to name the seam.
155
+ * Returns rather than throwing so callers own the error message — a config
156
+ * resolver wants to name the offending config key, a workflow wants to name
157
+ * the seam. The two failures are **distinguishable** (Story #5285):
158
+ *
159
+ * - `null` — a well-formed id that resolves under neither root. The remedy
160
+ * is to author the skill, so the caller says so.
161
+ * - `{ reason: 'invalid-id' }` — an id the pattern rejects (`../../secrets`,
162
+ * `Core/Foo`, a bare `core`). No filesystem lookup happens, and telling
163
+ * the operator to author `../../secrets/SKILL.md` would be advice that
164
+ * cannot be followed. The remedy is to fix the id.
151
165
  *
152
166
  * @param {string} repoRoot
153
167
  * @param {string} skillId Tier-relative id, e.g. `stack/qa/acme-sso`.
154
- * @returns {{ path: string, root: string } | null} absolute `SKILL.md`
155
- * path and the POSIX repo-relative root it resolved under.
168
+ * @returns {{ path: string, root: string } | { reason: 'invalid-id' } | null}
169
+ * the absolute `SKILL.md` path and the POSIX repo-relative root it resolved
170
+ * under, the malformed-id marker, or `null` when nothing resolved.
156
171
  */
157
172
  export function resolveSkillFile(repoRoot, skillId) {
158
- if (typeof skillId !== 'string' || !SKILL_ID_RE.test(skillId)) return null;
173
+ if (typeof skillId !== 'string' || !SKILL_ID_RE.test(skillId)) {
174
+ return { reason: 'invalid-id' };
175
+ }
159
176
  for (const segments of [PAYLOAD_SKILLS_SEGMENTS, LOCAL_SKILLS_SEGMENTS]) {
160
177
  const candidate = path.join(repoRoot, ...segments, skillId, 'SKILL.md');
161
178
  try {
@@ -51,12 +51,69 @@ export const SUITE_ROOT_PREFIX = 'mandrel-suite-';
51
51
  */
52
52
  export const SUITE_ROOTS_KEY = '#suiteRoots';
53
53
 
54
+ /** Default `warn` sink: one line on stderr, shared by every seam below. */
55
+ const stderrWarn = (msg) => process.stderr.write(`${msg}\n`);
56
+
57
+ /**
58
+ * Remove one directory, reporting a failure rather than throwing it.
59
+ *
60
+ * Both reapers need exactly this: a suite that passed must not start
61
+ * failing because a directory could not be unlinked (a Windows file lock, a
62
+ * read-only mount). The leak is the lesser defect, and the guard reports it
63
+ * separately.
64
+ *
65
+ * @param {string} target absolute path to remove
66
+ * @param {string} label what `target` is, for the failure message
67
+ * @param {typeof fs} fsImpl
68
+ * @param {(msg: string) => void} warn
69
+ * @returns {void}
70
+ */
71
+ function rmQuietly(target, label, fsImpl, warn) {
72
+ try {
73
+ fsImpl.rmSync(target, { recursive: true, force: true });
74
+ } catch (err) {
75
+ warn(`[test-temp] failed to reap ${label} ${target}: ${err.message}`);
76
+ }
77
+ }
78
+
54
79
  /** Per-process suite root, or `null` before the first `makeTempDir`. */
55
80
  let _suiteRoot = null;
56
81
 
57
82
  /** Guards against registering the exit reaper more than once. */
58
83
  let _reaperRegistered = false;
59
84
 
85
+ /** Lead-in of the one warning a root disappearing mid-run produces. */
86
+ const VANISHED =
87
+ '[test-temp] suite temp root disappeared mid-run (removed by something ' +
88
+ 'outside this process):';
89
+
90
+ /**
91
+ * Suite roots this process minted and later found gone.
92
+ *
93
+ * Recovery is the right behaviour — see {@link suiteTempRoot} — but a silent
94
+ * recovery is not. Incident #5272 was a required check going red with an
95
+ * unattributable `ENOENT`, and the hardening written to expose it made the
96
+ * run *quieter*: the re-mint succeeded, the suite went green, and the fact
97
+ * that something outside the process is deleting the temp tree left no trace
98
+ * anyone would look at. This list is that trace, and the exit reaper turns it
99
+ * into a verdict.
100
+ */
101
+ const _vanishedRoots = [];
102
+
103
+ /** Exit code a run that lost a suite root reports, when nothing worse did. */
104
+ const VANISHED_EXIT_CODE = 1;
105
+
106
+ /**
107
+ * Default `setExitCode` sink. Never lowers an exit code already set: a real
108
+ * test failure is the more informative verdict.
109
+ *
110
+ * @param {number} code
111
+ * @returns {void}
112
+ */
113
+ function raiseExitCode(code) {
114
+ if (!process.exitCode) process.exitCode = code;
115
+ }
116
+
60
117
  /**
61
118
  * Test-only: forget the per-process suite root without removing it, so a
62
119
  * test can exercise the creation branch repeatedly in one process.
@@ -64,10 +121,40 @@ let _reaperRegistered = false;
64
121
  export function _resetSuiteTempRootForTests() {
65
122
  _suiteRoot = null;
66
123
  _reaperRegistered = false;
124
+ _vanishedRoots.length = 0;
125
+ }
126
+
127
+ /**
128
+ * Report every root that vanished under this process, and fail the run.
129
+ *
130
+ * Called from the exit reaper, so it is the last word on a run that
131
+ * otherwise passed. The exit code is only raised when nothing else already
132
+ * failed: a real test failure is the more informative verdict and must not
133
+ * be overwritten by this one.
134
+ *
135
+ * @param {{ warn?: (msg: string) => void, setExitCode?: (code: number) => void }} [deps]
136
+ * @returns {void}
137
+ */
138
+ function reportVanishedRoots({
139
+ warn = stderrWarn,
140
+ setExitCode = raiseExitCode,
141
+ } = {}) {
142
+ if (_vanishedRoots.length === 0) return;
143
+ warn(
144
+ `[test-temp] FAIL — ${_vanishedRoots.length} suite temp root(s) disappeared mid-run:\n` +
145
+ _vanishedRoots.map((root) => ` - ${root}`).join('\n') +
146
+ '\n[test-temp] the suite recovered by re-minting, so the tests passed — but something outside this process is deleting the temp tree, and a fixture that hits the window between the removal and the re-mint fails with an unattributable ENOENT (#5272). Find the pruner before trusting this run.',
147
+ );
148
+ setExitCode(VANISHED_EXIT_CODE);
67
149
  }
68
150
 
69
151
  /**
70
- * Test-only: report whether this process currently owns a suite root.
152
+ * Report the suite root this process currently owns, without minting one.
153
+ *
154
+ * Test-only in spirit, but also the honest way for a failing fixture to say
155
+ * whether the root still exists when it reports a copy failure: asking
156
+ * {@link suiteTempRoot} would *create* one and destroy the evidence.
157
+ *
71
158
  * @returns {string|null}
72
159
  */
73
160
  export function _currentSuiteTempRoot() {
@@ -77,29 +164,23 @@ export function _currentSuiteTempRoot() {
77
164
  /**
78
165
  * Remove this process's suite root and everything under it.
79
166
  *
80
- * A teardown failure is reported on stderr and swallowed: a suite that
81
- * passed must not start failing because a directory could not be unlinked
82
- * (a Windows file lock, a read-only mount). The leak is the lesser defect
83
- * and the guard reports it separately.
167
+ * A teardown failure is reported on stderr and swallowed — see
168
+ * {@link rmQuietly}.
84
169
  *
85
170
  * Only the process that minted the root can reach a non-null `_suiteRoot`,
86
- * so this is creator-only by construction.
171
+ * so this is creator-only by construction. It reads that variable *live*
172
+ * rather than a path captured at arming time, which is what keeps a root
173
+ * re-created mid-run (see {@link suiteTempRoot}) reapable and a root it has
174
+ * replaced unreachable — a stale path is never passed to `rmSync`.
87
175
  *
88
176
  * @param {{ fsImpl?: typeof fs, warn?: (msg: string) => void }} [deps]
89
177
  * @returns {string|null} the removed root, or `null` when there was none
90
178
  */
91
- export function reapSuiteTempRoot({
92
- fsImpl = fs,
93
- warn = (msg) => process.stderr.write(`${msg}\n`),
94
- } = {}) {
179
+ export function reapSuiteTempRoot({ fsImpl = fs, warn = stderrWarn } = {}) {
95
180
  const root = _suiteRoot;
96
181
  if (root === null) return null;
97
182
  _suiteRoot = null;
98
- try {
99
- fsImpl.rmSync(root, { recursive: true, force: true });
100
- } catch (err) {
101
- warn(`[test-temp] failed to reap suite temp root ${root}: ${err.message}`);
102
- }
183
+ rmQuietly(root, 'suite temp root', fsImpl, warn);
103
184
  return root;
104
185
  }
105
186
 
@@ -110,21 +191,79 @@ export function reapSuiteTempRoot({
110
191
  * cannot exist without its teardown already armed — including when the
111
192
  * suite fails, since a failing `node --test` run still exits normally.
112
193
  *
113
- * @param {{ fsImpl?: typeof fs, tmpdir?: () => string, onExit?: (fn: () => void) => void }} [deps]
194
+ * ## Why the memoised path is re-checked every call
195
+ *
196
+ * The root lives under a temp tree this process does not own exclusively
197
+ * (see the module docstring: a `/tmp` shared with self-hosted runners, an
198
+ * OS or operator pruner). Memoising the path without re-checking it made a
199
+ * single external removal terminal: every later `makeTempDir` in the
200
+ * process failed with an `ENOENT` naming an interior path, and with 204
201
+ * call sites reaching this helper one deletion cascaded through the rest
202
+ * of the file. Re-creating is strictly better than failing — nothing in
203
+ * the suite holds a handle to the root itself, only to directories minted
204
+ * beneath it, which the removal already took.
205
+ *
206
+ * The reaper armed at first use needs no re-arming: it reads `_suiteRoot`
207
+ * live, so it already covers whatever root this process owns at exit.
208
+ * Re-creation leaves the replaced path unreachable, so the creator-only
209
+ * invariant holds — this process still reaps only a root it minted, and
210
+ * never one it has replaced.
211
+ *
212
+ * @param {{ fsImpl?: typeof fs, tmpdir?: () => string, onExit?: (fn: () => void) => void, warn?: (msg: string) => void, setExitCode?: (code: number) => void }} [deps]
114
213
  * @returns {string} absolute path to the suite root
115
214
  */
116
- export function suiteTempRoot({
215
+ export function suiteTempRoot(deps = {}) {
216
+ const fsImpl = deps.fsImpl ?? fs;
217
+ if (_suiteRoot !== null && fsImpl.existsSync(_suiteRoot)) return _suiteRoot;
218
+ return mintSuiteRoot(deps);
219
+ }
220
+
221
+ /**
222
+ * Mint the process's suite root — the first one, or a replacement for one
223
+ * that disappeared underneath it — and arm the reaper if nothing has.
224
+ *
225
+ * Separate from {@link suiteTempRoot} so the hot path stays the single
226
+ * existence check callers pay on every `makeTempDir`, and the recovery it
227
+ * guards reads as the exceptional branch it is.
228
+ *
229
+ * @param {{ fsImpl?: typeof fs, tmpdir?: () => string, onExit?: (fn: () => void) => void, warn?: (msg: string) => void, setExitCode?: (code: number) => void }} deps
230
+ * @returns {string} absolute path to the new suite root
231
+ */
232
+ function mintSuiteRoot({
117
233
  fsImpl = fs,
118
234
  tmpdir = os.tmpdir,
119
235
  onExit = (fn) => process.once('exit', fn),
236
+ warn = stderrWarn,
237
+ setExitCode = raiseExitCode,
120
238
  } = {}) {
121
- if (_suiteRoot !== null) return _suiteRoot;
239
+ const vanished = _suiteRoot;
240
+ const base = tmpdir();
241
+ // Recovery re-creates the suite's OWN root and nothing above it. An
242
+ // earlier revision called `mkdirSync(base, { recursive: true })` here, so a
243
+ // process whose OS temp root had been removed silently re-created `/tmp`
244
+ // — with this process's umask rather than the sticky 1777 the system sets
245
+ // — and carried on. That is a broken machine reported as a passing suite;
246
+ // name it instead.
247
+ if (!fsImpl.existsSync(base))
248
+ throw new Error(
249
+ `[test-temp] OS temp root ${base} does not exist; refusing to create it. Something removed the system temp directory (or TMPDIR points at a path that was never created) — fix the environment rather than letting the suite mint it.`,
250
+ );
122
251
  _suiteRoot = fsImpl.mkdtempSync(
123
- path.join(tmpdir(), `${SUITE_ROOT_PREFIX}${process.pid}-`),
252
+ path.join(base, `${SUITE_ROOT_PREFIX}${process.pid}-`),
124
253
  );
254
+ // Say it once, loudly: this is the only trace that something outside the
255
+ // process touched the temp tree, and the incident it explains (#5272) was
256
+ // filed against the wrong mechanism for want of it.
257
+ if (vanished !== null) {
258
+ _vanishedRoots.push(vanished);
259
+ warn(`${VANISHED} ${vanished}; re-created as ${_suiteRoot}`);
260
+ }
125
261
  if (!_reaperRegistered) {
126
262
  _reaperRegistered = true;
127
- onExit(() => reapSuiteTempRoot({ fsImpl }));
263
+ onExit(() => {
264
+ reapSuiteTempRoot({ fsImpl, warn });
265
+ reportVanishedRoots({ warn, setExitCode });
266
+ });
128
267
  }
129
268
  return _suiteRoot;
130
269
  }
@@ -137,8 +276,12 @@ export function suiteTempRoot({
137
276
  * path is absolute and unique, so call sites change only where the
138
277
  * directory comes from, never how it is used.
139
278
  *
279
+ * A vanished suite root is re-created by {@link suiteTempRoot} first, so
280
+ * this never fails with an `ENOENT` naming a directory the caller did not
281
+ * ask for.
282
+ *
140
283
  * @param {string} [prefix='t-'] label kept for readability in a stack trace
141
- * @param {{ fsImpl?: typeof fs, tmpdir?: () => string, onExit?: (fn: () => void) => void }} [deps]
284
+ * @param {{ fsImpl?: typeof fs, tmpdir?: () => string, onExit?: (fn: () => void) => void, warn?: (msg: string) => void }} [deps]
142
285
  * @returns {string} absolute path to the new directory
143
286
  */
144
287
  export function makeTempDir(prefix = 't-', deps = {}) {
@@ -157,7 +300,7 @@ export function makeTempDir(prefix = 't-', deps = {}) {
157
300
  * never reap it, or it deletes its parent's scratch mid-run.
158
301
  *
159
302
  * Teardown failures are swallowed for the same reason as
160
- * {@link reapSuiteTempRoot}: a leak must not turn a passing suite red.
303
+ * {@link reapSuiteTempRoot} — see {@link rmQuietly}.
161
304
  *
162
305
  * @param {string} dirPath absolute path this process minted
163
306
  * @param {{ fsImpl?: typeof fs, onExit?: (fn: () => void) => void, warn?: (msg: string) => void }} [deps]
@@ -168,16 +311,10 @@ export function reapOnExit(
168
311
  {
169
312
  fsImpl = fs,
170
313
  onExit = (fn) => process.once('exit', fn),
171
- warn = (msg) => process.stderr.write(`${msg}\n`),
314
+ warn = stderrWarn,
172
315
  } = {},
173
316
  ) {
174
- onExit(() => {
175
- try {
176
- fsImpl.rmSync(dirPath, { recursive: true, force: true });
177
- } catch (err) {
178
- warn(`[test-temp] failed to reap scratch dir ${dirPath}: ${err.message}`);
179
- }
180
- });
317
+ onExit(() => rmQuietly(dirPath, 'scratch dir', fsImpl, warn));
181
318
  }
182
319
 
183
320
  /**
@@ -265,6 +265,43 @@ export function recordPass(
265
265
  return record;
266
266
  }
267
267
 
268
+ /**
269
+ * The content identity of a working tree, as an evidence `inputFingerprint`
270
+ * (Story #5278).
271
+ *
272
+ * `commitSha` is the wrong key for "have these inputs already been checked".
273
+ * Close's own base-sync moves HEAD immediately before the gates run, so every
274
+ * gate the worker paid for is re-run against a commit whose *content* the
275
+ * evidence already covers whenever the sync brought nothing in — the record
276
+ * is discarded as `sha-mismatch` and lint, typecheck and the suite are all
277
+ * paid for twice.
278
+ *
279
+ * `git rev-parse HEAD^{tree}` is the exact answer: the tree object id is a
280
+ * hash of the committed content and nothing else, so it is stable across a
281
+ * fast-forward, a rebase, an empty merge and a commit-message amend, and it
282
+ * differs the instant any tracked byte does. It deliberately ignores
283
+ * uncommitted changes for the same reason `commitSha` did — the gates run on
284
+ * a committed Story branch.
285
+ *
286
+ * Returns `null` when the tree cannot be read, which routes every caller to
287
+ * the pre-#5278 SHA-only behaviour rather than to a false match.
288
+ *
289
+ * @param {string} cwd Absolute worktree root.
290
+ * @param {Function} [gitSpawnFn] `(cwd, ...args) => { status, stdout }`.
291
+ * @returns {string|null} `tree:<oid>`, or `null` when unavailable.
292
+ */
293
+ export function treeFingerprint(cwd, gitSpawnFn) {
294
+ if (typeof gitSpawnFn !== 'function') return null;
295
+ try {
296
+ const res = gitSpawnFn(cwd, 'rev-parse', 'HEAD^{tree}');
297
+ if (res?.status !== 0) return null;
298
+ const oid = String(res.stdout ?? '').trim();
299
+ return /^[0-9a-f]{40,64}$/.test(oid) ? `tree:${oid}` : null;
300
+ } catch {
301
+ return null;
302
+ }
303
+ }
304
+
268
305
  /**
269
306
  * Decide whether a gate can be skipped given the current HEAD + command
270
307
  * config. Skip is granted only on full triple-match: gateName + commitSha +
@@ -174,25 +174,106 @@ function isUnderTempRoot(path, tempRoot) {
174
174
  * narrowing the scrape can never co-dispatch a pair the declared comparison
175
175
  * would have caught.
176
176
  *
177
+ * Each path is returned **with the field it was scraped from** (Story #5265).
178
+ * "This pair collided on a path neither declared" is only half an
179
+ * explanation: the operator's next question is always *where did that path
180
+ * come from*, and until they can answer it they cannot tell an unpredicted
181
+ * edit target from a citation the guard read as one. The measured case is a
182
+ * gate script every Story merely **runs** in `verify[]` — attribution turns
183
+ * that from an unexplained serialisation into a one-glance verdict.
184
+ *
177
185
  * @param {object} story
178
186
  * @param {object} [options]
179
187
  * @param {string} [options.tempRoot='temp'] Resolved `project.paths.tempRoot`.
180
- * @returns {Set<string>}
188
+ * @returns {Map<string, Set<string>>} Path → the field label(s) that named it.
181
189
  */
182
190
  function storyEvidencePaths(story, { tempRoot = DEFAULT_TEMP_ROOT } = {}) {
183
- const out = new Set();
184
- for (const field of [story?.title, story?.body, story?.spec]) {
185
- if (typeof field !== 'string') continue;
186
- const scannable = field
187
- .replace(PROVENANCE_FOOTER_RE, ' ')
188
- .replace(MARKDOWN_LINK_URL_RE, ']()');
189
- for (const [token] of scannable.matchAll(PROSE_PATH_RE)) {
190
- if (!isUnderTempRoot(token, tempRoot)) out.add(token);
191
+ const out = new Map();
192
+ for (const [field, text] of attributedSegments(story)) {
193
+ for (const [token] of text.matchAll(PROSE_PATH_RE)) {
194
+ if (isUnderTempRoot(token, tempRoot)) continue;
195
+ const fields = out.get(token);
196
+ if (fields) fields.add(field);
197
+ else out.set(token, new Set([field]));
191
198
  }
192
199
  }
193
200
  return out;
194
201
  }
195
202
 
203
+ /**
204
+ * A markdown section heading in a serialized Story body — the attribution
205
+ * grain (Story #5265).
206
+ *
207
+ * `body` alone would be a true but useless label: a Story body is the whole
208
+ * document, so every scraped path would report the same field. The section is
209
+ * where the distinction actually lives — a path under `## Changes` is a
210
+ * declaration restated, one under `## Verify` is a command line, one under
211
+ * `## Non-Goals` is explicitly *not* an edit target.
212
+ */
213
+ const BODY_SECTION_RE = /^#{2,6}[ \t]+(\S.*?)[ \t]*$/gm;
214
+
215
+ /**
216
+ * Strip the two token sources that are structurally incapable of naming an
217
+ * edit target. Applied to the whole field **before** segmentation, so the
218
+ * scanned text is byte-identical to what the pre-attribution scrape read and
219
+ * a stripped footer can never be mistaken for a section boundary.
220
+ *
221
+ * @param {string} text
222
+ * @returns {string}
223
+ */
224
+ function stripNonIntentTokens(text) {
225
+ return text
226
+ .replace(PROVENANCE_FOOTER_RE, ' ')
227
+ .replace(MARKDOWN_LINK_URL_RE, ']()');
228
+ }
229
+
230
+ /**
231
+ * Split a Story body into `[label, text]` segments at its `##` headings.
232
+ *
233
+ * The heading line stays with the section it opens rather than being consumed
234
+ * as a delimiter: a heading can itself name a path, and dropping that text
235
+ * would *narrow* the footprint — the one direction this layer must never move
236
+ * (Story #4875 / #5265 AC-8). Text before the first heading keeps the bare
237
+ * `body` label.
238
+ *
239
+ * @param {string} body
240
+ * @returns {Array<[string, string]>}
241
+ */
242
+ function bodySegments(body) {
243
+ const out = [];
244
+ let cursor = 0;
245
+ let label = 'body';
246
+ for (const match of body.matchAll(BODY_SECTION_RE)) {
247
+ if (match.index > cursor)
248
+ out.push([label, body.slice(cursor, match.index)]);
249
+ label = `body:${match[1].trim()}`;
250
+ cursor = match.index;
251
+ }
252
+ out.push([label, body.slice(cursor)]);
253
+ return out;
254
+ }
255
+
256
+ /**
257
+ * Every scannable `[fieldLabel, text]` pair on a Story record: `title` and
258
+ * `spec` whole, `body` split by section.
259
+ *
260
+ * @param {object} story
261
+ * @returns {Array<[string, string]>}
262
+ */
263
+ function attributedSegments(story) {
264
+ const out = [];
265
+ if (typeof story?.title === 'string') {
266
+ out.push(['title', stripNonIntentTokens(story.title)]);
267
+ }
268
+ if (typeof story?.body === 'string') {
269
+ out.push(...bodySegments(stripNonIntentTokens(story.body)));
270
+ }
271
+ if (typeof story?.spec === 'string') {
272
+ out.push(['spec', stripNonIntentTokens(story.spec)]);
273
+ }
274
+ return out;
275
+ }
276
+
196
277
  /**
197
278
  * A Story's declared footprint **and** the evidence-widened one.
198
279
  *
@@ -203,15 +284,54 @@ function storyEvidencePaths(story, { tempRoot = DEFAULT_TEMP_ROOT } = {}) {
203
284
  * the scrape produced may be an artifact of how a body was worded. An operator
204
285
  * reading an unfilled slot needs to tell those apart.
205
286
  *
287
+ * `evidence` carries the third answer (Story #5265): *which field* produced
288
+ * each scraped path, so a collision can say where the token was written
289
+ * rather than only that nobody declared it.
290
+ *
206
291
  * @param {object} story
207
292
  * @param {object} [options]
208
- * @returns {{ declared: Set<string>, widened: Set<string> }}
293
+ * @returns {{ declared: Set<string>, widened: Set<string>, evidence: Map<string, Set<string>> }}
209
294
  */
210
295
  function storyFootprints(story, options) {
211
296
  const declared = storyFootprint(story);
297
+ const evidence = storyEvidencePaths(story, options);
212
298
  const widened = new Set(declared);
213
- for (const path of storyEvidencePaths(story, options)) widened.add(path);
214
- return { declared, widened };
299
+ for (const path of evidence.keys()) widened.add(path);
300
+ return { declared, widened, evidence };
301
+ }
302
+
303
+ /**
304
+ * The field labels that scraped `path` on one side — empty when that side
305
+ * **declared** it, because a declaration is not evidence and reporting the
306
+ * prose restatement of a declared path would read as if the scrape had caused
307
+ * the collision.
308
+ *
309
+ * @param {{ declared: Set<string>, evidence: Map<string, Set<string>> }} side
310
+ * @param {string} path
311
+ * @returns {string[]}
312
+ */
313
+ function scrapedFields(side, path) {
314
+ if (side.declared.has(path)) return [];
315
+ return [...(side.evidence.get(path) ?? [])];
316
+ }
317
+
318
+ /**
319
+ * Per-path provenance for one colliding path (Story #5265): whether both
320
+ * sides declared it, and — when at least one side did not — the field labels
321
+ * the scrape found it in, unioned across the two sides and sorted.
322
+ *
323
+ * @param {object} fa
324
+ * @param {object} fb
325
+ * @param {string} path
326
+ * @param {boolean} declared
327
+ * @returns {{ path: string, declared: boolean, fields: string[] }}
328
+ */
329
+ function attributePath(fa, fb, path, declared) {
330
+ const fields = new Set([
331
+ ...scrapedFields(fa, path),
332
+ ...scrapedFields(fb, path),
333
+ ]);
334
+ return { path, declared, fields: [...fields].sort() };
215
335
  }
216
336
 
217
337
  /**
@@ -269,12 +389,19 @@ function recordGlobs(hits, side) {
269
389
  * run for hours — and `resolve-stories.js` substitutes an UNKNOWN sentinel for
270
390
  * any body it cannot parse, so one malformed Story would make a run serial.
271
391
  *
392
+ * `attribution` (Story #5265) reports the same `paths`, one entry each, with
393
+ * the provenance a consumer needs to explain the withhold: `declared` says
394
+ * whether both sides named the path in `changes[]`, and `fields` names the
395
+ * field label(s) the scrape read it from otherwise (`title`, `spec`, or
396
+ * `body:<section>`). It is strictly additive — `paths` and `source` are
397
+ * unchanged, so no pair that collided before collides differently now.
398
+ *
272
399
  * @param {object} a
273
400
  * @param {object} b
274
401
  * @param {object} [options]
275
402
  * @param {boolean} [options.concreteOnly=false] Skip glob paths on both sides.
276
403
  * @param {string} [options.tempRoot]
277
- * @returns {{ paths: string[], source: string }|null}
404
+ * @returns {{ paths: string[], source: string, attribution: Array<{ path: string, declared: boolean, fields: string[] }> }|null}
278
405
  */
279
406
  export function detectCollision(
280
407
  a,
@@ -297,10 +424,36 @@ export function detectCollision(
297
424
  recordGlobs(hits, fb);
298
425
  }
299
426
  if (hits.size === 0) return null;
427
+ const paths = [...hits.keys()].sort();
300
428
  return {
301
- paths: [...hits.keys()].sort(),
429
+ paths,
302
430
  source: [...hits.values()].some(Boolean)
303
431
  ? OVERLAP_SOURCES.DECLARED
304
432
  : OVERLAP_SOURCES.SCRAPED,
433
+ attribution: paths.map((path) =>
434
+ attributePath(fa, fb, path, hits.get(path)),
435
+ ),
305
436
  };
306
437
  }
438
+
439
+ /**
440
+ * Render one collision's scraped-path provenance as a single operator-facing
441
+ * clause, or `''` when every colliding path was declared by both sides.
442
+ *
443
+ * Shared by every report that names a withhold so the tick's envelope note
444
+ * and plan-persist's predicted-serialisation table read identically — the two
445
+ * surfaces describe the same computation and an operator comparing them
446
+ * should not have to translate (Story #5265).
447
+ *
448
+ * @param {Array<{ path: string, declared: boolean, fields: string[] }>} attribution
449
+ * @returns {string}
450
+ */
451
+ export function renderScrapeAttribution(attribution) {
452
+ const scraped = (Array.isArray(attribution) ? attribution : []).filter(
453
+ (entry) => Array.isArray(entry?.fields) && entry.fields.length > 0,
454
+ );
455
+ if (scraped.length === 0) return '';
456
+ return scraped
457
+ .map((entry) => `${entry.path} ← ${entry.fields.join(', ')}`)
458
+ .join('; ');
459
+ }
@@ -264,7 +264,13 @@ export async function probeLiveState({
264
264
  self,
265
265
  warn,
266
266
  }) {
267
- const stories = await fetchStories(provider, ids);
267
+ // `allowUnlabelled` deliberately: the `agent::*` guard is an ADMISSION check
268
+ // at the entry resolution, where an operator names ids, and this is a
269
+ // per-beat REPORT on work already admitted. A Story dispatched a moment ago
270
+ // is legitimately unlabelled until `single-story-init.js` flips it — the
271
+ // init window this probe models explicitly — and refusing it here would fail
272
+ // a healthy beat mid-run over a Story the run already accepted.
273
+ const stories = await fetchStories(provider, ids, { allowUnlabelled: true });
268
274
  const nativeEdges = native
269
275
  ? await readNativeEdges({ provider, stories, owner, repo })
270
276
  : new Map();
@@ -301,7 +301,7 @@ function reservesConcretePath(held, candidate, options = {}) {
301
301
  * @returns {{
302
302
  * selected: StoryRecord[],
303
303
  * withheldByInFlight: Array<{id: number, blockedBy: number}>,
304
- * footprintWithholds: Array<{id: number, blockedBy: number, scope: string, source: string, paths: string[], enforced: boolean}>,
304
+ * footprintWithholds: Array<{id: number, blockedBy: number, scope: string, source: string, paths: string[], attribution: object[], enforced: boolean}>,
305
305
  * guardMode: 'enforce'|'advisory'
306
306
  * }}
307
307
  * `selected` is the dispatch set: a subset of `stories`, ascending by id,