mandrel 1.76.0 → 1.78.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 (68) hide show
  1. package/.agents/docs/configuration.md +2 -2
  2. package/.agents/docs/workflows.md +19 -0
  3. package/.agents/schemas/agentrc.schema.json +1 -1
  4. package/.agents/schemas/dispatch-manifest.json +1 -1
  5. package/.agents/schemas/lifecycle/loop.tick.schema.json +20 -0
  6. package/.agents/schemas/loop-unit.schema.json +70 -0
  7. package/.agents/schemas/validation-evidence.schema.json +2 -1
  8. package/.agents/scripts/audit-to-stories.js +43 -1
  9. package/.agents/scripts/check-doc-links.js +24 -1
  10. package/.agents/scripts/check-loop-units.js +204 -0
  11. package/.agents/scripts/epic-deliver-prepare.js +31 -0
  12. package/.agents/scripts/evidence-gate.js +48 -12
  13. package/.agents/scripts/generate-workflows-doc.js +37 -4
  14. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +141 -34
  15. package/.agents/scripts/lib/cli-args.js +6 -0
  16. package/.agents/scripts/lib/close-validation/process.js +61 -5
  17. package/.agents/scripts/lib/close-validation/runner.js +42 -9
  18. package/.agents/scripts/lib/config/temp-paths.js +1 -1
  19. package/.agents/scripts/lib/config/worktree-isolation.js +18 -3
  20. package/.agents/scripts/lib/config-resolver.js +4 -1
  21. package/.agents/scripts/lib/config-settings-schema-delivery.js +1 -1
  22. package/.agents/scripts/lib/git-branch-lifecycle.js +90 -0
  23. package/.agents/scripts/lib/loop-units/validate-loop-unit.js +197 -0
  24. package/.agents/scripts/lib/mandrel-catalog.js +36 -0
  25. package/.agents/scripts/lib/orchestration/auto-merge-cwd.js +128 -0
  26. package/.agents/scripts/lib/orchestration/column-sync.js +88 -9
  27. package/.agents/scripts/lib/orchestration/lifecycle/emit-loop-tick.js +183 -0
  28. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +20 -2
  29. package/.agents/scripts/lib/orchestration/project-meta-cache.js +238 -0
  30. package/.agents/scripts/lib/orchestration/reassert-status-column.js +3 -1
  31. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +25 -2
  32. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +80 -14
  33. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +74 -25
  34. package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +10 -1
  35. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +48 -1
  36. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +148 -4
  37. package/.agents/scripts/lib/orchestration/ticketing/transition.js +8 -1
  38. package/.agents/scripts/lib/story-body/story-body.js +76 -7
  39. package/.agents/scripts/lib/story-init/branch-initializer.js +29 -43
  40. package/.agents/scripts/lib/story-init/hierarchy-tracer.js +25 -4
  41. package/.agents/scripts/lib/story-init/task-graph-builder.js +22 -12
  42. package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -0
  43. package/.agents/scripts/lib/validation-evidence.js +63 -25
  44. package/.agents/scripts/lib/worktree/node-modules-strategy.js +239 -31
  45. package/.agents/scripts/providers/github/branch-protection.js +1 -1
  46. package/.agents/scripts/providers/github/errors.js +53 -2
  47. package/.agents/scripts/providers/github/labels.js +1 -1
  48. package/.agents/scripts/providers/github/projects-v2-graphql.js +1 -1
  49. package/.agents/scripts/resync-status-column.js +5 -0
  50. package/.agents/scripts/run-coverage.js +85 -45
  51. package/.agents/scripts/run-lint.js +11 -0
  52. package/.agents/scripts/single-story-init.js +22 -29
  53. package/.agents/scripts/story-init.js +38 -63
  54. package/.agents/scripts/story-phase.js +46 -4
  55. package/.agents/scripts/sync-claude-commands.js +112 -29
  56. package/.agents/scripts/update-maintainability-baseline.js +19 -76
  57. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
  58. package/.agents/workflows/helpers/acceptance-self-eval.md +27 -0
  59. package/.agents/workflows/helpers/deliver-epic.md +19 -2
  60. package/.agents/workflows/helpers/epic-deliver-story.md +50 -14
  61. package/.agents/workflows/helpers/single-story-deliver.md +12 -0
  62. package/.agents/workflows/loops/README.md +65 -0
  63. package/.agents/workflows/loops/fix-failing-tests.md +74 -0
  64. package/.agents/workflows/loops/nightly-audit.md +71 -0
  65. package/.agents/workflows/loops/watch-ci.md +68 -0
  66. package/docs/CHANGELOG.md +51 -0
  67. package/package.json +1 -1
  68. package/.agents/scripts/providers/github/transient-retry.js +0 -62
@@ -7,6 +7,15 @@
7
7
  *
8
8
  * - `per-worktree` — run the project's package-manager install inside the
9
9
  * worktree (lock-file aware).
