mandrel 2.35.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 (108) hide show
  1. package/.agents/docs/agentrc-reference.json +3 -1
  2. package/.agents/docs/configuration.md +27 -0
  3. package/.agents/rules/testing-standards.md +56 -0
  4. package/.agents/schemas/agentrc.schema.json +11 -0
  5. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  6. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  7. package/.agents/scripts/boot-sweep.js +7 -1
  8. package/.agents/scripts/check-context-budget.js +195 -103
  9. package/.agents/scripts/check-cyclomatic.js +112 -42
  10. package/.agents/scripts/check-doc-links.js +23 -2
  11. package/.agents/scripts/check-generated-validator.js +202 -0
  12. package/.agents/scripts/check-lifecycle-lint.js +2 -68
  13. package/.agents/scripts/check-schema-references.js +2 -28
  14. package/.agents/scripts/drain-pending-cleanup.js +18 -1
  15. package/.agents/scripts/git-cleanup.js +2 -0
  16. package/.agents/scripts/lib/baselines/components.js +32 -2
  17. package/.agents/scripts/lib/baselines/env-overrides.js +1 -1
  18. package/.agents/scripts/lib/bootstrap/branch-protection.js +1 -1
  19. package/.agents/scripts/lib/bootstrap/install-ledger.js +1 -1
  20. package/.agents/scripts/lib/bootstrap/prompt.js +1 -1
  21. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +2 -2
  22. package/.agents/scripts/lib/checks/loop-health.js +1 -1
  23. package/.agents/scripts/lib/cli-args.js +0 -2
  24. package/.agents/scripts/lib/close-validation/gates.js +1 -1
  25. package/.agents/scripts/lib/close-validation/process.js +1 -1
  26. package/.agents/scripts/lib/config/ci.js +18 -0
  27. package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
  28. package/.agents/scripts/lib/config-settings-schema-delivery.js +13 -0
  29. package/.agents/scripts/lib/config-settings-schema.js +46 -6
  30. package/.agents/scripts/lib/coverage-baseline.js +2 -2
  31. package/.agents/scripts/lib/cpu-pool.js +90 -10
  32. package/.agents/scripts/lib/crap-utils.js +6 -2
  33. package/.agents/scripts/lib/cyclomatic-ceiling.js +28 -6
  34. package/.agents/scripts/lib/cyclomatic-scope.js +144 -0
  35. package/.agents/scripts/lib/dependency-version.js +86 -0
  36. package/.agents/scripts/lib/duplicate-search.js +1 -1
  37. package/.agents/scripts/lib/errors/index.js +1 -1
  38. package/.agents/scripts/lib/feedback-loop/graduator-core.js +2 -2
  39. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +1 -1
  40. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +1 -1
  41. package/.agents/scripts/lib/generated/agentrc-validator.js +16 -0
  42. package/.agents/scripts/lib/install-cmd-parser.js +1 -1
  43. package/.agents/scripts/lib/knip-entry-sync.js +1 -57
  44. package/.agents/scripts/lib/maintainability-utils.js +108 -10
  45. package/.agents/scripts/lib/observability/source-classifier.js +1 -1
  46. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +22 -7
  47. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -14
  48. package/.agents/scripts/lib/orchestration/git-cleanup/phases/merged-tip.js +132 -0
  49. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +56 -11
  50. package/.agents/scripts/lib/orchestration/merge-block-class.js +10 -1
  51. package/.agents/scripts/lib/orchestration/merge-poll.js +164 -0
  52. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +70 -9
  53. package/.agents/scripts/lib/orchestration/pr-watch.js +1 -30
  54. package/.agents/scripts/lib/orchestration/reassert-status-column.js +2 -2
  55. package/.agents/scripts/lib/orchestration/review-providers/codex.js +3 -3
  56. package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +1 -1
  57. package/.agents/scripts/lib/orchestration/review-providers/native.js +9 -2
  58. package/.agents/scripts/lib/orchestration/review-providers/review-depth.js +2 -2
  59. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +3 -3
  60. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +145 -0
  61. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -5
  62. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +52 -1
  63. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +9 -1
  64. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +2 -5
  65. package/.agents/scripts/lib/orchestration/story-follow-ups.js +1 -1
  66. package/.agents/scripts/lib/orchestration/ticket-lease.js +34 -9
  67. package/.agents/scripts/lib/orchestration/ticketing/reads.js +3 -1
  68. package/.agents/scripts/lib/preflight-runner.js +3 -3
  69. package/.agents/scripts/lib/qa/qa-session.js +1 -1
  70. package/.agents/scripts/lib/runtime-deps/manifest.js +1 -7
  71. package/.agents/scripts/lib/runtime-deps/scan-imports.js +3 -58
  72. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +284 -25
  73. package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
  74. package/.agents/scripts/lib/source-text/strip-js-comments.js +115 -0
  75. package/.agents/scripts/lib/test-isolate/runner.js +3 -3
  76. package/.agents/scripts/lib/test-runner-contract.js +14 -6
  77. package/.agents/scripts/lib/test-tiers.js +135 -18
  78. package/.agents/scripts/lib/transpile.js +16 -3
  79. package/.agents/scripts/lib/worktree/lifecycle/pending-cleanup.js +169 -31
  80. package/.agents/scripts/lib/worktree/node-modules-strategy.js +2 -5
  81. package/.agents/scripts/notify.js +4 -10
  82. package/.agents/scripts/pr-watch-with-update.js +305 -137
  83. package/.agents/scripts/providers/github/tickets.js +109 -16
  84. package/.agents/scripts/quality-preview.js +162 -70
  85. package/.agents/scripts/run-test-profile.js +8 -5
  86. package/.agents/scripts/run-tests.js +79 -14
  87. package/.agents/scripts/single-story-close.js +0 -2
  88. package/.agents/scripts/single-story-init.js +5 -1
  89. package/.agents/workflows/audit-documentation.md +5 -6
  90. package/docs/CHANGELOG.md +50 -0
  91. package/lib/cli/migrate.js +100 -94
  92. package/lib/migrations/helpers/retire-agentrc-key.js +200 -0
  93. package/lib/migrations/index.js +32 -33
  94. package/lib/migrations/steps/2.1.0-retire-mi-drop-knobs.js +21 -80
  95. package/lib/migrations/steps/2.1.0-retire-verify-concurrency-cap.js +15 -87
  96. package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +13 -71
  97. package/lib/migrations/steps/2.20.0-retire-codebase-snapshot.js +13 -101
  98. package/lib/migrations/steps/2.32.0-retire-lint-baseline-command.js +13 -100
  99. package/package.json +9 -5
  100. package/.agents/scripts/generate-lifecycle-docs.js +0 -237
  101. package/.agents/scripts/lib/audit-suite/frontmatter-lint.js +0 -32
  102. package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +0 -37
  103. package/.agents/scripts/lib/cli/parse-numeric.js +0 -60
  104. package/.agents/scripts/lib/close-validation/telemetry.js +0 -79
  105. package/.agents/scripts/lib/orchestration/label-transitions.js +0 -44
  106. package/.agents/scripts/lib/orchestration/parked-follow-ons.js +0 -147
  107. package/.agents/scripts/lib/orchestration/phase-runner.js +0 -88
  108. package/.agents/scripts/lib/orchestration/recut.js +0 -56
