mandrel 2.56.0 → 2.58.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 (114) hide show
  1. package/.agents/agents/plan-critic.md +13 -18
  2. package/.agents/agents/story-worker.md +25 -33
  3. package/.agents/docs/agentrc-reference.json +0 -30
  4. package/.agents/docs/configuration.md +8 -28
  5. package/.agents/docs/execution-reference.md +5 -5
  6. package/.agents/docs/quality-gates.md +8 -7
  7. package/.agents/instructions.md +9 -10
  8. package/.agents/schemas/agentrc.schema.json +9 -185
  9. package/.agents/schemas/story-deliver-terminal.schema.json +1 -1
  10. package/.agents/scripts/acceptance-eval.js +107 -17
  11. package/.agents/scripts/ceremony-derive.js +191 -0
  12. package/.agents/scripts/check-context-budget.js +28 -33
  13. package/.agents/scripts/check-cyclomatic.js +4 -3
  14. package/.agents/scripts/deliver-light.js +31 -94
  15. package/.agents/scripts/evidence-gate.js +17 -1
  16. package/.agents/scripts/lib/audit-suite/checklist-threading.js +15 -2
  17. package/.agents/scripts/lib/baselines/coverage-updater-cli.js +110 -0
  18. package/.agents/scripts/lib/baselines/crap-preview-scan.js +25 -0
  19. package/.agents/scripts/lib/baselines/crap-updater-cli.js +223 -0
  20. package/.agents/scripts/lib/bdd-scenario-budget.js +21 -3
  21. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +0 -1
  22. package/.agents/scripts/lib/close-validation/gates.js +52 -1
  23. package/.agents/scripts/lib/config/acceptance-eval.js +25 -57
  24. package/.agents/scripts/lib/config/delivery-routing.js +7 -33
  25. package/.agents/scripts/lib/config/explain.js +0 -19
  26. package/.agents/scripts/lib/config/limits.js +18 -78
  27. package/.agents/scripts/lib/config/quality.js +6 -3
  28. package/.agents/scripts/lib/config/runners.js +3 -2
  29. package/.agents/scripts/lib/config-settings-schema-delivery.js +15 -68
  30. package/.agents/scripts/lib/config-settings-schema-quality.js +0 -14
  31. package/.agents/scripts/lib/config-settings-schema.js +16 -143
  32. package/.agents/scripts/lib/crap-engine.js +35 -4
  33. package/.agents/scripts/lib/crap-utils.js +17 -1
  34. package/.agents/scripts/lib/cyclomatic-ceiling.js +19 -7
  35. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  36. package/.agents/scripts/lib/observability/runtime-friction.js +1 -1
  37. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  38. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +5 -4
  39. package/.agents/scripts/lib/orchestration/ceremony-routing.js +19 -73
  40. package/.agents/scripts/lib/orchestration/code-review.js +7 -3
  41. package/.agents/scripts/lib/orchestration/complexity-gate.js +46 -212
  42. package/.agents/scripts/lib/orchestration/file-assumptions.js +32 -17
  43. package/.agents/scripts/lib/orchestration/light-escalation.js +3 -3
  44. package/.agents/scripts/lib/orchestration/light-suitability.js +66 -233
  45. package/.agents/scripts/lib/orchestration/pinned-identifier-lint.js +137 -0
  46. package/.agents/scripts/lib/orchestration/plan-context.js +189 -387
  47. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +42 -153
  48. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +14 -70
  49. package/.agents/scripts/lib/orchestration/plan-persist/acceptance-handle-repair.js +107 -0
  50. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +305 -0
  51. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +138 -170
  52. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +128 -297
  53. package/.agents/scripts/lib/orchestration/plan-persist/soft-findings.js +55 -0
  54. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +16 -65
  55. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +22 -35
  56. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +36 -135
  57. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +61 -223
  58. package/.agents/scripts/lib/orchestration/review-base-ref.js +138 -0
  59. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -0
  60. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +37 -5
  61. package/.agents/scripts/lib/orchestration/single-story-close/phases/pre-gate-steps.js +46 -16
  62. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +6 -1
  63. package/.agents/scripts/lib/orchestration/story-close/context-budget-writeback.js +213 -0
  64. package/.agents/scripts/lib/orchestration/task-body-validator.js +10 -63
  65. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +33 -539
  66. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +21 -414
  67. package/.agents/scripts/lib/orchestration/ticket-validator.js +54 -118
  68. package/.agents/scripts/lib/orchestration/verify-credit.js +69 -24
  69. package/.agents/scripts/lib/story-body/body-format-lints.js +15 -85
  70. package/.agents/scripts/lib/story-body/story-body.js +54 -240
  71. package/.agents/scripts/lib/templates/decomposer-prompts.js +133 -121
  72. package/.agents/scripts/lib/test-isolate/cli-options.js +93 -0
  73. package/.agents/scripts/lib/test-isolate/progress-log.js +45 -0
  74. package/.agents/scripts/lib/test-isolate/render-report.js +97 -0
  75. package/.agents/scripts/lib/test-isolate/run-isolate.js +87 -0
  76. package/.agents/scripts/lib/test-run-credit.js +277 -0
  77. package/.agents/scripts/lib/wave-runner/footprint.js +48 -358
  78. package/.agents/scripts/lib/wave-runner/ready-set.js +6 -5
  79. package/.agents/scripts/lib/workers/crap-worker.js +32 -41
  80. package/.agents/scripts/plan-context.js +7 -9
  81. package/.agents/scripts/plan-critics.js +28 -54
  82. package/.agents/scripts/plan-persist.js +25 -68
  83. package/.agents/scripts/quality-preview.js +51 -0
  84. package/.agents/scripts/run-tests.js +12 -0
  85. package/.agents/scripts/stories-wave-tick.js +23 -45
  86. package/.agents/scripts/test-isolate.js +13 -180
  87. package/.agents/scripts/update-coverage-baseline.js +25 -70
  88. package/.agents/scripts/update-crap-baseline.js +19 -123
  89. package/.agents/skills/core/scope-triage/SKILL.md +3 -3
  90. package/.agents/workflows/audit-clean-code.md +4 -3
  91. package/.agents/workflows/helpers/acceptance-self-eval.md +41 -41
  92. package/.agents/workflows/helpers/code-quality-guardrails.md +4 -4
  93. package/.agents/workflows/helpers/code-review.md +2 -3
  94. package/.agents/workflows/helpers/deliver-digest.md +46 -55
  95. package/.agents/workflows/helpers/deliver-light.md +40 -105
  96. package/.agents/workflows/helpers/deliver-reference.md +1 -1
  97. package/.agents/workflows/helpers/deliver-story-reference.md +54 -55
  98. package/.agents/workflows/helpers/deliver-story.md +10 -13
  99. package/.agents/workflows/helpers/plan-reference.md +163 -221
  100. package/.agents/workflows/mandrel-plan.md +31 -40
  101. package/.agents/workflows/memory-consolidate.md +9 -13
  102. package/docs/CHANGELOG.md +36 -0
  103. package/lib/cli/registry.js +98 -2
  104. package/lib/migrations/index.js +4 -0
  105. package/lib/migrations/steps/2.57.0-retire-delivery-limit-knobs.js +45 -0
  106. package/lib/migrations/steps/2.57.0-retire-planning-limit-knobs.js +59 -0
  107. package/package.json +1 -1
  108. package/.agents/scripts/lib/framework-version.js +0 -39
  109. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +0 -223
  110. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +0 -97
  111. package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +0 -26
  112. package/.agents/scripts/lib/orchestration/spec-budget.js +0 -89
  113. package/.agents/scripts/lib/orchestration/spec-spill.js +0 -74
  114. package/.agents/scripts/lib/orchestration/verify-tier-repair.js +0 -107
