mandrel 2.58.0 → 2.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.agents/README.md +6 -3
  2. package/.agents/docs/SDLC.md +6 -7
  3. package/.agents/docs/quality-gates.md +1 -1
  4. package/.agents/instructions.md +2 -3
  5. package/.agents/runtime-deps.json +7 -2
  6. package/.agents/schemas/crap-baseline.schema.json +1 -1
  7. package/.agents/schemas/crap-report.schema.json +1 -1
  8. package/.agents/scripts/install-matrix-assert.js +48 -3
  9. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +51 -33
  10. package/.agents/scripts/lib/baselines/kinds/_crap-read.js +0 -8
  11. package/.agents/scripts/lib/baselines/kinds/crap.js +35 -18
  12. package/.agents/scripts/lib/crap-engine.js +2 -2
  13. package/.agents/scripts/lib/crap-utils.js +21 -5
  14. package/.agents/scripts/lib/escomplex-ast-compat.js +39 -17
  15. package/.agents/scripts/lib/escomplex-kernel.js +298 -0
  16. package/.agents/scripts/lib/maintainability-engine.js +3 -3
  17. package/.agents/scripts/lib/orchestration/plan-context.js +31 -25
  18. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +28 -24
  19. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +8 -9
  20. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +1 -1
  21. package/.agents/scripts/lib/orchestration/plan-persist/wave-collision-gate.js +107 -0
  22. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +25 -209
  23. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +8 -5
  24. package/.agents/scripts/lib/runtime-deps/dep-resolution.js +155 -0
  25. package/.agents/scripts/lib/runtime-deps/ensure-installed.js +44 -9
  26. package/.agents/scripts/lib/runtime-deps/parser-major.js +110 -0
  27. package/.agents/scripts/lib/runtime-deps/preflight.js +6 -25
  28. package/.agents/scripts/lib/runtime-deps/scan-imports.js +46 -1
  29. package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
  30. package/.agents/scripts/lib/templates/decomposer-prompts.js +21 -18
  31. package/.agents/scripts/plan-persist.js +0 -11
  32. package/.agents/skills/skills.index.json +1 -11
  33. package/.agents/workflows/audit-to-stories.md +14 -11
  34. package/.agents/workflows/helpers/plan-reference.md +18 -7
  35. package/.agents/workflows/mandrel-plan.md +14 -13
  36. package/README.md +3 -3
  37. package/docs/CHANGELOG.md +8 -0
  38. package/lib/cli/registry.js +45 -25
  39. package/package.json +7 -2
  40. package/.agents/scripts/lib/orchestration/split-policy-validator.js +0 -188
  41. package/.agents/scripts/lib/templates/spec-author-prompts.js +0 -76
  42. package/.agents/skills/core/scope-triage/SKILL.md +0 -48