@@ -54,6 +54,7 @@
54
54
  import {
55
55
  migrations as defaultRegistry,
56
56
  runMigrations as defaultRunMigrations,
57
+ selectStepsInRange,
57
58
  } from '../migrations/index.js';
58
59
 
59
60
  /**
@@ -77,36 +78,97 @@ function parseOption(argv, flag) {
77
78
  }
78
79
 
79
80
  /**
80
- * Parse a dotted semver-ish string into a numeric tuple. Mirrors the runner's
81
- * own comparator so the dry-run in-range filter matches the live filter
82
- * exactly.
81
+ * The envelope shape every `runMigrate` exit returns. Centralised so the four
82
+ * exits cannot drift in which fields they populate.
83
83
  *
84
- * @param {string} version
85
- * @returns {[number, number, number]}
84
+ * @param {object} fields
85
+ * @returns {object}
86
86
  */
87
- function parseVersion(version) {
88
- const [major, minor, patch] = String(version).split('.');
89
- return [
90
- Number.parseInt(major, 10) || 0,
91
- Number.parseInt(minor, 10) || 0,
92
- Number.parseInt(patch, 10) || 0,
93
- ];
87
+ function migrateResult(fields) {
88
+ return {
89
+ ok: false,
90
+ action: 'usage-error',
91
+ fromVersion: null,
92
+ toVersion: null,
93
+ dryRun: false,
94
+ applied: [],
95
+ skipped: [],
96
+ wouldApply: [],
97
+ wouldSkip: [],
98
+ ...fields,
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Both bounds are required: the runner filters `fromVersion < v <= toVersion`,
104
+ * so an absent bound is ambiguous rather than a sensible default.
105
+ *
106
+ * @param {{ writeErr: (s: string) => void, exit: (code: number) => void }} io
107
+ * @returns {void}
108
+ */
109
+ function reportUsageError({ writeErr, exit }) {
110
+ writeErr(
111
+ 'mandrel migrate: both --from <version> and --to <version> are required.\n' +
112
+ ' → Usage: mandrel migrate --from <version> --to <version> [--dry-run]\n',
113
+ );
114
+ exit(1);
94
115
  }
95
116
 
96
117
  /**
97
- * Compare two version strings (standard `Array.sort` comparator contract).
118
+ * Report the in-range steps and whether each WOULD apply or be skipped,
119
+ * probing a throwaway context so no step's `apply` runs and nothing on disk
120
+ * changes. The ordering comes from `selectStepsInRange`, the same selector the
121
+ * live run uses, so the preview cannot disagree with what would happen.
98
122
  *
99
- * @param {string} a
100
- * @param {string} b
101
- * @returns {number}
123
+ * @param {{
124
+ * registry: Array<object>,
125
+ * fromVersion: string,
126
+ * toVersion: string,
127
+ * write: (s: string) => void,
128
+ * }} params
129
+ * @returns {{ wouldApply: string[], wouldSkip: string[] }}
102
130
  */
103
- function compareVersions(a, b) {
104
- const pa = parseVersion(a);
105
- const pb = parseVersion(b);
106
- for (let i = 0; i < 3; i += 1) {
107
- if (pa[i] !== pb[i]) return pa[i] - pb[i];
131
+ function previewMigrations({ registry, fromVersion, toVersion, write }) {
132
+ const inRange = selectStepsInRange({ registry, fromVersion, toVersion });
133
+ const wouldApply = [];
134
+ const wouldSkip = [];
135
+ const probeCtx = {};
136
+
137
+ write(`mandrel migrate — dry run v${fromVersion} → v${toVersion}\n`);
138
+ if (inRange.length === 0) {
139
+ write(' (no migration steps in range)\n');
108
140
  }
109
- return 0;
141
+ for (const step of inRange) {
142
+ const bucket = step.detect(probeCtx) ? wouldApply : wouldSkip;
143
+ const verb = bucket === wouldApply ? 'would apply ' : 'would skip ';
144
+ bucket.push(step.version);
145
+ write(` ${verb} ${step.version}: ${step.description}\n`);
146
+ }
147
+ write('Dry run: no migrations applied, nothing written.\n');
148
+
149
+ return { wouldApply, wouldSkip };
150
+ }
151
+
152
+ /**
153
+ * @param {{
154
+ * applied: string[],
155
+ * fromVersion: string,
156
+ * toVersion: string,
157
+ * write: (s: string) => void,
158
+ * }} params
159
+ * @returns {void}
160
+ */
161
+ function reportApplied({ applied, fromVersion, toVersion, write }) {
162
+ if (applied.length === 0) {
163
+ write(
164
+ `mandrel migrate: no migrations to apply for v${fromVersion} → v${toVersion}.\n`,
165
+ );
166
+ return;
167
+ }
168
+ const plural = applied.length === 1 ? '' : 's';
169
+ write(
170
+ `✅ Applied ${applied.length} migration${plural} (v${fromVersion} → v${toVersion}).\n`,
171
+ );
110
172
  }
111
173
 
112
174
  /**
@@ -146,107 +208,51 @@ export function runMigrate({
146
208
  const fromVersion = parseOption(argv, '--from');
147
209
  const toVersion = parseOption(argv, '--to');
148
210
 
149
- // --- Usage validation -----------------------------------------------------
150
- // Both bounds are required: the runner filters fromVersion < v <= toVersion,
151
- // so an absent bound is ambiguous rather than a sensible default.
152
211
  if (!fromVersion || !toVersion) {
153
- writeErr(
154
- 'mandrel migrate: both --from <version> and --to <version> are required.\n' +
155
- ' → Usage: mandrel migrate --from <version> --to <version> [--dry-run]\n',
156
- );
157
- exit(1);
158
- return {
159
- ok: false,
160
- action: 'usage-error',
212
+ reportUsageError({ writeErr, exit });
213
+ return migrateResult({
161
214
  fromVersion: fromVersion ?? null,
162
215
  toVersion: toVersion ?? null,
163
216
  dryRun,
164
- applied: [],
165
- skipped: [],
166
- wouldApply: [],
167
- wouldSkip: [],
168
- };
217
+ });
169
218
  }
170
219
 
171
- // --- Dry run --------------------------------------------------------------
172
- // Report the in-range steps and whether each WOULD apply or be skipped,
173
- // probing a throwaway context so no step's apply runs and nothing on disk
174
- // changes.
175
220
  if (dryRun) {
176
- const inRange = registry
177
- .filter(
178
- (step) =>
179
- compareVersions(step.version, fromVersion) > 0 &&
180
- compareVersions(step.version, toVersion) <= 0,
181
- )
182
- .sort((a, b) => compareVersions(a.version, b.version));
183
-
184
- const wouldApply = [];
185
- const wouldSkip = [];
186
- const probeCtx = {};
187
-
188
- write(`mandrel migrate — dry run v${fromVersion} → v${toVersion}\n`);
189
- if (inRange.length === 0) {
190
- write(' (no migration steps in range)\n');
191
- }
192
- for (const step of inRange) {
193
- const willApply = step.detect(probeCtx);
194
- if (willApply) {
195
- wouldApply.push(step.version);
196
- write(` would apply ${step.version}: ${step.description}\n`);
197
- } else {
198
- wouldSkip.push(step.version);
199
- write(` would skip ${step.version}: ${step.description}\n`);
200
- }
201
- }
202
- write('Dry run: no migrations applied, nothing written.\n');
203
-
204
- return {
221
+ const { wouldApply, wouldSkip } = previewMigrations({
222
+ registry,
223
+ fromVersion,
224
+ toVersion,
225
+ write,
226
+ });
227
+ return migrateResult({
205
228
  ok: true,
206
229
  action: 'dry-run',
207
230
  fromVersion,
208
231
  toVersion,
209
232
  dryRun: true,
210
- applied: [],
211
- skipped: [],
212
233
  wouldApply,
213
234
  wouldSkip,
214
- };
235
+ });
215
236
  }
216
237
 
217
- // --- Live run -------------------------------------------------------------
218
- // Delegate to the runner, which owns ordering, range filtering, idempotency,
219
- // and the per-step `migrated …` log line.
238
+ // The runner owns ordering, range filtering, idempotency, and the per-step
239
+ // `migrated …` log line.
220
240
  const { applied, skipped } = runMigrations({
221
241
  fromVersion,
222
242
  toVersion,
223
243
  ctx,
224
244
  registry,
225
245
  });
246
+ reportApplied({ applied, fromVersion, toVersion, write });
226
247
 
227
- if (applied.length === 0) {
228
- write(
229
- `mandrel migrate: no migrations to apply for v${fromVersion} → v${toVersion}.\n`,
230
- );
231
- } else {
232
- write(
233
- `✅ Applied ${applied.length} migration${
234
- applied.length === 1 ? '' : 's'
235
- } (v${fromVersion} → v${toVersion}).\n`,
236
- );
237
- }
238
-
239
- return {
248
+ return migrateResult({
240
249
  ok: true,
241
250
  action: 'migrated',
242
251
  fromVersion,
243
252
  toVersion,
244
- dryRun: false,
245
253
  applied,
246
254
  skipped,
247
- wouldApply: [],
248
- wouldSkip: [],
249
- };
255
+ });
250
256
  }
251
257
 
252
258
  /**
@@ -0,0 +1,200 @@
1
+ // lib/migrations/helpers/retire-agentrc-key.js
2
+ /**
3
+ * The shared scaffold behind every "strip a retired `.agentrc.json` key"
4
+ * migration step.
5
+ *
6
+ * Retiring a config key is the framework's most common migration: a key is
7
+ * dropped from the runtime AJV schema, the block it lived under carries
8
+ * `additionalProperties: false`, and a consumer whose config still sets it
9
+ * hits a hard validation failure on upgrade rather than a warning. Every such
10
+ * step reads the config, decides whether the key is present, deletes it,
11
+ * prunes whatever containers that emptied, and writes the file back — the
12
+ * same mechanics each time, previously copy-pasted per step (jscpd recorded
13
+ * five of them between 48 and 58 percent duplicated).
14
+ *
15
+ * Only three things actually vary between steps, so only those three are
16
+ * declared:
17
+ *
18
+ * - **which config surfaces to sweep** — `.agentrc.json` alone, or that plus
19
+ * the gitignored `.agentrc.local.json`. `config-resolver.js` deep-merges
20
+ * the overlay over the base *before* the AJV gate runs, so a key surviving
21
+ * in the overlay fails exactly as a base one would. A step that swept only
22
+ * the base would report "nothing to migrate" and leave that consumer hard
23
+ * broken with no self-service remedy.
24
+ * - **which key paths are retired** — a step may retire more than one.
25
+ * - **how far to prune emptied ancestors** — this is deliberately per-key
26
+ * and not a global policy. `planning` is optional, so an emptied
27
+ * `planning` block is removed; `project` is required and an empty
28
+ * `commands` object is valid against the schema, so pruning it would be a
29
+ * cosmetic edit to a config the consumer owns.
30
+ *
31
+ * Builtins only, and the `fs` seam stays injectable via `ctx.fs`, because
32
+ * these steps run during `mandrel update` before third-party packages are
33
+ * guaranteed to be present.
34
+ */
35
+
36
+ import nodeFs from 'node:fs';
37
+ import path from 'node:path';
38
+
39
+ /** The committed config every consumer has. */
40
+ export const AGENTRC_BASE_FILENAME = '.agentrc.json';
41
+
42
+ /** The operator-owned, gitignored overlay the resolver merges over the base. */
43
+ const AGENTRC_LOCAL_FILENAME = '.agentrc.local.json';
44
+
45
+ /**
46
+ * Both surfaces the resolver reads — the default sweep for a retired key.
47
+ *
48
+ * Module-local: a step that wants both surfaces takes the default and names
49
+ * nothing, so exporting this would ship a symbol with no importer.
50
+ */
51
+ const AGENTRC_FILENAMES = Object.freeze([
52
+ AGENTRC_BASE_FILENAME,
53
+ AGENTRC_LOCAL_FILENAME,
54
+ ]);
55
+
56
+ /**
57
+ * @param {unknown} ctx
58
+ * @param {string} filename
59
+ * @returns {string}
60
+ */
61
+ function resolveAgentrcPath(ctx, filename) {
62
+ const projectRoot = ctx?.projectRoot ?? process.cwd();
63
+ return path.join(projectRoot, filename);
64
+ }
65
+
66
+ /**
67
+ * Read and parse one config surface. An absent or unparseable file is the
68
+ * common case (no overlay, fresh checkout), not an error.
69
+ *
70
+ * @param {unknown} ctx
71
+ * @param {string} filename
72
+ * @param {typeof nodeFs} fsImpl
73
+ * @returns {object | null}
74
+ */
75
+ function readAgentrcConfig(ctx, filename, fsImpl) {
76
+ try {
77
+ const raw = fsImpl.readFileSync(resolveAgentrcPath(ctx, filename), 'utf8');
78
+ return JSON.parse(raw);
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Walk the container chain for a key path, returning every object along it.
86
+ * Returns `null` as soon as a level is missing or is not a plain object, which
87
+ * is what makes `hasKey` false for a config that never had the block.
88
+ *
89
+ * @param {object | null} config
90
+ * @param {string[]} keyPath
91
+ * @returns {object[] | null}
92
+ */
93
+ function resolveContainers(config, keyPath) {
94
+ if (!config || typeof config !== 'object') return null;
95
+ const containers = [config];
96
+ let cursor = config;
97
+ for (const segment of keyPath.slice(0, -1)) {
98
+ const next = cursor[segment];
99
+ if (!next || typeof next !== 'object') return null;
100
+ containers.push(next);
101
+ cursor = next;
102
+ }
103
+ return containers;
104
+ }
105
+
106
+ /**
107
+ * @param {object | null} config
108
+ * @param {string[]} keyPath
109
+ * @returns {boolean}
110
+ */
111
+ function hasKey(config, keyPath) {
112
+ const containers = resolveContainers(config, keyPath);
113
+ if (!containers) return false;
114
+ const leafOwner = containers[containers.length - 1];
115
+ return Object.hasOwn(leafOwner, keyPath[keyPath.length - 1]);
116
+ }
117
+
118
+ /**
119
+ * Delete the leaf key, then remove up to `pruneDepth` ancestors that the
120
+ * deletion left empty. `pruneDepth: 0` deletes the key and nothing else.
121
+ *
122
+ * @param {object} config
123
+ * @param {{ path: string[], pruneDepth?: number }} key
124
+ * @returns {void}
125
+ */
126
+ function stripKey(config, key) {
127
+ const keyPath = key.path;
128
+ const containers = resolveContainers(config, keyPath);
129
+ if (!containers) return;
130
+
131
+ const leafOwner = containers[containers.length - 1];
132
+ delete leafOwner[keyPath[keyPath.length - 1]];
133
+
134
+ const pruneDepth = key.pruneDepth ?? 0;
135
+ for (let level = 0; level < pruneDepth; level += 1) {
136
+ const index = containers.length - 1 - level;
137
+ const emptied = containers[index];
138
+ const parent = containers[index - 1];
139
+ if (!parent || Object.keys(emptied).length > 0) break;
140
+ delete parent[keyPath[index - 1]];
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Build a migration step that strips one or more retired keys from the
146
+ * consumer's config surfaces.
147
+ *
148
+ * `detect` is true when any declared key is present in any swept surface;
149
+ * `apply` rewrites only the surfaces that actually carry one, which is what
150
+ * makes a repeat pass a genuine no-op rather than a reformat.
151
+ *
152
+ * @param {{
153
+ * version: string,
154
+ * description: string,
155
+ * filenames?: readonly string[],
156
+ * keys: Array<{ path: string[], pruneDepth?: number }>,
157
+ * }} spec
158
+ * @returns {{
159
+ * version: string,
160
+ * description: string,
161
+ * detect: (ctx?: { projectRoot?: string, fs?: typeof nodeFs }) => boolean,
162
+ * apply: (ctx?: { projectRoot?: string, fs?: typeof nodeFs }) => void,
163
+ * }}
164
+ */
165
+ export function createRetireAgentrcKeyStep({
166
+ version,
167
+ description,
168
+ filenames = AGENTRC_FILENAMES,
169
+ keys,
170
+ }) {
171
+ const carriesRetiredKey = (config) =>
172
+ keys.some((key) => hasKey(config, key.path));
173
+
174
+ return {
175
+ version,
176
+ description,
177
+ detect(ctx) {
178
+ const fsImpl = ctx?.fs ?? nodeFs;
179
+ return filenames.some((filename) =>
180
+ carriesRetiredKey(readAgentrcConfig(ctx, filename, fsImpl)),
181
+ );
182
+ },
183
+ apply(ctx) {
184
+ const fsImpl = ctx?.fs ?? nodeFs;
185
+ for (const filename of filenames) {
186
+ const config = readAgentrcConfig(ctx, filename, fsImpl);
187
+ if (!carriesRetiredKey(config)) continue;
188
+
189
+ for (const key of keys) {
190
+ if (hasKey(config, key.path)) stripKey(config, key);
191
+ }
192
+
193
+ fsImpl.writeFileSync(
194
+ resolveAgentrcPath(ctx, filename),
195
+ `${JSON.stringify(config, null, 2)}\n`,
196
+ );
197
+ }
198
+ },
199
+ };
200
+ }
@@ -52,6 +52,7 @@
52
52
  * (ascending by version) with an idempotent `detect`/`apply`.
53
53
  */
54
54
 
55
+ import { compareVersions } from '../cli/version-helpers.js';
55
56
  import { retireMiDropKnobs } from './steps/2.1.0-retire-mi-drop-knobs.js';
56
57
  import { retireVerifyConcurrencyCap } from './steps/2.1.0-retire-verify-concurrency-cap.js';
57
58
  import { retireEpicAcTags } from './steps/2.2.0-retire-epic-ac-tags.js';
@@ -80,38 +81,42 @@ export const migrations = [
80
81
  ];
81
82
 
82
83
  /**
83
- * Parse a dotted semver-ish string into a numeric tuple for comparison.
84
- * Non-numeric or missing segments coerce to 0 so a partial version
85
- * (`'1.4'`) still compares sanely against a full one (`'1.4.0'`).
84
+ * The version ordering every migration decision uses.
86
85
  *
87
- * @param {string} version
88
- * @returns {[number, number, number]}
86
+ * Re-exported from `lib/cli/version-helpers.js` — the dependency-free leaf
87
+ * that owns the canonical `parseVersion` / `compareVersions` pair (Story
88
+ * #4048 B3). The runner previously carried its own copy, so an ordering fix
89
+ * had to be made in three places or the copies drifted apart. Re-exporting
90
+ * rather than moving keeps this module's public surface intact for the
91
+ * callers that import `compareVersions` from here.
89
92
  */
90
- function parseVersion(version) {
91
- const [major, minor, patch] = String(version).split('.');
92
- return [
93
- Number.parseInt(major, 10) || 0,
94
- Number.parseInt(minor, 10) || 0,
95
- Number.parseInt(patch, 10) || 0,
96
- ];
97
- }
93
+ export { compareVersions };
98
94
 
99
95
  /**
100
- * Compare two version strings. Returns a negative number when `a < b`, zero
101
- * when equal, and a positive number when `a > b` the standard `Array.sort`
102
- * comparator contract.
96
+ * The steps that apply for an upgrade, in the order they must run:
97
+ * `fromVersion < step.version <= toVersion`, ascending.
98
+ *
99
+ * Exported because `mandrel migrate --dry-run` has to preview exactly what the
100
+ * live run would do. That preview previously re-implemented this filter and
101
+ * carried a comment promising it "mirrors the runner's own comparator" — a
102
+ * sync obligation nothing enforced, and the reason a third `compareVersions`
103
+ * copy existed. One selector means the preview cannot drift from the run.
103
104
  *
104
- * @param {string} a
105
- * @param {string} b
106
- * @returns {number}
105
+ * @param {{ registry?: Array<object>, fromVersion: string, toVersion: string }} params
106
+ * @returns {Array<object>}
107
107
  */
108
- export function compareVersions(a, b) {
109
- const pa = parseVersion(a);
110
- const pb = parseVersion(b);
111
- for (let i = 0; i < 3; i += 1) {
112
- if (pa[i] !== pb[i]) return pa[i] - pb[i];
113
- }
114
- return 0;
108
+ export function selectStepsInRange({
109
+ registry = migrations,
110
+ fromVersion,
111
+ toVersion,
112
+ }) {
113
+ return registry
114
+ .filter(
115
+ (step) =>
116
+ compareVersions(step.version, fromVersion) > 0 &&
117
+ compareVersions(step.version, toVersion) <= 0,
118
+ )
119
+ .sort((a, b) => compareVersions(a.version, b.version));
115
120
  }
116
121
 
117
122
  /**
@@ -152,13 +157,7 @@ export function runMigrations({
152
157
  log = console.log,
153
158
  registry = migrations,
154
159
  } = {}) {
155
- const inRange = registry
156
- .filter(
157
- (step) =>
158
- compareVersions(step.version, fromVersion) > 0 &&
159
- compareVersions(step.version, toVersion) <= 0,
160
- )
161
- .sort((a, b) => compareVersions(a.version, b.version));
160
+ const inRange = selectStepsInRange({ registry, fromVersion, toVersion });
162
161
 
163
162
  const applied = [];
164
163
  const skipped = [];
@@ -11,90 +11,31 @@
11
11
  * sub-schemas with `additionalProperties: false`, so a consumer whose
12
12
  * config still carries either key hits a hard AJV validation failure on
13
13
  * upgrade, not a warning. This step strips them before that check runs.
14
+ *
15
+ * Unlike the later retire-steps this one sweeps the committed config only:
16
+ * the knobs were written by the bootstrap script, never hand-pinned in an
17
+ * operator's `.agentrc.local.json`.
14
18
  */
15
19
 
16
- import nodeFs from 'node:fs';
17
- import path from 'node:path';
18
-
19
- const AGENTRC_FILENAME = '.agentrc.json';
20
-
21
- /**
22
- * @param {unknown} ctx
23
- * @returns {string}
24
- */
25
- function resolveAgentrcPath(ctx) {
26
- const projectRoot = ctx?.projectRoot ?? process.cwd();
27
- return path.join(projectRoot, AGENTRC_FILENAME);
28
- }
29
-
30
- /**
31
- * @param {unknown} ctx
32
- * @param {typeof nodeFs} fsImpl
33
- * @returns {object | null}
34
- */
35
- function readAgentrcConfig(ctx, fsImpl) {
36
- try {
37
- const raw = fsImpl.readFileSync(resolveAgentrcPath(ctx), 'utf8');
38
- return JSON.parse(raw);
39
- } catch {
40
- return null;
41
- }
42
- }
43
-
44
- /**
45
- * @param {object | null} config
46
- * @returns {boolean}
47
- */
48
- function hasRetiredKey(config) {
49
- const guardrails = config?.delivery?.quality?.codingGuardrails;
50
- const autoRefresh = config?.delivery?.quality?.autoRefresh;
51
- return (
52
- (Boolean(guardrails) && Object.hasOwn(guardrails, 'miDropMustRefactor')) ||
53
- (Boolean(autoRefresh) && Object.hasOwn(autoRefresh, 'miDropCap'))
54
- );
55
- }
20
+ import {
21
+ AGENTRC_BASE_FILENAME,
22
+ createRetireAgentrcKeyStep,
23
+ } from '../helpers/retire-agentrc-key.js';
56
24
 
57
- export const retireMiDropKnobs = {
25
+ export const retireMiDropKnobs = createRetireAgentrcKeyStep({
58
26
  version: '2.1.0',
59
27
  description:
60
28
  'strip retired delivery.quality.codingGuardrails.miDropMustRefactor ' +
61
29
  'and delivery.quality.autoRefresh.miDropCap from .agentrc.json',
62
- /**
63
- * @param {{ projectRoot?: string, fs?: typeof nodeFs }} [ctx]
64
- * @returns {boolean}
65
- */
66
- detect(ctx) {
67
- const fsImpl = ctx?.fs ?? nodeFs;
68
- return hasRetiredKey(readAgentrcConfig(ctx, fsImpl));
69
- },
70
- /**
71
- * @param {{ projectRoot?: string, fs?: typeof nodeFs }} [ctx]
72
- * @returns {void}
73
- */
74
- apply(ctx) {
75
- const fsImpl = ctx?.fs ?? nodeFs;
76
- const config = readAgentrcConfig(ctx, fsImpl);
77
- if (!config) return;
78
-
79
- const guardrails = config.delivery?.quality?.codingGuardrails;
80
- if (guardrails && Object.hasOwn(guardrails, 'miDropMustRefactor')) {
81
- delete guardrails.miDropMustRefactor;
82
- if (Object.keys(guardrails).length === 0) {
83
- delete config.delivery.quality.codingGuardrails;
84
- }
85
- }
86
-
87
- const autoRefresh = config.delivery?.quality?.autoRefresh;
88
- if (autoRefresh && Object.hasOwn(autoRefresh, 'miDropCap')) {
89
- delete autoRefresh.miDropCap;
90
- if (Object.keys(autoRefresh).length === 0) {
91
- delete config.delivery.quality.autoRefresh;
92
- }
93
- }
94
-
95
- fsImpl.writeFileSync(
96
- resolveAgentrcPath(ctx),
97
- `${JSON.stringify(config, null, 2)}\n`,
98
- );
99
- },
100
- };
30
+ filenames: [AGENTRC_BASE_FILENAME],
31
+ keys: [
32
+ {
33
+ path: ['delivery', 'quality', 'codingGuardrails', 'miDropMustRefactor'],
34
+ pruneDepth: 1,
35
+ },
36
+ {
37
+ path: ['delivery', 'quality', 'autoRefresh', 'miDropCap'],
38
+ pruneDepth: 1,
39
+ },
40
+ ],
41
+ });