mandrel 1.57.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 (131) 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/normalize-pr-title.js +241 -0
  71. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +1 -1
  72. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +16 -3
  73. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -4
  74. package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +32 -35
  75. package/.agents/scripts/lib/orchestration/skill-capsule-loader.js +1 -2
  76. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +451 -503
  77. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/pre-merge-attribution.js +8 -2
  78. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/refresh-commit.js +47 -2
  79. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +2 -2
  80. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +358 -54
  81. package/.agents/scripts/lib/orchestration/story-close/phases/close.js +1 -1
  82. package/.agents/scripts/lib/orchestration/story-close/phases/gates.js +3 -2
  83. package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +30 -3
  84. package/.agents/scripts/lib/orchestration/story-close/post-merge-close.js +5 -18
  85. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +3 -3
  86. package/.agents/scripts/lib/orchestration/story-close-recovery.js +33 -16
  87. package/.agents/scripts/lib/orchestration/story-reachability.js +47 -0
  88. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +2 -33
  89. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +42 -64
  90. package/.agents/scripts/lib/orchestration/ticketing/reads.js +9 -0
  91. package/.agents/scripts/lib/orchestration/ticketing/state.js +50 -436
  92. package/.agents/scripts/lib/orchestration/ticketing/transition.js +471 -0
  93. package/.agents/scripts/lib/orchestration/ticketing.js +0 -1
  94. package/.agents/scripts/lib/orchestration/wave-record-notifications.js +1 -1
  95. package/.agents/scripts/lib/orchestration/wave-record-projection.js +1 -7
  96. package/.agents/scripts/lib/project-root.js +17 -0
  97. package/.agents/scripts/lib/story-adjacency.js +76 -0
  98. package/.agents/scripts/lib/story-lifecycle.js +1 -1
  99. package/.agents/scripts/lib/transpile.js +93 -0
  100. package/.agents/scripts/lib/wave-runner/tick.js +4 -153
  101. package/.agents/scripts/lib/workers/crap-worker.js +1 -1
  102. package/.agents/scripts/lib/workers/maintainability-report-worker.js +1 -1
  103. package/.agents/scripts/lib/worktree/lifecycle/creation.js +20 -2
  104. package/.agents/scripts/lib/worktree/lifecycle/force-drain.js +90 -0
  105. package/.agents/scripts/lib/worktree/lifecycle/reap.js +26 -8
  106. package/.agents/scripts/lib/worktree/node-modules-strategy.js +74 -0
  107. package/.agents/scripts/providers/github/tickets.js +110 -6
  108. package/.agents/scripts/run-lint.js +9 -0
  109. package/.agents/scripts/run-tests.js +24 -4
  110. package/.agents/scripts/stories-wave-tick.js +8 -5
  111. package/.agents/scripts/story-init.js +149 -10
  112. package/.agents/scripts/sync-branch-from-base.js +1 -1
  113. package/.agents/skills/stack/qa/lighthouse-baseline/SKILL.md +1 -1
  114. package/.agents/workflows/audit-documentation.md +226 -0
  115. package/.agents/workflows/epic-deliver.md +16 -23
  116. package/.agents/workflows/epic-plan.md +1 -1
  117. package/.agents/workflows/helpers/epic-deliver-story.md +17 -28
  118. package/.agents/workflows/helpers/single-story-deliver.md +2 -1
  119. package/.agents/workflows/onboard.md +4 -3
  120. package/.agents/workflows/story-deliver.md +1 -1
  121. package/README.md +21 -8
  122. package/lib/cli/init.js +336 -0
  123. package/package.json +2 -1
  124. package/.agents/scripts/lib/auto-refresh-baselines.js +0 -308
  125. package/.agents/scripts/lib/close-validation.js +0 -897
  126. package/.agents/scripts/lib/orchestration/cascade-grouping.js +0 -275
  127. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter.js +0 -69
  128. package/.agents/scripts/lib/orchestration/story-close/format-autofix-scoped.js +0 -221
  129. package/.agents/scripts/lib/orchestration/story-close/format-autofix-shared.js +0 -123
  130. package/.agents/scripts/lib/task-utils.js +0 -26
  131. package/.agents/scripts/story-deliver-prepare.js +0 -267