@@ -0,0 +1,107 @@
1
+ /**
2
+ * wave-collision-gate.js — the split gate (Story #5332).
3
+ *
4
+ * Kept out of `wave-serialisation.js` because it answers a different
5
+ * question. That module *predicts* what the dispatcher will do with a draft
6
+ * and renders the prediction as a receipt; this one decides whether the draft
7
+ * may be created at all, and so owns both the one enumeration the refusal and
8
+ * the receipt share and the shape reconciliation that enumeration needs.
9
+ *
10
+ * @module lib/orchestration/plan-persist/wave-collision-gate
11
+ */
12
+
13
+ import { predictWaveSerialisation } from './wave-serialisation.js';
14
+
15
+ /**
16
+ * Expose an assembled Story's declared footprint where `storyFootprint`
17
+ * looks for it.
18
+ *
19
+ * `assemblePlanStories` returns the persisted artifact — `{ slug, title,
20
+ * body, bodyObject, acceptance, depends_on, … }` — and carries the parsed
21
+ * `changes[]` inside `bodyObject`, not at the top level. `storyFootprint`
22
+ * reads `files` / `changes` / `changeset` only, so from Story #5313 (which
23
+ * retired the body scrape that had been widening the footprint out of the
24
+ * markdown) until this Story the production call saw an empty footprint for
25
+ * every assembled Story and predicted nothing — the unit fixtures passed a
26
+ * top-level `changes` and so could not show it. The prediction is now the
27
+ * split gate, and a gate that cannot see a declaration cannot fire, so the
28
+ * shapes are reconciled here rather than by widening the dispatcher's own
29
+ * predicate: the runtime's records (`resolve-stories.js`) already carry
30
+ * `changes` at the top level and pass through untouched.
31
+ *
32
+ * @param {object} story
33
+ * @returns {object} The same record, with a top-level `changes` when one can
34
+ * be resolved from its `bodyObject`.
35
+ */
36
+ function withDeclaredFootprint(story) {
37
+ if (!story || typeof story !== 'object') return story;
38
+ const declared = [story.files, story.changes, story.changeset].some(
39
+ (shape) => Array.isArray(shape) && shape.length > 0,
40
+ );
41
+ if (declared) return story;
42
+ const fromBody = story.bodyObject?.changes;
43
+ return Array.isArray(fromBody) ? { ...story, changes: fromBody } : story;
44
+ }
45
+
46
+ /**
47
+ * Render one refused pair as a report line.
48
+ *
49
+ * @param {{ wave: number, slugs: [string, string], paths: string[], source: string }} collision
50
+ * @returns {string}
51
+ */
52
+ function formatCollision({ wave, slugs, paths, source }) {
53
+ const declared = paths.map((p) => `\`${p}\``).join(', ');
54
+ return ` - wave ${wave}: "${slugs[0]}" + "${slugs[1]}" both declare ${declared} (${source})`;
55
+ }
56
+
57
+ /**
58
+ * Compute the same-wave collisions of a draft and refuse an N>1 draft that
59
+ * has any — the split gate.
60
+ *
61
+ * ADR `20260912-5312` deleted every numeric plan-time ceiling and left the
62
+ * default-single policy enforced by prose plus `assertAcceptancePartition`,
63
+ * which refused only byte-identical acceptance text across siblings — a shape
64
+ * model output does not produce. The measured result was a plan of 18 Stories
65
+ * whose own summary comment recorded 39 shared files across 14 same-wave
66
+ * Stories: the plan refuted its own parallelism claim, after persist, with
67
+ * nothing acting on it.
68
+ *
69
+ * So the gate is the dispatcher's own predicate rather than a proxy for it. A
70
+ * pair {@link predictWaveSerialisation} names is a pair
71
+ * `stories-wave-tick.js` will refuse to co-dispatch, so the split buys no
72
+ * parallelism while still paying a delivery session per Story. Two remedies,
73
+ * both the author's to take before anything is created: merge the pair into
74
+ * the one Story it already is, or order it with `depends_on` so the members
75
+ * land in different waves.
76
+ *
77
+ * **N=1 can never trip it.** A single-Story draft has no pair to score, so
78
+ * the prediction is empty by construction.
79
+ *
80
+ * The computed collisions are **returned** so the caller hands the same value
81
+ * to the plan-summary receipt instead of recomputing it — a recomputation is
82
+ * how the refusal and the receipt would come to disagree.
83
+ *
84
+ * @param {ReturnType<typeof import('./summary.js').buildWaveTable>} waveTable
85
+ * @param {Array<object>} stories The assembled Stories, in draft order.
86
+ * @param {{ tempRoot?: string }} [options] Threaded to the predicate.
87
+ * @returns {ReturnType<typeof predictWaveSerialisation>}
88
+ * @throws {Error} When an N>1 draft has at least one colliding same-wave pair.
89
+ */
90
+ export function assertNoWaveCollisions(waveTable, stories, options = {}) {
91
+ const list = Array.isArray(stories) ? stories : [];
92
+ const collisions = predictWaveSerialisation(
93
+ waveTable,
94
+ list.map(withDeclaredFootprint),
95
+ options,
96
+ );
97
+ if (list.length <= 1 || collisions.length === 0) return collisions;
98
+ throw new Error(
99
+ `[plan-persist] ${collisions.length} same-wave collision(s) — the ` +
100
+ 'dispatcher will refuse to co-dispatch these pairs, so the split buys ' +
101
+ 'no parallelism and costs a delivery session per Story:\n' +
102
+ `${collisions.map(formatCollision).join('\n')}\n` +
103
+ 'Remedy: merge each pair into the one Story it already is (its stages ' +
104
+ 'belong in `## Slicing`), or order the pair with `depends_on` so the ' +
105
+ 'members sit in different waves.',
106
+ );
107
+ }
@@ -8,13 +8,12 @@ import { computeStoryReachability } from './story-reachability.js';
8
8
  * `collectStoryAssumptionEntries` (Story #3302) and the sizing gate's
9
9
  * `resolveStoryBody` (Story #4271).
10
10
  *
11
- * The decomposer emits `body` as the canonical serialized **string**, but
12
- * the conflict passes (`indexConsumers`, `computeMissingBddScaffoldFindings`,
13
- * and the producer path scan in `collectStoryProducerPaths`) historically
14
- * read `story.body` only when it was already an object — so on the
15
- * production string shape the `implicit-cross-story-dep` and
16
- * `missing-bdd-scaffold` findings emitted nothing. Parsing the body once at the entry point and
17
- * threading the normalized Story through every pass restores parity.
11
+ * The decomposer emits `body` as the canonical serialized **string**, but the
12
+ * conflict passes (the producer path scan in `collectStoryProducerPaths`, and
13
+ * the two substring-match advisories Story #5332 retired) historically read
14
+ * `story.body` only when it was already an object — so on the production
15
+ * string shape they emitted nothing. Parsing the body once at the entry point
16
+ * and threading the normalized Story through every pass restores parity.
18
17
  *
19
18
  * `collectStoryAssumptionEntries` already parses string bodies itself, so a
20
19
  * normalized object body round-trips through it unchanged. The returned Story
@@ -76,16 +75,20 @@ function normalizeStoryBody(story) {
76
75
  * @property {string} path Producer path written by ≥2 Stories.
77
76
  * @property {string[]} storySlugs Story slugs in the conflict cluster.
78
77
  *
79
- * @typedef {object} ImplicitCrossStoryDepFinding
80
- * @property {'implicit-cross-story-dep'} kind
81
- * @property {'hard'|'soft'} severity
82
- * @property {string} path Path consumed without a depends_on link.
83
- * @property {{ storySlug: string, taskSlug: string }} producer
84
- * @property {{ storySlug: string, taskSlug: string, sourceField: 'acceptance'|'verify' }} consumer
85
- *
86
- * @typedef {SharedEditorFinding | ImplicitCrossStoryDepFinding} ConflictFinding
78
+ * @typedef {SharedEditorFinding} ConflictFinding
87
79
  */
88
80
 
81
+ /**
82
+ * Story #5332 retired the `implicit-cross-story-dep` and
83
+ * `missing-bdd-scaffold` findings, leaving `shared-editor` as the one
84
+ * conflict kind. Both matched a producer path as a **substring** of a
85
+ * consumer's `acceptance[]` / `verify[]` text — the noise-prone shape the
86
+ * planning-diet ADR (`20260912-5312`) itself calls out — and both had been
87
+ * unreachable on the real payload for most of their life (see
88
+ * {@link computeAssembledConflictFindings}). What they nudged for, ordering a
89
+ * consumer after its producer, the same-wave collision refusal now enforces
90
+ * on declarations rather than guesses at from prose.
91
+
89
92
  /**
90
93
  * Every conflict class is advisory (`'soft'`) since Story #5312: the
91
94
  * `planning.failOnSharedEditors` / `requireExplicitCrossStoryDeps` /
@@ -160,46 +163,6 @@ function indexProducers(stories) {
160
163
  return producers;
161
164
  }
162
165
 
163
- /**
164
- * Build the consumers index — `Array<{path, storySlug, taskSlug, sourceField}>`.
165
- *
166
- * For each Task, scan `body.acceptance` and `body.verify` joined text for
167
- * literal substring occurrences of any known producer path. Only producer
168
- * paths are matched (intersect-then-test), so free-text path-like tokens
169
- * that no one writes never produce false positives.
170
- *
171
- * A Story is not its own consumer — entries whose producer is the same
172
- * Story are skipped to keep the surface focused on cross-Story signal.
173
- */
174
- function indexConsumers(stories, producers) {
175
- const consumers = [];
176
- if (producers.size === 0) return consumers;
177
- const producerPaths = Array.from(producers.keys()).sort(
178
- (a, b) => b.length - a.length,
179
- );
180
- for (const story of stories) {
181
- const body = story.body;
182
- if (!body || typeof body !== 'object') continue;
183
- for (const sourceField of ['acceptance', 'verify']) {
184
- const items = Array.isArray(body[sourceField]) ? body[sourceField] : [];
185
- if (items.length === 0) continue;
186
- const joined = items.map((it) => String(it ?? '')).join('\n');
187
- for (const path of producerPaths) {
188
- if (!joined.includes(path)) continue;
189
- const producerEntries = producers.get(path) ?? [];
190
- if (producerEntries.some((p) => p.taskSlug === story.slug)) continue;
191
- consumers.push({
192
- path,
193
- storySlug: storySlugOf(story),
194
- taskSlug: story.slug,
195
- sourceField,
196
- });
197
- }
198
- }
199
- }
200
- return consumers;
201
- }
202
-
203
166
  function inSameWave(reach, slugA, slugB) {
204
167
  if (slugA === slugB) return false;
205
168
  const a = reach.get(slugA);
@@ -240,133 +203,6 @@ function computeSharedEditorFindings(producers, reach, severity) {
240
203
  return findings;
241
204
  }
242
205
 
243
- /**
244
- * Emit one `implicit-cross-story-dep` finding per consumer entry whose
245
- * producer Story is not transitively reachable from the consumer Story.
246
- *
247
- * Multiple producers per path are possible — the finding pins the *first*
248
- * producer in declaration order (sufficient signal; the operator typically
249
- * fixes the missing `depends_on` by linking to whichever Story they
250
- * recognize). Consumers already covered by a transitive dependency to
251
- * *some* producer are silently allowed even if other producers exist.
252
- */
253
- function computeImplicitDepFindings(consumers, producers, reach, severity) {
254
- const findings = [];
255
- for (const consumer of consumers) {
256
- const producerEntries = producers.get(consumer.path) ?? [];
257
- if (producerEntries.length === 0) continue;
258
- const reachable = reach.get(consumer.storySlug) ?? new Set();
259
- const alreadyDependsOnSome = producerEntries.some(
260
- (p) => p.storySlug === consumer.storySlug || reachable.has(p.storySlug),
261
- );
262
- if (alreadyDependsOnSome) continue;
263
- const producer = producerEntries[0];
264
- findings.push({
265
- kind: 'implicit-cross-story-dep',
266
- severity,
267
- path: consumer.path,
268
- producer: {
269
- storySlug: producer.storySlug,
270
- taskSlug: producer.taskSlug,
271
- },
272
- consumer: {
273
- storySlug: consumer.storySlug,
274
- taskSlug: consumer.taskSlug,
275
- sourceField: consumer.sourceField,
276
- },
277
- });
278
- }
279
- return findings;
280
- }
281
-
282
- /**
283
- * Compute `missing-bdd-scaffold` findings (Story #3857).
284
- *
285
- * The features-first delivery model requires every `.feature` file a Story
286
- * verifies against to already exist when that Story runs. When a Story's
287
- * `verify[]` references a `.feature` path that another Story declares with
288
- * `assumption: "creates"`, the consumer is correct only if the producer
289
- * lands in an *earlier* wave — otherwise the consumer's `verify[]` runs
290
- * against a file that does not yet exist and verification fails mid-delivery.
291
- *
292
- * A finding fires for each consumer/producer pair where:
293
- * - the path ends in `.feature`,
294
- * - a *different* Story declares that path as `assumption: "creates"`, and
295
- * - the consumer Story does not transitively `depends_on` the producer
296
- * (i.e. they share a wave, or the producer runs later).
297
- *
298
- * The finding is advisory (`'soft'`) — it is a nudge to add a `depends_on`
299
- * link to the wave-0 scaffold Story (or to the producing Story), not a hard
300
- * block. The remediation is the same shape as `implicit-cross-story-dep`:
301
- * order the consumer after the producer so the scaffold lands first.
302
- *
303
- * @param {object[]} stories
304
- * @param {Map<string, Set<string>>} reach Transitive predecessor sets.
305
- * @param {'soft'|'hard'} severity
306
- * @returns {object[]} `missing-bdd-scaffold` findings.
307
- */
308
- function computeMissingBddScaffoldFindings(stories, reach, severity) {
309
- // Index every `.feature` path declared `creates` to its producing Story.
310
- // A path may be created by more than one Story (unusual); pin the first in
311
- // declaration order, mirroring the implicit-dep finding's single-producer
312
- // shape.
313
- const featureCreators = new Map(); // path -> storySlug (first creator)
314
- for (const story of stories) {
315
- const body = story?.body;
316
- if (!body || typeof body !== 'object') continue;
317
- const changes = Array.isArray(body.changes) ? body.changes : [];
318
- for (const change of changes) {
319
- if (
320
- change === null ||
321
- typeof change !== 'object' ||
322
- change.assumption !== 'creates' ||
323
- typeof change.path !== 'string' ||
324
- !change.path.endsWith('.feature')
325
- )
326
- continue;
327
- if (!featureCreators.has(change.path)) {
328
- featureCreators.set(change.path, storySlugOf(story));
329
- }
330
- }
331
- }
332
- if (featureCreators.size === 0) return [];
333
-
334
- const creatorPaths = Array.from(featureCreators.keys()).sort(
335
- (a, b) => b.length - a.length,
336
- );
337
- const findings = [];
338
- const seen = new Set(); // dedupe `${consumerSlug}::${path}` pairs
339
- for (const story of stories) {
340
- const body = story?.body;
341
- if (!body || typeof body !== 'object') continue;
342
- const verifyItems = Array.isArray(body.verify) ? body.verify : [];
343
- if (verifyItems.length === 0) continue;
344
- const joined = verifyItems.map((it) => String(it ?? '')).join('\n');
345
- const consumerSlug = storySlugOf(story);
346
- for (const path of creatorPaths) {
347
- if (!joined.includes(path)) continue;
348
- const producerSlug = featureCreators.get(path);
349
- // A Story that creates the file it verifies is fine — no cross-Story gap.
350
- if (producerSlug === consumerSlug) continue;
351
- // Producer already runs in an earlier wave → consumer is correctly
352
- // ordered, scaffold lands first, no finding.
353
- const reachable = reach.get(consumerSlug) ?? new Set();
354
- if (reachable.has(producerSlug)) continue;
355
- const key = `${consumerSlug}::${path}`;
356
- if (seen.has(key)) continue;
357
- seen.add(key);
358
- findings.push({
359
- kind: 'missing-bdd-scaffold',
360
- severity,
361
- path,
362
- producer: { storySlug: producerSlug },
363
- consumer: { storySlug: consumerSlug, sourceField: 'verify' },
364
- });
365
- }
366
- }
367
- return findings;
368
- }
369
-
370
206
  /**
371
207
  * Public entry point. Walks the normalized ticket spec once and returns
372
208
  * the structured cross-Story findings array. Every finding is `'soft'`
@@ -384,13 +220,8 @@ export function computeConflictFindings({ stories } = {}) {
384
220
  // shape across every conflict pass.
385
221
  const storyList = (stories ?? []).map(normalizeStoryBody);
386
222
  const producers = indexProducers(storyList);
387
- const consumers = indexConsumers(storyList, producers);
388
223
  const reach = computeStoryReachability(storyList);
389
- return [
390
- ...computeSharedEditorFindings(producers, reach, SOFT),
391
- ...computeImplicitDepFindings(consumers, producers, reach, SOFT),
392
- ...computeMissingBddScaffoldFindings(storyList, reach, SOFT),
393
- ];
224
+ return computeSharedEditorFindings(producers, reach, SOFT);
394
225
  }
395
226
 
396
227
  /**
@@ -402,11 +233,11 @@ export function computeConflictFindings({ stories } = {}) {
402
233
  * persisted. That is not a cosmetic ordering nit: the canonical authoring shape
403
234
  * carries `acceptance[]` / `verify[]` at the ticket's **top level**, and it is
404
235
  * assembly's `syncContractFieldFromTopLevel` that folds them into the body.
405
- * `indexConsumers` scans `body.acceptance` / `body.verify` for producer paths —
406
- * so on the real payload it scanned two empty arrays, and every
407
- * `implicit-cross-story-dep` and `missing-bdd-scaffold` finding was silently
408
- * unreachable. Running the passes again over the serialized bodies restores
409
- * them.
236
+ * The two retired advisories scanned `body.acceptance` / `body.verify` for
237
+ * producer paths, so on the real payload they scanned two empty arrays and
238
+ * were silently unreachable. Running the passes again over the serialized
239
+ * bodies is what keeps the surviving `shared-editor` pass honest about what
240
+ * persist actually writes.
410
241
  *
411
242
  * @param {{ stories: Array<{ slug: string, title: string, body: string, depends_on?: string[] }> }} args
412
243
  * @returns {ConflictFinding[]}
@@ -457,13 +288,7 @@ export function conflictFindingKey(finding) {
457
288
  * of this list is how readers drift apart, so it is defined exactly once and
458
289
  * imported.
459
290
  */
460
- export const CONFLICT_KINDS = Object.freeze(
461
- new Set([
462
- 'shared-editor',
463
- 'implicit-cross-story-dep',
464
- 'missing-bdd-scaffold',
465
- ]),
466
- );
291
+ export const CONFLICT_KINDS = Object.freeze(new Set(['shared-editor']));
467
292
 
468
293
  /**
469
294
  * Render a conflict finding as a human-readable line. Every finding is soft
@@ -476,12 +301,6 @@ export function renderHardConflictError(finding) {
476
301
  const stories = finding.storySlugs.map((s) => `"${s}"`).join(', ');
477
302
  return `Shared-editor conflict: "${finding.path}" is written by ${finding.storySlugs.length} concurrent Stories (${stories}). Add depends_on chains between them or split the edits into a dedicated late-wave wiring Story.`;
478
303
  }
479
- if (finding.kind === 'implicit-cross-story-dep') {
480
- return `Implicit cross-Story dependency: Story "${finding.consumer.storySlug}" references "${finding.path}" (produced by Story "${finding.producer.storySlug}") via body.${finding.consumer.sourceField}, but Story "${finding.consumer.storySlug}" has no depends_on link to Story "${finding.producer.storySlug}". Add depends_on: ["${finding.producer.storySlug}"] to the consumer Story or remove the reference.`;
481
- }
482
- if (finding.kind === 'missing-bdd-scaffold') {
483
- return `Missing BDD scaffold: Story "${finding.consumer.storySlug}" verifies against "${finding.path}" (created by Story "${finding.producer.storySlug}") via body.${finding.consumer.sourceField}, but "${finding.consumer.storySlug}" has no depends_on path to "${finding.producer.storySlug}" — the .feature file is scaffolded in the same wave (or later), so verification runs before the file exists. Add depends_on: ["${finding.producer.storySlug}"] to the consumer Story so the scaffold lands in an earlier wave.`;
484
- }
485
304
  // Findings from other passes carry their own message — render it rather
486
305
  // than a shape-blind generic line, so the soft surface
487
306
  // (`surfaceSoftConflictFindings`) stays legible for every kind.
@@ -496,10 +315,7 @@ export const _internal = {
496
315
  collectStoryProducerPaths,
497
316
  WRITE_IMPLYING_ASSUMPTIONS,
498
317
  indexProducers,
499
- indexConsumers,
500
318
  computeStoryReachability,
501
319
  inSameWave,
502
320
  computeSharedEditorFindings,
503
- computeImplicitDepFindings,
504
- computeMissingBddScaffoldFindings,
505
321
  };
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Story authoring guidance — the two prose constants the story-author prompt
3
- * and the `core/scope-triage` skill both cite, stated once so the surfaces
4
- * cannot drift.
3
+ * cites, stated once so no second copy can drift.
5
4
  *
6
5
  * Story #5312 deleted the numeric sizing model that used to live beside
7
6
  * them: `DEFAULT_MODEL_CAPACITY` with its soft / hard session-mass ceilings,
@@ -18,12 +17,16 @@
18
17
  /**
19
18
  * `DELIVERABLE_GRANULARITY_GUIDANCE` is the **single source of truth** for the
20
19
  * deliverable-granularity definition of a Story (Story #3777). It is stated
21
- * ONCE here and consumed by BOTH the story-author prompt template and the
22
- * authoring SKILL.
20
+ * ONCE here and consumed by the story-author prompt template.
21
+ *
22
+ * Story #5332 re-anchored the definition off "a single reviewer-sized PR":
23
+ * that anchor read as a size ceiling and fragmented cohesive sweeps, so the
24
+ * only stated sizing test is now cohesion — one coherent change with one
25
+ * reason to exist.
23
26
  */
24
27
  export const DELIVERABLE_GRANULARITY_GUIDANCE = Object.freeze({
25
28
  definition:
26
- 'A Story is a **capability slice a frontier model delivers and self-verifies in one pass** — a shippable slice a reviewer would accept as a single PR, a capability or user-visible surface, **not a single module or file**. Fold module-level slices into the capability they belong to rather than emitting one Story per module.',
29
+ 'A Story is a **capability slice a frontier model delivers and self-verifies in one pass** — one coherent change with one reason to exist, a capability or user-visible surface, **not a single module or file**. Fold module-level slices into the capability they belong to rather than emitting one Story per module. A remediation sweep over one subsystem is one Story; its stages belong in `## Slicing`, not in sibling tickets.',
27
30
  singleConsumerRule:
28
31
  '**Single-consumer merge rule.** A Story whose only consumer is one sibling Story should be **merged into that sibling** rather than emitted separately — a single-consumer downstream slice is not its own unit of work.',
29
32
  envelopeFloor:
@@ -0,0 +1,155 @@
1
+ /**
2
+ * runtime-deps/dep-resolution — is a declared runtime dependency actually
3
+ * there, is it the right major, and how do we say so.
4
+ *
5
+ * `.agents/` materializes into the consumer's repository root, so every
6
+ * framework runtime dependency resolves from *their* `node_modules`. A range
7
+ * in `.agents/runtime-deps.json` therefore documents a requirement it cannot
8
+ * enforce, and the preflight guard needs to compare the range against what
9
+ * actually resolved.
10
+ *
11
+ * Deliberately major-only, and deliberately not `semver`. The framework's
12
+ * runtime ranges are all `^`, whose whole contract is "this major"; pulling in
13
+ * a semver implementation to decide one comparison would add a dependency to
14
+ * the very closure this module exists to keep honest.
15
+ *
16
+ * @module lib/runtime-deps/dep-resolution
17
+ */
18
+
19
+ /**
20
+ * Leading major number of a version or a caret/tilde range, or `null`.
21
+ *
22
+ * Module-local: `majorMismatch` is the only question callers have.
23
+ *
24
+ * Anything this cannot read as `<major>.` — `*`, a tag, a git URL, a
25
+ * `>=x <y` span — yields `null` and is treated as "not range-checked". That
26
+ * asymmetry is intentional: a conservative miss is a no-op, while a false
27
+ * positive blocks a working install.
28
+ *
29
+ * @param {string|null|undefined} spec
30
+ * @returns {number|null}
31
+ */
32
+ function majorOf(spec) {
33
+ if (typeof spec !== 'string') return null;
34
+ const match = /^[\^~]?(\d+)\./.exec(spec.trim());
35
+ return match ? Number(match[1]) : null;
36
+ }
37
+
38
+ /**
39
+ * Does `resolved` sit outside the major `range` names?
40
+ *
41
+ * Module-local: `checkRuntimeDeps` is the only caller, and exporting it only
42
+ * for a test would be a production-dead export.
43
+ *
44
+ * `false` whenever either side is unreadable, so an unparseable range or an
45
+ * unreadable installed version is never reported as a mismatch.
46
+ *
47
+ * `0.x` majors compare as written: `^0.1.0` and `0.2.1` differ in minor, not
48
+ * major, so this does not separate them. Accepted — the `0.x` packages in the
49
+ * closure are terminal, and the range this exists to enforce is
50
+ * `@babel/parser`'s `^7`.
51
+ *
52
+ * @param {string|null|undefined} range
53
+ * @param {string|null|undefined} resolved
54
+ * @returns {boolean}
55
+ */
56
+ function majorMismatch(range, resolved) {
57
+ const want = majorOf(range);
58
+ if (want === null) return false;
59
+ const got = majorOf(resolved);
60
+ if (got === null) return false;
61
+ return want !== got;
62
+ }
63
+
64
+ /**
65
+ * Is a package present in the resolvable tree?
66
+ *
67
+ * The bare specifier is tried first, then `<name>/package.json`. The fallback
68
+ * is not belt-and-braces: a package with no `main` and no `exports` — which
69
+ * `typhonjs-escomplex-commons` and `babel-runtime` both are — cannot be
70
+ * resolved by name at all, and is reached only by deep path. Probing the bare
71
+ * name alone would report such a package missing while it sits installed, and
72
+ * this guard exits the process on that verdict.
73
+ *
74
+ * @param {string} dep
75
+ * @param {(specifier: string) => string} resolve
76
+ * @returns {boolean}
77
+ */
78
+ export function isResolvable(dep, resolve) {
79
+ try {
80
+ resolve(dep);
81
+ return true;
82
+ } catch {
83
+ // fall through to the manifest probe
84
+ }
85
+ try {
86
+ resolve(`${dep}/package.json`);
87
+ return true;
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Remediation text for a resolved dependency whose major differs from the
95
+ * range the framework declares.
96
+ *
97
+ * Named separately from the missing-deps message because the remedy differs:
98
+ * the package is installed, so installing again changes nothing. What is
99
+ * wrong is the version the consumer's own tree resolves, which only they can
100
+ * change.
101
+ *
102
+ * @param {{name: string, required: string, resolved: string}[]} mismatched
103
+ * @param {{ root: string }} ctx
104
+ * @returns {string}
105
+ */
106
+ export function formatMismatchedDepsMessage(mismatched, { root }) {
107
+ const lines = mismatched.map(
108
+ (m) => ` - ${m.name}: need ${m.required}, resolved ${m.resolved}`,
109
+ );
110
+ return [
111
+ 'Mandrel framework runtime dependency version mismatch:',
112
+ ...lines,
113
+ '',
114
+ `Resolved from: ${root}`,
115
+ 'These packages are resolved from your repository, not from mandrel, so',
116
+ 'the version your tree installs is the version the framework gets. Pin a',
117
+ 'compatible major in your package.json and reinstall.',
118
+ ].join('\n');
119
+ }
120
+
121
+ /**
122
+ * Resolve each required package via the injected `resolve` seam and collect
123
+ * the ones that fail. `resolve` is typically `require.resolve` bound to the
124
+ * framework module location; it throws `MODULE_NOT_FOUND` when a package is
125
+ * absent from the resolvable `node_modules`.
126
+ *
127
+ * @param {{ required: string[], resolve: (specifier: string) => string }} opts
128
+ * @returns {{ ok: boolean, missing: string[] }}
129
+ */
130
+ export function checkRuntimeDeps({
131
+ required,
132
+ resolve,
133
+ ranges = null,
134
+ readVersion = null,
135
+ }) {
136
+ const missing = [];
137
+ const mismatched = [];
138
+ for (const dep of required) {
139
+ if (!isResolvable(dep, resolve)) {
140
+ missing.push(dep);
141
+ continue;
142
+ }
143
+ if (!ranges || !readVersion) continue;
144
+ const range = ranges[dep];
145
+ const resolved = readVersion(dep);
146
+ if (majorMismatch(range, resolved)) {
147
+ mismatched.push({ name: dep, required: range, resolved });
148
+ }
149
+ }
150
+ return {
151
+ ok: missing.length === 0 && mismatched.length === 0,
152
+ missing,
153
+ mismatched,
154
+ };
155
+ }
@@ -28,12 +28,13 @@
28
28
  */
29
29
 
30
30
  import { createRequire } from 'node:module';
31
- import { loadRuntimeDepsManifest } from './manifest.js';
31
+ import { resolveDependencyVersion } from '../dependency-version.js';
32
32
  import {
33
33
  checkRuntimeDeps,
34
- detectPackageManager,
35
- formatMissingDepsMessage,
36
- } from './preflight.js';
34
+ formatMismatchedDepsMessage,
35
+ } from './dep-resolution.js';
36
+ import { loadRuntimeDepsManifest } from './manifest.js';
37
+ import { detectPackageManager, formatMissingDepsMessage } from './preflight.js';
37
38
 
38
39
  // `require.resolve` bound to this module's location walks `node_modules`
39
40
  // upward from `.agents/scripts/lib/runtime-deps/` to the consumer root —
@@ -50,7 +51,8 @@ const frameworkRequire = createRequire(import.meta.url);
50
51
  * cwd?: string,
51
52
  * stderr?: { write: (s: string) => void },
52
53
  * exit?: (code: number) => void,
53
- * manifest?: { required: string[] },
54
+ * manifest?: { required: string[], dependencies?: Record<string,string> },
55
+ * readVersion?: (name: string) => string | null,
54
56
  * }} [opts]
55
57
  * @returns {{ ok: boolean, missing: string[] }}
56
58
  */
@@ -61,6 +63,7 @@ export function ensureRuntimeDepsInstalled(opts = {}) {
61
63
  stderr = process.stderr,
62
64
  exit = process.exit,
63
65
  manifest = safeLoadManifest(),
66
+ readVersion,
64
67
  } = opts;
65
68
 
66
69
  // A manifest we cannot read is a packaging defect the drift test owns —
@@ -70,17 +73,49 @@ export function ensureRuntimeDepsInstalled(opts = {}) {
70
73
  const result = checkRuntimeDeps({
71
74
  required: manifest.required,
72
75
  resolve: requireResolve,
76
+ ranges: manifest.dependencies ?? null,
77
+ readVersion: readVersion ?? defaultReadVersion,
73
78
  });
74
79
  if (result.ok) return result;
75
80
 
76
- const packageManager = detectPackageManager(cwd);
77
- stderr.write(
78
- `${formatMissingDepsMessage(result.missing, { root: cwd, packageManager })}\n`,
79
- );
81
+ stderr.write(`${describeFailure(result, cwd)}\n`);
80
82
  exit(1);
81
83
  return result;
82
84
  }
83
85
 
86
+ /**
87
+ * Remediation text for a failed check.
88
+ *
89
+ * Absence is reported first: a package that is not installed cannot have a
90
+ * version, and installing it is the prerequisite for any version complaint
91
+ * being actionable.
92
+ *
93
+ * @param {{ missing: string[], mismatched: {name: string, required: string, resolved: string}[] }} result
94
+ * @param {string} cwd
95
+ * @returns {string}
96
+ */
97
+ function describeFailure(result, cwd) {
98
+ if (result.missing.length === 0) {
99
+ return formatMismatchedDepsMessage(result.mismatched, { root: cwd });
100
+ }
101
+ const packageManager = detectPackageManager(cwd);
102
+ return formatMissingDepsMessage(result.missing, {
103
+ root: cwd,
104
+ packageManager,
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Read a resolved package's version through the framework's own resolution,
110
+ * so the version checked is the one the framework's imports will load.
111
+ *
112
+ * @param {string} name
113
+ * @returns {string | null}
114
+ */
115
+ function defaultReadVersion(name) {
116
+ return resolveDependencyVersion(name, frameworkRequire);
117
+ }
118
+
84
119
  /**
85
120
  * Load the manifest, swallowing a read/parse failure to `null` so the guard
86
121
  * stays inert on a packaging defect (see `ensureRuntimeDepsInstalled`).