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.
- package/.agents/README.md +6 -3
- package/.agents/docs/SDLC.md +6 -7
- package/.agents/docs/quality-gates.md +1 -1
- package/.agents/instructions.md +2 -3
- package/.agents/runtime-deps.json +7 -2
- package/.agents/schemas/crap-baseline.schema.json +1 -1
- package/.agents/schemas/crap-report.schema.json +1 -1
- package/.agents/scripts/install-matrix-assert.js +48 -3
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +51 -33
- package/.agents/scripts/lib/baselines/kinds/_crap-read.js +0 -8
- package/.agents/scripts/lib/baselines/kinds/crap.js +35 -18
- package/.agents/scripts/lib/crap-engine.js +2 -2
- package/.agents/scripts/lib/crap-utils.js +21 -5
- package/.agents/scripts/lib/escomplex-ast-compat.js +39 -17
- package/.agents/scripts/lib/escomplex-kernel.js +298 -0
- package/.agents/scripts/lib/maintainability-engine.js +3 -3
- package/.agents/scripts/lib/orchestration/plan-context.js +31 -25
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +28 -24
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +8 -9
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +1 -1
- package/.agents/scripts/lib/orchestration/plan-persist/wave-collision-gate.js +107 -0
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +25 -209
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +8 -5
- package/.agents/scripts/lib/runtime-deps/dep-resolution.js +155 -0
- package/.agents/scripts/lib/runtime-deps/ensure-installed.js +44 -9
- package/.agents/scripts/lib/runtime-deps/parser-major.js +110 -0
- package/.agents/scripts/lib/runtime-deps/preflight.js +6 -25
- package/.agents/scripts/lib/runtime-deps/scan-imports.js +46 -1
- package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
- package/.agents/scripts/lib/templates/decomposer-prompts.js +21 -18
- package/.agents/scripts/plan-persist.js +0 -11
- package/.agents/skills/skills.index.json +1 -11
- package/.agents/workflows/audit-to-stories.md +14 -11
- package/.agents/workflows/helpers/plan-reference.md +18 -7
- package/.agents/workflows/mandrel-plan.md +14 -13
- package/README.md +3 -3
- package/docs/CHANGELOG.md +8 -0
- package/lib/cli/registry.js +45 -25
- package/package.json +7 -2
- package/.agents/scripts/lib/orchestration/split-policy-validator.js +0 -188
- package/.agents/scripts/lib/templates/spec-author-prompts.js +0 -76
- 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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
406
|
-
* so on the real payload
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
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
|
-
*
|
|
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
|
|
22
|
-
*
|
|
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** —
|
|
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 {
|
|
31
|
+
import { resolveDependencyVersion } from '../dependency-version.js';
|
|
32
32
|
import {
|
|
33
33
|
checkRuntimeDeps,
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
} from './
|
|
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
|
-
|
|
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`).
|