@@ -0,0 +1,305 @@
1
+ /**
2
+ * changes-repair.js — repair-before-judging for `changes[]` entries
3
+ * (Story #5312).
4
+ *
5
+ * The `{ path, assumption }` object shape is a deterministic, mechanically
6
+ * derivable formality. The validator already knew how to salvage a path from
7
+ * a plain-string bullet (`suggestPathEntryFix`) — then rejected the plan
8
+ * anyway and charged the author a full re-drafting round to paste that exact
9
+ * object back. Story #5005 made the same call for the verify-tier suffix and
10
+ * repaired it instead; the suffix is gone now, and the repair moves to the
11
+ * one formality left: this module applies the inference the validator
12
+ * trusts, so the dry-run rewrites and **reports** each repair rather than
13
+ * refusing on it.
14
+ *
15
+ * Two shapes are repaired, on both authoring surfaces (a structured object
16
+ * body's `changes[]` array and a serialized string body's `## Changes`
17
+ * section):
18
+ *
19
+ * - a **plain-string bullet** (`src/app.js`, `` `src/app.js` ``,
20
+ * `src/app.js — adds the route`) becomes `{ path, assumption }`, the
21
+ * assumption resolved by probing the base branch — a path present at
22
+ * base is a `refactors-existing`, an absent one a `creates`;
23
+ * - a **trailing parenthetical** on a path (`src/app.js (new)`,
24
+ * `` `src/app.js (creates)` — refactors-existing ``) is stripped; an
25
+ * authored assumption is kept, an absent one probed as above.
26
+ *
27
+ * A string nothing path-shaped can be salvaged from is left untouched and
28
+ * still fails the body-shape validator — that is the one `changes[]` failure
29
+ * only the author can resolve.
30
+ *
31
+ * It lives beside the validator rather than inside it because the validator's
32
+ * job is to *judge*: mixing a mutating repair pass into a module of pure
33
+ * collectors muddies both. `persist-helpers.js#validateTickets` calls this
34
+ * first, then the validators.
35
+ *
36
+ * @module lib/orchestration/plan-persist/changes-repair
37
+ */
38
+
39
+ import { FILE_ASSUMPTION_VALUES } from '../file-assumption-enum.js';
40
+
41
+ /** The `## Changes` heading (either level the parser accepts). */
42
+ const CHANGES_HEADING_RE = /^#{2,3}\s+Changes\s*$/i;
43
+
44
+ /** Any heading — the end of the `## Changes` section. */
45
+ const ANY_HEADING_RE = /^#{1,6}\s+\S/;
46
+
47
+ /** A trailing `(…)` on a path token. */
48
+ const TRAILING_PARENTHETICAL_RE = /\s*\([^)]*\)\s*$/;
49
+
50
+ /** The humanized canonical bullet: `` `path` — assumption ``. */
51
+ const HUMANIZED_RE = /^`([^`]+)`\s+—\s+(\S+)$/;
52
+
53
+ // A token that looks like a file path / glob / module id: it carries a `/` or a
54
+ // `.`-separated segment. Deliberately loose — a false positive only produces a
55
+ // `{ path, assumption }` entry the base-branch probes then judge.
56
+ const PATH_LIKE_RE = /^[\w@*-]*[/.][\w@./*-]+$/;
57
+
58
+ /**
59
+ * Strip a trailing parenthetical from a path token, reporting whether one
60
+ * was present.
61
+ *
62
+ * @param {string} raw
63
+ * @returns {{ path: string, stripped: boolean }}
64
+ */
65
+ function stripParenthetical(raw) {
66
+ const path = raw.replace(TRAILING_PARENTHETICAL_RE, '').trim();
67
+ return { path, stripped: path !== raw.trim() };
68
+ }
69
+
70
+ /**
71
+ * Salvage the path token from a plain-string bullet: drop a leading list
72
+ * marker, take the segment before any humanized ` — ` tail, peel quotes and
73
+ * backticks, strip a trailing parenthetical. Returns `null` when nothing
74
+ * path-shaped survives.
75
+ *
76
+ * @param {string} raw
77
+ * @returns {string|null}
78
+ */
79
+ function salvagePath(raw) {
80
+ let s = raw
81
+ .trim()
82
+ .replace(/^[-*]\s+/, '')
83
+ .trim();
84
+ s = s.split('—')[0].trim();
85
+ s = s
86
+ .replace(/^[`'"]+/, '')
87
+ .replace(/[`'"]+$/, '')
88
+ .trim();
89
+ s = stripParenthetical(s).path;
90
+ return s !== '' && PATH_LIKE_RE.test(s) ? s : null;
91
+ }
92
+
93
+ /**
94
+ * Resolve the assumption for a path with none authored: present at base →
95
+ * `refactors-existing`, absent → `creates`.
96
+ *
97
+ * @param {string} path
98
+ * @param {(path: string) => boolean} existsAtBase
99
+ * @returns {'refactors-existing'|'creates'}
100
+ */
101
+ function probeAssumption(path, existsAtBase) {
102
+ return existsAtBase(path) ? 'refactors-existing' : 'creates';
103
+ }
104
+
105
+ /**
106
+ * Repair one structured `changes[]` item. Returns the corrected entry and a
107
+ * repair record, or `null` when the item needs no repair (or cannot be
108
+ * repaired).
109
+ *
110
+ * @param {unknown} item
111
+ * @param {(path: string) => boolean} existsAtBase
112
+ * @returns {{ entry: { path: string, assumption: string }, repair: object }|null}
113
+ */
114
+ function repairStructuredItem(item, existsAtBase) {
115
+ if (typeof item === 'string') {
116
+ const path = salvagePath(item);
117
+ if (path === null) return null;
118
+ const assumption = probeAssumption(path, existsAtBase);
119
+ return {
120
+ entry: { path, assumption },
121
+ repair: { from: item, path, assumption, reason: 'plain-string' },
122
+ };
123
+ }
124
+ if (item === null || typeof item !== 'object') return null;
125
+ if (typeof item.path !== 'string') return null;
126
+ const { path, stripped } = stripParenthetical(item.path);
127
+ const authored = FILE_ASSUMPTION_VALUES.includes(item.assumption);
128
+ if (!stripped && authored) return null;
129
+ if (path === '') return null;
130
+ const assumption = authored
131
+ ? item.assumption
132
+ : probeAssumption(path, existsAtBase);
133
+ return {
134
+ entry: { path, assumption },
135
+ repair: {
136
+ from: item.path,
137
+ path,
138
+ assumption,
139
+ reason: stripped ? 'trailing-parenthetical' : 'missing-assumption',
140
+ },
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Repair one line of a serialized `## Changes` section. Returns the rewritten
146
+ * line and a repair record, or `null` when the line is already canonical or
147
+ * cannot be repaired.
148
+ *
149
+ * @param {string} line
150
+ * @param {(path: string) => boolean} existsAtBase
151
+ * @returns {{ line: string, repair: object }|null}
152
+ */
153
+ function repairSectionLine(line, existsAtBase) {
154
+ const marker = line.match(/^(\s*[-*]\s+)/);
155
+ if (!marker) return null;
156
+ const content = line.slice(marker[1].length).trim();
157
+ if (content === '') return null;
158
+ const humanized = content.match(HUMANIZED_RE);
159
+ if (humanized) {
160
+ const { path, stripped } = stripParenthetical(humanized[1]);
161
+ const authored = FILE_ASSUMPTION_VALUES.includes(humanized[2]);
162
+ if (!stripped && authored) return null;
163
+ if (path === '') return null;
164
+ const assumption = authored
165
+ ? humanized[2]
166
+ : probeAssumption(path, existsAtBase);
167
+ return {
168
+ line: `${marker[1]}\`${path}\` — ${assumption}`,
169
+ repair: {
170
+ from: content,
171
+ path,
172
+ assumption,
173
+ reason: stripped ? 'trailing-parenthetical' : 'missing-assumption',
174
+ },
175
+ };
176
+ }
177
+ if (content.startsWith('{')) {
178
+ let parsed;
179
+ try {
180
+ parsed = JSON.parse(content);
181
+ } catch {
182
+ return null;
183
+ }
184
+ const repaired = repairStructuredItem(parsed, existsAtBase);
185
+ if (repaired === null) return null;
186
+ return {
187
+ line: `${marker[1]}\`${repaired.entry.path}\` — ${repaired.entry.assumption}`,
188
+ repair: repaired.repair,
189
+ };
190
+ }
191
+ const path = salvagePath(content);
192
+ if (path === null) return null;
193
+ const assumption = probeAssumption(path, existsAtBase);
194
+ return {
195
+ line: `${marker[1]}\`${path}\` — ${assumption}`,
196
+ repair: { from: content, path, assumption, reason: 'plain-string' },
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Repair the `## Changes` section of a serialized body in place. Only lines
202
+ * between the heading and the next heading are touched; the rest of the
203
+ * body is byte-identical.
204
+ *
205
+ * @param {string} body
206
+ * @param {(path: string) => boolean} existsAtBase
207
+ * @returns {{ body: string, repairs: object[] }}
208
+ */
209
+ function repairSerializedBody(body, existsAtBase) {
210
+ const lines = body.split('\n');
211
+ const repairs = [];
212
+ let inChanges = false;
213
+ for (let i = 0; i < lines.length; i += 1) {
214
+ const line = lines[i];
215
+ if (CHANGES_HEADING_RE.test(line.trim())) {
216
+ inChanges = true;
217
+ continue;
218
+ }
219
+ if (!inChanges) continue;
220
+ if (ANY_HEADING_RE.test(line) || line.trim().startsWith('---')) {
221
+ inChanges = false;
222
+ continue;
223
+ }
224
+ const repaired = repairSectionLine(line, existsAtBase);
225
+ if (repaired === null) continue;
226
+ lines[i] = repaired.line;
227
+ repairs.push(repaired.repair);
228
+ }
229
+ return { body: lines.join('\n'), repairs };
230
+ }
231
+
232
+ /**
233
+ * Repair the `changes[]` of one ticket, on whichever surface carries it.
234
+ *
235
+ * @param {object} ticket Mutated in place.
236
+ * @param {(path: string) => boolean} existsAtBase
237
+ * @returns {object[]} The repairs applied to this ticket.
238
+ */
239
+ function repairTicket(ticket, existsAtBase) {
240
+ const body = ticket.body;
241
+ if (typeof body === 'string') {
242
+ const { body: next, repairs } = repairSerializedBody(body, existsAtBase);
243
+ if (repairs.length > 0) ticket.body = next;
244
+ return repairs;
245
+ }
246
+ const changes =
247
+ body && typeof body === 'object' && Array.isArray(body.changes)
248
+ ? body.changes
249
+ : Array.isArray(ticket.changes)
250
+ ? ticket.changes
251
+ : null;
252
+ if (changes === null) return [];
253
+ const repairs = [];
254
+ for (let i = 0; i < changes.length; i += 1) {
255
+ const repaired = repairStructuredItem(changes[i], existsAtBase);
256
+ if (repaired === null) continue;
257
+ changes[i] = repaired.entry;
258
+ repairs.push(repaired.repair);
259
+ }
260
+ return repairs;
261
+ }
262
+
263
+ /**
264
+ * Render one `changes[]` repair as the dry-run line the operator reads.
265
+ *
266
+ * The dry-run's repair list is mixed — an `acceptance[]` handle strip is
267
+ * reported on it too — but the dispatch across kinds lives in
268
+ * [`acceptance-handle-repair.js`](acceptance-handle-repair.js)`#renderRepair`,
269
+ * which delegates here for this kind. Each producer owns its own line.
270
+ *
271
+ * @param {{ slug: string, from: string, path: string, assumption: string, reason: string }} repair
272
+ * @returns {string}
273
+ */
274
+ export function renderChangeRepair({ slug, from, path, assumption, reason }) {
275
+ const why =
276
+ reason === 'plain-string'
277
+ ? 'plain-string bullet'
278
+ : reason === 'trailing-parenthetical'
279
+ ? 'trailing parenthetical'
280
+ : 'missing assumption';
281
+ return `Story "${slug}": changes[] entry "${from}" (${why}) repaired to {"path":"${path}","assumption":"${assumption}"} by probing base.`;
282
+ }
283
+
284
+ /**
285
+ * Rewrite every repairable `changes[]` entry across the draft, probing the
286
+ * base branch for the assumption where none was authored. Mutates `tickets`
287
+ * in place (the persist pipeline threads this same array on to assembly)
288
+ * and returns the repairs, each tagged with the Story's slug. Total — a
289
+ * non-array argument and non-Story tickets are no-ops.
290
+ *
291
+ * @param {object[]} tickets
292
+ * @param {{ existsAtBase: (path: string) => boolean }} args
293
+ * @returns {Array<{ slug: string, from: string, path: string, assumption: string, reason: string }>}
294
+ */
295
+ export function repairChangeEntries(tickets, { existsAtBase }) {
296
+ const repairs = [];
297
+ for (const ticket of Array.isArray(tickets) ? tickets : []) {
298
+ if (!ticket || ticket.type !== 'story') continue;
299
+ const slug = ticket.slug ?? ticket.title ?? '<unknown>';
300
+ for (const repair of repairTicket(ticket, existsAtBase)) {
301
+ repairs.push({ slug, ...repair });
302
+ }
303
+ }
304
+ return repairs;
305
+ }
@@ -3,224 +3,192 @@
3
3
  *
4
4
  * Exports:
5
5
  * - `resolveBaseBranchRef(config)` — the one place the persist gates learn
6
- * which ref to probe.
7
- * - `validateTickets(tickets, config)` — runs the cross-link, model-capacity,
8
- * freshness, and task-body validators in one pass. Capacity settings are
9
- * explicit inputs so the validator and decomposer share one live delivery
10
- * envelope instead of silently falling back to framework defaults.
11
- * - `makeDefaultFanOutCounter({ baseBranchRef, cwd, git })` — production
12
- * fan-out probe used by the conflict policy.
6
+ * which branch name the operator configured.
7
+ *
8
+ * The ref the probes read is resolved per checkout (`resolveProbeRef`): the
9
+ * local branch when it exists, else its `origin/` tracking ref (a PR
10
+ * checkout on CI has no local `main`), else nothing — a shallow checkout
11
+ * with no base at all skips the probes instead of reading every path as
12
+ * absent.
13
+ * - `validateTickets(tickets, config, opts)` — normalises the authored
14
+ * `acceptance[]` handles and repairs the mechanical `changes[]`
15
+ * formalities against the base branch, then runs the cross-link,
16
+ * freshness, and task-body validators in one pass.
17
+ *
18
+ * Story #5312 deleted the fan-out probe that lived here: the delete
19
+ * blast-radius count never refused a real plan, and the `git grep` it paid
20
+ * for on every persist bought a warning nobody acted on.
13
21
  *
14
22
  * @module lib/orchestration/plan-persist/persist-helpers
15
23
  */
16
24
 
17
- import posix from 'node:path/posix';
18
25
  import { gitSpawn } from '../../git-utils.js';
19
26
  import { validateTaskBodies } from '../task-body-validator.js';
20
27
  import { validateAndNormalizeTickets } from '../ticket-validator.js';
21
- import { resolveConflictPolicy } from '../ticket-validator-conflicts.js';
22
- import { normalizeVerifyTiers } from '../verify-tier-repair.js';
28
+ import { normalizeAcceptanceHandles } from './acceptance-handle-repair.js';
29
+ import { repairChangeEntries } from './changes-repair.js';
23
30
 
24
31
  /**
25
- * Extensions an import specifier may elide. Probed longest-path-first when
26
- * resolving an extensionless specifier back onto a concrete repo path.
32
+ * Resolve the ref the persist gates probe against.
33
+ *
34
+ * The canonical resolved config carries the base branch at
35
+ * `project.baseBranch` (`lib/config-resolver.js` defaults it to `main`).
36
+ * This helper used to read `config.baseBranch` — a key the resolver never
37
+ * produces — so every freshness / file-assumption probe silently targeted
38
+ * the literal `main` regardless of configuration. Benign in a repo whose
39
+ * base branch *is* `main`; wrong for any consumer that configured something
40
+ * else (Story #4541).
41
+ *
42
+ * The flat `config.baseBranch` fallback is retained for the legacy
43
+ * `settings`-bag callers that pass `{ baseBranch, paths, planning }`.
44
+ *
45
+ * @param {object} [config] Resolved config, or a legacy settings bag.
46
+ * @returns {string}
27
47
  */
28
- const RESOLVABLE_EXTENSIONS = ['', '.js', '.mjs', '.cjs', '.jsx', '.json'];
29
-
30
- /** Every quoted string on a candidate line — the specifier lives in one. */
31
- const QUOTED_RE = /['"]([^'"\n]+)['"]/g;
32
-
33
- function escapeRegExp(value) {
34
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
48
+ export function resolveBaseBranchRef(config) {
49
+ return config?.project?.baseBranch ?? config?.baseBranch ?? 'main';
35
50
  }
36
51
 
37
52
  /**
38
- * The specifier tails an importer of `path` could plausibly write as the
39
- * final segment of its quoted specifier: the basename, the basename minus
40
- * its extension, and — for a directory-index module — the directory name
41
- * (`./foo` resolving to `foo/index.js`).
42
- *
43
- * This is a *candidate* net only. It is deliberately generous because the
44
- * resolution pass below re-checks every hit against the real path; a tail
45
- * that over-matches costs one extra resolve, a tail that under-matches
46
- * loses a genuine importer.
53
+ * Does `ref` name a commit in the repo at `cwd`?
54
+ *
55
+ * @param {string} ref
56
+ * @param {string} cwd
57
+ * @returns {boolean}
47
58
  */
48
- function specifierTails(path) {
49
- const base = posix.basename(path);
50
- const ext = posix.extname(base);
51
- const stem = ext ? base.slice(0, -ext.length) : base;
52
- const tails = new Set([base, stem]);
53
- if (stem === 'index') {
54
- const dir = posix.basename(posix.dirname(path));
55
- if (dir && dir !== '.') tails.add(dir);
56
- }
57
- return [...tails].filter((t) => t.length > 0);
59
+ function refResolves(ref, cwd) {
60
+ return (
61
+ gitSpawn(cwd, 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`)
62
+ .status === 0
63
+ );
58
64
  }
59
65
 
60
66
  /**
61
- * ERE matching a line that carries an `import` / `export … from` /
62
- * `require(` / dynamic `import(` whose quoted specifier *ends* at one of
63
- * `tails`. The `(^|/)`-equivalent guard (`([^'"]*\/)?`) is what keeps
64
- * `notification.js` from matching `push-notification.js`.
67
+ * Resolve the configured base branch to a ref the footprint probes can read
68
+ * in this checkout, or `null` when none exists.
69
+ *
70
+ * A developer checkout carries a local `main`; a CI pull-request checkout
71
+ * (`actions/checkout` at the merge ref, detached) carries only
72
+ * `origin/main`; a shallow smoke checkout carries neither. Probing the
73
+ * bare branch name in the last two answers "absent" for every path, which
74
+ * turns each bare-path repair into a `creates` and every declared path into
75
+ * a stale reference. So: the local branch when it resolves, else its
76
+ * `origin/` tracking ref when that does, else `null` — the caller then
77
+ * skips the probes and says so, rather than reporting the tree as missing.
78
+ *
79
+ * @param {{ baseBranch: string, cwd?: string }} opts
80
+ * @returns {string|null}
65
81
  */
66
- function buildProbePattern(tails) {
67
- const alt = tails.map(escapeRegExp).join('|');
68
- return `(from|require|import)[[:space:]]*\\(?[[:space:]]*['"]([^'"]*/)?(${alt})['"]`;
82
+ function resolveProbeRef({ baseBranch, cwd }) {
83
+ const repo = cwd ?? process.cwd();
84
+ const candidates = [baseBranch, `origin/${baseBranch}`];
85
+ return candidates.find((ref) => refResolves(ref, repo)) ?? null;
69
86
  }
70
87
 
71
88
  /**
72
- * Resolve a relative specifier written in `importerPath` back onto a repo
73
- * path and report whether it names `deletedPath`.
74
- *
75
- * **Known boundary:** only relative (`./`, `../`) specifiers resolve. A
76
- * consumer repo that imports its own modules through bare specifiers or a
77
- * path alias (`#lib/x`, `@app/x`, a `tsconfig` `paths` entry) would
78
- * under-count, because resolving those needs the resolver config this probe
79
- * deliberately does not read. Mandrel's own internal imports are all
80
- * relative. Under-counting is the *quiet* failure direction — it argues for
81
- * a deletion rather than against one — so if alias-importing consumers
82
- * appear, this is the place to teach the probe their resolver.
89
+ * Default git probe: returns true when `path` exists at `ref` in the cwd repo.
90
+ * `git cat-file -e <ref>:<path>` is the standard low-cost existence check —
91
+ * the same probe the validator's own gates run, so the repair pass and the
92
+ * gates cannot disagree about what is at base.
93
+ *
94
+ * @param {{ baseBranchRef: string, path: string, cwd?: string }} opts
95
+ * @returns {boolean}
83
96
  */
84
- function specifierResolvesTo(importerPath, specifier, deletedPath) {
85
- if (!specifier.startsWith('./') && !specifier.startsWith('../')) return false;
86
- const resolved = posix.normalize(
87
- posix.join(posix.dirname(importerPath), specifier),
97
+ function defaultGitRunner({ baseBranchRef, path, cwd }) {
98
+ const result = gitSpawn(
99
+ cwd ?? process.cwd(),
100
+ 'cat-file',
101
+ '-e',
102
+ `${baseBranchRef}:${path}`,
88
103
  );
89
- for (const ext of RESOLVABLE_EXTENSIONS) {
90
- if (`${resolved}${ext}` === deletedPath) return true;
91
- if (ext && `${resolved}/index${ext}` === deletedPath) return true;
92
- }
93
- return false;
104
+ return result.status === 0;
94
105
  }
95
106
 
96
107
  /**
97
- * Quote one argv entry so the reported probe is **runnable as emitted**.
98
- *
99
- * The probe is the operator's route to checking the number, so it has to
100
- * survive a paste into a shell. Unquoted, the ERE's `(`, `|`, `[[:space:]]`
101
- * and `?` are glob/grouping metacharacters: zsh fails the paste with
102
- * `no matches found` *and exits 0*, which reads as "zero importers" — the
103
- * gate's own audit trail would then argue for the deletion it is meant to
104
- * question (Story #4547).
108
+ * The `existsAtBase` predicate the `changes[]` repair pass probes with. With
109
+ * no base to read, a bare bullet is taken as the in-place edit it almost
110
+ * always is.
111
+ *
112
+ * @param {{ baseBranchRef: string|null, cwd?: string, gitRunner?: Function }} opts
113
+ * @returns {(path: string) => boolean}
105
114
  */
106
- function shellQuote(value) {
107
- if (/^[A-Za-z0-9_./-]+$/.test(value)) return value;
108
- return `'${value.replaceAll("'", "'\\''")}'`;
115
+ function makeExistsAtBase({ baseBranchRef, cwd, gitRunner }) {
116
+ if (baseBranchRef === null) return () => true;
117
+ const runner = gitRunner ?? defaultGitRunner;
118
+ return (path) => Boolean(runner({ baseBranchRef, path, cwd }));
109
119
  }
110
120
 
111
121
  /**
112
- * Parse one `git grep -n` output line of the form `<ref>:<path>:<lineno>:<text>`.
122
+ * The one warning a checkout with no readable base leaves on the dry-run.
123
+ *
124
+ * @param {string} baseBranch
125
+ * @param {string|null} baseBranchRef
126
+ * @returns {string[]}
113
127
  */
114
- function parseGrepLine(line, baseBranchRef) {
115
- const prefix = `${baseBranchRef}:`;
116
- if (!line.startsWith(prefix)) return null;
117
- const rest = line.slice(prefix.length);
118
- const pathEnd = rest.indexOf(':');
119
- if (pathEnd === -1) return null;
120
- const path = rest.slice(0, pathEnd);
121
- const afterPath = rest.slice(pathEnd + 1);
122
- const lineEnd = afterPath.indexOf(':');
123
- if (lineEnd === -1) return null;
124
- return { path, text: afterPath.slice(lineEnd + 1) };
128
+ function probeSkipWarnings(baseBranch, baseBranchRef) {
129
+ if (baseBranchRef !== null) return [];
130
+ return [
131
+ `base branch ${baseBranch} does not resolve in this checkout (tried ` +
132
+ `${baseBranch} and origin/${baseBranch}) — footprint probes skipped; ` +
133
+ 'the plan summary reports its references as ambiguous, not stale.',
134
+ ];
125
135
  }
126
136
 
127
137
  /**
128
- * Default fan-out probe — resolves the *importers* of the deleted module at
129
- * `baseBranchRef`, and reports the referencing files alongside the exact
130
- * probe that produced them.
131
- *
132
- * Two-stage, because accuracy and cost pull in opposite directions:
133
- *
134
- * 1. `git grep -n -E` narrows the tree to lines whose quoted import /
135
- * require specifier could name the module (final-segment match).
136
- * 2. Each candidate specifier is resolved against its own importer's
137
- * directory and compared to the deleted path. Only a real resolution
138
- * counts.
139
- *
140
- * The predecessor (Story #2962) grepped the basename stem as a bare word
141
- * across the whole tree, so a module named `notification` or `options`
142
- * reported dozens of call sites drawn from prose, schemas, and unrelated
143
- * modules — and the gate that fired on that number told the operator to
144
- * split a migration that did not exist. It also returned 0 without probing
145
- * for any stem under three characters, under-reporting in silence. Both are
146
- * gone: coupling is measured by resolution, not vocabulary (Story #4547).
147
- *
148
- * @returns {(arg: { path: string }) => { count: number, files: string[], probe: string }}
138
+ * Attach non-enumerable bookkeeping to the validated array.
139
+ *
140
+ * @param {object[]} validated
141
+ * @param {Record<string, unknown>} extras
149
142
  */
150
- export function makeDefaultFanOutCounter({ baseBranchRef, cwd, git } = {}) {
151
- const spawn = git?.gitSpawn ?? gitSpawn;
152
- return ({ path }) => {
153
- const tails = specifierTails(path);
154
- const pattern = buildProbePattern(tails);
155
- const args = ['grep', '-n', '-E', '--full-name', pattern, baseBranchRef];
156
- const probe = `git ${args.map(shellQuote).join(' ')}`;
157
- const result = spawn(cwd ?? process.cwd(), ...args);
158
- // git grep exits 1 on "no matches" — an empty result, not a failure.
159
- if (result.status !== 0) return { count: 0, files: [], probe };
160
- const files = new Set();
161
- for (const line of result.stdout.split('\n')) {
162
- if (line.trim().length === 0) continue;
163
- const hit = parseGrepLine(line, baseBranchRef);
164
- // The deleted module's own self-references are not call sites.
165
- if (!hit || hit.path === path) continue;
166
- for (const match of hit.text.matchAll(QUOTED_RE)) {
167
- if (specifierResolvesTo(hit.path, match[1], path)) {
168
- files.add(hit.path);
169
- break;
170
- }
171
- }
172
- }
173
- const sorted = [...files].sort();
174
- return { count: sorted.length, files: sorted, probe };
175
- };
143
+ function defineHidden(validated, extras) {
144
+ for (const [key, value] of Object.entries(extras)) {
145
+ Object.defineProperty(validated, key, {
146
+ value,
147
+ enumerable: false,
148
+ configurable: true,
149
+ writable: true,
150
+ });
151
+ }
176
152
  }
177
153
 
178
154
  /**
179
- * Resolve the ref the persist gates probe against.
180
- *
181
- * The canonical resolved config carries the base branch at
182
- * `project.baseBranch` (`lib/config-resolver.js` defaults it to `main`).
183
- * This helper used to read `config.baseBranch` — a key the resolver never
184
- * produces — so every freshness / file-assumption / fan-out probe silently
185
- * targeted the literal `main` regardless of configuration. Benign in a repo
186
- * whose base branch *is* `main`; wrong for any consumer that configured
187
- * something else (Story #4541).
155
+ * Repair the mechanical `changes[]` formalities, then validate.
188
156
  *
189
- * The flat `config.baseBranch` fallback is retained for the legacy
190
- * `settings`-bag callers that pass `{ baseBranch, paths, planning }`.
157
+ * Repair before judging (Story #5312, the shape Story #5005 set for the
158
+ * verify tier): a plain-string bullet or a trailing parenthetical is
159
+ * rewritten into `{ path, assumption }` by probing the base branch, and
160
+ * each rewrite is reported on the returned array's `repairs` so the dry-run
161
+ * can print it. An entry nothing path-shaped can be salvaged from survives
162
+ * untouched and still hard-errors in `validateTaskBodies`.
191
163
  *
192
- * @param {object} [config] Resolved config, or a legacy settings bag.
193
- * @returns {string}
164
+ * @param {object[]} tickets Mutated in place.
165
+ * @param {object} config Resolved config.
166
+ * @param {{ cwd?: string, gitRunner?: Function }} [opts]
167
+ * @returns {object[] & { findings: object[], errors: string[], warnings: string[], normalizations: object[], repairs: object[], probeRef: string|null }}
194
168
  */
195
- export function resolveBaseBranchRef(config) {
196
- return config?.project?.baseBranch ?? config?.baseBranch ?? 'main';
197
- }
198
-
199
169
  export function validateTickets(tickets, config, opts = {}) {
200
- const baseBranchRef = resolveBaseBranchRef(config);
201
- const conflictPolicy = resolveConflictPolicy(config);
202
- if (typeof opts.fanOutCounter === 'function') {
203
- conflictPolicy.fanOutCounter = opts.fanOutCounter;
204
- } else {
205
- conflictPolicy.fanOutCounter = makeDefaultFanOutCounter({
206
- baseBranchRef,
207
- cwd: opts.cwd,
208
- });
209
- }
170
+ const baseBranch = resolveBaseBranchRef(config);
171
+ const baseBranchRef = resolveProbeRef({ baseBranch, cwd: opts.cwd });
172
+ const repairs = [
173
+ ...normalizeAcceptanceHandles(tickets),
174
+ ...repairChangeEntries(tickets, {
175
+ existsAtBase: makeExistsAtBase({
176
+ baseBranchRef,
177
+ cwd: opts.cwd,
178
+ gitRunner: opts.gitRunner,
179
+ }),
180
+ }),
181
+ ];
210
182
  const validated = validateAndNormalizeTickets(tickets, {
211
- baseBranchRef,
212
- conflictPolicy,
213
- modelCapacity: opts.modelCapacity,
183
+ baseBranchRef: baseBranchRef ?? undefined,
184
+ gitRunner: opts.gitRunner,
214
185
  // Thread the repo cwd into the AC-freshness / file-assumption git
215
186
  // probes (#4474 PR7) — without it they silently ran against
216
187
  // process.cwd(), which is only the repo root by coincidence.
217
188
  cwd: opts.cwd,
218
189
  });
219
- // Repair before judging (Story #5005): a `verify[]` entry whose tier the
220
- // validator can already infer is auto-corrected rather than rejected, so a
221
- // mechanically-fixable formality no longer costs a re-authoring round. An
222
- // uninferable entry survives untouched and still hard-errors below.
223
- normalizeVerifyTiers(validated);
190
+ validated.warnings.push(...probeSkipWarnings(baseBranch, baseBranchRef));
224
191
  validateTaskBodies(validated);
192
+ defineHidden(validated, { repairs, probeRef: baseBranchRef });
225
193
  return validated;
226
194
  }