10
+ * - `clone` — copy-on-write (reflink/clonefile) clone of the donor's
11
+ * `node_modules` into the worktree (`cp -c` on darwin,
12
+ * `cp --reflink=auto` on linux). Effectively free in time
13
+ * and disk on APFS / reflink-capable filesystems. Falls
14
+ * back to `per-worktree` cleanly on any failure
15
+ * (unsupported filesystem, cross-volume clone, Windows).
16
+ * When the worktree's lockfile byte-matches the donor's
17
+ * and the donor's install markers are present, the
18
+ * per-tree install is skipped.
10
19
  * - `symlink` — symlink (or junction on Windows) the worktree's
11
20
  * `node_modules` to a donor worktree's copy. Refuses on
12
21
  * Windows unless `allowSymlinkOnWindows=true`.
@@ -14,10 +23,11 @@
14
23
  * shared content-addressable store.
15
24
  *
16
25
  * The context passed to each helper carries the minimum state the strategy
17
- * needs: config, platform, logger, and repoRoot (for `symlink`).
26
+ * needs: config, platform, logger, and repoRoot (for `symlink` / `clone`).
18
27
  */
19
28
 
20
29
  import { spawnSync } from 'node:child_process';
30
+ import crypto from 'node:crypto';
21
31
  import fs from 'node:fs';
22
32
  import path from 'node:path';
23
33
  import { detectPackageManager } from '../detect-package-manager.js';
@@ -28,6 +38,21 @@ function sleepSync(ms) {
28
38
  Atomics.wait(signal, 0, 0, ms);
29
39
  }
30
40
 
