mandrel 2.36.0 → 2.37.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 (88) hide show
  1. package/.agents/docs/configuration.md +25 -0
  2. package/.agents/rules/testing-standards.md +56 -0
  3. package/.agents/scripts/boot-sweep.js +7 -1
  4. package/.agents/scripts/check-context-budget.js +195 -103
  5. package/.agents/scripts/check-cyclomatic.js +112 -42
  6. package/.agents/scripts/check-generated-validator.js +202 -0
  7. package/.agents/scripts/check-lifecycle-lint.js +2 -68
  8. package/.agents/scripts/check-schema-references.js +2 -28
  9. package/.agents/scripts/drain-pending-cleanup.js +18 -1
  10. package/.agents/scripts/lib/baselines/components.js +32 -2
  11. package/.agents/scripts/lib/baselines/env-overrides.js +1 -1
  12. package/.agents/scripts/lib/bootstrap/branch-protection.js +1 -1
  13. package/.agents/scripts/lib/bootstrap/install-ledger.js +1 -1
  14. package/.agents/scripts/lib/bootstrap/prompt.js +1 -1
  15. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +2 -2
  16. package/.agents/scripts/lib/checks/loop-health.js +1 -1
  17. package/.agents/scripts/lib/cli-args.js +0 -2
  18. package/.agents/scripts/lib/close-validation/gates.js +1 -1
  19. package/.agents/scripts/lib/close-validation/process.js +1 -1
  20. package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
  21. package/.agents/scripts/lib/config-settings-schema.js +46 -6
  22. package/.agents/scripts/lib/coverage-baseline.js +2 -2
  23. package/.agents/scripts/lib/cpu-pool.js +90 -10
  24. package/.agents/scripts/lib/crap-utils.js +6 -2
  25. package/.agents/scripts/lib/cyclomatic-ceiling.js +28 -6
  26. package/.agents/scripts/lib/cyclomatic-scope.js +144 -0
  27. package/.agents/scripts/lib/dependency-version.js +86 -0
  28. package/.agents/scripts/lib/duplicate-search.js +1 -1
  29. package/.agents/scripts/lib/errors/index.js +1 -1
  30. package/.agents/scripts/lib/feedback-loop/graduator-core.js +2 -2
  31. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +1 -1
  32. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +1 -1
  33. package/.agents/scripts/lib/generated/agentrc-validator.js +16 -0
  34. package/.agents/scripts/lib/install-cmd-parser.js +1 -1
  35. package/.agents/scripts/lib/knip-entry-sync.js +1 -57
  36. package/.agents/scripts/lib/maintainability-utils.js +108 -10
  37. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  38. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +70 -9
  39. package/.agents/scripts/lib/orchestration/pr-watch.js +1 -30
  40. package/.agents/scripts/lib/orchestration/reassert-status-column.js +2 -2
  41. package/.agents/scripts/lib/orchestration/review-providers/codex.js +3 -3
  42. package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +1 -1
  43. package/.agents/scripts/lib/orchestration/review-providers/native.js +9 -2
  44. package/.agents/scripts/lib/orchestration/review-providers/review-depth.js +2 -2
  45. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +3 -3
  46. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +52 -1
  47. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +2 -5
  48. package/.agents/scripts/lib/orchestration/story-follow-ups.js +1 -1
  49. package/.agents/scripts/lib/orchestration/ticket-lease.js +34 -9
  50. package/.agents/scripts/lib/orchestration/ticketing/reads.js +3 -1
  51. package/.agents/scripts/lib/preflight-runner.js +3 -3
  52. package/.agents/scripts/lib/qa/qa-session.js +1 -1
  53. package/.agents/scripts/lib/runtime-deps/manifest.js +1 -7
  54. package/.agents/scripts/lib/runtime-deps/scan-imports.js +3 -58
  55. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +284 -25
  56. package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
  57. package/.agents/scripts/lib/source-text/strip-js-comments.js +115 -0
  58. package/.agents/scripts/lib/test-isolate/runner.js +3 -3
  59. package/.agents/scripts/lib/test-runner-contract.js +14 -6
  60. package/.agents/scripts/lib/test-tiers.js +135 -18
  61. package/.agents/scripts/lib/transpile.js +16 -3
  62. package/.agents/scripts/lib/worktree/lifecycle/pending-cleanup.js +169 -31
  63. package/.agents/scripts/lib/worktree/node-modules-strategy.js +2 -5
  64. package/.agents/scripts/pr-watch-with-update.js +305 -137
  65. package/.agents/scripts/providers/github/tickets.js +109 -16
  66. package/.agents/scripts/quality-preview.js +162 -70
  67. package/.agents/scripts/run-test-profile.js +8 -5
  68. package/.agents/scripts/run-tests.js +79 -14
  69. package/.agents/scripts/single-story-close.js +0 -2
  70. package/.agents/scripts/single-story-init.js +5 -1
  71. package/docs/CHANGELOG.md +32 -0
  72. package/lib/cli/migrate.js +100 -94
  73. package/lib/migrations/helpers/retire-agentrc-key.js +200 -0
  74. package/lib/migrations/index.js +32 -33
  75. package/lib/migrations/steps/2.1.0-retire-mi-drop-knobs.js +21 -80
  76. package/lib/migrations/steps/2.1.0-retire-verify-concurrency-cap.js +15 -87
  77. package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +13 -71
  78. package/lib/migrations/steps/2.20.0-retire-codebase-snapshot.js +13 -101
  79. package/lib/migrations/steps/2.32.0-retire-lint-baseline-command.js +13 -100
  80. package/package.json +7 -3
  81. package/.agents/scripts/lib/audit-suite/frontmatter-lint.js +0 -32
  82. package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +0 -37
  83. package/.agents/scripts/lib/cli/parse-numeric.js +0 -60
  84. package/.agents/scripts/lib/close-validation/telemetry.js +0 -79
  85. package/.agents/scripts/lib/orchestration/label-transitions.js +0 -44
  86. package/.agents/scripts/lib/orchestration/parked-follow-ons.js +0 -147
  87. package/.agents/scripts/lib/orchestration/phase-runner.js +0 -88
  88. package/.agents/scripts/lib/orchestration/recut.js +0 -56