@@ -6,22 +6,17 @@ import {
6
6
  coverageForMethodInEntry,
7
7
  findCoverageEntry,
8
8
  } from './coverage-utils.js';
9
- import { runOnPool } from './cpu-pool.js';
9
+ import { POOL_SERIAL_THRESHOLD, runOnPool } from './cpu-pool.js';
10
10
  import { crapFormula } from './crap-engine.js';
11
11
  import { Logger } from './Logger.js';
12
- import {
13
- resolveTsTranspilerVersion,
14
- scanDirectory,
15
- transpileIfNeeded,
16
- } from './maintainability-utils.js';
12
+ import { scanDirectory } from './maintainability-utils.js';
13
+ import { resolveTsTranspilerVersion, transpileIfNeeded } from './transpile.js';
17
14
 
18
15
  const CRAP_WORKER_URL = new URL('./workers/crap-worker.js', import.meta.url);
19
16
 
20
- // Below this batch size the pool's spawn overhead dominates — fall
21
- // back to in-process serial scoring. The full repo (~200+ files)
22
- // always takes the pool path; tests with handful-of-files fixtures
23
- // stay serial and remain byte-identical to the pre-pool output.
24
- const SERIAL_THRESHOLD = 8;
17
+ // Pool-vs-serial cutover single-sourced in cpu-pool.js (see the
18
+ // POOL_SERIAL_THRESHOLD docstring for the tuning rationale).
19
+ const SERIAL_THRESHOLD = POOL_SERIAL_THRESHOLD;
25
20
  // 1.1.0 — TypeScript support landed in 5.29.0. Bumped from 1.0.0 because
26
21
  // the scanner now emits CRAP rows for TS/TSX paths that the previous
27
22
  // kernel could never reach. The CRAP formula and per-method scoring
@@ -0,0 +1,87 @@
1
+ // .agents/scripts/lib/dynamic-workflow/documentation-report-contract.js
2
+ import { assertSectionsContract } from './report-contract-core.js';
3
+
4
+ /**
5
+ * The `audit-documentation` report contract (Story #4024).
6
+ *
7
+ * This is the **single source of truth** for the report shape that BOTH the
8
+ * sequential lens (`.agents/workflows/audit-documentation.md` Step 3) and the
9
+ * orchestrated dynamic-workflow path
10
+ * (`.claude/workflows/audit-documentation.workflow.js`) MUST emit to
11
+ * `{{auditOutputDir}}/audit-documentation-results.md`. Keeping it here lets
12
+ * the contract-tier test assert report conformance against one definition
13
+ * rather than re-deriving headings from prose in two places.
14
+ *
15
+ * @module dynamic-workflow/documentation-report-contract
16
+ */
17
+
18
+ /** The artifact filename the lens writes under `auditOutputDir`. */
19
+ export const REPORT_ARTIFACT_BASENAME = 'audit-documentation-results.md';
20
+
21
+ /**
22
+ * The required top-level (`##`) section headings, in document order, that the
23
+ * lens markdown's Step 3 template defines. A conformant report MUST contain
24
+ * each of these headings; the orchestrated path assembles its verified
25
+ * findings into exactly this skeleton.
26
+ */
27
+ export const REQUIRED_SECTIONS = Object.freeze([
28
+ 'Executive Summary',
29
+ 'Target Set Coverage',
30
+ 'Detailed Findings',
31
+ ]);
32
+
33
+ /** The H1 title the report opens with. */
34
+ export const REPORT_TITLE = 'Documentation Audit Report';
35
+
36
+ /**
37
+ * The required field labels inside each `### <finding>` block under
38
+ * `## Detailed Findings`. Mirrors the strict per-finding structure in the
39
+ * lens template.
40
+ */
41
+ export const FINDING_FIELDS = Object.freeze([
42
+ 'Category',
43
+ 'Impact',
44
+ 'Current State',
45
+ 'Recommendation & Rationale',
46
+ 'Agent Prompt',
47
+ ]);
48
+
49
+ /**
50
+ * The finding categories the per-finding `Category` field draws from. These
51
+ * mirror the lens's Step 3 template enumeration: deterministic-gate findings
52
+ * (Generator Drift, Link Integrity) plus semantic-verification findings
53
+ * (Broken Instruction, Stale Description, Missing Coverage).
54
+ */
55
+ export const FINDING_CATEGORIES = Object.freeze([
56
+ 'Broken Instruction',
57
+ 'Stale Description',
58
+ 'Missing Coverage',
59
+ 'Generator Drift',
60
+ 'Link Integrity',
61
+ ]);
62
+
63
+ /**
64
+ * The impact buckets the Executive Summary and per-finding `Impact` field draw
65
+ * from. The lens speaks in High/Medium/Low; this list is the canonical
66
+ * taxonomy the downstream `audit-to-stories` consumer maps onto Story
67
+ * priority.
68
+ */
69
+ export const IMPACT_LEVELS = Object.freeze(['High', 'Medium', 'Low']);
70
+
71
+ /**
72
+ * Assert that a rendered markdown report conforms to the contract: it has the
73
+ * H1 title and every required `##` section heading. Returns a structured
74
+ * result rather than throwing, so callers (tests, the orchestrated path's
75
+ * self-check) can report precisely which sections are missing.
76
+ *
77
+ * Pure function — string analysis only.
78
+ *
79
+ * @param {string} markdown - The rendered report body.
80
+ * @returns {{ conformant: boolean, missingSections: string[], hasTitle: boolean }}
81
+ */
82
+ export function assertReportContract(markdown) {
83
+ return assertSectionsContract(markdown, {
84
+ title: REPORT_TITLE,
85
+ requiredSections: REQUIRED_SECTIONS,
86
+ });
87
+ }
@@ -231,25 +231,28 @@ export function __setSleep(fn, opts = {}) {
231
231
  }