41
+ /**
42
+ * Resolve the copy-on-write clone donor directory. Defaults to the repo root
43
+ * (the main checkout, which is primed by the host's own install) unless the
44
+ * operator pinned a `primeFromPath`.
45
+ *
46
+ * @param {{ config: object, repoRoot: string }} ctx
47
+ * @returns {string} Absolute donor path.
48
+ */
49
+ function resolveCloneDonor(ctx) {
50
+ const primeFromPath = ctx.config?.primeFromPath;
51
+ return primeFromPath
52
+ ? path.resolve(ctx.repoRoot, primeFromPath)
53
+ : path.resolve(ctx.repoRoot);
54
+ }
55
+
31
56
  /**
32
57
  * Apply the configured `nodeModulesStrategy` after a fresh worktree is added.
33
58
  * Called only during creation.
@@ -43,6 +68,11 @@ export function applyNodeModulesStrategy(ctx, wtPath) {
43
68
  case 'pnpm-store':
44
69
  return;
45
70
 
71
+ case 'clone': {
72
+ cloneNodeModules(ctx, wtPath);
73
+ return;
74
+ }
75
+
46
76
  case 'symlink': {
47
77
  const primeFromPath = ctx.config.primeFromPath;
48
78
  if (!primeFromPath) {
@@ -89,17 +119,106 @@ export function applyNodeModulesStrategy(ctx, wtPath) {
89
119
  default:
90
120
  throw new Error(
91
121
  `WorktreeManager: unknown nodeModulesStrategy '${strategy}'. ` +
92
- 'Expected per-worktree | symlink | pnpm-store.',
122
+ 'Expected per-worktree | clone | symlink | pnpm-store.',
93
123
  );
94
124
  }
95
125
  }
96
126
 
127
+ /**
128
+ * Copy-on-write clone the donor's `node_modules` into the worktree. On a
129
+ * reflink-capable filesystem (APFS on darwin, btrfs/XFS/etc. on linux) the
130
+ * clone is effectively free in both time and disk; the subsequent
131
+ * lockfile-match probe in `installDependencies` then skips the per-tree
132
+ * install entirely.
133
+ *
134
+ * Non-fatal by design: any failure (unsupported filesystem, cross-volume
135
+ * clone, Windows, missing donor) falls back to `per-worktree` cleanly — the
136
+ * worktree is simply left without a cloned `node_modules`, and the regular
137
+ * per-tree install runs. Failure NEVER throws; the clone is a fast-path
138
+ * optimization, not a correctness requirement.
139
+ *
140
+ * @param {{ config: object, platform: NodeJS.Platform, logger: object, repoRoot: string }} ctx
141
+ * @param {string} wtPath Absolute worktree path.
142
+ * @param {{ spawnFn?: typeof spawnSync, fsLike?: typeof fs }} [io] Injectable for tests.
143
+ * @returns {{ cloned: boolean, reason?: string }}
144
+ */
145
+ export function cloneNodeModules(ctx, wtPath, io = {}) {
146
+ const spawnFn = io.spawnFn ?? spawnSync;
147
+ const fsLike = io.fsLike ?? fs;
148
+ // Key off the real host OS, not the test-injection `ctx.platform`, for the
149
+ // capability decision: Windows has no `cp -c`/`--reflink` equivalent here.
150
+ if (process.platform === 'win32') {
151
+ ctx.logger.info(
152
+ 'worktree.node_modules strategy=clone fallback=per-worktree reason=windows-unsupported',
153
+ );
154
+ return { cloned: false, reason: 'windows-unsupported' };
155
+ }
156
+
157
+ const donor = resolveCloneDonor(ctx);
158
+ const donorNodeModules = path.join(donor, 'node_modules');
159
+ if (!fsLike.existsSync(donorNodeModules)) {
160
+ ctx.logger.info(
161
+ `worktree.node_modules strategy=clone fallback=per-worktree reason=donor-node-modules-missing donor=${donorNodeModules}`,
162
+ );
163
+ return { cloned: false, reason: 'donor-node-modules-missing' };
164
+ }
165
+
166
+ const target = path.join(wtPath, 'node_modules');
167
+ if (fsLike.existsSync(target)) {
168
+ // A worktree that already carries node_modules must not be clobbered by a
169
+ // clone-on-top (cp would nest into the existing dir). Treat as already
170
+ // populated and let the install-skip probe decide freshness.
171
+ ctx.logger.info(
172
+ `worktree.node_modules strategy=clone skip reason=target-exists target=${target}`,
173
+ );
174
+ return { cloned: false, reason: 'target-exists' };
175
+ }
176
+
177
+ // darwin: `cp -c` requests a clonefile (APFS). linux: `cp --reflink=auto`
178
+ // requests a reflink, silently degrading to a full copy on non-reflink
179
+ // filesystems — which we deliberately reject below so a slow full-tree copy
180
+ // never masquerades as a free clone.
181
+ const cloneArgs =
182
+ process.platform === 'darwin'
183
+ ? ['-c', '-R', donorNodeModules, target]
184
+ : ['--reflink=always', '-R', donorNodeModules, target];
185
+ const result = spawnFn('cp', cloneArgs, {
186
+ cwd: wtPath,
187
+ stdio: 'pipe',
188
+ encoding: 'utf-8',
189
+ timeout: 120_000,
190
+ });
191
+ if (result.status !== 0) {
192
+ // Clean up a partial copy so the per-worktree fallback install starts from
193
+ // a known-empty target.
194
+ try {
195
+ fsLike.rmSync(target, { recursive: true, force: true });
196
+ } catch {
197
+ // best-effort
198
+ }
199
+ ctx.logger.warn(
200
+ `worktree.node_modules strategy=clone fallback=per-worktree reason=clone-command-failed ` +
201
+ `(${describeAttemptFailure(result, 120_000)}) stderr=${(result.stderr ?? '').slice(0, 300)}`,
202
+ );
203
+ return { cloned: false, reason: 'clone-command-failed' };
204
+ }
205
+
206
+ ctx.logger.info(
207
+ `worktree.node_modules strategy=clone target=${target} source=${donorNodeModules}`,
208
+ );
209
+ return { cloned: true };
210
+ }
211
+
97
212
  /**
98
213
  * Pure: pick the package-manager command + args for a given strategy and
99
214
  * worktree path. Returns `null` when the strategy is `symlink` (handled
100
215
  * elsewhere) or the worktree has no `package.json`.
101
216
  *
102
- * @param {string} strategy One of `per-worktree | pnpm-store | symlink`.
217
+ * `clone` shares `per-worktree`'s PM-detection: when the install is *not*
218
+ * skipped (lockfile mismatch), the worktree still installs with the project's
219
+ * detected package manager.
220
+ *
221
+ * @param {string} strategy One of `per-worktree | clone | pnpm-store | symlink`.
103
222
  * @param {string} wtPath Absolute worktree path.
104
223
  * @param {{ existsSync: (p: string) => boolean }} [fsLike] Injectable for tests.
105
224
  * @returns {{ cmd: string, args: string[] } | null}
@@ -112,6 +231,7 @@ export function selectInstallCommand(strategy, wtPath, fsLike = fs) {
112
231
  return { cmd: 'pnpm', args: ['install', '--frozen-lockfile'] };
113
232
  }
114
233
  // Shared lockfile probe (Story #4048 B3 — one implementation per concept).
234
+ // `per-worktree` and `clone` both fall through to PM detection here.
115
235
  const pm = detectPackageManager(wtPath, (p) => fsLike.existsSync(p)) ?? 'npm';
116
236
  if (pm === 'pnpm') {
117
237
  return { cmd: 'pnpm', args: ['install', '--frozen-lockfile'] };
@@ -138,14 +258,78 @@ const INSTALL_MARKERS = [
138
258
 
139
259
  const LOCKFILES = ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock'];
140
260
 
141
- function safeMtimeMs(fsLike, p) {
261
+ /**
262
+ * Pure: locate the first present lockfile in `dir` and return its byte-exact
263
+ * SHA-256 hash, or `null` when no lockfile exists / cannot be read.
264
+ *
265
+ * The hash is the load-bearing freshness key for the install-skip decision —
266
+ * never an mtime, which clone (clonefile/reflink) does not preserve reliably
267
+ * and which is trivially perturbed by a no-op `git checkout`.
268
+ *
269
+ * @param {string} dir Absolute directory containing the lockfile.
270
+ * @param {{ existsSync: Function, readFileSync: Function }} [fsLike]
271
+ * @returns {string | null}
272
+ */
273
+ export function lockfileHash(dir, fsLike = fs) {
274
+ const lockfile = LOCKFILES.map((l) => path.join(dir, l)).find((p) =>
275
+ fsLike.existsSync(p),
276
+ );
277
+ if (!lockfile) return null;
142
278
  try {
143
- return fsLike.statSync(p).mtimeMs;
279
+ const bytes = fsLike.readFileSync(lockfile);
280
+ return crypto.createHash('sha256').update(bytes).digest('hex');
144
281
  } catch {
145
282
  return null;
146
283
  }
147
284
  }
