mandrel 2.10.0 → 2.11.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/docs/configuration.md +35 -33
- package/.agents/schemas/agentrc.schema.json +13 -8
- package/.agents/scripts/acceptance-eval.js +9 -5
- package/.agents/scripts/lib/baselines/env-overrides.js +33 -0
- package/.agents/scripts/lib/baselines/git-base.js +0 -0
- package/.agents/scripts/lib/baselines/preview-gates.js +5 -0
- package/.agents/scripts/lib/config/gates/maintainability.schema.js +10 -1
- package/.agents/scripts/lib/config/quality.js +13 -0
- package/.agents/scripts/lib/config-settings-schema.js +12 -16
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +45 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +97 -4
- package/.agents/scripts/lib/orchestration/check-baselines/phases/parse-args.js +7 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +509 -180
- package/.agents/scripts/lib/orchestration/plan-context.js +69 -10
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +111 -60
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +21 -15
- package/.agents/scripts/lib/orchestration/resolve-stories.js +11 -7
- package/.agents/scripts/lib/orchestration/review-depth.js +9 -4
- package/.agents/scripts/lib/orchestration/spec-budget.js +78 -0
- package/.agents/scripts/lib/orchestration/story-body-gate.js +72 -0
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +6 -0
- package/.agents/scripts/lib/orchestration/ticket-validator.js +18 -62
- package/.agents/scripts/plan-context.js +23 -5
- package/.agents/scripts/resolve-stories.js +2 -0
- package/.agents/workflows/deliver.md +2 -0
- package/.agents/workflows/helpers/acceptance-self-eval.md +16 -5
- package/.agents/workflows/helpers/deliver-reference.md +9 -5
- package/.agents/workflows/helpers/deliver-story-reference.md +32 -12
- package/.agents/workflows/helpers/deliver-story.md +4 -3
- package/.agents/workflows/helpers/plan-reference.md +79 -44
- package/.agents/workflows/plan.md +11 -10
- package/docs/CHANGELOG.md +19 -0
- package/lib/cli/registry.js +31 -14
- package/lib/migrations/index.js +2 -0
- package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +92 -0
- package/package.json +1 -1
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
renderTechSpecSystemPrompt,
|
|
27
27
|
} from '../templates/spec-author-prompts.js';
|
|
28
28
|
import { concurrentMap } from '../util/concurrent-map.js';
|
|
29
|
-
import {
|
|
29
|
+
import { buildComplexitySignals } from './complexity-gate.js';
|
|
30
30
|
import { parseDeliverySlicingTable } from './consolidation-precondition.js';
|
|
31
31
|
import { buildDocsDigest } from './docs-digest.js';
|
|
32
32
|
import { buildAuthoringContext } from './planning/authoring-context.js';
|
|
@@ -144,6 +144,41 @@ export const TICKET_SCHEMA_DESCRIPTOR = Object.freeze({
|
|
|
144
144
|
*/
|
|
145
145
|
export const STORIES_TEMPLATE_FILENAME = 'stories.template.json';
|
|
146
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Build the template's `changes[]` entries from the envelope's advisory
|
|
149
|
+
* complexity signals (Story #4723). Each seed-predicted path is
|
|
150
|
+
* pre-resolved to its creates-vs-refactors assumption against the repo
|
|
151
|
+
* snapshot the signals already probed: a path present in the repo is a
|
|
152
|
+
* `refactors-existing`, a missing one is a `creates`. Order follows
|
|
153
|
+
* `predictedPaths` (first appearance in the seed). Falls back to the
|
|
154
|
+
* single instructive placeholder entry when the seed predicted no paths.
|
|
155
|
+
*
|
|
156
|
+
* @param {{
|
|
157
|
+
* predictedPaths?: string[],
|
|
158
|
+
* repoState?: { existingPaths?: string[], missingPaths?: string[] },
|
|
159
|
+
* }|null|undefined} complexitySignals
|
|
160
|
+
* @returns {Array<{ path: string, assumption: string }>}
|
|
161
|
+
*/
|
|
162
|
+
function buildTemplateChanges(complexitySignals) {
|
|
163
|
+
const predicted = Array.isArray(complexitySignals?.predictedPaths)
|
|
164
|
+
? complexitySignals.predictedPaths.filter(
|
|
165
|
+
(p) => typeof p === 'string' && p.length > 0,
|
|
166
|
+
)
|
|
167
|
+
: [];
|
|
168
|
+
if (predicted.length === 0) {
|
|
169
|
+
return [{ path: 'path/to/file.ext', assumption: 'refactors-existing' }];
|
|
170
|
+
}
|
|
171
|
+
const existing = new Set(
|
|
172
|
+
Array.isArray(complexitySignals?.repoState?.existingPaths)
|
|
173
|
+
? complexitySignals.repoState.existingPaths
|
|
174
|
+
: [],
|
|
175
|
+
);
|
|
176
|
+
return predicted.map((path) => ({
|
|
177
|
+
path,
|
|
178
|
+
assumption: existing.has(path) ? 'refactors-existing' : 'creates',
|
|
179
|
+
}));
|
|
180
|
+
}
|
|
181
|
+
|
|
147
182
|
/**
|
|
148
183
|
* Render the ready-to-fill `stories.json` authoring template (Story #4707).
|
|
149
184
|
*
|
|
@@ -158,12 +193,23 @@ export const STORIES_TEMPLATE_FILENAME = 'stories.template.json';
|
|
|
158
193
|
* / `verify[]` live at the ticket's top level — the machine contract persist
|
|
159
194
|
* syncs into the body.
|
|
160
195
|
*
|
|
196
|
+
* Correct-by-construction skeleton (Story #4723): the emitted `verify[]`
|
|
197
|
+
* placeholder already ends with a valid `(tier)` tag (swap `(unit)` for
|
|
198
|
+
* `(contract)` / `(e2e)` / `(validate)` where appropriate), and when the
|
|
199
|
+
* envelope's `complexitySignals` predicted a footprint the `changes[]`
|
|
200
|
+
* entries arrive pre-resolved to creates-vs-refactors against the repo
|
|
201
|
+
* snapshot — a faithfully-filled skeleton passes the persist ticket
|
|
202
|
+
* validators without a mechanical round-trip. The persist gates stay
|
|
203
|
+
* authoritative (they probe the base branch ref, not the working tree).
|
|
204
|
+
*
|
|
161
205
|
* Pure and deterministic; the output is valid JSON (parseable as-is), with
|
|
162
206
|
* instructive placeholder values rather than comments.
|
|
163
207
|
*
|
|
208
|
+
* @param {{ complexitySignals?: object|null }} [opts] Envelope signals to
|
|
209
|
+
* pre-resolve the skeleton against; omit for the bare placeholder shape.
|
|
164
210
|
* @returns {string} Pretty-printed JSON template content.
|
|
165
211
|
*/
|
|
166
|
-
export function renderStoriesTemplate() {
|
|
212
|
+
export function renderStoriesTemplate({ complexitySignals = null } = {}) {
|
|
167
213
|
const template = [
|
|
168
214
|
{
|
|
169
215
|
slug: 'fill-hyphen-case-slug',
|
|
@@ -176,11 +222,9 @@ export function renderStoriesTemplate() {
|
|
|
176
222
|
'codes, security invariants, and load-bearing constraints with ' +
|
|
177
223
|
'their why. Implementation choices belong to the deliverer unless ' +
|
|
178
224
|
'load-bearing. No per-file behavior paragraphs, no current-state ' +
|
|
179
|
-
'narration.
|
|
180
|
-
'contract.',
|
|
181
|
-
changes:
|
|
182
|
-
{ path: 'path/to/file.ext', assumption: 'refactors-existing' },
|
|
183
|
-
],
|
|
225
|
+
'narration. Keep it under ~250 words (soft advisory budget). ' +
|
|
226
|
+
'Delete this field when acceptance[] carries the whole contract.',
|
|
227
|
+
changes: buildTemplateChanges(complexitySignals),
|
|
184
228
|
non_goals: [],
|
|
185
229
|
reason_to_exist:
|
|
186
230
|
'Fill: the single coherent reason this Story exists (one sentence).',
|
|
@@ -188,7 +232,9 @@ export function renderStoriesTemplate() {
|
|
|
188
232
|
acceptance: [
|
|
189
233
|
'Fill: a testable, observable criterion (a command exits 0, a file exists, a test matches)',
|
|
190
234
|
],
|
|
191
|
-
verify: [
|
|
235
|
+
verify: [
|
|
236
|
+
'Fill: exact command or test path — keep the trailing tier tag valid: unit, contract, e2e, or validate (unit)',
|
|
237
|
+
],
|
|
192
238
|
depends_on: [],
|
|
193
239
|
},
|
|
194
240
|
];
|
|
@@ -540,7 +586,15 @@ async function buildSeedFileModeEnvelope({
|
|
|
540
586
|
return {
|
|
541
587
|
mode: modeLabel,
|
|
542
588
|
seed: { path: seedFilePath ?? null, content },
|
|
543
|
-
|
|
589
|
+
// Advisory complexity signals only (Story #4722): no route, no routing
|
|
590
|
+
// authority. The planner authors the trivial-vs-standard verdict; persist
|
|
591
|
+
// validates a lite claim against the authored Story's shape.
|
|
592
|
+
complexitySignals: buildComplexitySignals({
|
|
593
|
+
seedText: content,
|
|
594
|
+
config,
|
|
595
|
+
riskHeuristics: heuristics,
|
|
596
|
+
cwd,
|
|
597
|
+
}),
|
|
544
598
|
duplicates,
|
|
545
599
|
docsContext,
|
|
546
600
|
codebaseSnapshot: authoring.codebaseSnapshot,
|
|
@@ -694,7 +748,12 @@ async function buildTicketsModeEnvelope({
|
|
|
694
748
|
mode: 'tickets',
|
|
695
749
|
sourceTickets,
|
|
696
750
|
seed: { text: seed, path: null },
|
|
697
|
-
|
|
751
|
+
complexitySignals: buildComplexitySignals({
|
|
752
|
+
seedText: seed,
|
|
753
|
+
config,
|
|
754
|
+
riskHeuristics: heuristics,
|
|
755
|
+
cwd,
|
|
756
|
+
}),
|
|
698
757
|
duplicates,
|
|
699
758
|
docsContext,
|
|
700
759
|
codebaseSnapshot: authoring.codebaseSnapshot,
|
|
@@ -44,7 +44,12 @@ import { anchorTempRoot, tempRootFrom } from '../../config/temp-paths.js';
|
|
|
44
44
|
import { getLimits, PROJECT_ROOT } from '../../config-resolver.js';
|
|
45
45
|
import { gitSpawn } from '../../git-utils.js';
|
|
46
46
|
import { Logger } from '../../Logger.js';
|
|
47
|
-
import {
|
|
47
|
+
import {
|
|
48
|
+
deriveStoryShape,
|
|
49
|
+
LITE_ROUTE_LABEL,
|
|
50
|
+
resolveComplexityGate,
|
|
51
|
+
resolvePlannerRouteVerdict,
|
|
52
|
+
} from '../complexity-gate.js';
|
|
48
53
|
import {
|
|
49
54
|
appendCriticSkip,
|
|
50
55
|
readPlanMetrics,
|
|
@@ -264,65 +269,105 @@ async function renderRunScopedPlanMetricsLine({
|
|
|
264
269
|
|
|
265
270
|
/**
|
|
266
271
|
* Resolve the plan's **effective** complexity route for persist
|
|
267
|
-
* (Story #4707).
|
|
272
|
+
* (Story #4722, superseding the envelope-verdict model of Story #4707).
|
|
273
|
+
*
|
|
274
|
+
* Two staged inputs, no word count anywhere:
|
|
268
275
|
*
|
|
269
|
-
* The
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
276
|
+
* 1. **The planner's authored verdict** — `--route-downgrade-reason` is the
|
|
277
|
+
* lite claim's recorded reason (`resolvePlannerRouteVerdict`). No
|
|
278
|
+
* recorded reason means no claim: the plan persists as standard `full`
|
|
279
|
+
* and nothing is ledgered (`null`).
|
|
280
|
+
* 2. **The deterministic shape backstop** — a lite claim is validated
|
|
281
|
+
* against every assembled Story's own shape (`deriveStoryShape` over its
|
|
282
|
+
* `changes[]`, acceptance count, creates-vs-refactors mix, and
|
|
283
|
+
* sensitive-path classes). Any Story exceeding the ceilings **fails the
|
|
284
|
+
* claim closed to `full`** — the honest gate: after authoring, the work
|
|
285
|
+
* has measurable shape, so complexity is read from the work, not guessed
|
|
286
|
+
* from the seed.
|
|
275
287
|
*
|
|
276
288
|
* The resolved route decides whether the created Stories carry the
|
|
277
|
-
* {@link LITE_ROUTE_LABEL}
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
289
|
+
* {@link LITE_ROUTE_LABEL} **hint** (never the control signal — `/deliver`
|
|
290
|
+
* re-derives the route from the Story body's shape) and the `route` block
|
|
291
|
+
* ledgered on their `story-plan-state` checkpoint, including the authored
|
|
292
|
+
* verdict, its recorded reason, and the per-Story shape evidence. A refused
|
|
293
|
+
* claim is ledgered too (route `full` with the refusal reasons), so the
|
|
294
|
+
* judgment stays auditable either way.
|
|
281
295
|
*
|
|
282
296
|
* Module-private: reachable end to end through {@link runPlanPersist}
|
|
283
297
|
* (whose result reports the resolved route), so there is no test-only
|
|
284
298
|
* export to leave production-dead.
|
|
285
299
|
*
|
|
286
|
-
* @param {{
|
|
287
|
-
*
|
|
288
|
-
*
|
|
300
|
+
* @param {{
|
|
301
|
+
* stories: ReturnType<typeof assemblePlanStories>['stories'],
|
|
302
|
+
* routeDowngradeReason?: string|null,
|
|
303
|
+
* config?: object,
|
|
304
|
+
* }} args
|
|
305
|
+
* @returns {{
|
|
306
|
+
* route: 'lite'|'full',
|
|
307
|
+
* reasons: string[],
|
|
308
|
+
* authored: { route: 'lite', reason: string },
|
|
309
|
+
* shape: Array<{ slug: string, route: string, reasons: string[], shape: object|null }>,
|
|
310
|
+
* }|null} `null` when the planner authored no verdict (nothing to persist).
|
|
289
311
|
*/
|
|
290
312
|
function resolveEffectiveRoute({
|
|
291
|
-
|
|
313
|
+
stories,
|
|
292
314
|
routeDowngradeReason = null,
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
315
|
+
config = {},
|
|
316
|
+
}) {
|
|
317
|
+
const verdict = resolvePlannerRouteVerdict({ reason: routeDowngradeReason });
|
|
318
|
+
if (verdict.route !== 'lite') return null;
|
|
319
|
+
|
|
320
|
+
// The schema's documented contract: with the gate disabled
|
|
321
|
+
// (`planning.complexityGate.enabled=false`), persist refuses lite claims —
|
|
322
|
+
// the same switch dispatch reads (`resolveStoryDispatchMode` falls back to
|
|
323
|
+
// sub-agent), so neither read point can honor a lite claim the operator
|
|
324
|
+
// has switched off. The refusal is ledgered like any other, keeping the
|
|
325
|
+
// judgment auditable.
|
|
326
|
+
if (!resolveComplexityGate(config).enabled) {
|
|
327
|
+
return {
|
|
328
|
+
route: 'full',
|
|
329
|
+
reasons: [
|
|
330
|
+
'planner lite verdict refused: complexity routing is disabled ' +
|
|
331
|
+
'(planning.complexityGate.enabled=false)',
|
|
332
|
+
],
|
|
333
|
+
authored: verdict.authored,
|
|
334
|
+
shape: [],
|
|
335
|
+
};
|
|
308
336
|
}
|
|
309
|
-
|
|
310
|
-
|
|
337
|
+
|
|
338
|
+
const perStory = (Array.isArray(stories) ? stories : []).map((story) => {
|
|
339
|
+
const derived = deriveStoryShape({
|
|
340
|
+
changes: story.bodyObject?.changes,
|
|
341
|
+
acceptance: story.acceptance,
|
|
342
|
+
});
|
|
343
|
+
return {
|
|
344
|
+
slug: story.slug,
|
|
345
|
+
route: derived.route,
|
|
346
|
+
reasons: derived.reasons,
|
|
347
|
+
shape: derived.shape,
|
|
348
|
+
};
|
|
311
349
|
});
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
350
|
+
const offenders = perStory.filter((entry) => entry.route !== 'lite');
|
|
351
|
+
if (offenders.length > 0) {
|
|
352
|
+
return {
|
|
353
|
+
route: 'full',
|
|
354
|
+
reasons: [
|
|
355
|
+
`planner lite verdict refused: ${offenders.length} of ${perStory.length} ` +
|
|
356
|
+
'Story(ies) exceed the lite shape ceilings — failing closed to full',
|
|
357
|
+
...offenders.map((entry) => `${entry.slug}: ${entry.reasons[0]}`),
|
|
358
|
+
],
|
|
359
|
+
authored: verdict.authored,
|
|
360
|
+
shape: perStory,
|
|
361
|
+
};
|
|
321
362
|
}
|
|
322
363
|
return {
|
|
323
|
-
route:
|
|
324
|
-
reasons:
|
|
325
|
-
|
|
364
|
+
route: 'lite',
|
|
365
|
+
reasons: [
|
|
366
|
+
...verdict.reasons,
|
|
367
|
+
'shape backstop: every authored Story fits the lite shape ceilings',
|
|
368
|
+
],
|
|
369
|
+
authored: verdict.authored,
|
|
370
|
+
shape: perStory,
|
|
326
371
|
};
|
|
327
372
|
}
|
|
328
373
|
|
|
@@ -424,7 +469,6 @@ export async function runPlanPersist({
|
|
|
424
469
|
stories: rawStories = null,
|
|
425
470
|
techSpecContent = null,
|
|
426
471
|
planAcceptance = null,
|
|
427
|
-
planContextEnvelope = null,
|
|
428
472
|
} = artifacts ?? {};
|
|
429
473
|
const {
|
|
430
474
|
forceReview = false,
|
|
@@ -513,22 +557,28 @@ export async function runPlanPersist({
|
|
|
513
557
|
sourceTicketIds,
|
|
514
558
|
});
|
|
515
559
|
|
|
516
|
-
// Effective complexity route (Story #
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
// persists
|
|
560
|
+
// Effective complexity route (Story #4722): the planner's authored lite
|
|
561
|
+
// verdict (recorded reason), validated against every assembled Story's own
|
|
562
|
+
// shape — a claim exceeding the shape ceilings fails closed to full. Lite
|
|
563
|
+
// persists the `route::lite` HINT label + a checkpoint route block; a
|
|
564
|
+
// refused claim ledgers the refusal (no label); no claim persists nothing.
|
|
520
565
|
const route = resolveEffectiveRoute({
|
|
521
|
-
|
|
566
|
+
stories,
|
|
522
567
|
routeDowngradeReason,
|
|
568
|
+
config,
|
|
523
569
|
});
|
|
524
570
|
const isLiteRoute = route?.route === 'lite';
|
|
525
571
|
if (isLiteRoute) {
|
|
526
572
|
Logger.info(
|
|
527
|
-
`[plan-persist] ceremony-lite route
|
|
528
|
-
|
|
529
|
-
(route.
|
|
530
|
-
|
|
531
|
-
|
|
573
|
+
`[plan-persist] ceremony-lite route upheld by the shape backstop: ` +
|
|
574
|
+
`created Stories carry the ${LITE_ROUTE_LABEL} hint ` +
|
|
575
|
+
`(recorded reason: ${route.authored.reason}). /deliver re-derives ` +
|
|
576
|
+
'the route from each Story body — the label is never the control signal.',
|
|
577
|
+
);
|
|
578
|
+
} else if (route) {
|
|
579
|
+
Logger.warn(
|
|
580
|
+
`[plan-persist] ${route.reasons.join('; ')} — persisting as full ` +
|
|
581
|
+
'(no route hint label).',
|
|
532
582
|
);
|
|
533
583
|
}
|
|
534
584
|
|
|
@@ -586,10 +636,11 @@ export async function runPlanPersist({
|
|
|
586
636
|
id: createdStory.id,
|
|
587
637
|
})),
|
|
588
638
|
},
|
|
589
|
-
// Ledger the
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
|
|
639
|
+
// Ledger the authored route verdict — the recorded reason and the
|
|
640
|
+
// per-Story shape evidence, including a shape-refused claim — on plan
|
|
641
|
+
// state (Story #4722). No authored verdict writes no block: absence
|
|
642
|
+
// is the standard full path.
|
|
643
|
+
...(route ? { route } : {}),
|
|
593
644
|
});
|
|
594
645
|
}
|
|
595
646
|
await upsertStructuredComment(
|
|
@@ -46,7 +46,11 @@ export const PLAN_RUN_LABEL_PREFIX = 'plan-run::';
|
|
|
46
46
|
/** Stable color for the cohort grouping label (`ensureLabels`). */
|
|
47
47
|
const PLAN_RUN_LABEL_COLOR = '#C5DEF5';
|
|
48
48
|
|
|
49
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* Stable color for the `route::lite` ceremony-route hint (Story #4707;
|
|
51
|
+
* hint-only since Story #4722 — `/deliver` re-derives the route from the
|
|
52
|
+
* Story body's shape).
|
|
53
|
+
*/
|
|
50
54
|
const LITE_ROUTE_LABEL_COLOR = '#D4C5F9';
|
|
51
55
|
|
|
52
56
|
/** Length of the derived plan-run id (hex chars). */
|
|
@@ -694,9 +698,10 @@ async function mirrorNativeDependencyEdges({ provider, stories, idBySlug }) {
|
|
|
694
698
|
* route, and an opaque derived label never exists yet.
|
|
695
699
|
*
|
|
696
700
|
* **Non-fatal by design**, matching the native-blocked_by mirroring posture:
|
|
697
|
-
* neither label is load-bearing for correctness (grouping is cosmetic
|
|
698
|
-
*
|
|
699
|
-
*
|
|
701
|
+
* neither label is load-bearing for correctness (grouping is cosmetic, and
|
|
702
|
+
* the route label is a human-visible hint only — `/deliver` re-derives the
|
|
703
|
+
* route from the Story body's shape, Story #4722), so it is never a reason
|
|
704
|
+
* to fail persist. On an ensure
|
|
700
705
|
* failure (throw, or the label reported `missing` by the post-loop
|
|
701
706
|
* reconcile) the create loop proceeds **without** the label — applying an
|
|
702
707
|
* unensured label could fail the issue create itself, and the Stories matter
|
|
@@ -782,15 +787,16 @@ async function ensurePersistLabel({
|
|
|
782
787
|
* every id is known (Story #4544), so plan-created order stops depending on
|
|
783
788
|
* prose. That pass is non-fatal — see `mirrorNativeDependencyEdges`.
|
|
784
789
|
*
|
|
785
|
-
* **A lite-routed cohort carries the `route::lite`
|
|
786
|
-
* When the caller resolves the plan's
|
|
787
|
-
*
|
|
788
|
-
*
|
|
789
|
-
*
|
|
790
|
-
*
|
|
791
|
-
*
|
|
792
|
-
*
|
|
793
|
-
*
|
|
790
|
+
* **A lite-routed cohort carries the `route::lite` hint** (Story #4707,
|
|
791
|
+
* hint-only since Story #4722). When the caller resolves the plan's
|
|
792
|
+
* effective complexity route to `lite` (the planner's recorded verdict,
|
|
793
|
+
* upheld by the shape backstop), it passes the label via `opts.routeLabel`
|
|
794
|
+
* and every created Story carries it — a **human-visible hint only**, never
|
|
795
|
+
* the control signal: `/deliver` re-derives the route from each Story body's
|
|
796
|
+
* own shape (`resolveStoryDispatchMode`), so a lost or failed label write
|
|
797
|
+
* cannot misroute delivery. A full-routed plan passes nothing and its
|
|
798
|
+
* Stories carry **no** route label. Like the cohort label, the ensure is
|
|
799
|
+
* non-fatal — the label is cosmetic either way.
|
|
794
800
|
*
|
|
795
801
|
* @param {object} args
|
|
796
802
|
* @param {object} args.provider
|
|
@@ -857,8 +863,8 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
|
|
|
857
863
|
label: routeLabel,
|
|
858
864
|
color: LITE_ROUTE_LABEL_COLOR,
|
|
859
865
|
description:
|
|
860
|
-
'Ceremony-lite
|
|
861
|
-
'
|
|
866
|
+
'Ceremony-lite hint (human-visible only): /deliver re-derives the ' +
|
|
867
|
+
'route from the Story body shape; every close gate runs unchanged.',
|
|
862
868
|
role: 'route-marker',
|
|
863
869
|
}));
|
|
864
870
|
|
|
@@ -307,23 +307,27 @@ export function buildStoriesEnvelope({
|
|
|
307
307
|
nativeEdges = new Map(),
|
|
308
308
|
foreignDone = [],
|
|
309
309
|
warn,
|
|
310
|
+
config,
|
|
310
311
|
}) {
|
|
311
312
|
const sorted = [...stories].sort((a, b) => a.id - b.id);
|
|
312
313
|
const inSetDone = sorted.filter(isSatisfiedBlocker).map((s) => s.id);
|
|
313
314
|
return {
|
|
314
315
|
kind: 'stories',
|
|
315
|
-
// `dispatchMode` (Story #
|
|
316
|
-
// execution mode from the
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
|
|
316
|
+
// `dispatchMode` (Story #4722): the resolver derives the per-Story
|
|
317
|
+
// execution mode from the fetched Story BODY's own shape (the shared
|
|
318
|
+
// shape function in `complexity-gate.js`) so `/deliver` reads one field —
|
|
319
|
+
// `inline` (lite-shaped: no story-worker / acceptance-critic sub-agent
|
|
320
|
+
// boots) or `subagent` (everything else, the conservative default). The
|
|
321
|
+
// `route::lite` label is a human-visible hint only, never the control
|
|
322
|
+
// signal: a lost label cannot misroute delivery. Model-side fan-out
|
|
323
|
+
// only; close gates are untouched.
|
|
324
|
+
stories: sorted.map(({ id, title, body, url, labels, state }) => ({
|
|
321
325
|
id,
|
|
322
326
|
title,
|
|
323
327
|
url,
|
|
324
328
|
labels,
|
|
325
329
|
state,
|
|
326
|
-
dispatchMode: resolveStoryDispatchMode({ labels }).mode,
|
|
330
|
+
dispatchMode: resolveStoryDispatchMode({ body, labels, config }).mode,
|
|
327
331
|
})),
|
|
328
332
|
dag: storiesToDag(sorted, nativeEdges, warn),
|
|
329
333
|
done: [...new Set([...inSetDone, ...foreignDone])].sort((a, b) => a - b),
|
|
@@ -74,10 +74,15 @@ export const DEFAULT_DIFF_WIDTH = Object.freeze({
|
|
|
74
74
|
* changed-file set intersect any sensitive-path class registered in
|
|
75
75
|
* `audit-rules.json`?
|
|
76
76
|
*
|
|
77
|
-
* This is the **single source** of the derived level —
|
|
78
|
-
* ({@link resolveDepth})
|
|
79
|
-
* (`ceremony-routing.js#resolveCeremonyForRisk`)
|
|
80
|
-
*
|
|
77
|
+
* This is the **single source** of the derived level — the review depth
|
|
78
|
+
* ({@link resolveDepth}), the acceptance-critic fresh-vs-inline routing
|
|
79
|
+
* (`ceremony-routing.js#resolveCeremonyForRisk`), and the dispatch-side
|
|
80
|
+
* complexity routing (`complexity-gate.js#deriveStoryShape`, Story #4722)
|
|
81
|
+
* all consume what this returns, so no ceremony decision can disagree about
|
|
82
|
+
* how risky a change is. Dispatch reads the **predicted** shape (the Story's
|
|
83
|
+
* declared `changes[]` footprint) and close reads the **actual** diff — one
|
|
84
|
+
* taxonomy, two read points, which is what keeps a lite-shaped Story whose
|
|
85
|
+
* footprint touches a sensitive path on the full route with its fresh critic.
|
|
81
86
|
*
|
|
82
87
|
* Returns `null` — the fail-safe "no derivable signal" level — when the change
|
|
83
88
|
* set is empty/unknown or the manifest cannot be read. Both downstream
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/orchestration/spec-budget.js — the soft `## Spec` word-budget pass
|
|
3
|
+
* (Story #4723), extracted from `ticket-validator.js` so the advisory
|
|
4
|
+
* length nudge lives beside neither the hard validators nor their error
|
|
5
|
+
* channel: everything here is `'soft'` by construction and can never fail
|
|
6
|
+
* a persist.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { parse as parseStoryBody } from '../story-body/story-body.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Soft advisory word budget for a Story's inline `## Spec` (Story #4723).
|
|
13
|
+
* ~250 words is the #4707 contract-level-prose target: interfaces,
|
|
14
|
+
* invariants, and load-bearing constraints — not route-by-route behavior
|
|
15
|
+
* narration. Distinct from the hard ~1500-token fail-closed ceiling in
|
|
16
|
+
* `spec-spill.js`: this budget only warns; it never fails the persist.
|
|
17
|
+
*/
|
|
18
|
+
export const SPEC_SOFT_WORD_BUDGET = 250;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve a Story's Spec prose across both authoring shapes: the canonical
|
|
22
|
+
* serialized string body (parsed; `## Spec` text block) and the
|
|
23
|
+
* pre-serialize structured object body (`body.spec`). Returns `''` when
|
|
24
|
+
* absent — or when a string body does not parse: this pass is advisory, so
|
|
25
|
+
* an unreadable body contributes no finding here and is left to the hard
|
|
26
|
+
* parse gate (`assertStoryBodiesParse`) to reject.
|
|
27
|
+
*
|
|
28
|
+
* @param {object} story
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
function resolveSpecText(story) {
|
|
32
|
+
const body = story?.body;
|
|
33
|
+
if (typeof body === 'string' && body.trim().length > 0) {
|
|
34
|
+
let spec;
|
|
35
|
+
try {
|
|
36
|
+
spec = parseStoryBody(body).body.spec;
|
|
37
|
+
} catch {
|
|
38
|
+
return '';
|
|
39
|
+
}
|
|
40
|
+
return typeof spec === 'string' ? spec : '';
|
|
41
|
+
}
|
|
42
|
+
if (body !== null && typeof body === 'object') {
|
|
43
|
+
return typeof body?.spec === 'string' ? body.spec : '';
|
|
44
|
+
}
|
|
45
|
+
return '';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Advisory `## Spec` length pass (Story #4723). Emits one `'soft'` finding
|
|
50
|
+
* per Story whose Spec prose exceeds {@link SPEC_SOFT_WORD_BUDGET} words,
|
|
51
|
+
* nudging the author toward contract-level prose (#4707). Soft only — the
|
|
52
|
+
* findings never reach the validator's `errors[]` channel, so an
|
|
53
|
+
* over-budget Spec never fails the persist.
|
|
54
|
+
*
|
|
55
|
+
* @param {{ stories: object[] }} opts
|
|
56
|
+
* @returns {object[]} Zero or more `spec-word-budget` findings.
|
|
57
|
+
*/
|
|
58
|
+
export function computeSpecBudgetFindings({ stories }) {
|
|
59
|
+
const findings = [];
|
|
60
|
+
for (const story of stories ?? []) {
|
|
61
|
+
const words = resolveSpecText(story).split(/\s+/).filter(Boolean).length;
|
|
62
|
+
if (words <= SPEC_SOFT_WORD_BUDGET) continue;
|
|
63
|
+
findings.push({
|
|
64
|
+
kind: 'spec-word-budget',
|
|
65
|
+
severity: 'soft',
|
|
66
|
+
ticketSlug: story.slug ?? '<unknown>',
|
|
67
|
+
words,
|
|
68
|
+
budget: SPEC_SOFT_WORD_BUDGET,
|
|
69
|
+
message:
|
|
70
|
+
`Story "${story.slug ?? '<unknown>'}" ## Spec is ~${words} words ` +
|
|
71
|
+
`(soft budget ${SPEC_SOFT_WORD_BUDGET}). Prefer contract-level prose ` +
|
|
72
|
+
'(interfaces, invariants, load-bearing constraints with their why) ' +
|
|
73
|
+
'over per-file behavior narration — advisory only; the persist ' +
|
|
74
|
+
'proceeds.',
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return findings;
|
|
78
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/orchestration/story-body-gate.js — the Story-body parse gate
|
|
3
|
+
* (Story #4541), extracted from `ticket-validator.js`: the one place a
|
|
4
|
+
* serialized Story body is parsed with parse failures translated into the
|
|
5
|
+
* validator's operator-legible `ValidationError` shape. Both the gate that
|
|
6
|
+
* refuses a plan up front (`assertStoryBodiesParse`) and the per-call
|
|
7
|
+
* translating parser (`parseStoryBodyOrThrow`) the downstream validators
|
|
8
|
+
* lean on live here.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { ValidationError } from '../errors/index.js';
|
|
12
|
+
import {
|
|
13
|
+
parse as parseStoryBody,
|
|
14
|
+
StoryBodyParseError,
|
|
15
|
+
} from '../story-body/story-body.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse a Story's serialized markdown body, translating a
|
|
19
|
+
* `StoryBodyParseError` into a `ValidationError` that names the offending
|
|
20
|
+
* **section** and **entry** (Story #4541).
|
|
21
|
+
*
|
|
22
|
+
* `StoryBodyParseError` already carries `field` (the section the parser was
|
|
23
|
+
* reading) and `raw` (the entry text that failed); this lifts both into an
|
|
24
|
+
* operator-legible message and a structured `violation` payload so an
|
|
25
|
+
* authoring loop can point at the exact bullet instead of re-deriving it
|
|
26
|
+
* from a downstream freshness miss.
|
|
27
|
+
*
|
|
28
|
+
* @param {object} story Story whose `body` is a non-empty markdown string.
|
|
29
|
+
* @returns {object} The structured body.
|
|
30
|
+
* @throws {ValidationError} `code: 'story-body-unparseable'`.
|
|
31
|
+
*/
|
|
32
|
+
export function parseStoryBodyOrThrow(story) {
|
|
33
|
+
try {
|
|
34
|
+
return parseStoryBody(story.body).body;
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (!(err instanceof StoryBodyParseError)) throw err;
|
|
37
|
+
const slug = story.slug ?? '<unknown>';
|
|
38
|
+
const section = err.field ?? 'body';
|
|
39
|
+
const entry = err.raw ?? null;
|
|
40
|
+
const entryLine = entry === null ? '' : `\n entry: ${entry}`;
|
|
41
|
+
const violation = { slug, section, entry, reason: err.message };
|
|
42
|
+
const error = new ValidationError(
|
|
43
|
+
`Cross-Validation Failed: Story "${slug}" has an unparseable body — ` +
|
|
44
|
+
`the ## ${section} section could not be read: ${err.message}` +
|
|
45
|
+
`${entryLine}\n\nFix the offending entry; this is a malformed body, ` +
|
|
46
|
+
'not a stale path reference.',
|
|
47
|
+
{ violations: [violation] },
|
|
48
|
+
);
|
|
49
|
+
error.code = 'story-body-unparseable';
|
|
50
|
+
error.violations = [violation];
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Refuse the plan when any Story's serialized body cannot be parsed, before
|
|
57
|
+
* either git-probe gate runs (Story #4541). Ordering matters: the freshness
|
|
58
|
+
* gate consults `body.changes` for its net-new whitelist, so an unparseable
|
|
59
|
+
* body used to reach the operator as a freshness miss naming declared paths.
|
|
60
|
+
*
|
|
61
|
+
* @param {{ tickets: object[] }} opts
|
|
62
|
+
* @throws {ValidationError} `code: 'story-body-unparseable'` on the first
|
|
63
|
+
* offending Story.
|
|
64
|
+
*/
|
|
65
|
+
export function assertStoryBodiesParse({ tickets }) {
|
|
66
|
+
for (const story of (tickets ?? []).filter((t) => t.type === 'story')) {
|
|
67
|
+
if (typeof story.body !== 'string' || story.body.trim().length === 0) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
parseStoryBodyOrThrow(story);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -829,6 +829,12 @@ export function renderHardConflictError(finding) {
|
|
|
829
829
|
if (finding.kind === 'missing-bdd-scaffold') {
|
|
830
830
|
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.`;
|
|
831
831
|
}
|
|
832
|
+
// Findings from other passes (sizing, spec-word-budget) carry their own
|
|
833
|
+
// message — render it rather than a shape-blind generic line, so the soft
|
|
834
|
+
// surface (`surfaceSoftConflictFindings`) stays legible for every kind.
|
|
835
|
+
if (typeof finding.message === 'string' && finding.message.length > 0) {
|
|
836
|
+
return finding.message;
|
|
837
|
+
}
|
|
832
838
|
return `Conflict finding ${finding.kind} on path "${finding.path ?? '<unknown>'}".`;
|
|
833
839
|
}
|
|
834
840
|
|