mandrel 2.25.0 → 2.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/.agents/agents/acceptance-critic.md +10 -6
  2. package/.agents/audit-checklists/baselines.md +21 -0
  3. package/.agents/docs/quality-gates.md +80 -18
  4. package/.agents/docs/workflows.md +3 -1
  5. package/.agents/instructions.md +1 -1
  6. package/.agents/schemas/audit-rules.json +15 -0
  7. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  8. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  9. package/.agents/schemas/baselines/crap.schema.json +8 -0
  10. package/.agents/schemas/model-attribution.schema.json +4 -0
  11. package/.agents/scripts/acceptance-eval.js +89 -6
  12. package/.agents/scripts/audit-baselines.js +136 -0
  13. package/.agents/scripts/check-arch-cycles.js +12 -93
  14. package/.agents/scripts/check-baseline-drift.js +16 -3
  15. package/.agents/scripts/check-baselines.js +19 -3
  16. package/.agents/scripts/check-cyclomatic.js +214 -0
  17. package/.agents/scripts/check-schema-references.js +392 -0
  18. package/.agents/scripts/check-test-temp-hygiene.js +38 -1
  19. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  20. package/.agents/scripts/diagnose-friction.js +85 -19
  21. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  22. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  23. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  24. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  25. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  26. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  27. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  28. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  29. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  30. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  31. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  32. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  33. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  34. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  35. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  36. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  37. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  38. package/.agents/scripts/lib/baselines/git-base.js +26 -4
  39. package/.agents/scripts/lib/baselines/kinds/crap.js +112 -15
  40. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  41. package/.agents/scripts/lib/baselines/scope.js +39 -90
  42. package/.agents/scripts/lib/baselines/writer.js +16 -11
  43. package/.agents/scripts/lib/changed-files.js +8 -1
  44. package/.agents/scripts/lib/cli-args.js +115 -1
  45. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  46. package/.agents/scripts/lib/crap-engine.js +32 -13
  47. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  48. package/.agents/scripts/lib/crap-utils.js +13 -0
  49. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  50. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  51. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  52. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  53. package/.agents/scripts/lib/git-utils.js +136 -80
  54. package/.agents/scripts/lib/import-graph.js +156 -0
  55. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  56. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  57. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  58. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  59. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  60. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  61. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  62. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  63. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  64. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  65. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  66. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +209 -109
  67. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +48 -12
  68. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  69. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  70. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  71. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  72. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  73. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  74. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +29 -3
  75. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  76. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  77. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  78. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +245 -140
  79. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  80. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  81. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  82. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  83. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  84. package/.agents/scripts/lib/story-adjacency.js +3 -3
  85. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  86. package/.agents/scripts/lib/test-tiers.js +11 -2
  87. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  88. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  89. package/.agents/scripts/lib/wave-runner/live-probe.js +24 -14
  90. package/.agents/scripts/lib/wave-runner/ready-set.js +189 -42
  91. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +4 -10
  92. package/.agents/scripts/lib/workers/crap-worker.js +2 -10
  93. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  94. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  95. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  96. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  97. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  98. package/.agents/scripts/lib/worktree-manager.js +14 -0
  99. package/.agents/scripts/plan-run-epilogue.js +17 -5
  100. package/.agents/scripts/providers/github/tickets.js +33 -10
  101. package/.agents/scripts/provision-git-hooks.js +85 -0
  102. package/.agents/scripts/quality-preview.js +112 -28
  103. package/.agents/scripts/resolve-stories.js +4 -1
  104. package/.agents/scripts/run-coverage.js +86 -35
  105. package/.agents/scripts/run-lint.js +20 -0
  106. package/.agents/scripts/run-tests.js +26 -36
  107. package/.agents/scripts/single-story-close.js +28 -2
  108. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  109. package/.agents/scripts/stories-wave-tick.js +214 -38
  110. package/.agents/scripts/update-coverage-baseline.js +34 -4
  111. package/.agents/scripts/update-duplication-baseline.js +209 -83
  112. package/.agents/scripts/validate-docs-freshness.js +1 -0
  113. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  114. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  115. package/.agents/skills/skills.index.json +2 -2
  116. package/.agents/workflows/audit-baselines.md +289 -0
  117. package/.agents/workflows/audit-navigability.md +5 -4
  118. package/.agents/workflows/deliver.md +13 -4
  119. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  120. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  121. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  122. package/.agents/workflows/helpers/deliver-reference.md +77 -1
  123. package/.agents/workflows/helpers/deliver-story-reference.md +47 -6
  124. package/.agents/workflows/helpers/plan-reference.md +15 -5
  125. package/.agents/workflows/memory-consolidate.md +116 -0
  126. package/.agents/workflows/plan.md +3 -0
  127. package/README.md +13 -6
  128. package/docs/CHANGELOG.md +64 -0
  129. package/package.json +9 -4
  130. package/.agents/schemas/friction-event.schema.json +0 -56
  131. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -307,5 +307,3 @@ export async function fetchPriorFeedback({
307
307
 
308
308
  return envelope;
309
309
  }
310
-
311
- export default fetchPriorFeedback;
@@ -495,5 +495,3 @@ export async function fileRetroProposals({
495
495
  );
496
496
  return { routedProposals: enriched, summary };
497
497
  }
498
-
499
- export default graduateRetroProposals;
@@ -40,6 +40,23 @@ import { execFileSync, spawnSync } from 'node:child_process';
40
40
  * @property {string} stderr - Trimmed stderr.
41
41
  */
42
42
 
43
+ /**
44
+ * Explicit stdout ceiling for every git invocation in this module.
45
+ *
46
+ * Both child-process runners default `maxBuffer` to 1 MB, at which point the
47
+ * child is killed and the call fails with `ENOBUFS` for a reason unrelated to
48
+ * git. The push path is the sharp edge: `git push` relays the whole `pre-push`
49
+ * hook output, which is unbounded by design — this repo's hook emits a full
50
+ * `check-baselines` envelope, measured at 2,166,643 bytes, so every Story
51
+ * close failed at `phase: push` once hooks became reachable inside worktrees.
52
+ *
53
+ * 64 MB is the bound Story #4914 already set on `baselines/git-base.js` for
54
+ * the identical failure, matching `run-test-profile.js`,
55
+ * `audit-baselines/trend.js` and `audit-baselines/weights.js`. This module was
56
+ * missed by that sweep.
57
+ */
58
+ const MAX_BUFFER_BYTES = 64 * 1024 * 1024;
59
+
43
60
  let _execFileSync = execFileSync;
44
61
  let _spawnSync = spawnSync;
45
62
 
@@ -108,38 +125,44 @@ export function __setGitRunners(exec, spawn) {
108
125
  }
109
126
 
110
127
  /**
111
- * Run a git command synchronously, returning trimmed stdout.
112
- * Throws an Error if the command exits with a non-zero code.
128
+ * The **single** throwing git runner. Both the module-level {@link gitSync} and
129
+ * the interface returned by {@link createGitInterface} route through this
130
+ * they differ only in which `execFileSync` they hand it.
113
131
  *
114
- * @param {string} cwd - Working directory for the git process.
115
- * @param {...string} args - Git sub-command and arguments.
132
+ * @param {typeof execFileSync} exec
133
+ * @param {string} cwd
134
+ * @param {string[]} args
116
135
  * @returns {string} Trimmed stdout text.
117
136
  */
118
- export function gitSync(cwd, ...args) {
119
- return _execFileSync('git', args, {
137
+ function runGitSync(exec, cwd, args) {
138
+ return exec('git', args, {
120
139
  cwd,
121
140
  encoding: 'utf8',
122
141
  stdio: ['pipe', 'pipe', 'pipe'],
123
142
  shell: false,
124
143
  env: cleanGitEnv(),
144
+ maxBuffer: MAX_BUFFER_BYTES,
125
145
  }).trim();
126
146
  }
127
147
 
128
148
  /**
129
- * Run a git command synchronously, returning a result object.
130
- * Never throws callers must inspect `status` to detect failure.
149
+ * The **single** non-throwing git runner the `spawnSync` counterpart of
150
+ * {@link runGitSync}, normalising `status`/stdout/stderr into a
151
+ * {@link GitResult}.
131
152
  *
132
- * @param {string} cwd - Working directory for the git process.
133
- * @param {...string} args - Git sub-command and arguments.
153
+ * @param {typeof spawnSync} spawn
154
+ * @param {string} cwd
155
+ * @param {string[]} args
134
156
  * @returns {GitResult}
135
157
  */
136
- export function gitSpawn(cwd, ...args) {
137
- const result = _spawnSync('git', args, {
158
+ function runGitSpawn(spawn, cwd, args) {
159
+ const result = spawn('git', args, {
138
160
  cwd,
139
161
  stdio: 'pipe',
140
162
  encoding: 'utf-8',
141
163
  shell: false,
142
164
  env: cleanGitEnv(),
165
+ maxBuffer: MAX_BUFFER_BYTES,
143
166
  });
144
167
  return {
145
168
  status: result.status ?? 1,
@@ -148,6 +171,30 @@ export function gitSpawn(cwd, ...args) {
148
171
  };
149
172
  }
150
173
 
174
+ /**
175
+ * Run a git command synchronously, returning trimmed stdout.
176
+ * Throws an Error if the command exits with a non-zero code.
177
+ *
178
+ * @param {string} cwd - Working directory for the git process.
179
+ * @param {...string} args - Git sub-command and arguments.
180
+ * @returns {string} Trimmed stdout text.
181
+ */
182
+ export function gitSync(cwd, ...args) {
183
+ return runGitSync(_execFileSync, cwd, args);
184
+ }
185
+
186
+ /**
187
+ * Run a git command synchronously, returning a result object.
188
+ * Never throws — callers must inspect `status` to detect failure.
189
+ *
190
+ * @param {string} cwd - Working directory for the git process.
191
+ * @param {...string} args - Git sub-command and arguments.
192
+ * @returns {GitResult}
193
+ */
194
+ export function gitSpawn(cwd, ...args) {
195
+ return runGitSpawn(_spawnSync, cwd, args);
196
+ }
197
+
151
198
  /**
152
199
  * Build a git interface closed over injected child-process runners. Preferred
153
200
  * seam for callers that want explicit injection without touching the
@@ -165,57 +212,25 @@ export function gitSpawn(cwd, ...args) {
165
212
  export function createGitInterface(deps = {}) {
166
213
  const exec = deps.exec ?? execFileSync;
167
214
  const spawn = deps.spawn ?? spawnSync;
168
- const sleep =
169
- deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
215
+ const sleep = deps.sleep ?? defaultSleep;
170
216
  const jitterFactor = deps.jitter ?? 0.5;
171
217
 
172
- const gitSync = (cwd, ...args) =>
173
- exec('git', args, {
174
- cwd,
175
- encoding: 'utf8',
176
- stdio: ['pipe', 'pipe', 'pipe'],
177
- shell: false,
178
- env: cleanGitEnv(),
179
- }).trim();
180
-
181
- const gitSpawn = (cwd, ...args) => {
182
- const result = spawn('git', args, {
183
- cwd,
184
- stdio: 'pipe',
185
- encoding: 'utf-8',
186
- shell: false,
187
- env: cleanGitEnv(),
188
- });
189
- return {
190
- status: result.status ?? 1,
191
- stdout: (result.stdout ?? '').trim(),
192
- stderr: (result.stderr ?? '').trim(),
193
- };
194
- };
195
-
196
- async function runWithRetry(leadingArgs, cwd, args) {
197
- const backoff = [250, 500, 1000];
198
- let attempt = 0;
199
- let last;
200
- for (;;) {
201
- attempt++;
202
- last = gitSpawn(cwd, ...leadingArgs, ...args);
203
- if (last.status === 0) return { ...last, attempts: attempt };
204
- if (!isPackedRefsContention(last.stderr))
205
- return { ...last, attempts: attempt };
206
- if (attempt > backoff.length) return { ...last, attempts: attempt };
207
- const base = backoff[attempt - 1];
208
- const jitter = Math.floor(Math.random() * base * jitterFactor);
209
- await sleep(base + jitter);
210
- }
211
- }
218
+ const boundGitSpawn = (cwd, ...args) => runGitSpawn(spawn, cwd, args);
219
+ const withRetry =
220
+ (argvPrefix) =>
221
+ (cwd, ...args) =>
222
+ gitWithContentionRetry(
223
+ { spawnGit: boundGitSpawn, sleep, jitterFactor },
224
+ cwd,
225
+ argvPrefix,
226
+ args,
227
+ );
212
228
 
213
229
  return {
214
- gitSync,
215
- gitSpawn,
216
- gitFetchWithRetry: (cwd, ...args) => runWithRetry(['fetch'], cwd, args),
217
- gitPullWithRetry: (cwd, ...args) =>
218
- runWithRetry(['pull', '--rebase'], cwd, args),
230
+ gitSync: (cwd, ...args) => runGitSync(exec, cwd, args),
231
+ gitSpawn: boundGitSpawn,
232
+ gitFetchWithRetry: withRetry(['fetch']),
233
+ gitPullWithRetry: withRetry(['pull', '--rebase']),
219
234
  };
220
235
  }
221
236
 
@@ -238,12 +253,21 @@ function isPackedRefsContention(stderr) {
238
253
  }
239
254
 
240
255
  /**
241
- * Sleep helper for retry backoff. Overridable via `__setSleep` so tests
242
- * can skip real wall-clock delays without relying on node:test timer mocks.
256
+ * Real wall-clock sleep the default backoff delay for both the module-level
257
+ * retry helpers and {@link createGitInterface}.
243
258
  * @param {number} ms
244
259
  * @returns {Promise<void>}
245
260
  */
246
- let _sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
261
+ function defaultSleep(ms) {
262
+ return new Promise((resolve) => setTimeout(resolve, ms));
263
+ }
264
+
265
+ /**
266
+ * Sleep helper for retry backoff. Overridable via `__setSleep` so tests
267
+ * can skip real wall-clock delays without relying on node:test timer mocks.
268
+ * @type {(ms: number) => Promise<void>}
269
+ */
270
+ let _sleep = defaultSleep;
247
271
  let _jitterFactor = 0.5;
248
272
 
249
273
  /**
@@ -258,38 +282,65 @@ export function __setSleep(fn, opts = {}) {
258
282
  }
259
283
 
260
284
  /**
261
- * Shared bounded retry loop for git commands that can hit packed-refs lock
262
- * contention. Only contention signatures trigger a retry — non-contention
285
+ * Backoff schedule for {@link gitWithContentionRetry}: 250ms, 500ms, 1000ms
286
+ * (3 retries 4 attempts total).
287
+ */
288
+ const CONTENTION_BACKOFF_MS = Object.freeze([250, 500, 1000]);
289
+
290
+ /**
291
+ * The **single** bounded retry loop for git commands that can hit packed-refs
292
+ * lock contention. Only contention signatures trigger a retry — non-contention
263
293
  * failures surface immediately, and success short-circuits the loop.
264
294
  *
265
- * Backoff schedule: 250ms, 500ms, 1000ms (3 retries → 4 attempts total).
266
295
  * Deliberately no global lock — a mutex would erase the parallelism the
267
- * worktree-isolation model is designed to enable. The schedule and the
268
- * jitter policy (`_sleep` / `_jitterFactor` seams) live only here so a
269
- * backoff tuning change has a single point of application.
296
+ * worktree-isolation model is designed to enable. The schedule and the jitter
297
+ * policy live only here, so a backoff tuning change has a single point of
298
+ * application: the module-level `gitFetchWithRetry` / `gitPullWithRetry` pass
299
+ * the `_sleep` / `_jitterFactor` seams, and {@link createGitInterface} passes
300
+ * its injected equivalents.
270
301
  *
302
+ * @param {{ spawnGit: (cwd: string, ...args: string[]) => GitResult,
303
+ * sleep: (ms: number) => Promise<void>, jitterFactor: number }} runners
271
304
  * @param {string} cwd
272
305
  * @param {string[]} argvPrefix - Leading git argv (e.g. `['fetch']`).
273
306
  * @param {string[]} args - Trailing arguments (e.g. `['origin']`).
274
307
  * @returns {Promise<{ status: number, stdout: string, stderr: string, attempts: number }>}
275
308
  */
276
- async function gitWithContentionRetry(cwd, argvPrefix, args) {
277
- const backoff = [250, 500, 1000];
309
+ async function gitWithContentionRetry(
310
+ { spawnGit, sleep, jitterFactor },
311
+ cwd,
312
+ argvPrefix,
313
+ args,
314
+ ) {
278
315
  let attempt = 0;
279
- let last;
280
316
  for (;;) {
281
317
  attempt++;
282
- last = gitSpawn(cwd, ...argvPrefix, ...args);
283
- if (last.status === 0) return { ...last, attempts: attempt };
284
- if (!isPackedRefsContention(last.stderr))
318
+ const last = spawnGit(cwd, ...argvPrefix, ...args);
319
+ const exhausted = attempt > CONTENTION_BACKOFF_MS.length;
320
+ if (
321
+ last.status === 0 ||
322
+ exhausted ||
323
+ !isPackedRefsContention(last.stderr)
324
+ ) {
285
325
  return { ...last, attempts: attempt };
286
- if (attempt > backoff.length) return { ...last, attempts: attempt };
287
- const base = backoff[attempt - 1];
288
- const jitter = Math.floor(Math.random() * base * _jitterFactor);
289
- await _sleep(base + jitter);
326
+ }
327
+ const base = CONTENTION_BACKOFF_MS[attempt - 1];
328
+ await sleep(base + Math.floor(Math.random() * base * jitterFactor));
290
329
  }
291
330
  }
292
331
 
332
+ /**
333
+ * The module-level retry runners — the `__setSleep`-overridable seams bound to
334
+ * the module-global {@link gitSpawn}. Read lazily so `__setSleep` and
335
+ * `__setGitRunners` still take effect after import.
336
+ *
337
+ * @returns {{ spawnGit: typeof gitSpawn, sleep: (ms: number) => Promise<void>,
338
+ * jitterFactor: number }}
339
+ */
340
+ function moduleRetryRunners() {
341
+ return { spawnGit: gitSpawn, sleep: _sleep, jitterFactor: _jitterFactor };
342
+ }
343
+
293
344
  /**
294
345
  * Run `git fetch …` with the bounded packed-refs-contention retry loop
295
346
  * (see `gitWithContentionRetry`).
@@ -299,7 +350,7 @@ async function gitWithContentionRetry(cwd, argvPrefix, args) {
299
350
  * @returns {Promise<{ status: number, stdout: string, stderr: string, attempts: number }>}
300
351
  */
301
352
  export function gitFetchWithRetry(cwd, ...args) {
302
- return gitWithContentionRetry(cwd, ['fetch'], args);
353
+ return gitWithContentionRetry(moduleRetryRunners(), cwd, ['fetch'], args);
303
354
  }
304
355
 
305
356
  /**
@@ -312,7 +363,12 @@ export function gitFetchWithRetry(cwd, ...args) {
312
363
  * @returns {Promise<{ status: number, stdout: string, stderr: string, attempts: number }>}
313
364
  */
314
365
  export function gitPullWithRetry(cwd, ...args) {
315
- return gitWithContentionRetry(cwd, ['pull', '--rebase'], args);
366
+ return gitWithContentionRetry(
367
+ moduleRetryRunners(),
368
+ cwd,
369
+ ['pull', '--rebase'],
370
+ args,
371
+ );
316
372
  }
317
373
 
318
374
  /**
@@ -0,0 +1,156 @@
1
+ /**
2
+ * import-graph.js — the shared static-import graph seam (Story #4902).
3
+ *
4
+ * Extracted verbatim from `check-arch-cycles.js`, which owned the only
5
+ * import-graph builder in the repository and kept it private to its own
6
+ * cycle ratchet. A second consumer now needs the same graph for a very
7
+ * different question — `audit-baselines.js` ranks hotspot files by import
8
+ * in-degree — and re-deriving "which module imports which" a second time
9
+ * would guarantee the two answers drift.
10
+ *
11
+ * Note this is a **module** graph, not the task/DAG graph in `lib/Graph.js`;
12
+ * the two are unrelated despite the shared word.
13
+ *
14
+ * The extraction is behaviour-preserving: `check-arch-cycles.js` imports
15
+ * these helpers and re-exports them, so its public surface (and the ratchet's
16
+ * output) is unchanged.
17
+ *
18
+ * @module lib/import-graph
19
+ */
20
+
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+
24
+ /**
25
+ * Default scan roots making up the project's distributed surface — the
26
+ * directories published to npm via `package.json` `files[]`. Resolving
27
+ * them into one graph (relativized against the repo root) means a cycle
28
+ * crossing two roots is visible to consumers of the graph.
29
+ *
30
+ * @type {string[]}
31
+ */
32
+ export const DEFAULT_ROOTS = [path.join('.agents', 'scripts'), 'bin', 'lib'];
33
+
34
+ /**
35
+ * Recursively collect `.js` files under `rootDir`, skipping
36
+ * `node_modules`. Returns absolute paths, sorted for determinism.
37
+ *
38
+ * @param {string} rootDir
39
+ * @returns {string[]}
40
+ */
41
+ export function collectJsFiles(rootDir) {
42
+ const out = [];
43
+ const walk = (dir) => {
44
+ let entries;
45
+ try {
46
+ entries = fs.readdirSync(dir, { withFileTypes: true });
47
+ } catch {
48
+ return;
49
+ }
50
+ for (const entry of entries) {
51
+ if (entry.name === 'node_modules') continue;
52
+ const full = path.join(dir, entry.name);
53
+ if (entry.isDirectory()) {
54
+ walk(full);
55
+ } else if (entry.isFile() && entry.name.endsWith('.js')) {
56
+ out.push(full);
57
+ }
58
+ }
59
+ };
60
+ walk(rootDir);
61
+ return out.sort();
62
+ }
63
+
64
+ const IMPORT_RE = /from\s+['"](\.\.?\/[^'"]+\.js)['"]/g;
65
+
66
+ /**
67
+ * Pure helper: extract relative static-import specifiers from source text.
68
+ *
69
+ * @param {string} source
70
+ * @returns {string[]}
71
+ */
72
+ export function parseRelativeImports(source) {
73
+ const specs = [];
74
+ for (const m of source.matchAll(IMPORT_RE)) {
75
+ specs.push(m[1]);
76
+ }
77
+ return specs;
78
+ }
79
+
80
+ /**
81
+ * Build a directed import graph over the given files. Node identity is the
82
+ * file path relative to `rootDir`, posix-separated, so the graph (and any
83
+ * cycles found in it) serializes identically across platforms. Edges that
84
+ * resolve outside the scanned file set are dropped.
85
+ *
86
+ * @param {string[]} files absolute paths
87
+ * @param {string} rootDir
88
+ * @param {{ readFile?: (p: string) => string }} [opts]
89
+ * @returns {Map<string, string[]>}
90
+ */
91
+ export function buildGraph(files, rootDir, { readFile } = {}) {
92
+ const read = readFile ?? ((p) => fs.readFileSync(p, 'utf-8'));
93
+ const toId = (abs) => path.relative(rootDir, abs).split(path.sep).join('/');
94
+ const idSet = new Set(files.map(toId));
95
+ const graph = new Map();
96
+ for (const file of files) {
97
+ const id = toId(file);
98
+ let source;
99
+ try {
100
+ source = read(file);
101
+ } catch {
102
+ graph.set(id, []);
103
+ continue;
104
+ }
105
+ const edges = [];
106
+ for (const spec of parseRelativeImports(source)) {
107
+ const target = path
108
+ .relative(rootDir, path.resolve(path.dirname(file), spec))
109
+ .split(path.sep)
110
+ .join('/');
111
+ if (idSet.has(target) && target !== id) edges.push(target);
112
+ }
113
+ graph.set(id, [...new Set(edges)].sort());
114
+ }
115
+ return graph;
116
+ }
117
+
118
+ /**
119
+ * Build the whole-repository import graph by scanning the roots that exist
120
+ * under `cwd`. Returns `null` when none of the roots is present — the
121
+ * "no resolvable import graph" degradation every consumer must tolerate
122
+ * rather than treating an absent graph as a graph with no edges.
123
+ *
124
+ * @param {string} cwd repository root the ids are relativized against
125
+ * @param {{ roots?: string[] }} [opts]
126
+ * @returns {Map<string, string[]> | null}
127
+ */
128
+ export function resolveRepoGraph(cwd, { roots = DEFAULT_ROOTS } = {}) {
129
+ const present = roots
130
+ .map((dir) => path.resolve(cwd, dir))
131
+ .filter((dir) => fs.existsSync(dir));
132
+ if (present.length === 0) return null;
133
+ const files = present.flatMap((dir) => collectJsFiles(dir));
134
+ if (files.length === 0) return null;
135
+ return buildGraph(files, path.resolve(cwd));
136
+ }
137
+
138
+ /**
139
+ * Count inbound edges per node. Nodes with no inbound edge are present in
140
+ * the result with a count of 0, so callers never have to distinguish
141
+ * "unknown module" from "module nothing imports".
142
+ *
143
+ * @param {Map<string, string[]> | null} graph
144
+ * @returns {Map<string, number>} empty when `graph` is null
145
+ */
146
+ export function computeInDegree(graph) {
147
+ const degrees = new Map();
148
+ if (!graph) return degrees;
149
+ for (const node of graph.keys()) degrees.set(node, 0);
150
+ for (const edges of graph.values()) {
151
+ for (const target of edges) {
152
+ degrees.set(target, (degrees.get(target) ?? 0) + 1);
153
+ }
154
+ }
155
+ return degrees;
156
+ }
@@ -534,18 +534,33 @@ function frictionForTerminal(envelope) {
534
534
  * Emit the friction record (if any) implied by a terminal envelope.
535
535
  * Best-effort; never throws.
536
536
  *
537
+ * `tool` is the CALLER's name, threaded in rather than assumed: TWO CLIs emit
538
+ * terminal envelopes — `single-story-close.js` (plus its runner) and
539
+ * `single-story-confirm-merge.js` — and `retro-proposals.js` reads
540
+ * `emitter.tool` (via {@link normalizeGatheredSignal}) to name the surface a
541
+ * candidate came from, so hard-coding the close name here attributed every
542
+ * confirm-merge record to a CLI that never ran and misdirected the follow-up.
543
+ * Both callers already declare their name once via `runAsCli({ source })`;
544
+ * pass that same string. The default keeps the close paths — the original
545
+ * callers, which pass none — emitting exactly what they always did.
546
+ *
537
547
  * @param {object} args
538
548
  * @param {object} args.envelope
549
+ * @param {string} [args.tool] Emitting surface (default `single-story-close`).
539
550
  * @param {object} [args.config]
540
551
  * @returns {Promise<boolean>} true when a record was appended.
541
552
  */
542
- export async function emitTerminalFriction({ envelope, config } = {}) {
553
+ export async function emitTerminalFriction({
554
+ envelope,
555
+ tool = 'single-story-close',
556
+ config,
557
+ } = {}) {
543
558
  const verdict = frictionForTerminal(envelope);
544
559
  if (!verdict) return false;
545
560
  return emitRuntimeFriction({
546
561
  storyId: envelope?.storyId,
547
562
  category: verdict.category,
548
- tool: 'single-story-close',
563
+ tool,
549
564
  details: verdict.details,
550
565
  config,
551
566
  });