148
285
 
286
+ /**
287
+ * Pure: the single shared "is the per-tree install skippable?" predicate
288
+ * (generalized from `probeReusedInstall`). The install is safe to skip iff:
289
+ *
290
+ * 1. the worktree carries a completed-install marker
291
+ * (`node_modules/<INSTALL_MARKERS>`), AND
292
+ * 2. the worktree's lockfile byte-exactly matches the donor's lockfile.
293
+ *
294
+ * The lockfile match is keyed on a byte-exact SHA-256 hash, NEVER mtime: a
295
+ * reflink/clonefile clone does not preserve mtime relationships, so an mtime
296
+ * comparison would either mis-skip a real change or force a redundant install
297
+ * on every clone. When the donor is omitted (reuse path, no clone donor), the
298
+ * worktree's own lockfile is hashed against itself — i.e. the lockfile-match
299
+ * leg is vacuously satisfied and only the marker presence gates the skip.
300
+ *
301
+ * @param {object} opts
302
+ * @param {string} opts.wtPath Absolute worktree path.
303
+ * @param {string} [opts.donorPath] Absolute donor path (the clone source).
304
+ * @param {{ existsSync: Function, readFileSync: Function }} [opts.fsLike]
305
+ * @returns {{ skippable: boolean, reason: string }}
306
+ */
307
+ export function isInstallSkippable({ wtPath, donorPath, fsLike = fs }) {
308
+ const nmPath = path.join(wtPath, 'node_modules');
309
+ if (!fsLike.existsSync(nmPath)) {
310
+ return { skippable: false, reason: 'node-modules-missing' };
311
+ }
312
+ const marker = INSTALL_MARKERS.map((m) => path.join(nmPath, m)).find((p) =>
313
+ fsLike.existsSync(p),
314
+ );
315
+ if (!marker) {
316
+ return { skippable: false, reason: 'install-incomplete' };
317
+ }
318
+ const wtHash = lockfileHash(wtPath, fsLike);
319
+ if (wtHash === null) {
320
+ // No lockfile in the worktree — nothing to pin freshness against. The
321
+ // marker alone is the signal (matches a lockfile-less project).
322
+ return { skippable: true, reason: 'marker-present-no-lockfile' };
323
+ }
324
+ if (donorPath) {
325
+ const donorHash = lockfileHash(donorPath, fsLike);
326
+ if (donorHash !== null && donorHash !== wtHash) {
327
+ return { skippable: false, reason: 'lockfile-mismatch' };
328
+ }
329
+ }
330
+ return { skippable: true, reason: 'lockfile-match' };
331
+ }
332
+
149
333
  /**
150
334
  * Pure: probe whether a **reused** worktree already carries a completed,
151
335
  * up-to-date install. Worktree reuse must not blindly report
@@ -157,12 +341,17 @@ function safeMtimeMs(fsLike, p) {
157
341
  * - `{ status: 'skipped', reason: 'worktree-reused' }` — a completed
158
342
  * install was detected (or the strategy never installs per-tree);
159
343
  * safe to skip.
160
- * - `{ status: 'failed', reason }` — missing/incomplete/stale install
161
- * detected; callers should retry the install.
344
+ * - `{ status: 'failed', reason }` — missing/incomplete install detected;
345
+ * callers should retry the install.
162
346
  *
163
- * @param {string} strategy One of `per-worktree | pnpm-store | symlink`.
347
+ * Thin wrapper over the shared `isInstallSkippable` predicate (Story #4249
348
+ * one freshness predicate, keyed on a byte-exact lockfile hash, never mtime).
349
+ * The reuse path has no separate donor, so freshness reduces to marker
350
+ * presence (the worktree's own lockfile is its own baseline).
351
+ *
352
+ * @param {string} strategy One of `per-worktree | clone | pnpm-store | symlink`.
164
353
  * @param {string} wtPath Absolute worktree path.
165
- * @param {{ existsSync: Function, statSync: Function }} [fsLike] Injectable for tests.
354
+ * @param {{ existsSync: Function, readFileSync: Function }} [fsLike] Injectable for tests.
166
355
  * @returns {{ status: 'skipped' | 'failed', reason: string }}
167
356
  */
