mandrel 2.66.0 → 2.68.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 (85) hide show
  1. package/.agents/agents/acceptance-critic.md +2 -2
  2. package/.agents/agents/story-worker.md +15 -11
  3. package/.agents/docs/agentrc-reference.json +5 -2
  4. package/.agents/docs/configuration.md +38 -2
  5. package/.agents/docs/workflows.md +4 -2
  6. package/.agents/instructions.md +2 -1
  7. package/.agents/rules/git-conventions-reference.md +5 -5
  8. package/.agents/rules/git-conventions.md +1 -1
  9. package/.agents/schemas/agentrc.schema.json +20 -2
  10. package/.agents/schemas/story-deliver-terminal.schema.json +23 -1
  11. package/.agents/schemas/validation-evidence.schema.json +3 -1
  12. package/.agents/scripts/boot-sweep.js +97 -9
  13. package/.agents/scripts/{git-cleanup.js → clean-git.js} +2 -2
  14. package/.agents/scripts/clean-temp.js +54 -0
  15. package/.agents/scripts/clean-worktrees.js +593 -0
  16. package/.agents/scripts/coverage-capture.js +65 -9
  17. package/.agents/scripts/drain-pending-cleanup.js +5 -4
  18. package/.agents/scripts/evidence-gate.js +106 -8
  19. package/.agents/scripts/lib/baselines/coverage-refresh-scope.js +60 -0
  20. package/.agents/scripts/lib/baselines/crap-updater-cli.js +101 -4
  21. package/.agents/scripts/lib/baselines/refresh-service.js +1 -1
  22. package/.agents/scripts/lib/baselines/seat-missing.js +228 -0
  23. package/.agents/scripts/lib/child-exec.js +39 -1
  24. package/.agents/scripts/lib/clean-temp.js +440 -0
  25. package/.agents/scripts/lib/close-validation/gates.js +59 -19
  26. package/.agents/scripts/lib/close-validation/process.js +23 -24
  27. package/.agents/scripts/lib/close-validation/runner.js +71 -40
  28. package/.agents/scripts/lib/config/gates/coverage.schema.js +21 -0
  29. package/.agents/scripts/lib/config/quality.js +7 -1
  30. package/.agents/scripts/lib/config/temp-paths.js +15 -0
  31. package/.agents/scripts/lib/config-settings-schema-delivery.js +12 -3
  32. package/.agents/scripts/lib/coverage-baseline.js +78 -5
  33. package/.agents/scripts/lib/coverage-capture-affected.js +345 -0
  34. package/.agents/scripts/lib/coverage-capture-delta.js +180 -0
  35. package/.agents/scripts/lib/coverage-capture-fullscope.js +53 -32
  36. package/.agents/scripts/lib/coverage-capture-incremental.js +49 -26
  37. package/.agents/scripts/lib/coverage-capture-usage.js +1 -1
  38. package/.agents/scripts/lib/coverage-capture.js +121 -81
  39. package/.agents/scripts/lib/full-suite-lock.js +49 -46
  40. package/.agents/scripts/lib/full-suite-queue.js +83 -8
  41. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  42. package/.agents/scripts/lib/observability/source-classifier.js +4 -1
  43. package/.agents/scripts/lib/orchestration/code-review.js +15 -3
  44. package/.agents/scripts/lib/orchestration/git-cleanup/phases/cli.js +1 -1
  45. package/.agents/scripts/lib/orchestration/merge-poll.js +5 -0
  46. package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +149 -97
  47. package/.agents/scripts/lib/orchestration/review-deposit.js +219 -0
  48. package/.agents/scripts/lib/orchestration/review-providers/code-review.js +11 -7
  49. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +29 -10
  50. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +124 -73
  51. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +38 -20
  52. package/.agents/scripts/lib/orchestration/single-story-close/phases/lock-wait-pending.js +8 -2
  53. package/.agents/scripts/lib/orchestration/single-story-close/review-overlap.js +161 -0
  54. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +47 -7
  55. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +8 -16
  56. package/.agents/scripts/lib/process-group.js +1 -1
  57. package/.agents/scripts/lib/single-story-sweep.js +2 -2
  58. package/.agents/scripts/lib/supervised-suite.js +247 -0
  59. package/.agents/scripts/lib/temp-removal.js +110 -0
  60. package/.agents/scripts/lib/temp-retention.js +122 -73
  61. package/.agents/scripts/lib/wave-runner/cross-run-overlap.js +120 -0
  62. package/.agents/scripts/lib/wave-runner/live-probe.js +5 -1
  63. package/.agents/scripts/lib/worktree/canonical-path.js +34 -0
  64. package/.agents/scripts/lib/worktree/lifecycle/reap.js +15 -4
  65. package/.agents/scripts/quality-preview.js +112 -14
  66. package/.agents/scripts/single-story-init.js +120 -17
  67. package/.agents/scripts/stories-wave-tick.js +47 -0
  68. package/.agents/scripts/story-review-compute.js +207 -0
  69. package/.agents/scripts/update-coverage-baseline.js +15 -10
  70. package/.agents/scripts/update-crap-baseline.js +12 -2
  71. package/.agents/scripts/update-maintainability-baseline.js +12 -2
  72. package/.agents/workflows/{git-cleanup.md → clean-git.md} +10 -10
  73. package/.agents/workflows/clean-temp.md +67 -0
  74. package/.agents/workflows/clean-worktrees.md +63 -0
  75. package/.agents/workflows/git-deliver.md +1 -1
  76. package/.agents/workflows/helpers/acceptance-self-eval.md +3 -2
  77. package/.agents/workflows/helpers/code-review.md +7 -5
  78. package/.agents/workflows/helpers/deliver-digest.md +39 -36
  79. package/.agents/workflows/helpers/deliver-reference.md +115 -5
  80. package/.agents/workflows/helpers/deliver-story-reference.md +2 -2
  81. package/.agents/workflows/helpers/deliver-story.md +2 -1
  82. package/docs/CHANGELOG.md +39 -0
  83. package/lib/cli/registry.js +125 -18
  84. package/lib/migrations/steps/strip-removed-agentrc-keys.js +0 -5
  85. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Allowlisted auto-purge of spent temp artifacts: only a declared class's