@@ -17,7 +17,7 @@ import { spawnSync as defaultSpawnSync } from 'node:child_process';
17
17
  * @param {string} installCmd
18
18
  * @returns {{ bin: string, args: string[], shell: boolean }}
19
19
  */
20
- export function parseInstallCmd(installCmd) {
20
+ function parseInstallCmd(installCmd) {
21
21
  const tokens = String(installCmd ?? '')
22
22
  .trim()
23
23
  .split(/\s+/)
@@ -59,6 +59,7 @@ import {
59
59
  resolveKnipEntryPatterns,
60
60
  __testing as resolverTesting,
61
61
  } from './knip-config-resolver.js';
62
+ import { stripJsComments } from './source-text/strip-js-comments.js';
62
63
 
63
64
  // Only `resolveEntrySync` and `renderEntrySyncReport` are public — they are
64
65
  // what `check-knip-entries.js` calls. Everything else is module-private and
@@ -89,63 +90,6 @@ const INVOCATION_SURFACES = Object.freeze([
89
90
  { kind: 'dir', at: SCRIPTS_DIR, test: /\.js$/ },
90
91
  ]);
91
92
 
92
- /**
93
- * Strip line and block comments from JavaScript source, preserving string and
94
- * template literals so a `'https://…'` or a `` `${x}//y` `` is not mangled.
95
- *
96
- * Comments are stripped before scanning `.js` surfaces so a JSDoc paragraph
97
- * that merely *names* a CLI ("superseded by `node .agents/scripts/foo.js`")
98
- * cannot confer liveness on it. Several such mentions exist today; every one
99
- * of them is prose about a script's internals, not a call.
100
- *
101
- * Replaces comment bodies with equivalent whitespace rather than deleting
102
- * them, so byte offsets and line numbers survive for any future caller that
103
- * wants to report a position.
104
- *
105
- * @param {string} source
106
- * @returns {string}
107
- */
108
- function stripJsComments(source) {
109
- const text = String(source ?? '');
110
- let out = '';
111
- let i = 0;
112
- const blank = (s) => s.replace(/[^\n]/g, ' ');
113
- while (i < text.length) {
114
- const two = text.slice(i, i + 2);
115
- if (two === '//') {
116
- const end = text.indexOf('\n', i);
117
- const stop = end === -1 ? text.length : end;
118
- out += blank(text.slice(i, stop));
119
- i = stop;
120
- } else if (two === '/*') {
121
- const end = text.indexOf('*/', i + 2);
122
- const stop = end === -1 ? text.length : end + 2;
123
- out += blank(text.slice(i, stop));
124
- i = stop;
125
- } else {
126
- const ch = text[i];
127
- if (ch === "'" || ch === '"' || ch === '`') {
128
- let j = i + 1;
129
- while (j < text.length) {
130
- if (text[j] === '\\') {
131
- j += 2;
132
- continue;
133
- }
134
- if (text[j] === ch) break;
135
- j += 1;
136
- }
137
- const stop = Math.min(j + 1, text.length);
138
- out += text.slice(i, stop);
139
- i = stop;
140
- } else {
141
- out += ch;
142
- i += 1;
143
- }
144
- }
145
- }
146
- return out;
147
- }
148
-
149
93
  /**
150
94
  * Recursively list files under `dir`, skipping `node_modules` and `.git`.
151
95
  *
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { minimatch } from 'minimatch';
3
+ import { Minimatch } from 'minimatch';
4
4
  import { canonicalise as canonicalisePath } from './baselines/path-canon.js';
5
5
  import { POOL_SERIAL_THRESHOLD, runOnPool } from './cpu-pool.js';
6
6
  import { Logger } from './Logger.js';
@@ -27,6 +27,57 @@ const IGNORED_DIRS = new Set([
27
27
  '.next',
28
28
  ]);
29
29
 
30
+ /**
31
+ * Compiled-matcher cache for `isIgnoredByGlobs`, keyed on the pattern list.
32
+ *
33
+ * `minimatch(path, glob)` re-parses `glob` into an AST on every call, so the
34
+ * scan was paying O(files × globs) glob *compilations* to answer O(files ×
35
+ * globs) glob *matches* — 12–17 ms over the 619-file tree against 0.5 ms
36
+ * once the patterns are compiled (Story #5109). The number of distinct
37
+ * pattern lists in a process is tiny (one per gate), and each list is
38
+ * config-derived and immutable, so an unbounded `Map` keyed on the joined
39
+ * patterns is bounded in practice.
40
+ *
41
+ * Each entry also memoises the per-path verdict. The baseline gates ask the
42
+ * same question about the same path many times over — `crap.json` carries
43
+ * thousands of method rows spread over a few hundred files, and the evaluate
44
+ * phase filters every row — so the second and later asks about a path cost a
45
+ * `Map` lookup instead of a globstar walk. Both caches are keyed on
46
+ * config-derived, immutable inputs, and both are bounded by the tree the
47
+ * process is scanning.
48
+ *
49
+ * `\u0000` is the join separator because it cannot occur in a glob, so two
50
+ * different lists cannot collide onto one key.
51
+ *
52
+ * @type {Map<string, {matchers: import('minimatch').Minimatch[], verdicts: Map<string, boolean>}>}
53
+ */
54
+ const IGNORE_MATCHER_CACHE = new Map();
55
+
56
+ /**
57
+ * Compile (once) the `Minimatch` instances for a pattern list, alongside the
58
+ * per-path verdict memo that shares their lifetime.
59
+ *
60
+ * Non-string patterns are dropped rather than compiled — `minimatch()` would
61
+ * have thrown on them, and the functional call site never fed it any, so
62
+ * dropping preserves the observable result set.
63
+ *
64
+ * @param {string[]} ignoreGlobs
65
+ * @returns {{matchers: import('minimatch').Minimatch[], verdicts: Map<string, boolean>}}
66
+ */
67
+ function ignoreMatcherEntry(ignoreGlobs) {
68
+ const key = ignoreGlobs.join('\u0000');
69
+ let entry = IGNORE_MATCHER_CACHE.get(key);
70
+ if (entry) return entry;
71
+ entry = {
72
+ matchers: ignoreGlobs
73
+ .filter((g) => typeof g === 'string')
74
+ .map((g) => new Minimatch(g, { dot: true })),
75
+ verdicts: new Map(),
76
+ };
77
+ IGNORE_MATCHER_CACHE.set(key, entry);
78
+ return entry;
79
+ }
80
+
30
81
  /**
31
82
  * Test whether an absolute (or repo-relative) file path matches any of the
32
83
  * configured `ignoreGlobs`. This is the single source of truth for how the
@@ -39,22 +90,62 @@ const IGNORED_DIRS = new Set([
39
90
  *
40
91
  * Matching mirrors `scanDirectory`: the path is reduced to a canonicalised,
41
92
  * POSIX, repo-relative form and tested against each glob with minimatch's
42
- * `{ dot: true }` so dot-prefixed roots like `.agents/` match.
93
+ * `{ dot: true }` so dot-prefixed roots like `.agents/` match. The patterns
94
+ * are compiled at most once per distinct list (see `IGNORE_MATCHER_CACHE`);
95
+ * the matched set is identical to the functional `minimatch()` call this
96
+ * replaced, which is what the `gate-scan-fast-path` test pins over the real
97
+ * configured `ignoreGlobs`.
43
98
  *
44
99
  * @param {string} filePath absolute or relative path to the source file
45
100
  * @param {string[]} ignoreGlobs minimatch patterns; empty/absent is a no-op
46
101
  * @param {string} [cwd] root for repo-relative resolution; defaults to cwd
47
102
  * @returns {boolean} true when the file matches at least one ignore glob
48
103
  */
49
- export function isIgnoredByGlobs(filePath, ignoreGlobs = [], cwd) {
50
- if (!Array.isArray(ignoreGlobs) || ignoreGlobs.length === 0) return false;
51
- const matchCwd = cwd ?? process.cwd();
104
+ /**
105
+ * Reduce an absolute-or-relative path to the canonicalised, POSIX,
106
+ * repo-relative form the ignore patterns are written against.
107
+ *
108
+ * @param {string} filePath
109
+ * @param {string} matchCwd
110
+ * @returns {string}
111
+ */
112
+ function canonicalRelPath(filePath, matchCwd) {
52
113
  const absFilePath = path.isAbsolute(filePath)
53
114
  ? filePath
54
115
  : path.resolve(matchCwd, filePath);
55
116
  const rawRel = path.relative(matchCwd, absFilePath).replace(/\\/g, '/');
56
- const relPath = canonicalisePath(rawRel);
57
- return ignoreGlobs.some((g) => minimatch(relPath, g, { dot: true }));
117
+ return canonicalisePath(rawRel);
118
+ }
119
+
120
+ /**
121
+ * Answer "does this path match any of the entry's patterns", consulting and
122
+ * populating the entry's verdict memo.
123
+ *
124
+ * Split out of `isIgnoredByGlobs` rather than inlined: the memo's
125
+ * hit/miss test is a branch, and folding it into the caller pushed that
126
+ * function from cyclomatic 4 to 5 — over the per-method CRAP contract the
127
+ * pre-push preview enforces. The lookup belongs beside the cache it reads
128
+ * anyway, and the hot function keeps a flat shape.
129
+ *
130
+ * @param {{matchers: import('minimatch').Minimatch[], verdicts: Map<string, boolean>}} entry
131
+ * @param {string} relPath Canonicalised, POSIX, repo-relative path.
132
+ * @returns {boolean}
133
+ */
134
+ function memoisedIgnoreVerdict(entry, relPath) {
135
+ const memoised = entry.verdicts.get(relPath);
136
+ if (memoised !== undefined) return memoised;
137
+ const verdict = entry.matchers.some((m) => m.match(relPath));
138
+ entry.verdicts.set(relPath, verdict);
139
+ return verdict;
140
+ }
141
+
142
+ export function isIgnoredByGlobs(filePath, ignoreGlobs = [], cwd) {
143
+ if (!Array.isArray(ignoreGlobs) || ignoreGlobs.length === 0) return false;
144
+ const matchCwd = cwd ?? process.cwd();
145
+ return memoisedIgnoreVerdict(
146
+ ignoreMatcherEntry(ignoreGlobs),
147
+ canonicalRelPath(filePath, matchCwd),
148
+ );
58
149
  }
59
150
 
60
151
  /**
@@ -107,7 +198,7 @@ export function scanDirectory(dir, fileList = [], opts = {}) {
107
198
  * Calculates maintainability scores for a list of file paths.
108
199
  *
109
200
  * Each file's transpile-then-analyze unit is dispatched to a
110
- * worker_threads pool sized to `os.availableParallelism()`. Workers
201
+ * worker_threads pool whose width `runOnPool` resolves. Workers
111
202
  * are recycled across files so TypeScript loads at most once per
112
203
  * worker. The pool is bypassed for batches of fewer than
113
204
  * `SERIAL_THRESHOLD` files because spawn overhead dominates at small
@@ -129,9 +220,16 @@ export function scanDirectory(dir, fileList = [], opts = {}) {
129
220
  * and nothing said so.
130
221
  *
131
222
  * @param {string[]} paths
223
+ * @param {{serialThreshold?: number}} [opts] `serialThreshold` overrides the
224
+ * pool-vs-serial cutover for this call only. Production callers omit it;
225
+ * the parity tests use it to drive the pooled path on a small fixture set
226
+ * rather than materialising 256 files to clear the cutover.
132
227
  * @returns {Promise<Record<string, number>>}
133
228
  */
134
- export async function calculateAll(paths) {
229
+ export async function calculateAll(paths, opts = {}) {
230
+ const serialThreshold = Number.isFinite(opts?.serialThreshold)
231
+ ? opts.serialThreshold
232
+ : SERIAL_THRESHOLD;
135
233
  const cwd = process.cwd();
136
234
  const indexed = paths.map((p) => ({
137
235
  abs: p,
@@ -139,7 +237,7 @@ export async function calculateAll(paths) {
139
237
  }));
140
238
 
141
239
  let perFile;
142
- if (indexed.length < SERIAL_THRESHOLD) {
240
+ if (indexed.length < serialThreshold) {
143
241
  perFile = indexed.map(({ abs, relPath }) => {
144
242
  try {
145
243
  return { relPath, ...scoreFile(abs) };
@@ -94,6 +94,7 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
94
94
  'check-cyclomatic.js',
95
95
  'check-dead-exports.js',
96
96
  'check-doc-links.js',
97
+ 'check-generated-validator.js',
97
98
  'check-gherkin-corpus.js',
98
99
  'check-knip-entries.js',
99
100
  'check-lifecycle-lint.js',
@@ -197,6 +197,26 @@ function planFingerprintMarker(fingerprint) {
197
197
  return `<!-- ${PLAN_FINGERPRINT_MARKER_PREFIX} ${fingerprint} -->`;
198
198
  }
199
199
 
200
+ /** The one parser for the marker {@link planFingerprintMarker} renders. */
201
+ const PLAN_FINGERPRINT_MARKER_RE = new RegExp(
202
+ `<!--\\s*${PLAN_FINGERPRINT_MARKER_PREFIX}\\s*([0-9a-f]+)\\s*-->`,
203
+ );
204
+
205
+ /**
206
+ * Recover the plan fingerprint an issue body was stamped with, or `null` when
207
+ * the body carries no marker. Module-private: both readers of the marker (the
208
+ * resume index and the create-retry adoption probe) go through it so they can
209
+ * never drift into recognising different Stories as "already created".
210
+ *
211
+ * @param {unknown} body
212
+ * @returns {string|null}
213
+ */
214
+ function extractPlanFingerprint(body) {
215
+ if (typeof body !== 'string') return null;
216
+ const match = body.match(PLAN_FINGERPRINT_MARKER_RE);
217
+ return match ? match[1] : null;
218
+ }
219
+
200
220
  /**
201
221
  * Labels the authoring pass is never allowed to set. The `agent::*` axis is
202
222
  * the runtime's lifecycle state (persist owns the terminal `agent::ready`
@@ -617,14 +637,9 @@ async function indexExistingStories(provider) {
617
637
  if (title !== '') {
618
638
  idsByTitle.set(title, [...(idsByTitle.get(title) ?? []), id]);
619
639
  }
620
- const body = typeof issue?.body === 'string' ? issue.body : '';
621
- const match = body.match(
622
- new RegExp(
623
- `<!--\\s*${PLAN_FINGERPRINT_MARKER_PREFIX}\\s*([0-9a-f]+)\\s*-->`,
624
- ),
625
- );
626
- if (!match) continue;
627
- byFingerprint.set(match[1], {
640
+ const fingerprint = extractPlanFingerprint(issue?.body);
641
+ if (!fingerprint) continue;
642
+ byFingerprint.set(fingerprint, {
628
643
  id,
629
644
  title,
630
645
  url: issue.html_url ?? issue.url ?? undefined,
@@ -633,6 +648,42 @@ async function indexExistingStories(provider) {
633
648
  return { byFingerprint, idsByTitle };
634
649
  }
635
650
 
651
+ /**
652
+ * Re-run the resume lookup for a single fingerprint and return the **raw**
653
+ * issue, or `null`.
654
+ *
655
+ * This is the probe `createIssue` calls before any retry POST (Story #5112).
656
+ * A create whose response was lost has already filed the issue; retrying
657
+ * blind duplicates it. Because the body posted carries the fingerprint
658
+ * marker, the same content-keyed lookup the resume path uses answers "did
659
+ * attempt 1 land?" authoritatively — from the server's state, not from a
660
+ * client-side guess about where the connection broke.
661
+ *
662
+ * Best-effort like {@link indexExistingStories}: a provider without the
663
+ * listing surface, or a listing that throws, yields `null` and the retry
664
+ * proceeds exactly as it did before.
665
+ *
666
+ * @param {{ provider: object, fingerprint: string }} args
667
+ * @returns {Promise<object|null>}
668
+ */
669
+ async function findOpenStoryByPlanFingerprint({ provider, fingerprint }) {
670
+ if (typeof provider?.listIssuesByLabel !== 'function') return null;
671
+ if (typeof fingerprint !== 'string' || fingerprint.length === 0) return null;
672
+ let issues;
673
+ try {
674
+ issues = await provider.listIssuesByLabel({
675
+ state: 'open',
676
+ labels: TYPE_LABELS.STORY,
677
+ });
678
+ } catch {
679
+ return null;
680
+ }
681
+ for (const issue of Array.isArray(issues) ? issues : []) {
682
+ if (extractPlanFingerprint(issue?.body) === fingerprint) return issue;
683
+ }
684
+ return null;
685
+ }
686
+
636
687
  /**
637
688
  * Warn when a Story with this title is already open but did **not** match the
638
689
  * fingerprint — i.e. its authored content differs from what this run is about
@@ -1000,6 +1051,14 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
1000
1051
  ...(applyCohortLabel ? [cohortLabel] : []),
1001
1052
  ...(applyRouteLabel ? [routeLabel] : []),
1002
1053
  ],
1054
+ // Story #5112 — hand the provider the same content-keyed lookup this
1055
+ // loop's resume path uses, so a retry after a lost response adopts the
1056
+ // issue attempt 1 already filed instead of creating a twin.
1057
+ findExisting: () =>
1058
+ findOpenStoryByPlanFingerprint({
1059
+ provider,
1060
+ fingerprint: story.fingerprint,
1061
+ }),
1003
1062
  });
1004
1063
  const id = result?.id ?? result?.number;
1005
1064
  if (!Number.isInteger(id)) {
@@ -1012,7 +1071,9 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
1012
1071
  id,
1013
1072
  title: story.title,
1014
1073
  url: result.url,
1015
- adopted: false,
1074
+ // True when the provider's retry probe adopted an issue a lost-response
1075
+ // first attempt had already filed — pre-existing either way.
1076
+ adopted: result.adopted === true,
1016
1077
  });
1017
1078
  idBySlug.set(story.slug, id);
1018
1079
  }
@@ -50,35 +50,6 @@ import { applyBehindUpdate } from './behind-recovery.js';
50
50
  * values collapse to `'skipped'` so any future GitHub state we haven't
51
51
  * enumerated still maps into the vocabulary.
52
52
  */
53
- /**
54
- * The raw check-state tokens `normalizeCheckState` recognizes (lowercased).
55
- * A token absent from this set is one we have NOT enumerated — the watch
56
- * path collapses it to `'skipped'` (validate-anything), but a fail-closed
57
- * consumer (the auto-merge arming probe) must treat it as unknown-therefore-
58
- * blocking rather than trust the `'skipped'` collapse. Exported so that
59
- * stricter consumer lives here as the single vocabulary owner.
60
- */
61
- export const RECOGNIZED_CHECK_STATES = Object.freeze(
62
- new Set([
63
- '',
64
- 'pending',
65
- 'queued',
66
- 'in_progress',
67
- 'requested',
68
- 'waiting',
69
- 'success',
70
- 'completed',
71
- 'failure',
72
- 'startup_failure',
73
- 'neutral',
74
- 'cancelled',
75
- 'timed_out',
76
- 'action_required',
77
- 'stale',
78
- 'skipped',
79
- ]),
80
- );
81
-
82
53
  export function normalizeCheckState(raw) {
83
54
  const v = String(raw ?? '')
84
55
  .trim()
@@ -325,7 +296,7 @@ export function allTerminal(outcomes) {
325
296
  * red check): a `'still-running'` map means "re-arm the watch / hand off
326
297
  * to `/loop`," never "the change is broken."
327
298
  */
328
- export const STILL_RUNNING = 'still-running';
299
+ const STILL_RUNNING = 'still-running';
329
300
 
330
301
  /**
331
302
  * Promote any leftover `'pending'` outcomes to the schema-valid
@@ -38,14 +38,14 @@ import { ColumnSync, columnForLabels } from './column-sync.js';
38
38
  * long enough to outlast the typical GitHub built-in workflow fire
39
39
  * timing without ballooning the close-path wall clock.
40
40
  */
41
- export const DEFAULT_POLL_ATTEMPTS = 4;
41
+ const DEFAULT_POLL_ATTEMPTS = 4;
42
42
 
43
43
  /**
44
44
  * Default delay between drift checks (ms). Five seconds is a sweet
45
45
  * spot: short enough that the operator doesn't perceive the close
46
46
  * path as hung, long enough that one re-fire usually wins the race.
47
47
  */
48
- export const DEFAULT_POLL_DELAY_MS = 5000;
48
+ const DEFAULT_POLL_DELAY_MS = 5000;
49
49
 
50
50
  /**
51
51
  * Production sleep function. Tests inject `sleepFn: () => Promise.resolve()`
@@ -57,7 +57,7 @@ export const CODEX_REMEDIATIONS = Object.freeze({
57
57
  *
58
58
  * Exported so tests can extend the list without monkey-patching `os`.
59
59
  */
60
- export const DEFAULT_PLUGIN_MARKERS = Object.freeze([
60
+ const DEFAULT_PLUGIN_MARKERS = Object.freeze([
61
61
  path.join(os.homedir(), '.claude', 'plugins', 'codex-plugin-cc'),
62
62
  path.join(os.homedir(), '.claude', 'plugins', 'openai', 'codex-plugin-cc'),
63
63
  ]);
@@ -194,7 +194,7 @@ export function parseCodexFindings(rawStdout) {
194
194
  * @param {{ baseRef: string, headRef: string, depth?: import('./types.js').ReviewDepth }} args
195
195
  * @returns {string}
196
196
  */
197
- export function buildCodexReviewPrompt({ baseRef, headRef, depth }) {
197
+ function buildCodexReviewPrompt({ baseRef, headRef, depth }) {
198
198
  return (
199
199
  `/codex:review --base ${baseRef} --head ${headRef} --wait ` +
200
200
  `${renderDepthDirective(depth)}`
@@ -214,7 +214,7 @@ export function buildCodexReviewPrompt({ baseRef, headRef, depth }) {
214
214
  * @param {{ baseRef: string, headRef: string, depth?: import('./types.js').ReviewDepth }} args
215
215
  * @returns {{ status: number, stdout: string, stderr: string }}
216
216
  */
217
- export function defaultInvokeCodexReview({ baseRef, headRef, depth }) {
217
+ function defaultInvokeCodexReview({ baseRef, headRef, depth }) {
218
218
  const cliArgs = [
219
219
  '--print',
220
220
  buildCodexReviewPrompt({ baseRef, headRef, depth }),
@@ -102,7 +102,7 @@ export function renderFinding(finding) {
102
102
  * @param {ReadonlyArray<string>} messages
103
103
  * @returns {string[]} lines to append (empty when no messages)
104
104
  */
105
- export function renderManualPromptsSection(messages) {
105
+ function renderManualPromptsSection(messages) {
106
106
  const filtered = Array.isArray(messages)
107
107
  ? messages.filter((m) => typeof m === 'string' && m.trim().length > 0)
108
108
  : [];
@@ -267,8 +267,14 @@ function isJsMaintainabilityFile(relPath) {
267
267
  * `reportFn` forces the serial path (the injected scorer cannot cross the
268
268
  * worker boundary).
269
269
  *
270
+ * `serialThreshold` overrides the pool-vs-serial cutover for one call. It is
271
+ * the seam the parity test uses to drive the pooled branch: Story #5109
272
+ * retuned `POOL_SERIAL_THRESHOLD` from 8 to 256 against measured data, and a
273
+ * fixture sized to clear the old number would otherwise have gone quietly
274
+ * serial while still claiming to cover the pool.
275
+ *
270
276
  * @param {string[]} changedFiles
271
- * @param {{ reportFn?: Function, classifier?: Function, runOnPoolFn?: typeof runOnPool, headRef?: string|null, gitSpawnFn?: typeof gitSpawn, readHeadSourceFn?: typeof readHeadSource }} [deps]
277
+ * @param {{ reportFn?: Function, classifier?: Function, runOnPoolFn?: typeof runOnPool, headRef?: string|null, gitSpawnFn?: typeof gitSpawn, readHeadSourceFn?: typeof readHeadSource, serialThreshold?: number }} [deps]
272
278
  * @returns {Promise<{ totalFiles: number, jsFiles: number, maintainability: object[], criticalFindings: Finding[], mediumFindings: Finding[] }>}
273
279
  */
274
280
  export async function analyzeChangedFiles(
@@ -280,6 +286,7 @@ export async function analyzeChangedFiles(
280
286
  headRef = null,
281
287
  gitSpawnFn = gitSpawn,
282
288
  readHeadSourceFn = readHeadSource,
289
+ serialThreshold = SERIAL_THRESHOLD,
283
290
  } = {},
284
291
  ) {
285
292
  const results = {
@@ -308,7 +315,7 @@ export async function analyzeChangedFiles(
308
315
  const customReportFn = reportFn != null;
309
316
 
310
317
  // Serial path: small batches, or whenever a caller injects its own scorer.
311
- if (jsFiles.length < SERIAL_THRESHOLD || customReportFn) {
318
+ if (jsFiles.length < serialThreshold || customReportFn) {
312
319
  for (let i = 0; i < jsFiles.length; i += 1) {
313
320
  const relPath = jsFiles[i];
314
321
  const source = sources[i];
@@ -28,7 +28,7 @@
28
28
  *
29
29
  * @type {Readonly<Record<ReviewDepth, string>>}
30
30
  */
31
- export const DEPTH_DIRECTIVES = Object.freeze({
31
+ const DEPTH_DIRECTIVES = Object.freeze({
32
32
  light:
33
33
  'Review depth: LIGHT. Run a single pass focused on spec adherence over the ' +
34
34
  'changed surface — confirm the change matches its stated intent. Reduce the ' +
@@ -54,7 +54,7 @@ export const DEPTH_DIRECTIVES = Object.freeze({
54
54
  * @param {unknown} depth
55
55
  * @returns {ReviewDepth}
56
56
  */
57
- export function normalizeDepth(depth) {
57
+ function normalizeDepth(depth) {
58
58
  return depth === 'light' || depth === 'deep' ? depth : 'standard';
59
59
  }
60
60
 
@@ -59,7 +59,7 @@ export const SECURITY_REVIEW_REMEDIATIONS = Object.freeze({
59
59
  * @param {{ spawnFn?: typeof spawnSync }} [opts]
60
60
  * @returns {boolean}
61
61
  */
62
- export function defaultProbeClaudeCli(opts = {}) {
62
+ function defaultProbeClaudeCli(opts = {}) {
63
63
  const spawnFn = opts.spawnFn ?? spawnSync;
64
64
  try {
65
65
  const result = spawnFn('claude', ['--version'], {
@@ -162,7 +162,7 @@ export function parseSecurityReviewFindings(rawStdout) {
162
162
  * (Story #3937) so a high-risk Epic instructs the model toward a deeper
163
163
  * second-pass review while a low-risk one keeps it light.
164
164
  */
165
- export const SECURITY_REVIEW_INVOKE_PROMPT =
165
+ const SECURITY_REVIEW_INVOKE_PROMPT =
166
166
  'Run /security-review against the diff `{baseRef}`...`{headRef}` ' +
167
167
  'for {scopeLabel} #{ticketId}. {depthDirective} After the review, emit ' +
168
168
  'ONLY a JSON array of findings on stdout with this exact shape:\n\n' +
@@ -208,7 +208,7 @@ export function buildSecurityReviewPrompt(input) {
208
208
  * @param {ReviewInput} input
209
209
  * @returns {{ status: number, stdout: string, stderr: string }}
210
210
  */
211
- export function defaultInvokeSecurityReview(input) {
211
+ function defaultInvokeSecurityReview(input) {
212
212
  const prompt = buildSecurityReviewPrompt(input);
213
213
  const result = spawnSync('claude', ['--print', prompt], {
214
214
  encoding: 'utf-8',
@@ -87,6 +87,50 @@ export function resolveWaitForMerge({
87
87
  };
88
88
  }
89
89
 
90
+ /**
91
+ * Flags this CLI advertised in its `--help` descriptor and never implemented
92
+ * (Story #5100). `--dry-run` was transcribed into the usage block by the
93
+ * #4750 self-describing sweep and read by nothing in this pipeline —
94
+ * `git log -S dryRun` over it is empty in every revision — so an operator who
95
+ * trusted it got a real base-sync merge, an `agent::blocked` Story, and a
96
+ * terminal envelope that then captured `deliver-recover`'s routing.
97
+ * `--no-evidence` names a working flag on the gate wrappers
98
+ * (`evidence-gate.js`), but the slot this CLI parsed had no reader at all.
99
+ *
100
+ * Deleting them from the descriptor is NOT enough. `parseSprintArgs` runs
101
+ * `parseArgs` with `strict: false`, so an unknown flag is silently ignored and
102
+ * the close proceeds for real — the identical injury, now with no help text to
103
+ * explain it. Fail closed instead, exactly as `parseMergeWatchMode` does for an
104
+ * unrecognized mode: throw during option parsing, before the first phase, so
105
+ * the CLI boundary reports `failed` at phase `init` and nothing is mutated.
106
+ */
107
+ const RETIRED_FLAGS = Object.freeze({
108
+ '--dry-run':
109
+ 'this pipeline has never had a dry-run mode; it was advertised in error.',
110
+ '--no-evidence':
111
+ 'per-close evidence control was never wired here; the working flag of that name belongs to the gate wrappers.',
112
+ });
113
+
114
+ /**
115
+ * Reject a retired flag before any phase runs.
116
+ *
117
+ * Matches `--flag` and `--flag=value`; a bare `--` terminator or a positional
118
+ * that merely contains the text is not a flag and does not trip it.
119
+ *
120
+ * @param {string[]} argv argv tail (`process.argv.slice(2)`)
121
+ * @throws {Error} naming the flag, why it is gone, and that nothing was mutated
122
+ */
123
+ function assertNoRetiredFlags(argv) {
124
+ for (const [flag, why] of Object.entries(RETIRED_FLAGS)) {
125
+ const present = argv.some((a) => a === flag || a.startsWith(`${flag}=`));
126
+ if (!present) continue;
127
+ throw new Error(
128
+ `${flag} was retired: ${why} Nothing was mutated — no branch, label, ` +
129
+ `comment, or PR was touched. Re-run without it to close for real.`,
130
+ );
131
+ }
132
+ }
133
+
90
134
  /**
91
135
  * Parse and resolve all CLI / injection options for `runSingleStoryClose`.
92
136
  *
@@ -116,7 +160,14 @@ export function parseCloseOptions({
116
160
  // which is precisely what `resolveFlag` already does below, preferring the
117
161
  // param over the parsed slot. One expression per flag now serves both
118
162
  // callers, so a new flag is added in one place instead of two that can drift.
119
- const parsed = storyIdParam === undefined ? parseSprintArgs() : {};
163
+ // The guard runs on the argv door only: an injecting caller supplies
164
+ // `storyIdParam` and never reads argv, so the host process's flags (a test
165
+ // runner's, say) are none of its business.
166
+ let parsed = {};
167
+ if (storyIdParam === undefined) {
168
+ assertNoRetiredFlags(process.argv.slice(2));
169
+ parsed = parseSprintArgs();
170
+ }
120
171
  // Preserve undefined so resolveWaitForMerge can apply the closeAndLand
121
172
  // config default when neither flag was supplied.
122
173
  const waitForMergeExplicit = resolveFlag(
@@ -63,7 +63,7 @@ export const FORMAT_AUTOFIX_TIMEOUT_EXIT_CODE = 124;
63
63
  * @param {(args: string[], opts: object) => string} git
64
64
  * @returns {string[]}
65
65
  */
66
- export function listDirtyPaths(cwd, git) {
66
+ function listDirtyPaths(cwd, git) {
67
67
  const out = git(['status', '--porcelain'], {
68
68
  cwd,
69
69
  encoding: 'utf8',
@@ -91,10 +91,7 @@ export function listDirtyPaths(cwd, git) {
91
91
  * }} [opts]
92
92
  * @returns {{ writeCmdString: string, writeCmd: string, writeArgs: string[] }}
93
93
  */
94
- export function resolveFormatterCmd({
95
- commands,
96
- dropTrailingDot = false,
97
- } = {}) {
94
+ function resolveFormatterCmd({ commands, dropTrailingDot = false } = {}) {
98
95
  // `resolveFormatWriteCommand` reads `config.project.commands`; wrap the
99
96
  // caller-supplied `commands` map into that canonical shape.
100
97
  const writeCmdString = resolveFormatWriteCommand({ project: { commands } });
@@ -24,7 +24,7 @@ import {
24
24
  } from './retro-proposals.js';
25
25
  import { upsertStructuredComment } from './ticketing.js';
26
26
 
27
- export const FOLLOW_UPS_COMMENT_TYPE = 'follow-ups';
27
+ const FOLLOW_UPS_COMMENT_TYPE = 'follow-ups';
28
28
 
29
29
  /** Milliseconds in one day — the unit `frictionWindowDays` is expressed in. */
30
30
  const MS_PER_DAY = 24 * 60 * 60 * 1000;