232
232
 
233
233
  /**
234
- * Run `git fetch …` with a bounded retry loop that only triggers on known
235
- * packed-refs lock-contention signatures. Non-contention failures surface
236
- * immediately (no retry). Success short-circuits the loop.
234
+ * Shared bounded retry loop for git commands that can hit packed-refs lock
235
+ * contention. Only contention signatures trigger a retry — non-contention
236
+ * failures surface immediately, and success short-circuits the loop.
237
237
  *
238
238
  * Backoff schedule: 250ms, 500ms, 1000ms (3 retries → 4 attempts total).
239
239
  * Deliberately no global lock — a mutex would erase the parallelism the
240
- * worktree-isolation model is designed to enable.
240
+ * worktree-isolation model is designed to enable. The schedule and the
241
+ * jitter policy (`_sleep` / `_jitterFactor` seams) live only here so a
242
+ * backoff tuning change has a single point of application.
241
243
  *
242
244
  * @param {string} cwd
243
- * @param {...string} args - Arguments after `fetch` (e.g. `'origin'`).
245
+ * @param {string[]} argvPrefix - Leading git argv (e.g. `['fetch']`).
246
+ * @param {string[]} args - Trailing arguments (e.g. `['origin']`).
244
247
  * @returns {Promise<{ status: number, stdout: string, stderr: string, attempts: number }>}
245
248
  */
