mandrel 1.58.0 → 1.59.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 (129) hide show
  1. package/.agents/README.md +89 -87
  2. package/.agents/docs/SDLC.md +11 -7
  3. package/.agents/docs/workflows.md +2 -1
  4. package/.agents/schemas/audit-rules.json +20 -0
  5. package/.agents/scripts/acceptance-eval.js +20 -3
  6. package/.agents/scripts/assert-branch.js +1 -3
  7. package/.agents/scripts/bootstrap.js +1 -1
  8. package/.agents/scripts/check-arch-cycles.js +360 -0
  9. package/.agents/scripts/coverage-capture.js +24 -3
  10. package/.agents/scripts/epic-deliver-preflight.js +5 -3
  11. package/.agents/scripts/epic-deliver-prepare.js +12 -4
  12. package/.agents/scripts/epic-execute-record-wave.js +1 -1
  13. package/.agents/scripts/evidence-gate.js +1 -1
  14. package/.agents/scripts/git-rebase-and-resolve.js +1 -1
  15. package/.agents/scripts/hierarchy-gate.js +34 -14
  16. package/.agents/scripts/lib/baselines/kinds/coverage.js +33 -149
  17. package/.agents/scripts/lib/baselines/kinds/duplication.js +27 -116
  18. package/.agents/scripts/lib/baselines/kinds/kind-factory.js +192 -0
  19. package/.agents/scripts/lib/baselines/kinds/lighthouse.js +34 -133
  20. package/.agents/scripts/lib/baselines/kinds/maintainability.js +31 -124
  21. package/.agents/scripts/lib/baselines/kinds/mutation.js +25 -111
  22. package/.agents/scripts/lib/baselines/maintainability-baseline-io.js +59 -0
  23. package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +37 -0
  24. package/.agents/scripts/lib/baselines/writer.js +1 -1
  25. package/.agents/scripts/lib/close-validation/commands.js +188 -0
  26. package/.agents/scripts/lib/close-validation/gates.js +235 -0
  27. package/.agents/scripts/lib/close-validation/process.js +101 -0
  28. package/.agents/scripts/lib/close-validation/projections/maintainability.js +1 -1
  29. package/.agents/scripts/lib/close-validation/runner.js +325 -0
  30. package/.agents/scripts/lib/close-validation/telemetry.js +70 -0
  31. package/.agents/scripts/lib/config/quality.js +6 -6
  32. package/.agents/scripts/lib/config-resolver.js +2 -5
  33. package/.agents/scripts/lib/coverage-capture.js +147 -4
  34. package/.agents/scripts/lib/cpu-pool.js +14 -0
  35. package/.agents/scripts/lib/crap-utils.js +6 -11
  36. package/.agents/scripts/lib/dynamic-workflow/documentation-report-contract.js +87 -0
  37. package/.agents/scripts/lib/git-utils.js +24 -22
  38. package/.agents/scripts/lib/maintainability-engine.js +1 -1
  39. package/.agents/scripts/lib/maintainability-utils.js +4 -187
  40. package/.agents/scripts/lib/observability/perf-report-readers.js +32 -23
  41. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +80 -6
  42. package/.agents/scripts/lib/orchestration/code-review.js +90 -77
  43. package/.agents/scripts/lib/orchestration/dispatch-pipeline.js +5 -12
  44. package/.agents/scripts/lib/orchestration/epic-deliver-lease-guard.js +14 -14
  45. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/planning-artifacts.js +2 -2
  46. package/.agents/scripts/lib/orchestration/epic-plan-lease-guard.js +184 -49
  47. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/drain.js +1 -1
  48. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/plan-epic.js +26 -2
  49. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +26 -6
  50. package/.agents/scripts/lib/orchestration/epic-runner/phases/build-wave-dag.js +7 -20
  51. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +1 -2
  52. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +0 -6
  53. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +103 -0
  54. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +22 -64
  55. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +38 -76
  56. package/.agents/scripts/lib/orchestration/epic-runner/story-run-progress-writer.js +2 -2
  57. package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +4 -16
  58. package/.agents/scripts/lib/orchestration/file-assumptions.js +4 -3
  59. package/.agents/scripts/lib/orchestration/lease-guard-shared.js +144 -0
  60. package/.agents/scripts/lib/orchestration/lifecycle/emit-story-heartbeat.js +2 -2
  61. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +7 -7
  62. package/.agents/scripts/lib/orchestration/post-merge/phases/notification.js +3 -3
  63. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +7 -7
  64. package/.agents/scripts/lib/orchestration/preflight-cache.js +35 -12
  65. package/.agents/scripts/lib/orchestration/review-providers/codex.js +5 -60
  66. package/.agents/scripts/lib/orchestration/review-providers/native.js +7 -6
  67. package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +105 -0
  68. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +7 -59
  69. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +2 -4
  70. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +1 -1
  71. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -4
  72. package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +32 -35
  73. package/.agents/scripts/lib/orchestration/skill-capsule-loader.js +1 -2
  74. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +451 -503
  75. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/pre-merge-attribution.js +8 -2
  76. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/refresh-commit.js +47 -2
  77. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +2 -2
  78. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +358 -54
  79. package/.agents/scripts/lib/orchestration/story-close/phases/close.js +1 -1
  80. package/.agents/scripts/lib/orchestration/story-close/phases/gates.js +3 -2
  81. package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +30 -3
  82. package/.agents/scripts/lib/orchestration/story-close/post-merge-close.js +5 -18
  83. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +3 -3
  84. package/.agents/scripts/lib/orchestration/story-close-recovery.js +33 -16
  85. package/.agents/scripts/lib/orchestration/story-reachability.js +47 -0
  86. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +2 -33
  87. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +42 -64
  88. package/.agents/scripts/lib/orchestration/ticketing/reads.js +9 -0
  89. package/.agents/scripts/lib/orchestration/ticketing/state.js +50 -436
  90. package/.agents/scripts/lib/orchestration/ticketing/transition.js +471 -0
  91. package/.agents/scripts/lib/orchestration/ticketing.js +0 -1
  92. package/.agents/scripts/lib/orchestration/wave-record-notifications.js +1 -1
  93. package/.agents/scripts/lib/orchestration/wave-record-projection.js +1 -7
  94. package/.agents/scripts/lib/project-root.js +17 -0
  95. package/.agents/scripts/lib/story-adjacency.js +76 -0
  96. package/.agents/scripts/lib/story-lifecycle.js +1 -1
  97. package/.agents/scripts/lib/transpile.js +93 -0
  98. package/.agents/scripts/lib/wave-runner/tick.js +4 -153
  99. package/.agents/scripts/lib/workers/crap-worker.js +1 -1
  100. package/.agents/scripts/lib/workers/maintainability-report-worker.js +1 -1
  101. package/.agents/scripts/lib/worktree/lifecycle/creation.js +20 -2
  102. package/.agents/scripts/lib/worktree/lifecycle/force-drain.js +90 -0
  103. package/.agents/scripts/lib/worktree/lifecycle/reap.js +26 -8
  104. package/.agents/scripts/lib/worktree/node-modules-strategy.js +74 -0
  105. package/.agents/scripts/providers/github/tickets.js +110 -6
  106. package/.agents/scripts/run-lint.js +9 -0
  107. package/.agents/scripts/run-tests.js +24 -4
  108. package/.agents/scripts/stories-wave-tick.js +8 -5
  109. package/.agents/scripts/story-init.js +149 -10
  110. package/.agents/scripts/sync-branch-from-base.js +1 -1
  111. package/.agents/skills/stack/qa/lighthouse-baseline/SKILL.md +1 -1
  112. package/.agents/workflows/audit-documentation.md +226 -0
  113. package/.agents/workflows/epic-deliver.md +16 -23
  114. package/.agents/workflows/epic-plan.md +1 -1
  115. package/.agents/workflows/helpers/epic-deliver-story.md +17 -28
  116. package/.agents/workflows/helpers/single-story-deliver.md +2 -1
  117. package/.agents/workflows/onboard.md +4 -3
  118. package/.agents/workflows/story-deliver.md +1 -1
  119. package/README.md +13 -8
  120. package/lib/cli/init.js +336 -0
  121. package/package.json +2 -1
  122. package/.agents/scripts/lib/auto-refresh-baselines.js +0 -308
  123. package/.agents/scripts/lib/close-validation.js +0 -897
  124. package/.agents/scripts/lib/orchestration/cascade-grouping.js +0 -275
  125. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter.js +0 -69
  126. package/.agents/scripts/lib/orchestration/story-close/format-autofix-scoped.js +0 -221
  127. package/.agents/scripts/lib/orchestration/story-close/format-autofix-shared.js +0 -123
  128. package/.agents/scripts/lib/task-utils.js +0 -26
  129. package/.agents/scripts/story-deliver-prepare.js +0 -267
