mandrel 1.69.0 → 1.71.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 (57) hide show
  1. package/.agents/README.md +7 -7
  2. package/.agents/docs/SDLC.md +4 -5
  3. package/.agents/docs/configuration.md +9 -9
  4. package/.agents/docs/workflows.md +4 -6
  5. package/.agents/schemas/qa-finding.schema.json +1 -1
  6. package/.agents/scripts/apply-quality-bootstrap.js +79 -0
  7. package/.agents/scripts/audit-labels-bootstrap.js +52 -30
  8. package/.agents/scripts/audit-to-stories.js +54 -0
  9. package/.agents/scripts/bootstrap.js +13 -3
  10. package/.agents/scripts/generate-config-docs.js +189 -94
  11. package/.agents/scripts/lib/audit-suite/findings.js +0 -4
  12. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +99 -0
  13. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +13 -5
  14. package/.agents/scripts/lib/baseline-snapshot.js +163 -4
  15. package/.agents/scripts/lib/baselines/refresh-service.js +0 -4
  16. package/.agents/scripts/lib/bootstrap/ci-workflow-template.js +1 -1
  17. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +1 -1
  18. package/.agents/scripts/lib/config/baselines.js +0 -20
  19. package/.agents/scripts/lib/config/defaults.js +1 -1
  20. package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
  21. package/.agents/scripts/lib/config/temp-paths.js +0 -31
  22. package/.agents/scripts/lib/config-resolver.js +1 -1
  23. package/.agents/scripts/lib/crap-utils.js +281 -0
  24. package/.agents/scripts/lib/orchestration/dispatch-engine.js +0 -2
  25. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +0 -84
  26. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +3 -4
  27. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -4
  28. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +101 -70
  29. package/.agents/scripts/lib/orchestration/spec-renderer.js +42 -14
  30. package/.agents/scripts/lib/orchestration/ticket-lease.js +3 -0
  31. package/.agents/scripts/lib/qa/qa-context-hydrator.js +1 -1
  32. package/.agents/scripts/lib/qa/resolve-qa-contract.js +1 -1
  33. package/.agents/scripts/lib/story-body/story-body.js +110 -65
  34. package/.agents/scripts/lib/test-tiers.js +13 -7
  35. package/.agents/scripts/lib/wave-runner/tick.js +177 -53
  36. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +226 -0
  37. package/.agents/scripts/mandrel-update-preflight.js +235 -0
  38. package/.agents/scripts/providers/github/issues.js +48 -0
  39. package/.agents/scripts/providers/github.js +1 -0
  40. package/.agents/scripts/sync-agentrc.js +2 -2
  41. package/.agents/skills/skills.index.json +2 -2
  42. package/.agents/skills/stack/qa/playwright-bdd/SKILL.md +3 -3
  43. package/.agents/skills/stack/qa/qa-harness/SKILL.md +4 -4
  44. package/.agents/workflows/git-deliver.md +298 -0
  45. package/.agents/workflows/helpers/epic-testing.md +6 -6
  46. package/.agents/workflows/helpers/{agents-sync-config.md → mandrel-sync-config.md} +5 -4
  47. package/.agents/workflows/{agents-update.md → mandrel-update.md} +210 -33
  48. package/.agents/workflows/qa-explore.md +1 -1
  49. package/.agents/workflows/{qa-run-harness.md → qa-run.md} +5 -5
  50. package/README.md +40 -0
  51. package/docs/CHANGELOG.md +43 -0
  52. package/lib/cli/registry.js +49 -6
  53. package/lib/cli/update.js +335 -332
  54. package/package.json +16 -11
  55. package/.agents/workflows/git-commit-all.md +0 -15
  56. package/.agents/workflows/git-pr-all.md +0 -281
  57. package/.agents/workflows/git-push.md +0 -63
@@ -242,7 +242,7 @@ export function ensureQualityNpmScripts(ctx) {
242
242
  * underneath the project config at read time, so a key whose intended
243
243
  * value equals the framework default would be written redundantly — and
244
244
  * would then be flagged `[REDUNDANT]` by the sync-agentrc helper on the
245
- * next /agents-update. Default-aware seeding keeps the two helpers from
245
+ * next /mandrel-update. Default-aware seeding keeps the two helpers from
246
246
  * contradicting each other.
247
247
  *
248
248
  * Returns `{ merged, addedKeys[] }` so the caller can report exactly
@@ -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
- }
@@ -9,7 +9,7 @@
9
9
  * (tests/config/full-agentrc-runtime-parity.test.js) keeps the two in
10
10
  * lockstep.
11
11
  *
12
- * Story #1995: the `/agents-update` sync helper consults this module
12
+ * Story #1995: the `/mandrel-update` sync helper consults this module
13
13
  * (not the template directly) to decide whether a project value is
14
14
  * "just the default" and therefore safe to omit from `.agentrc.json`.
15
15
  *
@@ -16,7 +16,7 @@
16
16
  * untouched. Operators who want a leaner config can prune the
17
17
  * redundant keys by hand.
18
18
  *
19
- * Outcome: after `/agents-update`, `.agentrc.json` contains only keys
19
+ * Outcome: after `/mandrel-update`, `.agentrc.json` contains only keys
20
20
  * that materially diverge from framework defaults, plus the
21
21
  * consumer-identity keys with no sensible framework default (owner,
22
22
  * repo, operatorHandle).
@@ -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(
@@ -199,7 +199,7 @@ function applyDefaults(raw) {
199
199
  // `#/$defs/qa`). It needs no default-layering — the harness resolver
200
200
  // (`resolveQaContract`) owns normalization and required-field
201
201
  // enforcement — it only needs to survive the reshape so
202
- // `/qa-run-harness` Step 0 can read it off the resolved wrapper.
202
+ // `/qa-run` Step 0 can read it off the resolved wrapper.
203
203
  ...(raw.qa !== undefined ? { qa: raw.qa } : {}),
204
204
  };
205
205
  }