246
- export async function gitFetchWithRetry(cwd, ...args) {
249
+ async function gitWithContentionRetry(cwd, argvPrefix, args) {
247
250
  const backoff = [250, 500, 1000];
248
251
  let attempt = 0;
249
252
  let last;
250
253
  for (;;) {
251
254
  attempt++;
252
- last = gitSpawn(cwd, 'fetch', ...args);
255
+ last = gitSpawn(cwd, ...argvPrefix, ...args);
253
256
  if (last.status === 0) return { ...last, attempts: attempt };
254
257
  if (!isPackedRefsContention(last.stderr))
255
258
  return { ...last, attempts: attempt };
@@ -260,6 +263,18 @@ export async function gitFetchWithRetry(cwd, ...args) {
260
263
  }
261
264
  }
262
265
 
266
+ /**
267
+ * Run `git fetch …` with the bounded packed-refs-contention retry loop
268
+ * (see `gitWithContentionRetry`).
269
+ *
270
+ * @param {string} cwd
271
+ * @param {...string} args - Arguments after `fetch` (e.g. `'origin'`).
272
+ * @returns {Promise<{ status: number, stdout: string, stderr: string, attempts: number }>}
273
+ */
274
+ export function gitFetchWithRetry(cwd, ...args) {
275
+ return gitWithContentionRetry(cwd, ['fetch'], args);
276
+ }
277
+
263
278
  /**
264
279
  * Run `git pull --rebase …` with the same bounded retry loop as
265
280
  * `gitFetchWithRetry`. Packed-refs contention can occur during pulls
@@ -269,21 +284,8 @@ export async function gitFetchWithRetry(cwd, ...args) {
269
284
  * @param {...string} args - Arguments after `pull --rebase` (e.g. `'origin', 'main'`).
270
285
  * @returns {Promise<{ status: number, stdout: string, stderr: string, attempts: number }>}
271
286
  */
272
- export async function gitPullWithRetry(cwd, ...args) {
273
- const backoff = [250, 500, 1000];
274
- let attempt = 0;
275
- let last;
276
- for (;;) {
277
- attempt++;
278
- last = gitSpawn(cwd, 'pull', '--rebase', ...args);
279
- if (last.status === 0) return { ...last, attempts: attempt };
280
- if (!isPackedRefsContention(last.stderr))
281
- return { ...last, attempts: attempt };
282
- if (attempt > backoff.length) return { ...last, attempts: attempt };
283
- const base = backoff[attempt - 1];
284
- const jitter = Math.floor(Math.random() * base * _jitterFactor);
285
- await _sleep(base + jitter);
286
- }
287
+ export function gitPullWithRetry(cwd, ...args) {
288
+ return gitWithContentionRetry(cwd, ['pull', '--rebase'], args);
287
289
  }
288
290
 
289
291
  /**
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import escomplex from 'typhonjs-escomplex';
3
- import { transpileIfNeeded } from './maintainability-utils.js';
3
+ import { transpileIfNeeded } from './transpile.js';
4
4
 
5
5
  /**
6
6
  * Calculates the maintainability score of a JavaScript source file or string.
@@ -1,119 +1,24 @@
1
1
  import fs from 'node:fs';
2
- import { createRequire } from 'node:module';
3
2
  import path from 'node:path';
4
3
  import { minimatch } from 'minimatch';
5
4
  import { canonicalise as canonicalisePath } from './baselines/path-canon.js';
6
- import {
7
- write as writeBaselineEnvelope,
8
- writeFile as writeBaselineFile,
9
- } from './baselines/writer.js';
10
- import { runOnPool } from './cpu-pool.js';
5
+ import { POOL_SERIAL_THRESHOLD, runOnPool } from './cpu-pool.js';
11
6
  import { Logger } from './Logger.js';
12
7
  import { calculateForFile } from './maintainability-engine.js';
13
8
 
14
- const require = createRequire(import.meta.url);
15
-
16
9
  const MAINTAINABILITY_WORKER_URL = new URL(
17
10
  './workers/maintainability-worker.js',
18
11
  import.meta.url,
19
12
  );
20
13
 
21
- // Below this batch size the pool's spawn overhead dominates — fall back
22
- // to in-process serial scoring for `--changed-since` runs that touch
23
- // only a handful of files. Tuned against the test suite's tmpdir
24
- // fixtures (n=2 stays serial; the full repo n≈470 takes the pool path).
25
- const SERIAL_THRESHOLD = 8;
14
+ // Pool-vs-serial cutover single-sourced in cpu-pool.js (see the
15
+ // POOL_SERIAL_THRESHOLD docstring for the tuning rationale).
16
+ const SERIAL_THRESHOLD = POOL_SERIAL_THRESHOLD;
26
17
 
27
18
  const JS_EXTS = new Set(['.js', '.mjs', '.cjs']);
28
19
  const TS_EXTS = new Set(['.ts', '.tsx', '.mts', '.cts']);
29
20
  const SUPPORTED_EXTS = new Set([...JS_EXTS, ...TS_EXTS]);
30
21
 
31
- let _ts = null;
32
- let _tsLoadFailed = false;
33
-
34
- function loadTypeScript() {
35
- if (_ts) return _ts;
36
- if (_tsLoadFailed) return null;
37
- try {
38
- _ts = require('typescript');
39
- return _ts;
40
- } catch {
41
- _tsLoadFailed = true;
42
- return null;
43
- }
44
- }
45
-
46
- /**
47
- * Resolve the `typescript` package version, used to stamp baselines so
48
- * consumers can detect transpiler drift. Returns `'0.0.0'` when the
49
- * dependency is unresolvable — callers treat that sentinel as "unknown
50
- * environment" and may refuse to persist a baseline that includes TS rows.
51
- *
52
- * @returns {string}
53
- */
54
- export function resolveTsTranspilerVersion() {
55
- const ts = loadTypeScript();
56
- if (ts && typeof ts.version === 'string') return ts.version;
57
- return '0.0.0';
58
- }
59
-
60
- function isTypeScriptPath(filePath) {
61
- return TS_EXTS.has(path.extname(String(filePath)).toLowerCase());
62
- }
63
-
64
- /**
65
- * Pre-transpile TypeScript or TSX sources to JavaScript that the
66
- * Esprima-based escomplex kernel can parse. Returns the input unchanged
67
- * for `.js` / `.mjs` / `.cjs` paths.
68
- *
69
- * Type annotations introduce no control flow, so the transpiled output
70
- * scores identically to the original TS for cyclomatic complexity,
71
- * Halstead volume, and the maintainability index. `.tsx` uses the
72
- * `react-jsx` emit so JSX expressions become function calls escomplex
73
- * can read; `.preserve` would leave JSX in the output and Esprima would
74
- * choke on it.
75
- *
76
- * On transpile failure the helper returns `null` — callers treat that
77
- * as "skip this file" rather than crashing the scan.
78
- *
79
- * @param {string} filePath
80
- * @param {string} source
81
- * @returns {string|null}
82
- */
83
- export function transpileIfNeeded(filePath, source) {
84
- if (!isTypeScriptPath(filePath)) return source;
85
- const ts = loadTypeScript();
86
- if (!ts) {
87
- Logger.warn(
88
- `[Maintainability] ⚠ typescript package not resolvable; cannot score ${filePath}. ` +
89
- "Install with 'npm install --save-dev typescript' (peer dep, >=5.0.0).",
90
- );
91
- return null;
92
- }
93
- try {
94
- const result = ts.transpileModule(source, {
95
- compilerOptions: {
96
- target: ts.ScriptTarget.ESNext,
97
- module: ts.ModuleKind.ESNext,
98
- isolatedModules: true,
99
- noEmitHelpers: true,
100
- importHelpers: false,
101
- removeComments: false,
102
- jsx: ts.JsxEmit.ReactJSX,
103
- sourceMap: false,
104
- },
105
- fileName: path.basename(filePath),
106
- reportDiagnostics: false,
107
- });
108
- return result.outputText;
109
- } catch (err) {
110
- Logger.warn(
111
- `[Maintainability] ⚠ TS transpile failed for ${filePath}: ${err?.message ?? err}; skipping.`,
112
- );
113
- return null;
114
- }
115
- }
116
-
117
22
  /**
118
23
  * @returns {boolean} True when the path's extension is one the engines score.
119
24
  */
@@ -121,94 +26,6 @@ function isSupportedSourceFile(filePath) {
121
26
  return SUPPORTED_EXTS.has(path.extname(String(filePath)).toLowerCase());
122
27
  }
123
28
 
124
- /**
125
- * Loads the current maintainability baseline from disk. The on-disk path is
126
- * resolved by the caller via {@link getBaselines}; passing it explicitly
127
- * removes the silent-default behaviour the framework dropped in Epic #730
128
- * Story 5.5.
129
- *
130
- * @param {string} baselinePath Repo-relative or absolute path to the baseline
131
- * JSON. Required.
132
- * @returns {Record<string, number>}
133
- */
134
- /**
135
- * Story #1895: project the canonical maintainability envelope back to the
136
- * legacy flat `{ path: mi }` map so existing gate consumers keep working
137
- * without churn — Story #1912 will replace this shim with the shared
138
- * reader. Returns the parsed input unchanged when it doesn't look like an
139
- * envelope (legacy flat shape stays flat).
140
- */
141
- function projectMaintainabilityEnvelopeToFlat(parsed) {
142
- if (
143
- !parsed ||
144
- typeof parsed !== 'object' ||
145
- Array.isArray(parsed) ||
146
- !Array.isArray(parsed.rows) ||
147
- typeof parsed.$schema !== 'string'
148
- ) {
149
- return parsed;
150
- }
151
- const flat = {};
152
- for (const row of parsed.rows) {
153
- if (row && typeof row.path === 'string' && typeof row.mi === 'number') {
154
- flat[row.path] = row.mi;
155
- }
156
- }
157
- return flat;
158
- }
159
-
160
- export function getBaseline(baselinePath) {
161
- if (typeof baselinePath !== 'string' || baselinePath.length === 0) {
162
- throw new TypeError(
163
- 'maintainability-utils.getBaseline: baselinePath is required (Epic #730 ' +
164
- 'Story 5.5 — callers resolve the path via getBaselines(config).maintainability.path).',
165
- );
166
- }
167
- const abs = path.isAbsolute(baselinePath)
168
- ? baselinePath
169
- : path.resolve(process.cwd(), baselinePath);
170
- if (!fs.existsSync(abs)) return {};
171
- try {
172
- const parsed = JSON.parse(fs.readFileSync(abs, 'utf-8'));
173
- return projectMaintainabilityEnvelopeToFlat(parsed);
174
- } catch (err) {
175
- Logger.warn(`[Maintainability] Failed to parse baseline: ${err.message}`);
176
- return {};
177
- }
178
- }
179
-
180
- /**
181
- * Saves a new maintainability baseline to disk at `baselinePath`.
182
- *
183
- * Accepts the legacy flat `{ path: mi }` shape for backwards compatibility
184
- * with existing callers (`regenerateMainFromTree`, refresh helpers). The
185
- * map is transformed into the canonical envelope shape (`$schema`,
186
- * `kernelVersion`, `generatedAt`, `rollup`, `rows`) via the shared
187
- * `lib/baselines/writer.js` pipeline before being persisted, so every
188
- * write produces a file that round-trips through `lib/baselines/reader.js`
189
- * without schema errors.
190
- *
191
- * @param {Record<string, number>} baseline path→MI flat map.
192
- * @param {string} baselinePath Required — caller supplies via getBaselines().
193
- */
194
- export function saveBaseline(baseline, baselinePath) {
195
- if (typeof baselinePath !== 'string' || baselinePath.length === 0) {
196
- throw new TypeError(
197
- 'maintainability-utils.saveBaseline: baselinePath is required.',
198
- );
199
- }
200
- const abs = path.isAbsolute(baselinePath)
201
- ? baselinePath
202
- : path.resolve(process.cwd(), baselinePath);
203
-
204
- const rows = Object.entries(baseline ?? {}).map(([p, mi]) => ({
205
- path: p,
206
- mi,
207
- }));
208
- const envelope = writeBaselineEnvelope({ kind: 'maintainability', rows });
209
- writeBaselineFile(abs, envelope);
210
- }
211
-
212
29
  const IGNORED_DIRS = new Set([
213
30
  'node_modules',
214
31
  '.git',
@@ -16,6 +16,7 @@ import { storyArtifactPath } from '../config/temp-paths.js';
16
16
  import { gitSpawn } from '../git-utils.js';
17
17
  import { Logger } from '../Logger.js';
18
18
  import { read as readSignals } from '../signals/read.js';
19
+ import { concurrentMap } from '../util/concurrent-map.js';
19
20
  import { extractStoryPerfSummaryFromComment } from './perf-report-render.js';
20
21
  import { forEachLine } from './signals-writer.js';
21
22
 
@@ -154,30 +155,38 @@ export async function collectStorySummaries(provider, epicId, logger) {
154
155
  ),
155
156
  );
156
157
 
157
- const summaries = [];
158
- for (const ticket of storyTickets) {
159
- const id = Number(ticket.id ?? ticket.number);
160
- if (!Number.isInteger(id) || id < 1) continue;
161
- let comments;
162
- try {
163
- comments = (await provider.getTicketComments(id)) ?? [];
164
- } catch (err) {
165
- logger.warn?.(
166
- `[analyze-execution] getTicketComments(${id}) failed: ${
167
- err instanceof Error ? err.message : String(err)
168
- }`,
169
- );
170
- continue;
171
- }
172
- for (const c of comments) {
173
- const parsed = extractStoryPerfSummaryFromComment(c?.body);
174
- if (parsed) {
175
- summaries.push(parsed);
176
- break;
158
+ // Bounded-parallel comment fetch (Story #3990). Each Story's comment
159
+ // thread is an independent paginated REST read through a fresh `gh`
160
+ // spawn, so a serial loop pays N sequential round-trips. concurrentMap
161
+ // preserves input→output index, keeping summaries in Story order; the
162
+ // per-Story try/catch stays inside the mapper so one failed fetch
163
+ // degrades to warn-and-skip without aborting the batch (mirrors
164
+ // verifySingleResult in lib/orchestration/wave-record-io.js, #3024).
165
+ const perStory = await concurrentMap(
166
+ storyTickets,
167
+ async (ticket) => {
168
+ const id = Number(ticket.id ?? ticket.number);
169
+ if (!Number.isInteger(id) || id < 1) return null;
170
+ let comments;
171
+ try {
172
+ comments = (await provider.getTicketComments(id)) ?? [];
173
+ } catch (err) {
174
+ logger.warn?.(
175
+ `[analyze-execution] getTicketComments(${id}) failed: ${
176
+ err instanceof Error ? err.message : String(err)
177
+ }`,
178
+ );
179
+ return null;
177
180
  }
178
- }
179
- }
180
- return summaries;
181
+ for (const c of comments) {
182
+ const parsed = extractStoryPerfSummaryFromComment(c?.body);
183
+ if (parsed) return parsed;
184
+ }
185
+ return null;
186
+ },
187
+ { concurrency: 4 },
188
+ );
189
+ return perStory.filter((s) => s !== null);
181
190
  }
182
191
 
183
192
  /**
@@ -1,12 +1,22 @@
1
1
  /**
2
2
  * Acceptance self-eval decision core (Story #3819).
3
3
  *
4
- * Pure, I/O-free reducer that turns one round's critic verdict plus the
5
- * resolved round cap into the loop's next action. The CLI wrapper
4
+ * Pure reducer that turns one round's critic verdict plus the resolved
5
+ * round cap into the loop's next action. The CLI wrapper
6
6
  * (`acceptance-eval.js`) owns the file reads, schema validation, signal
7
7
  * emission, and ticket transitions; this module owns the *decision* so it
8
8
  * can be unit-tested in isolation.
9
9
  *
10
+ * ## Round derivation (Story #4019)
11
+ *
12
+ * The round number is **derived from the signals ledger**, not from the
13
+ * critic's self-reported `verdict.round`: every prior round appended one
14
+ * `acceptance-eval` signal to the Story's `signals.ndjson`, so the
15
+ * current round is `count(prior signals) + 1`. This survives a subagent
16
+ * restart (the ledger is on disk) and removes the critic's scratch value
17
+ * from the cap enforcement path — a critic that always reports `round: 1`
18
+ * can no longer defeat the bounded-loop guarantee.
19
+ *
10
20
  * ## The three terminal actions
11
21
  *
12
22
  * - `proceed` — every criterion is `met`. The Story may flip to
@@ -29,6 +39,10 @@
29
39
  * possible action is `block`.
30
40
  */
31
41
 
42
+ import { readFileSync } from 'node:fs';
43
+
44
+ import { signalsFile } from '../config/temp-paths.js';
45
+
32
46
  /**
33
47
  * Verdicts that clear a criterion. Anything else (`partial`, `unmet`, or
34
48
  * an unrecognised value) is treated as not-yet-met and triggers rework.
@@ -87,11 +101,16 @@ function partitionCriteria(criteria) {
87
101
  * Decide the next loop action from a single round's verdict.
88
102
  *
89
103
  * @param {object} args
90
- * @param {{ round?: number, criteria?: Array<object> }} args.verdict
104
+ * @param {{ criteria?: Array<object> }} args.verdict
91
105
  * A verdict already validated against the acceptance-eval-verdict schema.
106
+ * Its `round` field, when present, is ignored — the round is supplied by
107
+ * the caller (derived from the signals ledger; Story #4019).
92
108
  * @param {number} args.maxRounds
93
109
  * The resolved (already-clamped) redraft ceiling from
94
110
  * `getAcceptanceEval(config).maxRounds`.
111
+ * @param {number} [args.round]
112
+ * The current round number, derived via `deriveAcceptanceEvalRound`.
113
+ * Defaults to 1 when absent or invalid.
95
114
  * @returns {{
96
115
  * decision: 'proceed' | 'redraft' | 'block',
97
116
  * round: number,
@@ -102,10 +121,9 @@ function partitionCriteria(criteria) {
102
121
  * capReached: boolean,
103
122
  * }}
104
123
  */
105
- export function decideAcceptanceEval({ verdict, maxRounds }) {
124
+ export function decideAcceptanceEval({ verdict, maxRounds, round: roundIn }) {
106
125
  const cap = effectiveCap(maxRounds);
107
- const round =
108
- Number.isInteger(verdict?.round) && verdict.round >= 1 ? verdict.round : 1;
126
+ const round = Number.isInteger(roundIn) && roundIn >= 1 ? roundIn : 1;
109
127
  const { metCount, notMet } = partitionCriteria(verdict?.criteria);
110
128
  const totalCriteria = metCount + notMet.length;
111
129
  const allMet = notMet.length === 0;
@@ -171,3 +189,59 @@ export function buildAcceptanceEvalSignal({
171
189
  },
172
190
  };
173
191
  }
192
+
193
+ /**
194
+ * Derive the current acceptance-eval round for a Story by counting the
195
+ * `acceptance-eval` signals already appended to the Story's
196
+ * `signals.ndjson` (Story #4019). Round = prior-signal count + 1, so the
197
+ * first run reports round 1 and each completed round (which appends one
198
+ * signal via `acceptance-eval.js`) advances the derived round by one.
199
+ *
200
+ * The derivation is restart-safe: the ledger lives on disk, so a subagent
201
+ * that dies mid-loop and restarts still observes every prior round. A
202
+ * missing or malformed ledger degrades to round 1 (no prior rounds), and
203
+ * malformed lines are skipped — observability corruption never wedges the
204
+ * gate.
205
+ *
206
+ * @param {object} args
207
+ * @param {number|null} args.epicId Parent Epic ID, or `null` for a
208
+ * standalone Story (routes to `<tempRoot>/standalone/stories/...`).
209
+ * @param {number} args.storyId
210
+ * @param {object} [args.config] Resolved config (tempRoot resolution).
211
+ * @param {(p: string) => string} [args.readFile] Injectable reader (tests).
212
+ * @param {(eid: number|null, sid: number, config?: object) => string} [args.signalsPathResolver]
213
+ * Injectable path resolver (tests). Defaults to `signalsFile`.
214
+ * @returns {number} The 1-based current round.
215
+ */
216
+ export function deriveAcceptanceEvalRound({
217
+ epicId,
218
+ storyId,
219
+ config,
220
+ readFile = (p) => readFileSync(p, 'utf8'),
221
+ signalsPathResolver = signalsFile,
222
+ }) {
223
+ let text;
224
+ try {
225
+ text = readFile(signalsPathResolver(epicId ?? null, storyId, config));
226
+ } catch (_err) {
227
+ // No ledger yet → no prior rounds.
228
+ return 1;
229
+ }
230
+
231
+ let priorRounds = 0;
232
+ for (const line of String(text).split('\n')) {
233
+ const trimmed = line.trim();
234
+ if (trimmed === '') continue;
235
+ let record;
236
+ try {
237
+ record = JSON.parse(trimmed);
238
+ } catch (_err) {
239
+ continue; // Malformed line — skip, never throw.
240
+ }
241
+ if (!record || typeof record !== 'object') continue;
242
+ if (record.kind !== 'acceptance-eval') continue;
243
+ if (record.storyId !== storyId) continue;
244
+ priorRounds += 1;
245
+ }
246
+ return priorRounds + 1;
247
+ }