mandrel 1.68.0 → 1.70.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 (75) hide show
  1. package/.agents/README.md +1 -1
  2. package/.agents/docs/agentrc-reference.json +1 -2
  3. package/.agents/docs/configuration.md +2 -4
  4. package/.agents/docs/workflows.md +1 -1
  5. package/.agents/schemas/agentrc.schema.json +1 -5
  6. package/.agents/schemas/lifecycle/epic.automerge.end.schema.json +2 -1
  7. package/.agents/scripts/agents-update-preflight.js +235 -0
  8. package/.agents/scripts/apply-quality-bootstrap.js +79 -0
  9. package/.agents/scripts/audit-labels-bootstrap.js +52 -30
  10. package/.agents/scripts/audit-to-stories.js +54 -0
  11. package/.agents/scripts/bootstrap.js +13 -3
  12. package/.agents/scripts/epic-deliver-preflight.js +30 -13
  13. package/.agents/scripts/epic-deliver-prepare.js +40 -53
  14. package/.agents/scripts/epic-execute-record-wave.js +119 -133
  15. package/.agents/scripts/generate-config-docs.js +189 -94
  16. package/.agents/scripts/lib/audit-suite/findings.js +0 -4
  17. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +99 -0
  18. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +13 -5
  19. package/.agents/scripts/lib/baseline-snapshot.js +163 -4
  20. package/.agents/scripts/lib/baselines/refresh-service.js +13 -5
  21. package/.agents/scripts/lib/config/baselines.js +0 -20
  22. package/.agents/scripts/lib/config/explain.js +0 -2
  23. package/.agents/scripts/lib/config/limits.js +19 -8
  24. package/.agents/scripts/lib/config/temp-paths.js +0 -31
  25. package/.agents/scripts/lib/config-settings-schema.js +1 -2
  26. package/.agents/scripts/lib/crap-utils.js +281 -0
  27. package/.agents/scripts/lib/maintainability-utils.js +32 -9
  28. package/.agents/scripts/lib/orchestration/dispatch-engine.js +0 -2
  29. package/.agents/scripts/lib/orchestration/epic-cleanup.js +11 -7
  30. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/cli.js +6 -6
  31. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/context.js +11 -5
  32. package/.agents/scripts/lib/orchestration/epic-run-state-store.js +203 -110
  33. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +38 -162
  34. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +3 -4
  35. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/transport.js +16 -13
  36. package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +10 -7
  37. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +37 -24
  38. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -4
  39. package/.agents/scripts/lib/orchestration/manifest-builder.js +6 -0
  40. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +101 -70
  41. package/.agents/scripts/lib/orchestration/spec-renderer.js +42 -14
  42. package/.agents/scripts/lib/orchestration/ticket-lease.js +3 -0
  43. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +6 -2
  44. package/.agents/scripts/lib/orchestration/wave-record-io.js +18 -77
  45. package/.agents/scripts/lib/orchestration/wave-record-notifications.js +78 -122
  46. package/.agents/scripts/lib/orchestration/wave-record-projection.js +21 -226
  47. package/.agents/scripts/lib/presentation/dispatch-manifest-render.js +18 -1
  48. package/.agents/scripts/lib/presentation/manifest-render-waves.js +77 -4
  49. package/.agents/scripts/lib/story-adjacency.js +14 -10
  50. package/.agents/scripts/lib/story-body/story-body.js +142 -65
  51. package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -3
  52. package/.agents/scripts/lib/test-tiers.js +13 -7
  53. package/.agents/scripts/lib/wave-runner/ready-set.js +295 -0
  54. package/.agents/scripts/lib/wave-runner/tick.js +446 -216
  55. package/.agents/scripts/lib/wave-runner/wave-runner-error.js +2 -1
  56. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +226 -0
  57. package/.agents/scripts/lint-label-vocabulary.js +1 -1
  58. package/.agents/scripts/providers/github/issues.js +48 -0
  59. package/.agents/scripts/providers/github.js +1 -0
  60. package/.agents/scripts/stories-wave-tick.js +262 -161
  61. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +6 -0
  62. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +108 -101
  63. package/.agents/skills/skills.index.json +2 -2
  64. package/.agents/workflows/agents-update.md +205 -28
  65. package/.agents/workflows/deliver.md +12 -9
  66. package/.agents/workflows/helpers/deliver-epic.md +126 -90
  67. package/.agents/workflows/helpers/deliver-stories.md +131 -85
  68. package/.agents/workflows/helpers/plan-epic.md +13 -10
  69. package/.agents/workflows/plan.md +1 -1
  70. package/README.md +20 -0
  71. package/docs/CHANGELOG.md +46 -0
  72. package/lib/cli/registry.js +49 -6
  73. package/lib/cli/update.js +335 -332
  74. package/package.json +16 -11
  75. package/.agents/scripts/lib/wave-runner/wave-checkpoint.js +0 -91