@@ -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
+ }
@@ -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) {
@@ -37,20 +37,6 @@ export function escapePipes(s) {
37
37
  return String(s).replace(/\|/g, '\\|');
38
38
  }
39
39
 
40
- /**
41
- * Render a millisecond duration as a compact human-readable string. Used
42
- * in the wave-elapsed header to keep the snapshot tight.
43
- */
44
- export function formatElapsed(ms) {
45
- const s = Math.max(0, Math.floor(ms / 1000));
46
- const h = Math.floor(s / 3600);
47
- const m = Math.floor((s % 3600) / 60);
48
- const sec = s % 60;
49
- if (h) return `${h}h ${m}m`;
50
- if (m) return `${m}m ${sec}s`;
51
- return `${sec}s`;
52
- }
53
-
54
40
  /**
55
41
  * Derive the high-level state classification for a single ticket. Reads
56
42
  * the canonical `agent::*` label set first, then falls back to the GitHub
@@ -69,35 +55,6 @@ export function deriveState(ticket, AGENT_LABELS) {
69
55
  return 'unknown';
70
56
  }
71
57
 
72
- /**
73
- * Build the markdown table for the per-poll progress snapshot. Switches
74
- * between a 3-column (ID / State / Title) and a 4-column (Wave / ID /
75
- * State / Title) form depending on whether any row carries a wave index —
76
- * the wider form fires when the reporter has a plan set and is rendering
77
- * every wave instead of just the current one.
78
- */
79
- export function renderProgressTable(rows) {
80
- const includeWaveCol = rows.some((r) => Number.isInteger(r.wave));
81
- if (includeWaveCol) {
82
- return [
83
- '| Wave | ID | State | Title |',
84
- '|---|---|---|---|',
85
- ...rows.map(
86
- (r) =>
87
- `| ${r.wave + 1} | #${r.id} | ${STATE_EMOJI[r.state] ?? ''} ${r.state} | ${escapePipes(r.title)} |`,
88
- ),
89
- ].join('\n');
90
- }
91
- return [
92
- '| ID | State | Title |',
93
- '|---|---|---|',
94
- ...rows.map(
95
- (r) =>
96
- `| #${r.id} | ${STATE_EMOJI[r.state] ?? ''} ${r.state} | ${escapePipes(r.title)} |`,
97
- ),
98
- ].join('\n');
99
- }
100
-
101
58
  /**
102
59
  * Declarative descriptor table that drives the Notable bullet block. Each
103
60
  * descriptor names a row state, the emoji prefix to render, and the
@@ -196,47 +153,6 @@ export async function renderNotable({ rows, detectors = [], wave, logger }) {
196
153
  return items.join('\n');
197
154
  }
198
155
 
199
- /**
200
- * Compose the full per-poll snapshot body. Pure with respect to the
201
- * supplied state — the caller passes the resolved `rows`, the plan/wave
202
- * context, the optional aggregated phase-timings block, and the now()
203
- * clock; the renderer assembles header + table + Notable + phase-timings.
204
- */
205
- export async function renderProgressBody({
206
- rows,
207
- plan,
208
- currentWave,
209
- epicStartedAt,
210
- now,
211
- detectors,
212
- phaseSummariesBlock,
213
- logger,
214
- }) {
215
- const done = rows.filter((r) => r.state === 'done').length;
216
- const total = rows.length;
217
- const totalWaves = plan?.length ?? currentWave?.totalWaves ?? '?';
218
- const currentWaveNum = currentWave
219
- ? currentWave.index + 1
220
- : (plan?.length ?? '?');
221
- const waveLabel = `Wave ${currentWaveNum}/${totalWaves}`;
222
- const elapsedSrc = epicStartedAt ?? currentWave?.startedAt ?? null;
223
- const elapsed = elapsedSrc
224
- ? ` · ${formatElapsed(now() - new Date(elapsedSrc))} elapsed`
225
- : '';
226
-
227
- const header = `### 📊 Progress — ${waveLabel} · ${done}/${total} closed${elapsed}`;
228
- const table = renderProgressTable(rows);
229
- const notable = await renderNotable({
230
- rows,
231
- detectors,
232
- wave: currentWave,
233
- logger,
234
- });
235
- const parts = [header, '', table, '', '**Notable**', notable];
236
- if (phaseSummariesBlock) parts.push('', phaseSummariesBlock);
237
- return parts.join('\n');
238
- }
239
-
240
156
  /**
241
157
  * Render and upsert the rolled-up `epic-run-progress` comment on the Epic.
242
158
  *
@@ -67,10 +67,9 @@ export const PHASE_ORDER = [
67
67
  ];
68
68
 
69
69
  /**
70
- * Emoji prefix per high-level state. Shared between the per-poll table
71
- * (`composition.renderProgressTable`) and the rolled-up Epic table
72
- * (`composition.upsertEpicRunProgress`) so operators see the same icon
73
- * in both surfaces.
70
+ * Emoji prefix per high-level state. Consumed by the rolled-up Epic table
71
+ * (`composition.upsertEpicRunProgress`) so operators see a consistent icon
72
+ * per state across the progress surface.
74
73
  */
75
74
  export const STATE_EMOJI = {
76
75
  done: '✅',
@@ -353,7 +353,3 @@ export class TraceLogger {
353
353
  });
354
354
  }
355
355
  }
356
-
357
- export function createTraceLogger(opts) {
358
- return new TraceLogger(opts);
359
- }