168
357
  export function probeReusedInstall(strategy, wtPath, fsLike = fs) {
@@ -173,34 +362,33 @@ export function probeReusedInstall(strategy, wtPath, fsLike = fs) {
173
362
  if (!fsLike.existsSync(path.join(wtPath, 'package.json'))) {
174
363
  return { status: 'skipped', reason: 'no-package-json' };
175
364
  }
176
- const nmPath = path.join(wtPath, 'node_modules');
177
- if (!fsLike.existsSync(nmPath)) {
178
- return { status: 'failed', reason: 'reuse-node-modules-missing' };
179
- }
180
- const marker = INSTALL_MARKERS.map((m) => path.join(nmPath, m)).find((p) =>
181
- fsLike.existsSync(p),
182
- );
183
- if (!marker) {
184
- return { status: 'failed', reason: 'reuse-install-incomplete' };
185
- }
186
- const markerMtime = safeMtimeMs(fsLike, marker);
187
- const lockfile = LOCKFILES.map((l) => path.join(wtPath, l)).find((p) =>
188
- fsLike.existsSync(p),
189
- );
190
- if (lockfile && markerMtime !== null) {
191
- const lockMtime = safeMtimeMs(fsLike, lockfile);
192
- if (lockMtime !== null && lockMtime > markerMtime) {
193
- return { status: 'failed', reason: 'reuse-node-modules-stale' };
194
- }
365
+ const probe = isInstallSkippable({ wtPath, fsLike });
366
+ if (probe.skippable) {
367
+ return { status: 'skipped', reason: 'worktree-reused' };
195
368
  }
196
- return { status: 'skipped', reason: 'worktree-reused' };
369
+ return {
370
+ status: 'failed',
371
+ reason:
372
+ probe.reason === 'node-modules-missing'
373
+ ? 'reuse-node-modules-missing'
374
+ : 'reuse-install-incomplete',
375
+ };
197
376
  }
198
377
 
199
- /** Pure: retry policy keyed off the chosen command. pnpm gets 3× + 5min. */
378
+ /**
379
+ * Pure: retry policy keyed off the chosen command. pnpm gets 3× + 5min.
380
+ *
381
+ * Story #4249: npm/yarn now get a real `maxAttempts` of 2 (was 1). The
382
+ * formerly hardcoded `npm ci` retry inside `runStoryInitPrepare` was deleted
383
+ * in the same change; this in-`ensure` retry budget is what compensates so
384
+ * npm install reliability does not regress — a transient first-attempt
385
+ * failure now retries here, with the correct (PM-detected) command, instead
386
+ * of an unconditional `npm ci` re-run after init.
387
+ */
200
388
  export function installRetryPolicy(cmd) {
201
389
  const isPnpm = cmd === 'pnpm';
202
390
  return {
203
- maxAttempts: isPnpm ? 3 : 1,
391
+ maxAttempts: isPnpm ? 3 : 2,
204
392
  timeoutMs: isPnpm ? 300_000 : 120_000,
205
393
  backoffMs: [0, 2_000, 5_000],
206
394
  };
@@ -387,6 +575,26 @@ export function installDependencies(ctx, wtPath) {
387
575
  if (selection === null) {
388
576
  return { status: 'skipped', reason: 'no-package-json' };
389
577
  }
578
+ // `clone` reflink-clones the donor's node_modules in `applyNodeModulesStrategy`.
579
+ // When the cloned tree carries a completed-install marker AND the worktree's
580
+ // lockfile byte-matches the donor's, the per-tree install is pure waste —
581
+ // skip it. The freshness predicate is the single shared `isInstallSkippable`
582
+ // (byte-exact lockfile hash, never mtime). On a lockfile mismatch (donor
583
+ // drifted, or no clone happened) this returns false and the install below
584
+ // runs normally.
585
+ if (strategy === 'clone') {
586
+ const donor = resolveCloneDonor(ctx);
587
+ const probe = isInstallSkippable({ wtPath, donorPath: donor });
588
+ if (probe.skippable) {
589
+ ctx.logger.info(
590
+ `worktree.install strategy=clone skip reason=${probe.reason} path=${wtPath}`,
591
+ );
592
+ return { status: 'skipped', reason: `clone-${probe.reason}` };
593
+ }
594
+ ctx.logger.info(
595
+ `worktree.install strategy=clone install reason=${probe.reason} path=${wtPath}`,
596
+ );
597
+ }
390
598
  // Prime the pnpm content-addressable store once per machine before the
391
599
  // worktree's own install runs. No-op for non-pnpm-store strategies. Prime
392
600
  // failures are surfaced as warnings but do not short-circuit the install —
@@ -13,8 +13,8 @@
13
13
  * @see Story #2462 — Split GitHubProvider god class into seven composed gateways.
14
14
  */
15
15
 
16
+ import { withTransientRetry } from './errors.js';
16
17
  import { parseApiJson } from './request-helpers.js';
17
- import { withTransientRetry } from './transient-retry.js';
18
18
 
19
19
  /**
20
20
  * Detect a 404 across both error surfaces:
@@ -48,6 +48,40 @@ const TRANSIENT_MESSAGES = [
48
48
 
49
49
  const PERMISSION_MESSAGES = ['unauthorized', 'forbidden', 'permission'];
50
50
 
51
+ // Network/connectivity blips that the gh-CLI path surfaces on `err.stderr`
52
+ // (Go HTTP errors, e.g. `dial tcp ...: i/o timeout`) and the direct `fetch`
53
+ // path surfaces as `TypeError: fetch failed` with the real reason on
54
+ // `err.cause` (e.g. `ETIMEDOUT`, `ENOTFOUND`). Folded in from the former
55
+ // `transient-retry.js` predicate (Story #4298) so the single canonical
56
+ // classifier retries the **union** of transient HTTP statuses/codes AND
57
+ // transient network errors. The `\b50[234]\b` alternative also catches a
58
+ // bare 502/503/504 surfaced only in an error message string (no `.status`).
59
+ const TRANSIENT_NETWORK_RE =
60
+ /i\/o timeout|dial tcp|TLS handshake timeout|connection reset|connection refused|temporary failure|could not resolve host|no such host|network is unreachable|socket hang up|fetch failed|ConnectTimeoutError|UND_ERR_CONNECT_TIMEOUT|ETIMEDOUT|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|\b50[234]\b/i;
61
+
62
+ /**
63
+ * True when an error looks like a retryable network/connectivity blip.
64
+ * Scans the union of fields both transport paths populate (`stderr`,
65
+ * `message`, `code`, and the nested `cause.message` / `cause.code` the
66
+ * `fetch` path uses). Module-private — folded in from the former
67
+ * `transient-retry.js` predicate (Story #4298) and consumed only by
68
+ * `classifyGithubError` below; its behavior is exercised through that public
69
+ * classifier rather than as a standalone export (keeps the dead-export gate
70
+ * green — nothing outside this module imports it).
71
+ */
72
+ function isTransientNetworkError(err) {
73
+ const hay = [
74
+ err?.stderr,
75
+ err?.message,
76
+ err?.code,
77
+ err?.cause?.message,
78
+ err?.cause?.code,
79
+ ]
80
+ .filter(Boolean)
81
+ .join(' ');
82
+ return TRANSIENT_NETWORK_RE.test(hay);
83
+ }
84
+
51
85
  function matchesAny(haystack, needles) {
52
86
  for (const n of needles) if (haystack.includes(n)) return true;
53
87
  return false;
@@ -97,19 +131,36 @@ export function classifyGithubError(err) {
97
131
  if (matchesAny(lower, FEATURE_DISABLED_MESSAGES)) return 'feature-disabled';
98
132
  if (isTransientStatus(status)) return 'transient';
99
133
  if (isTransientByCodeOrMessage(code, lower)) return 'transient';
134
+ // Union with the former `transient-retry.js` predicate (Story #4298):
135
+ // retry on network/connectivity blips the status/code checks above miss
136
+ // (e.g. a `dial tcp ... i/o timeout` on `err.stderr` from the gh-CLI path,
137
+ // or `ECONNREFUSED` / `ENETUNREACH`). Checked before the permission rule so
138
+ // a transient network failure never masquerades as a permanent denial.
139
+ if (isTransientNetworkError(err)) return 'transient';
100
140
  if (isPermissionSignal(status, lower)) return 'permission';
101
141
  return 'permanent';
102
142
  }
103
143
 
104
144
  // ---------------------------------------------------------------------------
105
- // Transient-retry helper (Story #2852)
145
+ // Transient-retry helper (Story #2852; unified in Story #4298)
106
146
  // ---------------------------------------------------------------------------
107
147
  //
148
+ // The single canonical `withTransientRetry` for the GitHub provider. Story
149
+ // #4298 collapsed the former two divergent same-named implementations (this
150
+ // one + the network-only one in the deleted `transient-retry.js`) into this
151
+ // one primitive. Its default classifier (`classifyGithubError`) is the
152
+ // **union** predicate — it retries on transient HTTP statuses/codes AND on
153
+ // transient network/connectivity errors — so every former consumer of either
154
+ // module keeps (or gains) its prior retry coverage with no shim.
155
+ //
108
156
  // Mirrors the addSubIssue retry contract in `sub-issues.js` so read-path
109
157
  // callers (paginateRest, getTicket, getNativeSubIssues, …) absorb the same
110
158
  // jittered exponential backoff on transient GitHub errors instead of
111
159
  // bubbling a one-shot 502/429/ECONNRESET that kills a longer pipeline
112
- // (e.g. the /deliver Phase E retro).
160
+ // (e.g. the /deliver Phase E retro). The network consumers repointed here
161
+ // (branch-protection, labels, projects-v2-graphql) call with no opts, so
162
+ // they adopt these defaults; their retry *classes* (the network blips) are
163
+ // preserved via the unified classifier above.
113
164
 
114
165
  export const TRANSIENT_RETRY_DEFAULTS = Object.freeze({
115
166
  maxAttempts: 6,
@@ -16,7 +16,7 @@
16
16
  * @see Story #2462 — Split GitHubProvider god class into seven composed gateways.
17
17
  */
18
18
 
19
- import { withTransientRetry } from './transient-retry.js';
19
+ import { withTransientRetry } from './errors.js';
20
20
 
21
21
  /**
22
22
  * Detect the "label already exists" signal across the surfaces `gh label
@@ -7,7 +7,7 @@
7
7
  * `unavailable:true` envelopes. Wave 3 deletes the old submodules.
8
8
  */
9
9
  import { execSync } from 'node:child_process';
10
- import { withTransientRetry } from './transient-retry.js';
10
+ import { withTransientRetry } from './errors.js';
11
11
 
12
12
  // Resolve an owner node id per-scope. Querying `user` and `organization`
13
13
  // together in one request makes GitHub return a NOT_FOUND error for whichever
@@ -127,6 +127,7 @@ export function buildReassertOptions({
127
127
  logger,
128
128
  pollAttempts,
129
129
  pollDelayMs,
130
+ config,
130
131
  }) {
131
132
  const opts = {
132
133
  provider,
@@ -135,6 +136,9 @@ export function buildReassertOptions({
135
136
  };
136
137
  if (pollAttempts !== undefined) opts.pollAttempts = pollAttempts;
137
138
  if (pollDelayMs !== undefined) opts.pollDelayMs = pollDelayMs;
139
+ // Story #4252 — forward the resolved config so ColumnSync's on-disk
140
+ // board-metadata cache lands under the project's configured tempRoot.
141
+ if (config !== undefined) opts.config = config;
138
142
  return opts;
139
143
  }
140
144
 
@@ -168,6 +172,7 @@ export async function main(argv = process.argv.slice(2)) {
168
172
  logger: Logger,
169
173
  pollAttempts,
170
174
  pollDelayMs,
175
+ config: effectiveConfig,
171
176
  }),
172
177
  );
173
178
  process.stdout.write(`${JSON.stringify({ ticketId, ...result })}\n`);
@@ -27,6 +27,14 @@
27
27
  * `c8 report` path is ~19% faster end-to-end on a Windows dev host
28
28
  * while producing an identical `coverage-final.json` artifact for the
29
29
  * CRAP gate.
30
+ *
31
+ * Test-runner concurrency: the suite spawn reuses `TEST_RUNNER_FLAGS`
32
+ * from `run-tests.js` — the single source of truth for the
33
+ * `--test-concurrency` value, derived at startup from the host's
34
+ * available parallelism and clamped to `[TEST_CONCURRENCY_MIN,
35
+ * TEST_CONCURRENCY_MAX]`. This keeps the coverage gate (which runs the
36
+ * suite at every story close on both delivery paths) host-aware instead
37
+ * of pinned to the historical literal of 8.
30
38
  */
31
39
 
32
40
  import { spawnSync } from 'node:child_process';
@@ -37,6 +45,7 @@ import { fileURLToPath } from 'node:url';
37
45
 
38
46
  import { cleanupRepoTestTempArtifacts } from './cleanup-repo-test-temp.js';
39
47
  import { C8_CLI } from './lib/c8-cli-path.js';
48
+ import { TEST_RUNNER_FLAGS } from './run-tests.js';
40
49
 
41
50
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
42
51
  const ROOT = path.resolve(__dirname, '..', '..');
@@ -45,59 +54,90 @@ const require = createRequire(import.meta.url);
45
54
  const C8_CONFIG = require('../../.c8rc.cjs');
46
55
  const V8_TMP = path.join(COVERAGE_DIR, 'tmp');
47
56
 
48
- rmSync(COVERAGE_DIR, { recursive: true, force: true });
49
- mkdirSync(V8_TMP, { recursive: true });
57
+ /**
58
+ * Build the `node --test` argv for the coverage suite spawn.
59
+ *
60
+ * Reuses the shared `TEST_RUNNER_FLAGS` (the single source of truth for
61
+ * the host-aware, clamped `--test-concurrency` value) so the coverage
62
+ * path never drifts from `run-tests.js`. The `runnerFlags` parameter is
63
+ * injected in tests so the argv can be asserted without touching the OS.
64
+ *
65
+ * @param {object} [opts]
66
+ * @param {readonly string[]} [opts.runnerFlags]
67
+ * @param {string} [opts.testGlob]
68
+ * @returns {string[]}
69
+ */
70
+ export function buildCoverageTestArgs({
71
+ runnerFlags = TEST_RUNNER_FLAGS,
72
+ testGlob = 'tests/**/*.test.js',
73
+ } = {}) {
74
+ return [...runnerFlags, testGlob];
75
+ }
76
+
77
+ /**
78
+ * Execute the coverage pipeline: run the suite under `NODE_V8_COVERAGE`,
79
+ * post-process the dumps with `c8 report`, then gate on the coverage
80
+ * baseline. Returns the first non-zero exit code across the three stages
81
+ * (or the baseline check's status when both prior stages pass).
82
+ *
83
+ * @returns {number}
84
+ */
85
+ function runCoveragePipeline() {
86
+ rmSync(COVERAGE_DIR, { recursive: true, force: true });
87
+ mkdirSync(V8_TMP, { recursive: true });
50
88
 
51
- const testRun = spawnSync(
52
- process.execPath,
53
- [
54
- '--experimental-test-module-mocks',
55
- '--test',
56
- '--test-concurrency=8',
57
- 'tests/**/*.test.js',
58
- ],
59
- {
89
+ const testRun = spawnSync(process.execPath, buildCoverageTestArgs(), {
60
90
  cwd: ROOT,
61
91
  stdio: 'inherit',
62
92
  env: { ...process.env, NODE_V8_COVERAGE: V8_TMP },
63
- },
64
- );
93
+ });
65
94
 
66
- cleanupRepoTestTempArtifacts({ repoRoot: ROOT });
95
+ cleanupRepoTestTempArtifacts({ repoRoot: ROOT });
67
96
 
68
- const includeArgs = (C8_CONFIG.include ?? []).flatMap((p) => ['--include', p]);
69
- const excludeArgs = (C8_CONFIG.exclude ?? []).flatMap((p) => ['--exclude', p]);
97
+ const includeArgs = (C8_CONFIG.include ?? []).flatMap((p) => [
98
+ '--include',
99
+ p,
100
+ ]);
101
+ const excludeArgs = (C8_CONFIG.exclude ?? []).flatMap((p) => [
102
+ '--exclude',
103
+ p,
104
+ ]);
70
105
 
71
- const reportRun = spawnSync(
72
- process.execPath,
73
- [
74
- C8_CLI,
75
- 'report',
76
- '--reporter=json',
77
- '--reporter=text',
78
- '--temp-directory',
79
- V8_TMP,
80
- ...includeArgs,
81
- ...excludeArgs,
82
- ],
83
- { cwd: ROOT, stdio: 'inherit', shell: false },
84
- );
106
+ const reportRun = spawnSync(
107
+ process.execPath,
108
+ [
109
+ C8_CLI,
110
+ 'report',
111
+ '--reporter=json',
112
+ '--reporter=text',
113
+ '--temp-directory',
114
+ V8_TMP,
115
+ ...includeArgs,
116
+ ...excludeArgs,
117
+ ],
118
+ { cwd: ROOT, stdio: 'inherit', shell: false },
119
+ );
85
120
 
86
- const checkRun = spawnSync(
87
- process.execPath,
88
- [
89
- path.join(ROOT, '.agents', 'scripts', 'check-baselines.js'),
90
- '--gate',
91
- 'coverage',
92
- ],
93
- { cwd: ROOT, stdio: 'inherit' },
94
- );
121
+ const checkRun = spawnSync(
122
+ process.execPath,
123
+ [
124
+ path.join(ROOT, '.agents', 'scripts', 'check-baselines.js'),
125
+ '--gate',
126
+ 'coverage',
127
+ ],
128
+ { cwd: ROOT, stdio: 'inherit' },
129
+ );
95
130
 
96
- const exitCode =
97
- testRun.status !== 0
98
- ? testRun.status
131
+ return testRun.status !== 0
132
+ ? (testRun.status ?? 1)
99
133
  : reportRun.status !== 0
100
- ? reportRun.status
101
- : checkRun.status;
134
+ ? (reportRun.status ?? 1)
135
+ : (checkRun.status ?? 1);
136
+ }
102
137
 
103
- process.exit(exitCode ?? 1);
138
+ // Run the pipeline only when invoked directly as a CLI; importing the
139
+ // module (e.g. from a test asserting the spawn argv) must not spawn the
140
+ // real suite.
141
+ if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] ?? '')) {
142
+ process.exit(runCoveragePipeline());
143
+ }
@@ -78,6 +78,17 @@ const tasks = [
78
78
  cmd: 'node',
79
79
  args: ['.agents/scripts/check-arch-cycles.js'],
80
80
  },
81
+ {
82
+ // Loop-unit frontmatter gate (Story #4288, Epic #4284). Validates
83
+ // every `.agents/workflows/loops/*.md` loop unit against
84
+ // `.agents/schemas/loop-unit.schema.json`. An absent/empty loops
85
+ // directory is a clean pass; a malformed unit (e.g. a self-paced
86
+ // cadence missing its required `verify`) fails the lint gate with a
87
+ // message naming the offending file + field.
88
+ name: 'loop-units',
89
+ cmd: 'node',
90
+ args: ['.agents/scripts/check-loop-units.js'],
91
+ },
81
92
  ];
82
93
 
83
94
  function runTask({ name, cmd, args }) {