@@ -0,0 +1,360 @@
1
+ /**
2
+ * CLI: ratchet-down architecture gate for import cycles (Story #3991).
3
+ *
4
+ * Walks every `.js` file under `.agents/scripts/` (excluding
5
+ * `node_modules`), parses relative static-import edges
6
+ * (`from './…/x.js'`), detects directed cycles via DFS, and compares
7
+ * them against the committed allowlist at `baselines/arch-cycles.json`.
8
+ *
9
+ * Ratchet semantics mirror `check-dead-exports.js`:
10
+ * - Any detected cycle NOT in the allowlist → exit 1, cycle path printed.
11
+ * - Allowlisted cycle no longer detected → printed as `-` (removal),
12
+ * warning that the allowlist can shrink. Removals-only exits 0.
13
+ * - Clean diff → exit 0.
14
+ *
15
+ * Cycles are normalized by rotating to the lexicographically-smallest
16
+ * member so the same cycle always serializes identically regardless of
17
+ * the DFS entry point.
18
+ *
19
+ * Flags:
20
+ * --baseline <path> override the allowlist path (default
21
+ * `baselines/arch-cycles.json`, resolved from cwd)
22
+ * --root <path> override the scanned root (default
23
+ * `.agents/scripts`, resolved from cwd)
24
+ * --json write the structured envelope to stdout
25
+ */
26
+
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+ import process from 'node:process';
30
+ import { runAsCli } from './lib/cli-utils.js';
31
+
32
+ /**
33
+ * Parse argv for `--baseline <path>`, `--root <path>`, and `--json`.
34
+ * Exported so unit tests can pin the parser.
35
+ *
36
+ * @param {string[]} argv
37
+ * @returns {{ baselinePath: string | null, rootPath: string | null, json: boolean }}
38
+ */
39
+ export function parseArgv(argv = []) {
40
+ let baselinePath = null;
41
+ let rootPath = null;
42
+ let json = false;
43
+ for (let i = 0; i < argv.length; i += 1) {
44
+ const a = argv[i];
45
+ if (a === '--baseline') {
46
+ const next = argv[i + 1];
47
+ if (next && !next.startsWith('--')) {
48
+ baselinePath = next;
49
+ i += 1;
50
+ }
51
+ } else if (a === '--root') {
52
+ const next = argv[i + 1];
53
+ if (next && !next.startsWith('--')) {
54
+ rootPath = next;
55
+ i += 1;
56
+ }
57
+ } else if (a === '--json') {
58
+ json = true;
59
+ }
60
+ }
61
+ return { baselinePath, rootPath, json };
62
+ }
63
+
64
+ /**
65
+ * Recursively collect `.js` files under `rootDir`, skipping
66
+ * `node_modules`. Returns absolute paths, sorted for determinism.
67
+ *
68
+ * @param {string} rootDir
69
+ * @returns {string[]}
70
+ */
71
+ export function collectJsFiles(rootDir) {
72
+ const out = [];
73
+ const walk = (dir) => {
74
+ let entries;
75
+ try {
76
+ entries = fs.readdirSync(dir, { withFileTypes: true });
77
+ } catch {
78
+ return;
79
+ }
80
+ for (const entry of entries) {
81
+ if (entry.name === 'node_modules') continue;
82
+ const full = path.join(dir, entry.name);
83
+ if (entry.isDirectory()) {
84
+ walk(full);
85
+ } else if (entry.isFile() && entry.name.endsWith('.js')) {
86
+ out.push(full);
87
+ }
88
+ }
89
+ };
90
+ walk(rootDir);
91
+ return out.sort();
92
+ }
93
+
94
+ const IMPORT_RE = /from\s+['"](\.\.?\/[^'"]+\.js)['"]/g;
95
+
96
+ /**
97
+ * Pure helper: extract relative static-import specifiers from source text.
98
+ *
99
+ * @param {string} source
100
+ * @returns {string[]}
101
+ */
102
+ export function parseRelativeImports(source) {
103
+ const specs = [];
104
+ for (const m of source.matchAll(IMPORT_RE)) {
105
+ specs.push(m[1]);
106
+ }
107
+ return specs;
108
+ }
109
+
110
+ /**
111
+ * Build a directed import graph over the given files. Node identity is the
112
+ * file path relative to `rootDir`, posix-separated, so the graph (and any
113
+ * cycles found in it) serializes identically across platforms. Edges that
114
+ * resolve outside the scanned file set are dropped.
115
+ *
116
+ * @param {string[]} files absolute paths
117
+ * @param {string} rootDir
118
+ * @param {{ readFile?: (p: string) => string }} [opts]
119
+ * @returns {Map<string, string[]>}
120
+ */
121
+ export function buildGraph(files, rootDir, { readFile } = {}) {
122
+ const read = readFile ?? ((p) => fs.readFileSync(p, 'utf-8'));
123
+ const toId = (abs) => path.relative(rootDir, abs).split(path.sep).join('/');
124
+ const idSet = new Set(files.map(toId));
125
+ const graph = new Map();
126
+ for (const file of files) {
127
+ const id = toId(file);
128
+ let source;
129
+ try {
130
+ source = read(file);
131
+ } catch {
132
+ graph.set(id, []);
133
+ continue;
134
+ }
135
+ const edges = [];
136
+ for (const spec of parseRelativeImports(source)) {
137
+ const target = path
138
+ .relative(rootDir, path.resolve(path.dirname(file), spec))
139
+ .split(path.sep)
140
+ .join('/');
141
+ if (idSet.has(target) && target !== id) edges.push(target);
142
+ }
143
+ graph.set(id, [...new Set(edges)].sort());
144
+ }
145
+ return graph;
146
+ }
147
+
148
+ /**
149
+ * Pure helper: rotate a cycle (array of module ids, no repeated terminal
150
+ * element) so it starts at its lexicographically-smallest member. The same
151
+ * cycle therefore always serializes identically regardless of where the
152
+ * DFS entered it.
153
+ *
154
+ * @param {string[]} cycle
155
+ * @returns {string[]}
156
+ */
157
+ export function normalizeCycle(cycle) {
158
+ if (cycle.length === 0) return [];
159
+ let minIdx = 0;
160
+ for (let i = 1; i < cycle.length; i += 1) {
161
+ if (cycle[i] < cycle[minIdx]) minIdx = i;
162
+ }
163
+ return [...cycle.slice(minIdx), ...cycle.slice(0, minIdx)];
164
+ }
165
+
166
+ /**
167
+ * Detect directed cycles in the graph via iterative-stack DFS (white /
168
+ * gray / black coloring). Each back edge to a gray node yields the cycle
169
+ * slice currently on the DFS path. Cycles are normalized and deduplicated
170
+ * by their serialized form, then sorted for stable output.
171
+ *
172
+ * @param {Map<string, string[]>} graph
173
+ * @returns {string[][]} normalized cycles
174
+ */
175
+ export function findCycles(graph) {
176
+ const WHITE = 0;
177
+ const GRAY = 1;
178
+ const BLACK = 2;
179
+ const color = new Map();
180
+ for (const node of graph.keys()) color.set(node, WHITE);
181
+ const seen = new Map();
182
+
183
+ const pathStack = [];
184
+ const onPath = new Map(); // node -> index in pathStack
185
+
186
+ const visit = (start) => {
187
+ // Iterative DFS frame stack: [node, edge cursor].
188
+ const frames = [[start, 0]];
189
+ color.set(start, GRAY);
190
+ onPath.set(start, pathStack.length);
191
+ pathStack.push(start);
192
+ while (frames.length > 0) {
193
+ const frame = frames[frames.length - 1];
194
+ const [node] = frame;
195
+ const edges = graph.get(node) ?? [];
196
+ if (frame[1] < edges.length) {
197
+ const next = edges[frame[1]];
198
+ frame[1] += 1;
199
+ const c = color.get(next);
200
+ if (c === GRAY) {
201
+ const cycle = normalizeCycle(pathStack.slice(onPath.get(next)));
202
+ seen.set(cycle.join(' -> '), cycle);
203
+ } else if (c === WHITE) {
204
+ color.set(next, GRAY);
205
+ onPath.set(next, pathStack.length);
206
+ pathStack.push(next);
207
+ frames.push([next, 0]);
208
+ }
209
+ } else {
210
+ color.set(node, BLACK);
211
+ onPath.delete(node);
212
+ pathStack.pop();
213
+ frames.pop();
214
+ }
215
+ }
216
+ };
217
+
218
+ for (const node of [...graph.keys()].sort()) {
219
+ if (color.get(node) === WHITE) visit(node);
220
+ }
221
+ return [...seen.values()].sort((a, b) =>
222
+ a.join(' -> ').localeCompare(b.join(' -> ')),
223
+ );
224
+ }
225
+
226
+ /**
227
+ * Pure helper: read the allowlist envelope from disk. Returns the parsed
228
+ * object or `null` when the file is missing or unparseable.
229
+ *
230
+ * @param {string} baselinePath
231
+ * @returns {{ cycles?: string[][] } | null}
232
+ */
233
+ export function loadBaseline(baselinePath) {
234
+ try {
235
+ if (!fs.existsSync(baselinePath)) return null;
236
+ const parsed = JSON.parse(fs.readFileSync(baselinePath, 'utf-8'));
237
+ if (!parsed || typeof parsed !== 'object') return null;
238
+ return parsed;
239
+ } catch {
240
+ return null;
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Pure helper: diff detected cycles against the allowlist. Both sides are
246
+ * normalized before comparison so rotation differences never count as
247
+ * drift. Identity is the ` -> `-joined normalized cycle.
248
+ *
249
+ * @param {string[][]} allowlisted
250
+ * @param {string[][]} detected
251
+ * @returns {{ added: string[][], removed: string[][] }}
252
+ */
253
+ export function diffCycles(allowlisted, detected) {
254
+ const key = (c) => normalizeCycle(c).join(' -> ');
255
+ const baseSet = new Set((allowlisted ?? []).map(key));
256
+ const currentSet = new Set((detected ?? []).map(key));
257
+ const added = (detected ?? []).filter((c) => !baseSet.has(key(c)));
258
+ const removed = (allowlisted ?? []).filter((c) => !currentSet.has(key(c)));
259
+ const sortFn = (a, b) => key(a).localeCompare(key(b));
260
+ return { added: added.sort(sortFn), removed: removed.sort(sortFn) };
261
+ }
262
+
263
+ /**
264
+ * Pure helper: render the human-readable diff. `+` lines are new cycles
265
+ * (gate fail), `-` lines are fixed cycles whose allowlist entry can be
266
+ * removed. A one-line summary always follows.
267
+ *
268
+ * @param {{ added: string[][], removed: string[][] }} diff
269
+ * @returns {string}
270
+ */
271
+ export function renderDiff(diff) {
272
+ const lines = [];
273
+ const fmt = (c) => `${c.join(' -> ')} -> ${c[0]}`;
274
+ for (const c of diff.added) lines.push(`+ ${fmt(c)}`);
275
+ for (const c of diff.removed) lines.push(`- ${fmt(c)}`);
276
+ if (diff.removed.length > 0) {
277
+ lines.push(
278
+ `[arch-cycles] ⚠ ${diff.removed.length} allowlisted cycle(s) no longer detected — shrink baselines/arch-cycles.json`,
279
+ );
280
+ }
281
+ const tag = diff.added.length > 0 ? '(gate fail)' : '(ok)';
282
+ lines.push(
283
+ `[arch-cycles] added=${diff.added.length} removed=${diff.removed.length} ${tag}`,
284
+ );
285
+ return lines.join('\n');
286
+ }
287
+
288
+ /**
289
+ * Top-level CLI entry. Exported so tests can drive the full pipeline
290
+ * against a tmpdir fixture graph.
291
+ *
292
+ * @param {{
293
+ * argv?: string[],
294
+ * cwd?: string,
295
+ * stdout?: { write: (s: string) => void },
296
+ * stderr?: { write: (s: string) => void },
297
+ * }} [opts]
298
+ * @returns {Promise<number>} 0 = clean or removals-only; 1 = new cycle detected
299
+ */
300
+ export async function runCli({
301
+ argv = process.argv.slice(2),
302
+ cwd = process.cwd(),
303
+ stdout = process.stdout,
304
+ stderr = process.stderr,
305
+ } = {}) {
306
+ const { baselinePath, rootPath, json } = parseArgv(argv);
307
+ const resolvedRoot = path.resolve(
308
+ cwd,
309
+ rootPath ?? path.join('.agents', 'scripts'),
310
+ );
311
+ const resolvedBaselinePath = path.resolve(
312
+ cwd,
313
+ baselinePath ?? path.join('baselines', 'arch-cycles.json'),
314
+ );
315
+ if (!fs.existsSync(resolvedRoot)) {
316
+ throw new Error(`[arch-cycles] scan root not found: ${resolvedRoot}`);
317
+ }
318
+ const baseline = loadBaseline(resolvedBaselinePath);
319
+ const allowlisted = Array.isArray(baseline?.cycles) ? baseline.cycles : [];
320
+
321
+ const files = collectJsFiles(resolvedRoot);
322
+ const graph = buildGraph(files, resolvedRoot);
323
+ const detected = findCycles(graph);
324
+ const diff = diffCycles(allowlisted, detected);
325
+ const exitCode = diff.added.length > 0 ? 1 : 0;
326
+
327
+ if (json) {
328
+ const envelope = {
329
+ kind: 'arch-cycles-report',
330
+ root: resolvedRoot,
331
+ baselinePath: resolvedBaselinePath,
332
+ allowlisted: allowlisted.map(normalizeCycle),
333
+ detected,
334
+ added: diff.added,
335
+ removed: diff.removed,
336
+ exitCode,
337
+ };
338
+ stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
339
+ } else {
340
+ if (!baseline) {
341
+ stderr.write(
342
+ `[arch-cycles] ⚠ allowlist not found at ${resolvedBaselinePath} — treating as empty\n`,
343
+ );
344
+ }
345
+ stdout.write(`\n--- arch-cycles preview ---\n`);
346
+ stdout.write(`${renderDiff(diff)}\n`);
347
+ }
348
+
349
+ return exitCode;
350
+ }
351
+
352
+ async function main() {
353
+ return runCli();
354
+ }
355
+
356
+ runAsCli(import.meta.url, main, {
357
+ source: 'arch-cycles',
358
+ propagateExitCode: true,
359
+ errorPrefix: '[arch-cycles] ❌ Fatal error',
360
+ });
@@ -9,9 +9,11 @@
9
9
  * 2. With `--skip-when-no-crap-files`: read `git diff --name-only <ref>...HEAD`
10
10
  * (default ref `main`) and exit 0 if no changed file lives under
11
11
  * `crap.targetDirs`.
12
- * 3. Compare the coverage artifact mtime against the newest mtime in
13
- * `crap.targetDirs`. Exit 0 when fresh.
14
- * 4. Otherwise spawn `npm run test:coverage` and propagate its exit code.
12
+ * 3. Test freshness: content digest of `crap.targetDirs` vs. the persisted
13
+ * capture stamp (`coverage/.capture-stamp.json`), falling back to the
14
+ * artifact-mtime heuristic when no stamp exists. Exit 0 when fresh.
15
+ * 4. Otherwise spawn `npm run test:coverage`, write a fresh capture stamp
16
+ * on success, and propagate the exit code.
15
17
  *
16
18
  * Exit codes:
17
19
  * 0 — coverage is fresh (or capture skipped/succeeded).
@@ -24,8 +26,10 @@ import { getChangedFiles } from './lib/changed-files.js';
24
26
  import { getQuality, resolveConfig } from './lib/config-resolver.js';
25
27
  import {
26
28
  anyChangedUnderTargets,
29
+ computeContentDigest,
27
30
  isCoverageFresh,
28
31
  runCapture,
32
+ writeCaptureStamp,
29
33
  } from './lib/coverage-capture.js';
30
34
 
31
35
  import { Logger } from './lib/Logger.js';
@@ -99,6 +103,23 @@ function main() {
99
103
  Logger.error(
100
104
  `[coverage-capture] ✖ npm run test:coverage exited ${code}. Fix failing tests or coverage-threshold breaches before re-running the CRAP gate.`,
101
105
  );
106
+ return code;
107
+ }
108
+
109
+ // Persist the content digest next to the fresh artifact so subsequent
110
+ // freshness checks are content-aware (mtime churn from branch switches no
111
+ // longer invalidates). Best-effort — a missing stamp just means the next
112
+ // check falls back to the mtime heuristic.
113
+ const digest = computeContentDigest(args.cwd, crap.targetDirs);
114
+ if (
115
+ digest &&
116
+ writeCaptureStamp({
117
+ cwd: args.cwd,
118
+ coveragePath: crap.coveragePath,
119
+ digest,
120
+ })
121
+ ) {
122
+ Logger.info('[coverage-capture] Wrote content-digest capture stamp.');
102
123
  }
103
124
  return code;
104
125
  }
@@ -297,9 +297,11 @@ export async function runPreflight({
297
297
  // Persist the snapshot/DAG envelope so `epic-deliver-prepare.js` can
298
298
  // reuse it instead of re-walking the hierarchy. The cache key is a
299
299
  // deterministic fingerprint of the Epic ticket returned by the same
300
- // `getTicket(epicId)` call that drove `runSnapshotPhase`, so any drift
301
- // (label, body, updatedAt) forces a cache miss in prepare.
302
- const baseSha = computeBaseSha(state.epic);
300
+ // `getTicket(epicId)` call that drove `runSnapshotPhase` **plus** the
301
+ // Story snapshots that drove the wave DAG (Story #4019), so any drift —
302
+ // Epic label/body/updatedAt or a Story-dependency edit — forces a cache
303
+ // miss in prepare.
304
+ const baseSha = computeBaseSha(state.epic, state.stories);
303
305
  let cacheWritten = false;
304
306
  if (!dryRun) {
305
307
  await writePreflightCache({
@@ -226,16 +226,24 @@ export async function runEpicDeliverPrepare({
226
226
 
227
227
  // Story #3027: try the preflight cache first so we don't re-walk Epic
228
228
  // → Feature → Story when `epic-deliver-preflight.js` already did. The
229
- // cache key is a deterministic fingerprint of the Epic ticket; a
230
- // single getTicket(epicId) round-trip confirms the cache is still
231
- // valid. Cache miss or baseSha mismatch fall back to a fresh pass.
229
+ // cache key is a deterministic fingerprint of the Epic ticket plus the
230
+ // cached Story snapshots (Story #4019): the Epic re-fetch plus one
231
+ // getTicket per cached Story is still far cheaper than the full
232
+ // hierarchy BFS, and a Story-dependency edit now invalidates the cache.
233
+ // Cache miss or baseSha mismatch → fall back to a fresh pass.
232
234
  const ctx = { epicId, provider };
233
235
  let state = {};
234
236
  let cacheStatus = 'miss';
235
237
  const cached = await readPreflightCache({ epicId, cwd });
236
238
  if (cached) {
237
239
  const freshEpic = await provider.getTicket(epicId);
238
- const freshBaseSha = computeBaseSha(freshEpic);
240
+ const cachedStoryIds = cached.stories
241
+ .map((s) => Number(s?.id ?? s?.number))
242
+ .filter((id) => Number.isInteger(id) && id > 0);
243
+ const freshStories = await Promise.all(
244
+ cachedStoryIds.map((id) => provider.getTicket(id)),
245
+ );
246
+ const freshBaseSha = computeBaseSha(freshEpic, freshStories);
239
247
  if (freshBaseSha === cached.baseSha) {
240
248
  state = {
241
249
  epic: cached.epic,
@@ -42,7 +42,7 @@ import { getRunners } from './lib/config/runners.js';
42
42
  import { resolveConfig } from './lib/config-resolver.js';
43
43
  import { Logger } from './lib/Logger.js';
44
44
  import * as epicRunStateStore from './lib/orchestration/epic-run-state-store.js';
45
- import { upsertEpicRunProgress } from './lib/orchestration/epic-runner/progress-reporter.js';
45
+ import { upsertEpicRunProgress } from './lib/orchestration/epic-runner/progress-reporter/composition.js';
46
46
  import {
47
47
  emitStoryDispatchEnd,
48
48
  storyStatusToDispatchOutcome,
@@ -47,9 +47,9 @@
47
47
  import { spawnSync } from 'node:child_process';
48
48
  import { parseArgs } from 'node:util';
49
49
  import { runAsCli } from './lib/cli-utils.js';
50
- import { PROJECT_ROOT } from './lib/config-resolver.js';
51
50
  import { gitSpawn } from './lib/git-utils.js';
52
51
  import { Logger } from './lib/Logger.js';
52
+ import { PROJECT_ROOT } from './lib/project-root.js';
53
53
  import {
54
54
  hashCommandConfig,
55
55
  recordPass,
@@ -40,9 +40,9 @@
40
40
 
41
41
  import { parseArgs } from 'node:util';
42
42
  import { runAsCli } from './lib/cli-utils.js';
43
- import { PROJECT_ROOT } from './lib/config-resolver.js';
44
43
  import { gitSpawn } from './lib/git-utils.js';
45
44
  import { Logger } from './lib/Logger.js';
45
+ import { PROJECT_ROOT } from './lib/project-root.js';
46
46
 
47
47
  function currentBranch(cwd) {
48
48
  const res = gitSpawn(cwd, 'rev-parse', '--abbrev-ref', 'HEAD');
@@ -42,6 +42,13 @@ import { resolveConfig } from './lib/config-resolver.js';
42
42
  import { Logger } from './lib/Logger.js';
43
43
  import { CONTEXT_LABELS, TYPE_LABELS } from './lib/label-constants.js';
44
44
  import { createProvider } from './lib/provider-factory.js';
45
+ import { concurrentMap } from './lib/util/concurrent-map.js';
46
+
47
+ /**
48
+ * Bounded fan-out for per-level `getSubTickets` calls. Matches the
49
+ * wave-record-io.js precedent (Story #3024).
50
+ */
51
+ const SUB_TICKET_FETCH_CONCURRENCY = 4;
45
52
 
46
53
  function classify(ticket) {
47
54
  const labels = ticket.labels ?? [];
@@ -70,22 +77,35 @@ function ticketIsComplete(ticket) {
70
77
  */
71
78
  async function collectDescendants(provider, epicId) {
72
79
  const visited = new Set([epicId]);
73
- const queue = [epicId];
74
80
  const out = [];
75
- while (queue.length > 0) {
76
- const parentId = queue.shift();
77
- let children;
78
- try {
79
- children = await provider.getSubTickets(parentId);
80
- } catch (err) {
81
- throw new Error(`getSubTickets(#${parentId}) failed: ${err.message}`);
82
- }
83
- for (const child of children) {
84
- if (visited.has(child.id)) continue;
85
- visited.add(child.id);
86
- out.push(child);
87
- queue.push(child.id);
81
+ // Level-order BFS: each round fetches the whole frontier's children with a
82
+ // bounded-parallel map instead of one awaited round-trip per node. Stories
83
+ // are 3-tier leaves (no sub-issues by contract), so they are never expanded
84
+ // — that skip alone removes the largest class of wasted GraphQL calls.
85
+ let frontier = [epicId];
86
+ while (frontier.length > 0) {
87
+ const levels = await concurrentMap(
88
+ frontier,
89
+ async (parentId) => {
90
+ try {
91
+ return await provider.getSubTickets(parentId);
92
+ } catch (err) {
93
+ throw new Error(`getSubTickets(#${parentId}) failed: ${err.message}`);
94
+ }
95
+ },
96
+ { concurrency: SUB_TICKET_FETCH_CONCURRENCY },
97
+ );
98
+ const next = [];
99
+ for (const children of levels) {
100
+ for (const child of children) {
101
+ if (visited.has(child.id)) continue;
102
+ visited.add(child.id);
103
+ out.push(child);
104
+ const labels = child.labels ?? [];
105
+ if (!labels.includes(TYPE_LABELS.STORY)) next.push(child.id);
106
+ }
88
107
  }
108
+ frontier = next;
89
109
  }
90
110
  return out;
91
111
  }