3
- * entries are candidates; the rest are reported, never touched. Never throws
3
+ * entries are candidates; the rest are reported, never touched by the
4
+ * auto-purge (only `/clean-temp`'s operator-confirmed path). Never throws
4
5
  * — a failed purge must not fail a land, boot, or persist.
5
6
  */
6
7
 
@@ -13,6 +14,14 @@ import {
13
14
  tempRootFrom,
14
15
  } from './config/temp-paths.js';
15
16
  import { Logger } from './Logger.js';
17
+ import {
18
+ KEEP_BASENAMES,
19
+ removeSparingKept,
20
+ safeReaddir,
21
+ sizeOf,
22
+ } from './temp-removal.js';
23
+
24
+ export { KEEP_BASENAMES };
16
25
 
17
26
  /**
18
27
  * Defaults for `delivery.tempRetention`; purge is on unless turned off.
@@ -26,6 +35,7 @@ export const TEMP_RETENTION_DEFAULTS = Object.freeze({
26
35
  validationEvidence: true,
27
36
  auditResults: true,
28
37
  planDirs: true,
38
+ scratch: true,
29
39
  }),
30
40
  });
31
41
 
@@ -33,12 +43,6 @@ export const PURGE_CLASS_NAMES = Object.freeze(
33
43
  Object.keys(TEMP_RETENTION_DEFAULTS.classes),
34
44
  );
35
45
 
36
- /**
37
- * Never deleted, re-checked at the deletion site: `signals.ndjson` is read
38
- * long after merge and its loss is silent and unrecoverable.
39
- */
40
- export const KEEP_BASENAMES = Object.freeze(['signals.ndjson']);
41
-
42
46
  /** Explicit allowlist: an untaught file in a Story dir is kept. */
43
47
  const STORY_EVIDENCE_BASENAMES = Object.freeze([
44
48
  'validation-evidence.json',
@@ -49,6 +53,9 @@ const STORY_EVIDENCE_BASENAMES = Object.freeze([
49
53
  /** Framework-owned, never purged (`*.lock` files are also skipped). */
50
54
  const RESERVED_TOP_LEVEL = Object.freeze(['qa', 'cache']);
51
55
 
56
+ /** Agent-authored scratch: `scratch/story-<id>/` or any other child. */
57
+ const SCRATCH_DIRNAME = 'scratch';
58
+
52
59
  const MS_PER_DAY = 24 * 60 * 60 * 1000;
53
60
 
54
61
  const STORY_DIR_PATTERN = /^story-(\d+)$/;
@@ -77,50 +84,6 @@ export function resolveTempRetention(config) {
77
84
  };
78
85
  }
79
86
 
80
- /**
81
- * `readdir` yielding `[]` for an absent or unreadable directory.
82
- *
83
- * @param {typeof fsPromises} fsp
84
- * @param {string} dir
85
- * @returns {Promise<import('node:fs').Dirent[]>}
86
- */
87
- async function safeReaddir(fsp, dir) {
88
- try {
89
- return await fsp.readdir(dir, { withFileTypes: true });
90
- } catch {
91
- return [];
92
- }
93
- }
94
-
95
- /**
96
- * Recursive byte total; a vanished child is skipped.
97
- *
98
- * @param {typeof fsPromises} fsp
99
- * @param {string} target
100
- * @returns {Promise<number>}
101
- */
102
- async function sizeOf(fsp, target) {
103
- let total = 0;
104
- const stack = [target];
105
- while (stack.length > 0) {
106
- const current = stack.pop();
107
- let stats;
108
- try {
109
- stats = await fsp.stat(current);
110
- } catch {
111
- continue;
112
- }
113
- if (!stats.isDirectory()) {
114
- total += stats.size;
115
- continue;
116
- }
117
- for (const child of await safeReaddir(fsp, current)) {
118
- stack.push(path.join(current, child.name));
119
- }
120
- }
121
- return total;
122
- }
123
-
124
87
  /**
125
88
  * One classified entry. `mtimeMs` is the entry's own mtime, not the newest
126
89
  * beneath it; widening it would change when an abandoned dir becomes eligible.
@@ -257,13 +220,44 @@ async function scanPlanDirs(tempRoot, fsp) {
257
220
  return entries;
258
221
  }
259
222
 
223
+ /**
224
+ * `<tempRoot>/scratch/*`: `story-<id>/` is Story-keyed, anything else is
225
+ * age-floored — the one place an agent's ad-hoc files are reapable.
226
+ */
227
+ async function scanScratch(tempRoot, fsp) {
228
+ const dir = path.join(tempRoot, SCRATCH_DIRNAME);
229
+ const entries = [];
230
+ for (const dirent of await safeReaddir(fsp, dir)) {
231
+ const match = dirent.isDirectory()
232
+ ? STORY_DIR_PATTERN.exec(dirent.name)
233
+ : null;
234
+ const entry = await makeEntry(
235
+ fsp,
236
+ path.join(dir, dirent.name),
237
+ 'scratch',
238
+ match ? Number(match[1]) : null,
239
+ );
240
+ if (entry) entries.push(entry);
241
+ }
242
+ return entries;
243
+ }
244
+
260
245
  const SCANNERS = Object.freeze({
261
246
  orchestrationLogs: scanOrchestrationLogs,
262
247
  validationEvidence: scanValidationEvidence,
263
248
  auditResults: scanAuditResults,
264
249
  planDirs: scanPlanDirs,
250
+ scratch: scanScratch,
265
251
  });
266
252
 
253
+ /** Fixed top-level dirs a class scanner walks. */
254
+ const CLASS_OWNED_DIRNAMES = Object.freeze([
255
+ ORCHESTRATION_DIRNAME,
256
+ 'standalone',
257
+ 'audits',
258
+ SCRATCH_DIRNAME,
259
+ ]);
260
+
267
261
  /**
268
262
  * Keep in lockstep with the scanners: an entry no class walks must surface
269
263
  * as unrecognized.
@@ -273,29 +267,49 @@ const SCANNERS = Object.freeze({
273
267
  */
274
268
  function isClassOwnedTopLevel(name) {
275
269
  return (
276
- name === ORCHESTRATION_DIRNAME ||
277
- name === 'standalone' ||
278
- name === 'audits' ||
270
+ CLASS_OWNED_DIRNAMES.includes(name) ||
279
271
  name.startsWith('plan-') ||
280
272
  RUN_DIR_PATTERN.test(name)
281
273
  );
282
274
  }
283
275
 
284
276
  /**
285
- * Unclaimed, non-reserved top-level entries: reported with sizes, never deleted.
277
+ * Top-level names no path may ever delete: the reserved trees, lock files,
278
+ * and the never-purged basenames.
279
+ *
280
+ * @param {string} name
281
+ * @returns {boolean}
282
+ */
283
+ export function isReservedTopLevel(name) {
284
+ return (
285
+ RESERVED_TOP_LEVEL.includes(name) ||
286
+ name.endsWith('.lock') ||
287
+ KEEP_BASENAMES.includes(name)
288
+ );
289
+ }
290
+
291
+ /**
292
+ * Unclaimed, non-reserved top-level entries: reported with sizes and the
293
+ * entry's own mtime. The auto-purge never deletes one; only an operator-
294
+ * confirmed `purgeUnrecognizedEntries` call does.
286
295
  *
287
296
  * @param {string} tempRoot
288
297
  * @param {typeof fsPromises} fsp
289
- * @returns {Promise<Array<{ path: string, bytes: number }>>}
298
+ * @returns {Promise<Array<{ path: string, bytes: number, mtimeMs: number }>>}
290
299
  */
291
300
  async function collectUnrecognized(tempRoot, fsp) {
292
301
  const found = [];
293
302
  for (const dirent of await safeReaddir(fsp, tempRoot)) {
294
303
  const { name } = dirent;
295
- if (isClassOwnedTopLevel(name)) continue;
296
- if (RESERVED_TOP_LEVEL.includes(name) || name.endsWith('.lock')) continue;
297
- const target = path.join(tempRoot, name);
298
- found.push({ path: target, bytes: await sizeOf(fsp, target) });
304
+ if (isClassOwnedTopLevel(name) || isReservedTopLevel(name)) continue;
305
+ const entry = await makeEntry(fsp, path.join(tempRoot, name), null, null);
306
+ if (entry) {
307
+ found.push({
308
+ path: entry.path,
309
+ bytes: entry.bytes,
310
+ mtimeMs: entry.mtimeMs,
311
+ });
312
+ }
299
313
  }
300
314
  return found;
301
315
  }
@@ -304,7 +318,7 @@ async function collectUnrecognized(tempRoot, fsp) {
304
318
  * Classify a temp tree without deleting anything.
305
319
  *
306
320
  * @param {{ config?: object, tempRoot?: string, fsp?: typeof fsPromises }} [args]
307
- * @returns {Promise<{ tempRoot: string, entries: object[], unrecognized: Array<{ path: string, bytes: number }> }>}
321
+ * @returns {Promise<{ tempRoot: string, entries: object[], unrecognized: Array<{ path: string, bytes: number, mtimeMs: number }> }>}
308
322
  */
309
323
  export async function collectTempEntries({
310
324
  config,
@@ -353,6 +367,7 @@ function isPurgeable(entry, ctx) {
353
367
  * @param {typeof fsPromises} [args.fsp]
354
368
  * @param {{ info: Function }} [args.logger]
355
369
  * @param {string} [args.label]
370
+ * @param {boolean} [args.dryRun] Report what would go; delete nothing.
356
371
  * @returns {Promise<object>} Result envelope; never throws.
357
372
  */
358
373
  async function purgeTempArtifacts({
@@ -366,6 +381,7 @@ async function purgeTempArtifacts({
366
381
  fsp = fsPromises,
367
382
  logger = Logger,
368
383
  label = 'temp-retention',
384
+ dryRun = false,
369
385
  } = {}) {
370
386
  const policy = resolveTempRetention(config);
371
387
  const base = {
@@ -402,22 +418,55 @@ async function purgeTempArtifacts({
402
418
  if (entry.keep) result.kept.push(entry.path);
403
419
  continue;
404
420
  }
405
- try {
406
- await fsp.rm(entry.path, { recursive: true, force: true });
407
- result.purged.push({ path: entry.path, bytes: entry.bytes });
408
- result.bytesReclaimed += entry.bytes;
409
- } catch (err) {
410
- result.errors.push(`${entry.path}: ${String(err?.message ?? err)}`);
411
- }
421
+ await purgeOne(fsp, entry, result, dryRun);
412
422
  }
413
423
 
414
- if (result.purged.length > 0) {
415
- logger?.info?.(
416
- `[${label}] purged ${result.purged.length} spent temp artifact(s), ` +
417
- `reclaimed ${formatBytes(result.bytesReclaimed)} under ${result.tempRoot}.`,
424
+ if (!dryRun) reportPurge(logger, label, result);
425
+ return result;
426
+ }
427
+
428
+ /**
429
+ * One summary line for a purge that deleted something.
430
+ *
431
+ * @param {{ info?: Function }|undefined} logger
432
+ * @param {string} label
433
+ * @param {object} result
434
+ */
435
+ function reportPurge(logger, label, result) {
436
+ if (result.purged.length === 0) return;
437
+ logger?.info?.(
438
+ `[${label}] purged ${result.purged.length} spent temp artifact(s), ` +
439
+ `reclaimed ${formatBytes(result.bytesReclaimed)} under ${result.tempRoot}.`,
440
+ );
441
+ }
442
+
443
+ /**
444
+ * Remove (or, on a dry run, only record) one purgeable entry into `result`.
445
+ *
446
+ * @param {typeof fsPromises} fsp
447
+ * @param {{ path: string, bytes: number }} entry
448
+ * @param {object} result Mutated in place.
449
+ * @param {boolean} dryRun
450
+ * @returns {Promise<void>}
451
+ */
452
+ async function purgeOne(fsp, entry, result, dryRun) {
453
+ if (dryRun) {
454
+ result.purged.push({ path: entry.path, bytes: entry.bytes });
455
+ result.bytesReclaimed += entry.bytes;
456
+ return;
457
+ }
458
+ try {
459
+ const { bytes, kept } = await removeSparingKept(
460
+ fsp,
461
+ entry.path,
462
+ entry.bytes,
418
463
  );
464
+ result.kept.push(...kept);
465
+ result.purged.push({ path: entry.path, bytes });
466
+ result.bytesReclaimed += bytes;
467
+ } catch (err) {
468
+ result.errors.push(`${entry.path}: ${String(err?.message ?? err)}`);
419
469
  }
420
- return result;
421
470
  }
422
471
 
423
472
  /**
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Advisory report of probed Stories whose declared footprint shares a
3
+ * concrete path with a Story another session has in flight. Never feeds
4
+ * selection, ordering or the exit code.
5
+ *
6
+ * @module lib/wave-runner/cross-run-overlap
7
+ */
8
+
9
+ import { TYPE_LABELS } from '../label-constants.js';
10
+ import { storyFootprintPaths } from '../orchestration/resolve-stories.js';
11
+ import { currentOwner } from '../orchestration/ticket-lease.js';
12
+ import { detectCollision } from './footprint.js';
13
+ import { classifyStory, storyIdOf } from './ready-set.js';
14
+
15
+ /**
16
+ * One labelled list query. Never rejects, so a failed read reports
17
+ * "unavailable" instead of an empty list.
18
+ *
19
+ * @param {object} provider
20
+ * @returns {Promise<{ tickets?: object[], error?: string }>}
21
+ */
22
+ async function listOpenStories(provider) {
23
+ if (typeof provider?.listTicketsByLabel !== 'function') {
24
+ return { error: 'the provider cannot list issues by label' };
25
+ }
26
+ try {
27
+ const tickets = await provider.listTicketsByLabel({
28
+ state: 'open',
29
+ labels: TYPE_LABELS.STORY,
30
+ });
31
+ return { tickets: Array.isArray(tickets) ? tickets : [] };
32
+ } catch (err) {
33
+ return { error: String(err?.message ?? err) };
34
+ }
35
+ }
36
+
37
+ /**
38
+ * @param {object[]} tickets
39
+ * @param {Set<number>} inSetIds
40
+ * @returns {Array<{id: number, files: string[], holder: string|null}>}
41
+ */
42
+ function outsideInFlight(tickets, inSetIds) {
43
+ const outside = [];
44
+ for (const ticket of tickets) {
45
+ const id = storyIdOf(ticket);
46
+ if (id === null || inSetIds.has(id)) continue;
47
+ if (classifyStory(ticket) !== 'executing') continue;
48
+ outside.push({
49
+ id,
50
+ files: storyFootprintPaths(ticket.body, id),
51
+ holder: currentOwner(ticket.assignees),
52
+ });
53
+ }
54
+ return outside;
55
+ }
56
+
57
+ /**
58
+ * The reservation guard's `concreteOnly` rule, so advisory and guard never
59
+ * disagree: a glob or the UNKNOWN sentinel reports nothing.
60
+ *
61
+ * @param {Array<{id: number}>} probed
62
+ * @param {Array<{id: number, holder: string|null}>} outside
63
+ * @returns {object[]}
64
+ */
65
+ function findCrossRunOverlaps(probed, outside) {
66
+ const overlaps = [];
67
+ for (const rec of probed) {
68
+ for (const other of outside) {
69
+ const hit = detectCollision(rec, other, { concreteOnly: true });
70
+ if (!hit) continue;
71
+ overlaps.push({
72
+ id: rec.id,
73
+ otherId: other.id,
74
+ holder: other.holder,
75
+ paths: hit.paths,
76
+ });
77
+ }
78
+ }
79
+ return overlaps;
80
+ }
81
+
82
+ /**
83
+ * @param {{ tickets?: object[], error?: string }} listing
84
+ * @param {object[]} nodes
85
+ * @param {Set<number>} inSetIds
86
+ * @returns {object}
87
+ */
88
+ function buildReport(listing, nodes, inSetIds) {
89
+ if (listing.error) {
90
+ return {
91
+ crossRunOverlapProbe: 'unavailable',
92
+ crossRunOverlapProbeReason: `Could not list in-flight Stories outside this run: ${listing.error}`,
93
+ };
94
+ }
95
+ const candidates = nodes.filter((node) => {
96
+ const cls = classifyStory(node);
97
+ return cls === 'ready' || cls === 'executing';
98
+ });
99
+ return {
100
+ crossRunOverlaps: findCrossRunOverlaps(
101
+ candidates,
102
+ outsideInFlight(listing.tickets, inSetIds),
103
+ ),
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Starts the read now so it overlaps the per-Story reads; probed-set Stories
109
+ * are excluded (their overlap is the ready-set guard's job).
110
+ *
111
+ * @param {object} provider
112
+ * @returns {{ report: (nodes: object[]) => Promise<object> }}
113
+ */
114
+ export function startCrossRunProbe(provider) {
115
+ const listing = listOpenStories(provider);
116
+ return {
117
+ report: async (nodes) =>
118
+ buildReport(await listing, nodes, new Set(nodes.map((n) => n.id))),
119
+ };
120
+ }
@@ -19,6 +19,7 @@ import {
19
19
  currentOwner,
20
20
  normalizeOperatorHandle,
21
21
  } from '../orchestration/ticket-lease.js';
22
+ import { startCrossRunProbe } from './cross-run-overlap.js';
22
23
  import { classifyStory, storyIdOf } from './ready-set.js';
23
24
 
24
25
  /**
@@ -143,7 +144,8 @@ export function createProbeContext({
143
144
  * inFlight: number,
144
145
  * blockedIds: number[],
145
146
  * stalledDispatch: number[],
146
- * foreignHeld: Array<{id: number, holder: string}>
147
+ * foreignHeld: Array<{id: number, holder: string}>,
148
+ * crossRunOverlaps?: object[]
147
149
  * }>}
148
150
  */
149
151
  export async function probeLiveState({
@@ -156,6 +158,7 @@ export async function probeLiveState({
156
158
  self,
157
159
  warn,
158
160
  }) {
161
+ const crossRun = startCrossRunProbe(provider);
159
162
  // The `agent::*` guard is an admission check; a just-dispatched Story is
160
163
  // legitimately unlabelled until init flips it, so a per-beat probe allows it.
161
164
  const stories = await fetchStories(provider, ids, { allowUnlabelled: true });
@@ -207,6 +210,7 @@ export async function probeLiveState({
207
210
  .filter((id) => !foreignHeld.has(id))
208
211
  .sort((a, b) => a - b),
209
212
  foreignHeld: [...foreignHeld].map(([id, holder]) => ({ id, holder })),
213
+ ...(await crossRun.report(nodes)),
210
214
  };
211
215
  }
212
216
 
@@ -0,0 +1,34 @@
1
+ /**
2
+ * worktree/canonical-path.js — the on-disk identity of a path, for every
3
+ * worktree identity and containment comparison.
4
+ */
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ /**
10
+ * `realpathSync.native` resolves symlinks (macOS `/var` ↔ `/private/var`) and
11
+ * expands Windows 8.3 short names (`RUNNER~1` ↔ `runneradmin`) — `git
12
+ * worktree list` reports the long form while `os.tmpdir()` / `process.cwd()`
13
+ * may carry the short one. A path that no longer exists canonicalises its
14
+ * nearest existing ancestor and re-appends the rest.
15
+ *
16
+ * @param {string} p
17
+ * @param {{ realpath?: (p: string) => string }} [deps]
18
+ * @returns {string}
19
+ */
20
+ export function canonicalPath(p, { realpath = fs.realpathSync.native } = {}) {
21
+ const resolved = path.resolve(p);
22
+ const tail = [];
23
+ let current = resolved;
24
+ for (;;) {
25
+ try {
26
+ return path.join(realpath(current), ...tail);
27
+ } catch {
28
+ const parent = path.dirname(current);
29
+ if (parent === current) return resolved;
30
+ tail.unshift(path.basename(current));
31
+ current = parent;
32
+ }
33
+ }
34
+ }
@@ -7,6 +7,7 @@
7
7
  import fs from 'node:fs';
8
8
  import { rm as fsPromisesRm } from 'node:fs/promises';
9
9
  import { fileURLToPath } from 'node:url';
10
+ import { canonicalPath } from '../canonical-path.js';
10
11
  import { isInsideWorktree, samePath } from '../inspector.js';
11
12
  import { sleepSync } from '../node-modules-strategy.js';
12
13
  import { checkMergeReachability } from './merge-reachability.js';
@@ -441,15 +442,25 @@ function runningCodePaths() {
441
442
  * The running-code path inside `wtPath`, or `null`. Reaping the tree the
442
443
  * running script was loaded from kills the process later, at its first lazy
443
444
  * read or dynamic `import()`; refusing only defers the tree to the next sweep.
445
+ * Shared by every reaper (close, the boot worktree sweep, `/clean-worktrees`).
446
+ * Both sides are canonicalised first: a short-name or symlinked spelling of
447
+ * the same tree must still be recognised.
444
448
  *
445
- * @param {object} ctx
449
+ * @param {{ platform?: string }} ctx
446
450
  * @param {string} wtPath
451
+ * @param {string[]} [extraPaths] Further paths to guard (e.g. `process.cwd()`).
447
452
  * @returns {string|null}
448
453
  */
449
- function findRunningCodeInside(ctx, wtPath) {
454
+ export function findRunningCodeInside(ctx, wtPath, extraPaths = []) {
455
+ const guarded = [...runningCodePaths(), ...extraPaths];
456
+ const target = canonicalPath(wtPath);
450
457
  return (
451
- runningCodePaths().find((p) => isInsideWorktree(p, wtPath, ctx.platform)) ??
452
- null
458
+ guarded.find(
459
+ (p) =>
460
+ typeof p === 'string' &&
461
+ p !== '' &&
462
+ isInsideWorktree(canonicalPath(p), target, ctx.platform),
463
+ ) ?? null
453
464
  );
454
465
  }
455
466