@@ -90,6 +90,7 @@ import {
90
90
  } from '../crap-utils.js';
91
91
  import {
92
92
  calculateAll as calculateAllMi,
93
+ isIgnoredByGlobs as isIgnoredByGlobsMi,
93
94
  scanDirectory as scanDirectoryMi,
94
95
  } from '../maintainability-utils.js';
95
96
  import { filterExcludedRows } from './kinds/maintainability.js';
@@ -291,7 +292,18 @@ function buildDefaultMaintainabilityScorer({ cwd, config, quality } = {}) {
291
292
  const underTarget = targetAbsDirs.some(
292
293
  (root) => abs === root || abs.startsWith(`${root}${path.sep}`),
293
294
  );
294
- if (underTarget) sourceList.push(abs);
295
+ // Apply `ignoreGlobs` here too — the full-scope walk drops
296
+ // ignore-matched files via `scanDirectoryMi`, so the diff-scope path
297
+ // must do the same or an ignored-but-changed file (e.g. one matched by
298
+ // `config-settings-schema*.js`) enters `rows` and drags the
299
+ // `rollup["*"].min` below the maintainability floor. Reuse the same
300
+ // matcher `scanDirectoryMi` uses so behaviour is identical.
301
+ if (
302
+ underTarget &&
303
+ !isIgnoredByGlobsMi(abs, ignoreGlobs, effectiveCwd)
304
+ ) {
305
+ sourceList.push(abs);
306
+ }
295
307
  }
296
308
  }
297
309
  const scores = await calculateAllMi(sourceList);
@@ -727,7 +739,3 @@ function validateOptions({ kind, scopeFiles, fullScope, writePath }) {
727
739
  );
728
740
  }
729
741
  }
730
-
731
- // Exposed for the lint/test invariant (Task #2208) so the guard can list
732
- // every kind the service is contracted to dispatch without re-deriving it.
733
- export const REFRESH_SERVICE_SUPPORTED_KINDS = SUPPORTED_KINDS;
@@ -38,23 +38,3 @@ export function getBaselines(config) {
38
38
  maintainability: merge('maintainability'),
39
39
  };
40
40
  }
41
-
42
- /**
43
- * Legacy entry point retained for backward-compatible imports. Story #1737
44
- * retired the standalone `baselines.*` block; this helper now translates
45
- * `{ lint: { path }, ... }` input onto synthetic `gates.<tier>.baselinePath`.
46
- *
47
- * @param {object|undefined} userBlock
48
- */
49
- export function resolveBaselines(userBlock) {
50
- const block = userBlock ?? {};
51
- return getBaselines({
52
- quality: {
53
- gates: {
54
- lint: { baselinePath: block.lint?.path },
55
- crap: { baselinePath: block.crap?.path },
56
- maintainability: { baselinePath: block.maintainability?.path },
57
- },
58
- },
59
- });
60
- }
@@ -112,8 +112,6 @@ const KEY_MEANINGS = Object.freeze({
112
112
  'Allowlist of events that fire a webhook notification.',
113
113
 
114
114
  // planning.*
115
- 'planning.maxTickets':
116
- 'Upper bound on tickets a single /plan run may create.',
117
115
  'planning.context.maxBytes':
118
116
  'Byte budget for the planning-context payload before summary mode kicks in.',
119
117
  'planning.context.summaryMode':
@@ -2,10 +2,9 @@
2
2
  * Limits/budgets/signals accessors (Epic #1720 Story #1739 — top-level reshape).
3
3
  *
4
4
  * Pre-reshape, every runtime ceiling lived under the legacy `agentSettings.limits.*` bag.
5
- * Post-reshape, the surviving keys are split across `planning.*` and
6
- * `delivery.*`:
5
+ * Post-reshape, the surviving operator-configurable keys are split across
6
+ * `planning.*` and `delivery.*`:
7
7
  *
8
- * - `planning.maxTickets` (decomposer ceiling)
9
8
  * - `planning.context.{maxBytes, summaryMode}` (planning-context budget)
10
9
  * - `delivery.maxTokenBudget` (task-prompt hydration cap)
11
10
  * - `delivery.execution.timeoutMs` (per-process execution timeout)
@@ -13,6 +12,14 @@
13
12
  * - `delivery.signals.{hotspot, rework, retry}` (performance-signal
14
13
  * detector thresholds — `churn` and `idle` dropped)
15
14
  *
15
+ * `maxTickets` (the decomposer reviewability budget) is a **framework
16
+ * constant** — Story #4163 collapsed the never-overridden
17
+ * `planning.maxTickets` operator knob to `LIMITS_DEFAULTS.maxTickets` and
18
+ * removed it from the AJV schema, the published mirror, and the explain
19
+ * map. The persist-time over-budget gate (ADR-20260610) still reads the
20
+ * constant via `getLimits(config).maxTickets`; `resolveLimits` no longer
21
+ * reads `planning.maxTickets`, so setting it in a config is inert.
22
+ *
16
23
  * Dropped entirely: `maxInstructionSteps`, `friction.*` (the LLM
17
24
  * self-pacing thresholds rewritten as qualitative prose in
18
25
  * `.agents/instructions.md`), `executionMaxBuffer` (now a framework-internal
@@ -55,7 +62,7 @@ export const LEASE_TTL_MS_DEFAULT = 900000;
55
62
  * per-complexity budget branch anywhere in the resolver.
56
63
  */
57
64
  export const LIMITS_DEFAULTS = Object.freeze({
58
- maxTickets: 60,
65
+ maxTickets: 80,
59
66
  maxTokenBudget: 300000,
60
67
  executionTimeoutMs: 600000,
61
68
  leaseTtlMs: LEASE_TTL_MS_DEFAULT,
@@ -91,9 +98,10 @@ function mergeSignals(userSignals) {
91
98
  /**
92
99
  * Resolve the surviving limits surface against a `.agentrc.json` shape
93
100
  * (post-reshape). Accepts the resolved-config wrapper or a partial bag —
94
- * pulls `maxTickets` and `planningContext` from `planning.*`, pulls
95
- * `maxTokenBudget` and `executionTimeoutMs` from `delivery.*`, pulls
96
- * signals from `delivery.signals.*`.
101
+ * `maxTickets` is the framework constant `LIMITS_DEFAULTS.maxTickets`
102
+ * (no longer operator-configurable; Story #4163), pulls `planningContext`
103
+ * from `planning.*`, pulls `maxTokenBudget` and `executionTimeoutMs` from
104
+ * `delivery.*`, pulls signals from `delivery.signals.*`.
97
105
  *
98
106
  * @param {object|undefined} config
99
107
  * @returns {{
@@ -125,7 +133,10 @@ export function resolveLimits(config) {
125
133
  const lease =
126
134
  delivery.lease && typeof delivery.lease === 'object' ? delivery.lease : {};
127
135
  return {
128
- maxTickets: planning.maxTickets ?? LIMITS_DEFAULTS.maxTickets,
136
+ // `maxTickets` is a framework constant (Story #4163) — never read from
137
+ // `planning.maxTickets`. The persist-time over-budget gate still reads
138
+ // this value via getLimits().maxTickets.
139
+ maxTickets: LIMITS_DEFAULTS.maxTickets,
129
140
  maxTokenBudget: delivery.maxTokenBudget ?? LIMITS_DEFAULTS.maxTokenBudget,
130
141
  executionTimeoutMs:
131
142
  execution.timeoutMs ?? LIMITS_DEFAULTS.executionTimeoutMs,
@@ -56,8 +56,6 @@
56
56
  import { execFileSync } from 'node:child_process';
57
57
  import path from 'node:path';
58
58
 
59
- let _resolveConfig;
60
-
61
59
  /**
62
60
  * Cache the resolved main-checkout root per spawn cwd so the
63
61
  * `git rev-parse` shell-out runs at most once per distinct working
@@ -136,20 +134,6 @@ function anchorTempRoot(tempRoot) {
136
134
  return root ? path.join(root, tempRoot) : tempRoot;
137
135
  }
138
136
 
139
- /**
140
- * Lazy import of `resolveConfig` to side-step a circular module graph
141
- * (`config-resolver.js` re-exports from this directory and importing it
142
- * eagerly would resolve `temp-paths.js` before `lib/config/limits.js` is
143
- * ready). The resolver itself caches per-root, so the inner call is cheap.
144
- */
145
- async function getResolveConfig() {
146
- if (!_resolveConfig) {
147
- const mod = await import('../config-resolver.js');
148
- _resolveConfig = mod.resolveConfig;
149
- }
150
- return _resolveConfig;
151
- }
152
-
153
137
  /**
154
138
  * Synchronous tempRoot extraction. Accepts the canonical full resolved
155
139
  * config (`{ project, ... }`) and reads `project.paths.tempRoot`.
@@ -172,21 +156,6 @@ export function tempRootFrom(config) {
172
156
  : 'temp';
173
157
  }
174
158
 
175
- /**
176
- * Async tempRoot resolver. When the caller cannot pass a config bag, this
177
- * loads the project's `.agentrc.json` via `resolveConfig` (cached per
178
- * root). Most `.agents/scripts` consumers should prefer the synchronous
179
- * variant by threading their already-resolved config through.
180
- *
181
- * @param {{ cwd?: string }} [opts]
182
- * @returns {Promise<string>}
183
- */
184
- export async function tempRootAsync(opts) {
185
- const resolveConfig = await getResolveConfig();
186
- const resolved = resolveConfig({ cwd: opts?.cwd });
187
- return tempRootFrom(resolved);
188
- }
189
-
190
159
  const epicId = (id) => {
191
160
  if (!Number.isInteger(id) || id <= 0) {
192
161
  throw new Error(
@@ -249,7 +249,7 @@ const CODEBASE_SNAPSHOT_SCHEMA = {
249
249
  /**
250
250
  * `planning.taskSizing` — Story-sizing thresholds consumed by
251
251
  * `ticket-validator-sizing.js`. Operator overrides shallow-merge with
252
- * `DEFAULT_TASK_SIZING` defaults (softFiles 8, hardFiles 30,
252
+ * `DEFAULT_TASK_SIZING` defaults (softFiles 15, hardFiles 30,
253
253
  * maxAcceptance 14, softAcceptanceCount 10 — the uniform relaxed profile
254
254
  * from Story #3874). Story #3760 collapsed the per-profile matrix and the
255
255
  * parallel `testSurface` axis into a flat set of knobs; the `sizingProfile`
@@ -271,7 +271,6 @@ const PLANNING_SCHEMA = {
271
271
  type: 'object',
272
272
  properties: {
273
273
  riskHeuristics: LIST_OR_EXTENDER_OF_STRINGS,
274
- maxTickets: { type: 'integer', minimum: 1 },
275
274
  context: PLANNING_CONTEXT_SCHEMA,
276
275
  codebaseSnapshot: CODEBASE_SNAPSHOT_SCHEMA,
277
276
  taskSizing: TASK_SIZING_SCHEMA,
@@ -13,6 +13,10 @@ import { scanDirectory } from './maintainability-utils.js';
13
13
  import { resolveTsTranspilerVersion, transpileIfNeeded } from './transpile.js';
14
14
 
15
15
  const CRAP_WORKER_URL = new URL('./workers/crap-worker.js', import.meta.url);
16
+ const COMBINED_MI_CRAP_WORKER_URL = new URL(
17
+ './workers/combined-mi-crap-worker.js',
18
+ import.meta.url,
19
+ );
16
20
 
17
21
  // Pool-vs-serial cutover — single-sourced in cpu-pool.js (see the
18
22
  // POOL_SERIAL_THRESHOLD docstring for the tuning rationale).
@@ -472,3 +476,280 @@ async function scoreFilesViaPool(queue, coverage) {
472
476
  return { item, result: r };
473
477
  });
474
478
  }
479
+
480
+ /**
481
+ * In-process combined scorer: parse `abs` exactly once via `analyzeOnce` and
482
+ * derive BOTH the module MI score and the per-method CRAP rows. The reference
483
+ * implementation for the combined worker, used directly below
484
+ * `SERIAL_THRESHOLD` (matching the serial fast paths of `calculateAll` and
485
+ * `scanAndScore`).
486
+ *
487
+ * Return shape mirrors `combined-mi-crap-worker.js`:
488
+ * - `miScore` — `null` on read failure (MI dropped by the host), `0` on
489
+ * transpile-null / parse-error (parity with `calculateForFile` /
490
+ * `calculateForSource`), otherwise the module maintainability index.
491
+ * - `crapRows` — `null` on read/transpile/parse failure (CRAP drops the
492
+ * file), `[]` when coverage-skipped, otherwise the scored method rows.
493
+ * - `skippedFileNoCoverage` / `skippedMethodsNoCoverage` — CRAP counters.
494
+ */
495
+ function scoreFileCombinedSerial({ abs, relPath, requireCoverage }, coverage) {
496
+ const entry = findCoverageEntry(coverage, relPath);
497
+ let source;
498
+ try {
499
+ source = fs.readFileSync(abs, 'utf-8');
500
+ } catch {
501
+ return {
502
+ relPath,
503
+ miScore: null,
504
+ skippedFileNoCoverage: false,
505
+ crapRows: null,
506
+ skippedMethodsNoCoverage: 0,
507
+ };
508
+ }
509
+ const prepared = transpileIfNeeded(abs, source);
510
+ if (prepared === null) {
511
+ return {
512
+ relPath,
513
+ miScore: 0,
514
+ skippedFileNoCoverage: false,
515
+ crapRows: null,
516
+ skippedMethodsNoCoverage: 0,
517
+ };
518
+ }
519
+ const {
520
+ miScore,
521
+ crapRows: rawCrapRows,
522
+ parseError,
523
+ } = analyzeOnce(prepared, entry);
524
+ if (parseError) {
525
+ return {
526
+ relPath,
527
+ miScore: 0,
528
+ skippedFileNoCoverage: false,
529
+ crapRows: null,
530
+ skippedMethodsNoCoverage: 0,
531
+ };
532
+ }
533
+ if (requireCoverage && entry === null) {
534
+ return {
535
+ relPath,
536
+ miScore,
537
+ skippedFileNoCoverage: true,
538
+ crapRows: [],
539
+ skippedMethodsNoCoverage: 0,
540
+ };
541
+ }
542
+ const crapRows = [];
543
+ let skippedMethodsNoCoverage = 0;
544
+ for (const mr of rawCrapRows) {
545
+ if (mr.crap === null || mr.coverage === null) {
546
+ skippedMethodsNoCoverage += 1;
547
+ continue;
548
+ }
549
+ crapRows.push({
550
+ method: mr.method,
551
+ startLine: mr.startLine,
552
+ cyclomatic: mr.cyclomatic,
553
+ coverage: mr.coverage,
554
+ crap: mr.crap,
555
+ });
556
+ }
557
+ return {
558
+ relPath,
559
+ miScore,
560
+ skippedFileNoCoverage: false,
561
+ crapRows,
562
+ skippedMethodsNoCoverage,
563
+ };
564
+ }
565
+
566
+ async function scoreFilesCombinedViaPool(queue, coverage) {
567
+ const enrichedQueue = queue.map((item) => ({
568
+ ...item,
569
+ coverageEntry: findCoverageEntry(coverage, item.relPath),
570
+ }));
571
+ const results = await runOnPool(COMBINED_MI_CRAP_WORKER_URL, enrichedQueue, {
572
+ workerData: {},
573
+ });
574
+ return results.map((r, i) => {
575
+ const item = queue[i];
576
+ if (!r || r.__cpuPoolError) {
577
+ Logger.warn(
578
+ `[crap-utils] combined worker pool error for ${item.relPath}: ${r?.message ?? 'unknown'}`,
579
+ );
580
+ return { item, result: null };
581
+ }
582
+ return { item, result: r };
583
+ });
584
+ }
585
+
586
+ /**
587
+ * Combined MI + CRAP single-pass scan. Walks the shared `targetDirs` once (or
588
+ * reuses `preScannedFiles`), dispatches every file through ONE worker that
589
+ * calls `analyzeOnce` a single time, and returns BOTH the maintainability
590
+ * score map and the CRAP scan result.
591
+ *
592
+ * This collapses the two independent escomplex passes the full-tree baseline
593
+ * regenerator used to run (`calculateAll` → maintainability worker, then
594
+ * `scanAndScore` → CRAP worker) into one parse per file. The outputs are
595
+ * shaped to be drop-in equivalents of the two passes they replace, so the
596
+ * downstream envelope projection + writer logic stays byte-identical:
597
+ *
598
+ * - `miScores` — `Record<relPath, number>` keyed exactly as `calculateAll`
599
+ * keys its result (`path.relative(cwd, abs)`, POSIX-normalised), with
600
+ * read-failure files (`miScore === null`) dropped. Parity target:
601
+ * `calculateAll(files)`.
602
+ * - `crap` — `{ rows, scannedFiles, skippedFilesNoCoverage,
603
+ * skippedMethodsNoCoverage }`, identical in shape and content to
604
+ * `scanAndScore({ targetDirs, coverage, ... })`. The rows are
605
+ * CRAP-sorted (file → startLine → method) so the result matches
606
+ * `scanAndScore` even before the writer re-sorts.
607
+ *
608
+ * The `requireCoverage`, `scopeFiles`, `ignoreGlobs`, and `preScannedFiles`
609
+ * semantics match `scanAndScore` exactly — coverage gating, scope filtering,
610
+ * and the single-walk reuse path behave the same. Files dropped from CRAP by
611
+ * the coverage gate STILL contribute their MI score (the MI pass never
612
+ * required coverage), preserving the two-pass behaviour where MI scores every
613
+ * file in the target dirs.
614
+ *
615
+ * @param {{
616
+ * targetDirs: string[],
617
+ * coverage: object|null,
618
+ * requireCoverage?: boolean,
619
+ * cwd?: string,
620
+ * scopeFiles?: Set<string>|string[]|null,
621
+ * ignoreGlobs?: string[],
622
+ * preScannedFiles?: string[]|null,
623
+ * }} params
624
+ * @returns {Promise<{
625
+ * miScores: Record<string, number>,
626
+ * crap: {
627
+ * rows: Array<{
628
+ * file: string, method: string, startLine: number,
629
+ * cyclomatic: number, coverage: number, crap: number,
630
+ * }>,
631
+ * scannedFiles: number,
632
+ * skippedFilesNoCoverage: number,
633
+ * skippedMethodsNoCoverage: number,
634
+ * },
635
+ * }>}
636
+ */
637
+ export async function scanAndScoreCombined({
638
+ targetDirs,
639
+ coverage,
640
+ requireCoverage = true,
641
+ cwd = process.cwd(),
642
+ scopeFiles = null,
643
+ ignoreGlobs = [],
644
+ preScannedFiles = null,
645
+ }) {
646
+ if (!Array.isArray(targetDirs)) {
647
+ throw new TypeError('scanAndScoreCombined: targetDirs must be an array');
648
+ }
649
+ const scopeSet =
650
+ scopeFiles == null
651
+ ? null
652
+ : scopeFiles instanceof Set
653
+ ? scopeFiles
654
+ : new Set(scopeFiles);
655
+
656
+ // Single directory walk (or reuse the caller's pre-walked list), mirroring
657
+ // scanAndScore so the file discovery is byte-identical between paths.
658
+ const files = preScannedFiles != null ? [...preScannedFiles] : [];
659
+ if (preScannedFiles == null) {
660
+ for (const dir of targetDirs) {
661
+ const abs = path.isAbsolute(dir) ? dir : path.resolve(cwd, dir);
662
+ scanDirectory(abs, files, { cwd, ignoreGlobs });
663
+ }
664
+ }
665
+ files.sort();
666
+
667
+ // Build the work queue. Each item carries both the canonicalised relPath
668
+ // (CRAP's key + scope filter, matching scanAndScore) and the raw relPath
669
+ // (MI's key, matching calculateAll's `path.relative(cwd, p)` shape).
670
+ const queue = [];
671
+ for (const abs of files) {
672
+ const rawRel = path.relative(cwd, abs).replace(/\\/g, '/');
673
+ const relPath = canonicalisePath(rawRel);
674
+ if (scopeSet && !scopeSet.has(relPath)) continue;
675
+ queue.push({ abs, relPath, miRel: rawRel, requireCoverage });
676
+ }
677
+ const scannedFiles = queue.length;
678
+
679
+ const perFile =
680
+ queue.length < SERIAL_THRESHOLD
681
+ ? queue.map((item) => ({
682
+ item,
683
+ result: scoreFileCombinedSerial(item, coverage),
684
+ }))
685
+ : await scoreFilesCombinedViaPool(queue, coverage);
686
+
687
+ // MI assembly — mirror calculateAll: drop read-failure files (miScore
688
+ // null), key by the raw relative path, then sort ascending so the returned
689
+ // object is insertion-order-stable.
690
+ const miEntries = [];
691
+ // CRAP assembly — mirror scanAndScore: file-level skip counter, drop
692
+ // read/transpile/parse failures, accumulate method rows.
693
+ const crapRows = [];
694
+ let skippedFilesNoCoverage = 0;
695
+ let skippedMethodsNoCoverage = 0;
696
+
697
+ for (const { item, result } of perFile) {
698
+ if (!result) continue; // unrecoverable per-file failure: drop silently
699
+
700
+ // MI side.
701
+ if (result.miScore !== null) {
702
+ miEntries.push({ relPath: item.miRel, score: result.miScore });
703
+ }
704
+
705
+ // CRAP side.
706
+ if (result.skippedFileNoCoverage) {
707
+ skippedFilesNoCoverage += 1;
708
+ continue;
709
+ }
710
+ if (result.crapRows === null) {
711
+ if (result.error) {
712
+ Logger.warn(
713
+ `[crap-utils] failed to score ${item.relPath}: ${result.error}`,
714
+ );
715
+ }
716
+ continue;
717
+ }
718
+ skippedMethodsNoCoverage += result.skippedMethodsNoCoverage ?? 0;
719
+ for (const mr of result.crapRows) {
720
+ crapRows.push({
721
+ file: item.relPath,
722
+ method: mr.method,
723
+ startLine: mr.startLine,
724
+ cyclomatic: mr.cyclomatic,
725
+ coverage: mr.coverage,
726
+ crap: mr.crap,
727
+ });
728
+ }
729
+ }
730
+
731
+ miEntries.sort((a, b) =>
732
+ a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0,
733
+ );
734
+ const miScores = {};
735
+ for (const { relPath, score } of miEntries) {
736
+ miScores[relPath] = score;
737
+ }
738
+
739
+ crapRows.sort((a, b) => {
740
+ if (a.file !== b.file) return a.file < b.file ? -1 : 1;
741
+ if (a.startLine !== b.startLine) return a.startLine - b.startLine;
742
+ if (a.method !== b.method) return a.method < b.method ? -1 : 1;
743
+ return 0;
744
+ });
745
+
746
+ return {
747
+ miScores,
748
+ crap: {
749
+ rows: crapRows,
750
+ scannedFiles,
751
+ skippedFilesNoCoverage,
752
+ skippedMethodsNoCoverage,
753
+ },
754
+ };
755
+ }
@@ -36,6 +36,36 @@ const IGNORED_DIRS = new Set([
36
36
  '.next',
37
37
  ]);
38
38
 
39
+ /**
40
+ * Test whether an absolute (or repo-relative) file path matches any of the
41
+ * configured `ignoreGlobs`. This is the single source of truth for how the
42
+ * maintainability scorer decides a file is ignored: both the full-scope
43
+ * directory walk (`scanDirectory` below) and the diff-scope file-list path
44
+ * in `refresh-service.js` MUST funnel through it so an `ignoreGlobs`-listed
45
+ * file is excluded identically in both scopes (a diff-scope refresh that
46
+ * skipped this check would let an ignored file poison the `rollup["*"].min`
47
+ * floor — see `buildDefaultMaintainabilityScorer`).
48
+ *
49
+ * Matching mirrors `scanDirectory`: the path is reduced to a canonicalised,
50
+ * POSIX, repo-relative form and tested against each glob with minimatch's
51
+ * `{ dot: true }` so dot-prefixed roots like `.agents/` match.
52
+ *
53
+ * @param {string} filePath absolute or relative path to the source file
54
+ * @param {string[]} ignoreGlobs minimatch patterns; empty/absent is a no-op
55
+ * @param {string} [cwd] root for repo-relative resolution; defaults to cwd
56
+ * @returns {boolean} true when the file matches at least one ignore glob
57
+ */
58
+ export function isIgnoredByGlobs(filePath, ignoreGlobs = [], cwd) {
59
+ if (!Array.isArray(ignoreGlobs) || ignoreGlobs.length === 0) return false;
60
+ const matchCwd = cwd ?? process.cwd();
61
+ const absFilePath = path.isAbsolute(filePath)
62
+ ? filePath
63
+ : path.resolve(matchCwd, filePath);
64
+ const rawRel = path.relative(matchCwd, absFilePath).replace(/\\/g, '/');
65
+ const relPath = canonicalisePath(rawRel);
66
+ return ignoreGlobs.some((g) => minimatch(relPath, g, { dot: true }));
67
+ }
68
+
39
69
  /**
40
70
  * Recursively scans a directory for JS/TS source files. Accepts `.js`,
41
71
  * `.mjs`, `.cjs`, `.ts`, `.tsx`, `.mts`, and `.cts`. Directories listed
@@ -71,15 +101,8 @@ export function scanDirectory(dir, fileList = [], opts = {}) {
71
101
  scanDirectory(filePath, fileList, opts);
72
102
  }
73
103
  } else if (entry.isFile() && isSupportedSourceFile(entry.name)) {
74
- if (ignoreGlobs.length > 0) {
75
- const absFilePath = path.isAbsolute(filePath)
76
- ? filePath
77
- : path.resolve(filePath);
78
- const rawRel = path.relative(matchCwd, absFilePath).replace(/\\/g, '/');
79
- const relPath = canonicalisePath(rawRel);
80
- if (ignoreGlobs.some((g) => minimatch(relPath, g, { dot: true }))) {
81
- continue;
82
- }
104
+ if (isIgnoredByGlobs(filePath, ignoreGlobs, matchCwd)) {
105
+ continue;
83
106
  }
84
107
  fileList.push(filePath);
85
108
  }
@@ -27,8 +27,6 @@ import { buildManifest } from './manifest-builder.js';
27
27
  import { STATE_LABELS } from './ticketing.js';
28
28
 
29
29
  export const AGENT_DONE_LABEL = STATE_LABELS.DONE;
30
- export const AGENT_EXECUTING_LABEL = STATE_LABELS.EXECUTING;
31
- export const AGENT_READY_LABEL = STATE_LABELS.READY;
32
30
 
33
31
  /* node:coverage ignore next */
34
32
  export function ensureBranch(branchName, baseBranch) {
@@ -105,7 +105,12 @@ export function epicBranchHasOpenPr(opts) {
105
105
  /**
106
106
  * Build the list of branches owned by the Epic from the checkpoint.
107
107
  *
108
- * @param {{ epicId: number, waves?: Array<{ stories?: Array<{ id: number }> }> } | null} state
108
+ * Story #4155 the ready-set runtime records a flat per-Story status map
109
+ * (`stories: { [storyId]: { status, ... } }`) on the checkpoint instead of a
110
+ * per-wave `waves[]` history. The owned Story branches are the keys of that
111
+ * map.
112
+ *
113
+ * @param {{ epicId: number, stories?: Record<string, object> } | null} state
109
114
  * @returns {{ epicBranch: string, storyBranches: string[] }}
110
115
  */
111
116
  export function listEpicBranchesFromState(state) {
@@ -113,13 +118,12 @@ export function listEpicBranchesFromState(state) {
113
118
  if (!Number.isInteger(epicId) || epicId <= 0) {
114
119
  return { epicBranch: null, storyBranches: [] };
115
120
  }
121
+ const storyMap =
122
+ state?.stories && typeof state.stories === 'object' ? state.stories : {};
116
123
  const storyIds = new Set();
117
- for (const wave of state.waves ?? []) {
118
- for (const story of wave?.stories ?? []) {
119
- if (story && Number.isInteger(story.id) && story.id > 0) {
120
- storyIds.add(story.id);
121
- }
122
- }
124
+ for (const key of Object.keys(storyMap)) {
125
+ const id = Number(key);
126
+ if (Number.isInteger(id) && id > 0) storyIds.add(id);
123
127
  }
124
128
  return {
125
129
  epicBranch: `epic/${epicId}`,
@@ -89,13 +89,13 @@ async function runEmitContextPath({ epicId, provider, config, values }) {
89
89
  const ctx = await buildDecompositionContext(epicId, provider, config, {
90
90
  fullContext: values['full-context'],
91
91
  });
92
- // Surface the resolved budget on stderr so a misconfigured `.agentrc.json`
93
- // (e.g. flat-key `maxTickets` instead of grouped `planning.maxTickets`) is
94
- // visible to the operator. The decomposer prompt embeds the same value.
95
- // Story #2798 — language changed from "prompt cap" to "reviewability
96
- // budget" to match the new prompt/skill contract.
92
+ // Surface the resolved reviewability budget on stderr so the operator
93
+ // sees the same value the decomposer prompt embeds. `maxTickets` is a
94
+ // framework constant (LIMITS_DEFAULTS.maxTickets), no longer an
95
+ // operator-configurable key. Story #2798 — language is "reviewability
96
+ // budget", not "prompt cap".
97
97
  Logger.error(
98
- `[epic-plan-decompose] Resolved planning.maxTickets = ${ctx.maxTickets} (reviewability budget).`,
98
+ `[epic-plan-decompose] Resolved maxTickets reviewability budget = ${ctx.maxTickets} (framework constant).`,
99
99
  );
100
100
  const json = values.pretty
101
101
  ? JSON.stringify(ctx, null, 2)
@@ -22,9 +22,9 @@ import { applyBudget } from '../../planning-context-budget.js';
22
22
 
23
23
  export function buildDecomposerSystemPrompt(
24
24
  heuristics = [],
25
- { maxTickets } = {},
25
+ { maxTickets, maxTokenBudget } = {},
26
26
  ) {
27
- const base = renderDecomposerSystemPrompt({ maxTickets });
27
+ const base = renderDecomposerSystemPrompt({ maxTickets, maxTokenBudget });
28
28
  const heuristicsStr =
29
29
  heuristics.length > 0
30
30
  ? `### RISK HEURISTICS (planning metadata if any apply):\n- ${heuristics.join('\n- ')}`
@@ -115,9 +115,13 @@ export async function buildDecompositionContext(
115
115
  const heuristics = resolveHeuristics(config);
116
116
  const limits = getLimits(config);
117
117
  const maxTickets = limits.maxTickets;
118
+ const maxTokenBudget = limits.maxTokenBudget;
118
119
  const planningLimits = limits.planningContext;
119
120
  const { fullContext = false } = opts;
120
- const systemPrompt = buildDecomposerSystemPrompt(heuristics, { maxTickets });
121
+ const systemPrompt = buildDecomposerSystemPrompt(heuristics, {
122
+ maxTickets,
123
+ maxTokenBudget,
124
+ });
121
125
 
122
126
  const budgeted = applyBudget(
123
127
  [
@@ -137,8 +141,10 @@ export async function buildDecompositionContext(
137
141
  maxTickets,
138
142
  // Story #3875 — surface the real delivery envelope to the decomposer
139
143
  // so Stories are sized against the hydration budget and the
140
- // configured preflight ceilings rather than guessed.
141
- maxTokenBudget: limits.maxTokenBudget,
144
+ // configured preflight ceilings rather than guessed. Story #4162 also
145
+ // threads this value into the rendered systemPrompt above as a sizing
146
+ // input so the prompt itself names the budget.
147
+ maxTokenBudget,
142
148
  preflightCeilings: resolvePreflightCeilings(config),
143
149
  contextMode: budgeted.mode,
144
150
  // Story #2801 — surface the Phase 7 planning decision so the