mandrel 2.57.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/agents/story-worker.md +12 -11
- 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/evidence-gate.js +17 -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/code-review.js +7 -3
- package/.agents/scripts/lib/orchestration/pinned-identifier-lint.js +137 -0
- package/.agents/scripts/lib/orchestration/plan-context.js +41 -27
- package/.agents/scripts/lib/orchestration/plan-persist/acceptance-handle-repair.js +107 -0
- package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +6 -1
- package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +14 -9
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +45 -31
- 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/plan-text-hygiene.js +15 -5
- package/.agents/scripts/lib/orchestration/review-base-ref.js +138 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +37 -5
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +6 -1
- 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/story-body/story-body.js +36 -2
- package/.agents/scripts/lib/templates/decomposer-prompts.js +73 -21
- package/.agents/scripts/lib/test-run-credit.js +23 -12
- 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/deliver-digest.md +22 -15
- package/.agents/workflows/helpers/deliver-story-reference.md +31 -11
- package/.agents/workflows/helpers/deliver-story.md +6 -5
- package/.agents/workflows/helpers/plan-reference.md +53 -13
- package/.agents/workflows/mandrel-plan.md +19 -14
- package/README.md +3 -3
- package/docs/CHANGELOG.md +21 -0
- package/lib/cli/registry.js +143 -27
- 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
|
@@ -29,11 +29,8 @@ import { parse as parseStoryBody } from '../story-body/story-body.js';
|
|
|
29
29
|
import {
|
|
30
30
|
renderStoryAuthorCore,
|
|
31
31
|
renderStorySplitRules,
|
|
32
|
+
ticketsModePromptField,
|
|
32
33
|
} from '../templates/decomposer-prompts.js';
|
|
33
|
-
import {
|
|
34
|
-
renderAcceptanceSpecSystemPrompt,
|
|
35
|
-
renderTechSpecSystemPrompt,
|
|
36
|
-
} from '../templates/spec-author-prompts.js';
|
|
37
34
|
import { concurrentMap, FANOUT_CONCURRENCY } from '../util/concurrent-map.js';
|
|
38
35
|
import { buildComplexitySignals } from './complexity-gate.js';
|
|
39
36
|
import { findDependencyCandidates } from './dependency-candidates.js';
|
|
@@ -328,6 +325,17 @@ export function renderStoriesTemplate({ complexitySignals = null } = {}) {
|
|
|
328
325
|
title: 'Fill: short descriptive title',
|
|
329
326
|
body: {
|
|
330
327
|
goal: 'Fill: one sentence stating why this Story exists.',
|
|
328
|
+
// A filled, multi-checkpoint example rather than a placeholder
|
|
329
|
+
// (Story #5332): `## Slicing` is how one cohesive sweep stays one
|
|
330
|
+
// Story, so the skeleton has to show an author what a checkpoint
|
|
331
|
+
// list looks like. Each line is a stage of the work — a commit
|
|
332
|
+
// boundary inside one session — never a restatement of an
|
|
333
|
+
// acceptance item, which states what is true once the Story lands.
|
|
334
|
+
slicing:
|
|
335
|
+
'1. Re-anchor the shared constant and its consumers.\n' +
|
|
336
|
+
'2. Move the gate ahead of the first write and arm the refusal.\n' +
|
|
337
|
+
'3. Delete the superseded module, its test and its flag.\n' +
|
|
338
|
+
'4. Regenerate the affected baselines; run the full gate chain.',
|
|
331
339
|
spec:
|
|
332
340
|
'Optional — contract and invariants only: interfaces, status ' +
|
|
333
341
|
'codes, security invariants, and load-bearing constraints with ' +
|
|
@@ -339,7 +347,7 @@ export function renderStoriesTemplate({ complexitySignals = null } = {}) {
|
|
|
339
347
|
non_goals: [],
|
|
340
348
|
},
|
|
341
349
|
acceptance: [
|
|
342
|
-
'Fill: an outcome a PR reviewer can confirm from the diff and the verify output
|
|
350
|
+
'Fill: an outcome a PR reviewer can confirm from the diff and the verify output — as many as the capability has, no target and no ceiling',
|
|
343
351
|
],
|
|
344
352
|
verify: [
|
|
345
353
|
'Fill: exact command or test path — the mechanical check the acceptance item rests on',
|
|
@@ -366,25 +374,23 @@ function countEnumeratedItems(text) {
|
|
|
366
374
|
}
|
|
367
375
|
|
|
368
376
|
/**
|
|
369
|
-
* Delta-shaped change-request verbs —
|
|
370
|
-
*
|
|
371
|
-
* footprint stays inside Story width.
|
|
377
|
+
* Delta-shaped change-request verbs — a change request naming one of these
|
|
378
|
+
* stays a Story by default when the footprint stays inside Story width.
|
|
372
379
|
*/
|
|
373
380
|
const DELTA_VERB_RE =
|
|
374
381
|
/\b(fix(?:es)?|tweak(?:s)?|extend(?:s)?|update(?:s)?|adjust(?:s)?|rename(?:s)?|correct(?:s)?|patch(?:es)?|bug|regression|flaky)\b/i;
|
|
375
382
|
|
|
376
383
|
/**
|
|
377
|
-
* Deterministic, CLI-applied scope
|
|
378
|
-
* (#4496 fix 6). Embedding
|
|
379
|
-
*
|
|
380
|
-
* from the headless path; the attended path keeps the skill-based judgment.
|
|
384
|
+
* Deterministic, CLI-applied scope signal over a raw `--seed` text
|
|
385
|
+
* (#4496 fix 6). Embedding it in the `--seed` envelope keeps the headless
|
|
386
|
+
* path from needing a judgment pass of its own.
|
|
381
387
|
*
|
|
382
|
-
* The heuristics anchor to the
|
|
388
|
+
* The heuristics anchor to the granularity SSOT —
|
|
383
389
|
* `DELIVERABLE_GRANULARITY_GUIDANCE` in `ticket-validator-sizing.js` (one
|
|
384
390
|
* Story = one coherent capability slice; multiple independent capabilities =
|
|
385
|
-
* an Epic) — and to the
|
|
386
|
-
*
|
|
387
|
-
*
|
|
391
|
+
* an Epic) — and to the change-request delta rubric above. The verdict is
|
|
392
|
+
* **advisory**: being wrong in the `epic` direction is cheap, and
|
|
393
|
+
* `borderline` is a first-class output, not a forced call.
|
|
388
394
|
*
|
|
389
395
|
* @param {{ seedText?: string }} args
|
|
390
396
|
* @returns {{ verdict: 'epic'|'story'|'borderline', reasons: string[], advisory: true, appliedBy: 'cli' }}
|
|
@@ -633,21 +639,29 @@ function withAdvisorySignals(complexitySignals, { config, cwd } = {}) {
|
|
|
633
639
|
|
|
634
640
|
/**
|
|
635
641
|
* Render the authoring system prompts the collapsed pipeline's single
|
|
636
|
-
* authoring pass consumes
|
|
637
|
-
* `lib/templates/
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
+
* authoring pass consumes: the N=1 core from
|
|
643
|
+
* `lib/templates/decomposer-prompts.js`, with the schedule rules and the
|
|
644
|
+
* collision refusal a planner reads only when the default-single split policy
|
|
645
|
+
* clears carried separately as `storySplitRules` (Story #5312).
|
|
646
|
+
*
|
|
647
|
+
* Story #5332 deleted the `spec` / `acceptance` fields with the module that
|
|
648
|
+
* rendered them: nothing read either, and both contradicted the current
|
|
649
|
+
* contract — one asserting Spec budgets that no longer exist, the other
|
|
650
|
+
* demanding the verify tier suffixes tickets mode now strips.
|
|
642
651
|
*
|
|
643
|
-
*
|
|
652
|
+
* `storyTicketsRules` is the one mode-conditional field (Story #5323): it
|
|
653
|
+
* only means anything when the seed is an existing ticket, and an envelope
|
|
654
|
+
* that carries it in every mode teaches the author to look for a source
|
|
655
|
+
* ticket that a `--seed` run does not have.
|
|
656
|
+
*
|
|
657
|
+
* @param {{ mode?: string }} [args]
|
|
658
|
+
* @returns {{ story: string, storySplitRules: string, storyTicketsRules?: string }}
|
|
644
659
|
*/
|
|
645
|
-
export function buildSystemPrompts() {
|
|
660
|
+
export function buildSystemPrompts({ mode } = {}) {
|
|
646
661
|
return {
|
|
647
|
-
spec: renderTechSpecSystemPrompt(),
|
|
648
|
-
acceptance: renderAcceptanceSpecSystemPrompt(),
|
|
649
662
|
story: renderStoryAuthorCore(),
|
|
650
663
|
storySplitRules: renderStorySplitRules(),
|
|
664
|
+
...ticketsModePromptField(mode),
|
|
651
665
|
};
|
|
652
666
|
}
|
|
653
667
|
|
|
@@ -1007,7 +1021,7 @@ async function buildTicketsModeEnvelope({
|
|
|
1007
1021
|
memoryPoolAdvisory: authoring.memoryPoolAdvisory,
|
|
1008
1022
|
priorFeedback: authoring.priorFeedback,
|
|
1009
1023
|
ticketSchema: TICKET_SCHEMA_DESCRIPTOR,
|
|
1010
|
-
systemPrompts: buildSystemPrompts(),
|
|
1024
|
+
systemPrompts: buildSystemPrompts({ mode: 'tickets' }),
|
|
1011
1025
|
planState: null,
|
|
1012
1026
|
planProfile:
|
|
1013
1027
|
ticketIds.length === 1 ? 'story-default' : 'story-from-tickets',
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* acceptance-handle-repair.js — repair-before-judging for the `AC-<n>:`
|
|
3
|
+
* presentation handle on authored acceptance items (Story #5323).
|
|
4
|
+
*
|
|
5
|
+
* The handle belongs to the body renderer, which numbers each checkbox from
|
|
6
|
+
* its position in `acceptance[]` (`story-body.js`). An author planning from
|
|
7
|
+
* an existing ticket reads that rendered body as a template and carries the
|
|
8
|
+
* handle forward, so the persisted checkbox reads `- [ ] AC-1: AC-1: …`; a
|
|
9
|
+
* lettered handle copied out of a hand-edited source (`AC-14a:`) misnumbers
|
|
10
|
+
* the rest of the list against its own text.
|
|
11
|
+
*
|
|
12
|
+
* The correction is mechanical and total — strip the handle the renderer
|
|
13
|
+
* will re-apply — so this module applies it and **reports** it, exactly as
|
|
14
|
+
* `changes-repair.js` does for the `{ path, assumption }` formality. Charging
|
|
15
|
+
* the author a re-draft round to paste back text the strip already produced
|
|
16
|
+
* buys nothing.
|
|
17
|
+
*
|
|
18
|
+
* It lives beside the validator rather than inside it for the same reason
|
|
19
|
+
* `changes-repair.js` does: the validator's job is to judge, and mixing a
|
|
20
|
+
* mutating repair pass into a module of pure collectors muddies both.
|
|
21
|
+
* `persist-helpers.js#validateTickets` calls this first, then the validators.
|
|
22
|
+
*
|
|
23
|
+
* @module lib/orchestration/plan-persist/acceptance-handle-repair
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { stripAcceptanceHandle } from '../../story-body/story-body.js';
|
|
27
|
+
import { renderChangeRepair } from './changes-repair.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Strip the handle off one surface's `acceptance[]`, recording each distinct
|
|
31
|
+
* strip on `repairs`.
|
|
32
|
+
*
|
|
33
|
+
* @param {object} surface An object that may carry an `acceptance` array.
|
|
34
|
+
* @param {string} slug
|
|
35
|
+
* @param {Set<string>} reported Items already reported for this ticket.
|
|
36
|
+
* @param {object[]} repairs Accumulator, mutated.
|
|
37
|
+
* @returns {void}
|
|
38
|
+
*/
|
|
39
|
+
function repairSurface(surface, slug, reported, repairs) {
|
|
40
|
+
if (!Array.isArray(surface.acceptance)) return;
|
|
41
|
+
surface.acceptance = surface.acceptance.map((item) => {
|
|
42
|
+
const { text, stripped } = stripAcceptanceHandle(item);
|
|
43
|
+
if (!stripped) return item;
|
|
44
|
+
const from = String(item ?? '');
|
|
45
|
+
if (!reported.has(from)) {
|
|
46
|
+
reported.add(from);
|
|
47
|
+
repairs.push({ kind: 'acceptance-handle', slug, from, to: text });
|
|
48
|
+
}
|
|
49
|
+
return text;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Strip the presentation `AC-<n>:` handle off every authored `acceptance[]`
|
|
55
|
+
* item, on both surfaces that can carry one: the ticket's top-level array
|
|
56
|
+
* (the machine contract) and a structured body's.
|
|
57
|
+
*
|
|
58
|
+
* A serialized **string** body needs no pass of its own — `parse()` strips
|
|
59
|
+
* the handle with the same grammar this does, so the two surfaces converge
|
|
60
|
+
* on one list whichever carried the handle, and the contract sync (which
|
|
61
|
+
* fails closed on a disagreement) sees them agree.
|
|
62
|
+
*
|
|
63
|
+
* Mutates `tickets` in place — the persist pipeline threads this same array
|
|
64
|
+
* on to assembly. Total: a non-array argument and non-Story tickets are
|
|
65
|
+
* no-ops.
|
|
66
|
+
*
|
|
67
|
+
* @param {object[]} tickets
|
|
68
|
+
* @returns {Array<{ kind: 'acceptance-handle', slug: string, from: string, to: string }>}
|
|
69
|
+
*/
|
|
70
|
+
export function normalizeAcceptanceHandles(tickets) {
|
|
71
|
+
const repairs = [];
|
|
72
|
+
for (const ticket of Array.isArray(tickets) ? tickets : []) {
|
|
73
|
+
if (!ticket || typeof ticket !== 'object' || ticket.type !== 'story') {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const slug = ticket.slug ?? ticket.title ?? '<unknown>';
|
|
77
|
+
// A ticket normally carries the same list on both surfaces, so report
|
|
78
|
+
// each distinct item once — the operator reads one correction, not two.
|
|
79
|
+
const reported = new Set();
|
|
80
|
+
repairSurface(ticket, slug, reported, repairs);
|
|
81
|
+
const body = ticket.body;
|
|
82
|
+
if (body && typeof body === 'object') {
|
|
83
|
+
repairSurface(body, slug, reported, repairs);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return repairs;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Render one entry of the dry-run's mixed repair list.
|
|
91
|
+
*
|
|
92
|
+
* The list carries two kinds — a `changes[]` entry repaired by probing base,
|
|
93
|
+
* and an `acceptance[]` item whose handle was normalised off — and both are
|
|
94
|
+
* mechanical corrections the run applied on the author's behalf, so both
|
|
95
|
+
* belong on the one list the operator reads. This module owns the dispatch
|
|
96
|
+
* because it owns the newer kind: it renders its own line and delegates
|
|
97
|
+
* every other kind to `changes-repair.js`, so neither producer has to know
|
|
98
|
+
* the other's shape.
|
|
99
|
+
*
|
|
100
|
+
* @param {{ kind?: string, slug: string, from: string, to?: string }} repair
|
|
101
|
+
* @returns {string}
|
|
102
|
+
*/
|
|
103
|
+
export function renderRepair(repair) {
|
|
104
|
+
if (repair.kind !== 'acceptance-handle') return renderChangeRepair(repair);
|
|
105
|
+
const { slug, from, to } = repair;
|
|
106
|
+
return `Story "${slug}": acceptance[] item "${from}" carried an AC-<n> handle — normalised to "${to}"; the body renderer numbers the checkboxes.`;
|
|
107
|
+
}
|
|
@@ -261,7 +261,12 @@ function repairTicket(ticket, existsAtBase) {
|
|
|
261
261
|
}
|
|
262
262
|
|
|
263
263
|
/**
|
|
264
|
-
* Render one repair as the dry-run line the operator reads.
|
|
264
|
+
* Render one `changes[]` repair as the dry-run line the operator reads.
|
|
265
|
+
*
|
|
266
|
+
* The dry-run's repair list is mixed — an `acceptance[]` handle strip is
|
|
267
|
+
* reported on it too — but the dispatch across kinds lives in
|
|
268
|
+
* [`acceptance-handle-repair.js`](acceptance-handle-repair.js)`#renderRepair`,
|
|
269
|
+
* which delegates here for this kind. Each producer owns its own line.
|
|
265
270
|
*
|
|
266
271
|
* @param {{ slug: string, from: string, path: string, assumption: string, reason: string }} repair
|
|
267
272
|
* @returns {string}
|
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
* checkout on CI has no local `main`), else nothing — a shallow checkout
|
|
11
11
|
* with no base at all skips the probes instead of reading every path as
|
|
12
12
|
* absent.
|
|
13
|
-
* - `validateTickets(tickets, config, opts)` —
|
|
14
|
-
* `
|
|
15
|
-
*
|
|
13
|
+
* - `validateTickets(tickets, config, opts)` — normalises the authored
|
|
14
|
+
* `acceptance[]` handles and repairs the mechanical `changes[]`
|
|
15
|
+
* formalities against the base branch, then runs the cross-link,
|
|
16
|
+
* freshness, and task-body validators in one pass.
|
|
16
17
|
*
|
|
17
18
|
* Story #5312 deleted the fan-out probe that lived here: the delete
|
|
18
19
|
* blast-radius count never refused a real plan, and the `git grep` it paid
|
|
@@ -24,6 +25,7 @@
|
|
|
24
25
|
import { gitSpawn } from '../../git-utils.js';
|
|
25
26
|
import { validateTaskBodies } from '../task-body-validator.js';
|
|
26
27
|
import { validateAndNormalizeTickets } from '../ticket-validator.js';
|
|
28
|
+
import { normalizeAcceptanceHandles } from './acceptance-handle-repair.js';
|
|
27
29
|
import { repairChangeEntries } from './changes-repair.js';
|
|
28
30
|
|
|
29
31
|
/**
|
|
@@ -167,13 +169,16 @@ function defineHidden(validated, extras) {
|
|
|
167
169
|
export function validateTickets(tickets, config, opts = {}) {
|
|
168
170
|
const baseBranch = resolveBaseBranchRef(config);
|
|
169
171
|
const baseBranchRef = resolveProbeRef({ baseBranch, cwd: opts.cwd });
|
|
170
|
-
const repairs =
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
172
|
+
const repairs = [
|
|
173
|
+
...normalizeAcceptanceHandles(tickets),
|
|
174
|
+
...repairChangeEntries(tickets, {
|
|
175
|
+
existsAtBase: makeExistsAtBase({
|
|
176
|
+
baseBranchRef,
|
|
177
|
+
cwd: opts.cwd,
|
|
178
|
+
gitRunner: opts.gitRunner,
|
|
179
|
+
}),
|
|
175
180
|
}),
|
|
176
|
-
|
|
181
|
+
];
|
|
177
182
|
const validated = validateAndNormalizeTickets(tickets, {
|
|
178
183
|
baseBranchRef: baseBranchRef ?? undefined,
|
|
179
184
|
gitRunner: opts.gitRunner,
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* 1. `changes[]` repair + ticket validator + file-assumption + DAG
|
|
9
9
|
* 2. Draft reachability (named soft failure, exit 3)
|
|
10
|
-
* 3.
|
|
10
|
+
* 3. Same-wave collision refusal (`assertNoWaveCollisions`) + spec fold
|
|
11
11
|
* 4. Create Story issues (`type::story` + sanitized authored labels —
|
|
12
12
|
* deliberately NOT `agent::ready`), resumably via a plan fingerprint
|
|
13
13
|
* 5. Upsert `story-plan-state` on every created Story; upsert `plan-summary`
|
|
@@ -62,8 +62,8 @@ import {
|
|
|
62
62
|
conflictFindingKey,
|
|
63
63
|
} from '../ticket-validator-conflicts.js';
|
|
64
64
|
import { upsertStructuredComment } from '../ticketing.js';
|
|
65
|
+
import { renderRepair } from './acceptance-handle-repair.js';
|
|
65
66
|
import { recordAuditFilings, withAuditLabels } from './audit-provenance.js';
|
|
66
|
-
import { renderChangeRepair } from './changes-repair.js';
|
|
67
67
|
import {
|
|
68
68
|
resolveContainerEpic,
|
|
69
69
|
resolveCrossPlanLinks,
|
|
@@ -81,7 +81,7 @@ import {
|
|
|
81
81
|
PLAN_SUMMARY_COMMENT_TYPE,
|
|
82
82
|
} from './summary.js';
|
|
83
83
|
import { closeSupersededTickets } from './supersede-ops.js';
|
|
84
|
-
import {
|
|
84
|
+
import { assertNoWaveCollisions } from './wave-collision-gate.js';
|
|
85
85
|
|
|
86
86
|
/** Checkpoint schema version written on each Story's story-plan-state. */
|
|
87
87
|
const PLAN_CHECKPOINT_SCHEMA_VERSION_V2 = 2;
|
|
@@ -146,7 +146,7 @@ function enforceTicketValidation(validated) {
|
|
|
146
146
|
);
|
|
147
147
|
}
|
|
148
148
|
const warnings = [
|
|
149
|
-
...(validated.repairs ?? []).map(
|
|
149
|
+
...(validated.repairs ?? []).map(renderRepair),
|
|
150
150
|
...(validated.warnings ?? []),
|
|
151
151
|
];
|
|
152
152
|
return {
|
|
@@ -170,18 +170,28 @@ function freshnessCounts(probeRef, warnings) {
|
|
|
170
170
|
return { stale: warnings.length, ambiguous: 0 };
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
/** How each text-hygiene finding kind names itself on the warning list. */
|
|
174
|
+
const TEXT_HYGIENE_LABELS = {
|
|
175
|
+
'open-question': 'open question in body',
|
|
176
|
+
'pinned-identifier': 'pinned identifier in acceptance[]',
|
|
177
|
+
};
|
|
178
|
+
|
|
173
179
|
/**
|
|
174
|
-
* The
|
|
180
|
+
* The advisory text-hygiene lints over the draft (Story #5312, #5323) — an
|
|
175
181
|
* operator-directed question persisted into a Story a non-interactive
|
|
176
|
-
* sub-agent executes
|
|
182
|
+
* sub-agent executes, and an acceptance item pinning an internal symbol the
|
|
183
|
+
* advisory `changes[]` may reshape. Warnings the dry-run lists, never
|
|
184
|
+
* refusals.
|
|
177
185
|
*
|
|
178
186
|
* @param {object[]} rawStories
|
|
179
187
|
* @returns {string[]}
|
|
180
188
|
*/
|
|
181
|
-
function
|
|
189
|
+
function collectTextHygieneWarnings(rawStories) {
|
|
182
190
|
return evaluateTextHygiene({ draftStories: rawStories }).findings.map(
|
|
183
191
|
(finding) =>
|
|
184
|
-
`Story "${finding.slug}":
|
|
192
|
+
`Story "${finding.slug}": ${
|
|
193
|
+
TEXT_HYGIENE_LABELS[finding.kind] ?? finding.kind
|
|
194
|
+
} — "${finding.evidence}". ${finding.message}`,
|
|
185
195
|
);
|
|
186
196
|
}
|
|
187
197
|
|
|
@@ -533,7 +543,6 @@ function logPersistEpilogue({
|
|
|
533
543
|
* artifacts: {
|
|
534
544
|
* stories: Array<object>,
|
|
535
545
|
* techSpecContent?: string|null,
|
|
536
|
-
* planAcceptance?: string[]|null,
|
|
537
546
|
* planContextEnvelope?: object|null,
|
|
538
547
|
* },
|
|
539
548
|
* config?: object,
|
|
@@ -559,7 +568,6 @@ export async function runPlanPersist({
|
|
|
559
568
|
const {
|
|
560
569
|
stories: rawStories = null,
|
|
561
570
|
techSpecContent = null,
|
|
562
|
-
planAcceptance = null,
|
|
563
571
|
planContextEnvelope = null,
|
|
564
572
|
} = artifacts ?? {};
|
|
565
573
|
const {
|
|
@@ -595,7 +603,7 @@ export async function runPlanPersist({
|
|
|
595
603
|
enforceTicketValidation(validated);
|
|
596
604
|
const warnings = [
|
|
597
605
|
...validationWarnings,
|
|
598
|
-
...
|
|
606
|
+
...collectTextHygieneWarnings(rawStories),
|
|
599
607
|
];
|
|
600
608
|
logWarnings(warnings);
|
|
601
609
|
|
|
@@ -613,11 +621,10 @@ export async function runPlanPersist({
|
|
|
613
621
|
epicId: opts.adoptEpicId ?? null,
|
|
614
622
|
});
|
|
615
623
|
|
|
616
|
-
//
|
|
624
|
+
// Inline Spec fold (Specs stay inline, never under docs/).
|
|
617
625
|
const seedContent = planContextEnvelope?.seed?.content ?? '';
|
|
618
626
|
const { stories: assembled } = assemblePlanStories(rawStories, {
|
|
619
627
|
sharedSpec: techSpecContent,
|
|
620
|
-
planAcceptance: planAcceptance ?? undefined,
|
|
621
628
|
sourceTicketIds,
|
|
622
629
|
// The seed this plan was authored from: an audit sweep's Single-plan seed
|
|
623
630
|
// carries the `audit-fingerprints` / `audit-semantic-keys` footers, and
|
|
@@ -644,6 +651,31 @@ export async function runPlanPersist({
|
|
|
644
651
|
rawFindings: validated.findings,
|
|
645
652
|
});
|
|
646
653
|
|
|
654
|
+
// Story #5332 — the split gate, ahead of the first create. `buildWaveTable`
|
|
655
|
+
// needs only `{slug, title, depends_on}` and the prediction needs only the
|
|
656
|
+
// assembled bodies, so both can run before anything is written; they used
|
|
657
|
+
// to sit *after* creation, which is why the collision table could only ever
|
|
658
|
+
// be a receipt for a plan already live. The same computed value is handed
|
|
659
|
+
// to the summary rendering below rather than recomputed, so the refusal and
|
|
660
|
+
// the receipt can never disagree.
|
|
661
|
+
const waveTable = buildWaveTable(
|
|
662
|
+
stories.map((s) => ({
|
|
663
|
+
slug: s.slug,
|
|
664
|
+
title: s.title,
|
|
665
|
+
depends_on: s.depends_on,
|
|
666
|
+
})),
|
|
667
|
+
);
|
|
668
|
+
|
|
669
|
+
// Story #5265: the table says which Stories share an order; the dispatcher
|
|
670
|
+
// decides which of those actually run together. Run its own predicate over
|
|
671
|
+
// the assembled bodies — the exact artifact the tick will read back off
|
|
672
|
+
// GitHub — so an N>1 draft it would serialize is refused here, and the
|
|
673
|
+
// summary comment names the serialisation instead of promising parallelism
|
|
674
|
+
// the next tick refuses. One enumeration feeds both.
|
|
675
|
+
const waveCollisions = assertNoWaveCollisions(waveTable, stories, {
|
|
676
|
+
tempRoot: getPaths(config).tempRoot,
|
|
677
|
+
});
|
|
678
|
+
|
|
647
679
|
const { created, planRunLabel, planRunLabelApplied } =
|
|
648
680
|
await createStoryIssues({
|
|
649
681
|
provider,
|
|
@@ -656,24 +688,6 @@ export async function runPlanPersist({
|
|
|
656
688
|
recordAuditFilings({ stories, created, tickets: rawStories, dryRun });
|
|
657
689
|
|
|
658
690
|
const primary = created[0];
|
|
659
|
-
const waveTable = buildWaveTable(
|
|
660
|
-
stories.map((s) => ({
|
|
661
|
-
slug: s.slug,
|
|
662
|
-
title: s.title,
|
|
663
|
-
depends_on: s.depends_on,
|
|
664
|
-
})),
|
|
665
|
-
);
|
|
666
|
-
|
|
667
|
-
// Story #5265: the table says which Stories share an order; the dispatcher
|
|
668
|
-
// decides which of those actually run together, and it decides on the
|
|
669
|
-
// evidence-widened footprint. Run its own predicate over the assembled
|
|
670
|
-
// bodies — the exact artifact the tick will read back off GitHub — so the
|
|
671
|
-
// comment names the serialisation instead of promising parallelism the
|
|
672
|
-
// next tick refuses. `tempRoot` is threaded for the same reason the tick
|
|
673
|
-
// threads it: the scrape must ignore this project's scratch root.
|
|
674
|
-
const waveCollisions = predictWaveSerialisation(waveTable, stories, {
|
|
675
|
-
tempRoot: getPaths(config).tempRoot,
|
|
676
|
-
});
|
|
677
691
|
|
|
678
692
|
// Story #4541: `readPlanMetrics` is declared `(epicId, config)` but was
|
|
679
693
|
// called with `config` first, so the ledger path resolver received the
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Under the Story collapse (`docs/roadmap.md` § Stage 3), `/mandrel-plan` persists
|
|
5
5
|
* zero-or-more Story issues directly — no Epic parent, no reconciler tree,
|
|
6
6
|
* no `deliveryShape` mode matrix. Default is **one Story**; N>1 is gated by
|
|
7
|
-
* the
|
|
7
|
+
* the supersede partition check.
|
|
8
8
|
*
|
|
9
9
|
* Each Story body is the single executable document: Tech Spec stays inline
|
|
10
10
|
* under `## Spec`, at whatever length the work needs (Story #5312 deleted
|
|
@@ -34,7 +34,6 @@ import {
|
|
|
34
34
|
concurrentMap,
|
|
35
35
|
FANOUT_CONCURRENCY,
|
|
36
36
|
} from '../../util/concurrent-map.js';
|
|
37
|
-
import { assertAcceptancePartition } from '../split-policy-validator.js';
|
|
38
37
|
import {
|
|
39
38
|
externalDependencyId,
|
|
40
39
|
isExternalDependencyRef,
|
|
@@ -514,15 +513,18 @@ function assertSharedSpecAllowed(tickets, sharedSpec) {
|
|
|
514
513
|
|
|
515
514
|
/**
|
|
516
515
|
* Assemble markdown bodies for every Story: normalize → fold spec →
|
|
517
|
-
*
|
|
516
|
+
* assertSupersedePartition → serialize.
|
|
518
517
|
*
|
|
519
|
-
*
|
|
520
|
-
* plan never leaves Stories live against an inconsistent tracker.
|
|
518
|
+
* The partition check runs **before** any GitHub write so a mis-authored
|
|
519
|
+
* plan never leaves Stories live against an inconsistent tracker. Story #5332
|
|
520
|
+
* retired the acceptance partition that used to run beside it: it refused
|
|
521
|
+
* only byte-identical acceptance text across siblings, and the split gate is
|
|
522
|
+
* now `assertNoWaveCollisions` in `run-plan-persist.js`, ahead of the first
|
|
523
|
+
* create.
|
|
521
524
|
*
|
|
522
525
|
* @param {object[]} tickets
|
|
523
526
|
* @param {object} [opts]
|
|
524
527
|
* @param {string|null} [opts.sharedSpec]
|
|
525
|
-
* @param {string[]} [opts.planAcceptance]
|
|
526
528
|
* @param {number[]} [opts.sourceTicketIds] Ids passed to `/mandrel-plan --tickets`.
|
|
527
529
|
* @returns {{ stories: Array<{ slug: string, title: string, body: string, acceptance: string[], depends_on: string[], supersedes: Array<{ id: number, note: string|null }> }> }}
|
|
528
530
|
*/
|
|
@@ -539,9 +541,6 @@ export function assemblePlanStories(tickets, opts = {}) {
|
|
|
539
541
|
(ticket) => assembleOnePlanStory(ticket, opts).story,
|
|
540
542
|
);
|
|
541
543
|
|
|
542
|
-
assertAcceptancePartition(stories, {
|
|
543
|
-
planAcceptance: opts.planAcceptance,
|
|
544
|
-
});
|
|
545
544
|
assertSupersedePartition(stories, opts.sourceTicketIds ?? []);
|
|
546
545
|
|
|
547
546
|
return { stories };
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* Two halves, deliberately separated by the `createIssue` boundary:
|
|
17
17
|
*
|
|
18
18
|
* 1. **`assertSupersedePartition`** — a plan-time, fail-closed check that
|
|
19
|
-
* runs *before* any GitHub write.
|
|
19
|
+
* runs *before* any GitHub write. Same fail-closed shape as the
|
|
20
20
|
* every id passed to `--tickets` must be claimed by exactly one Story,
|
|
21
21
|
* and no Story may claim an id that was not a source ticket. A partial
|
|
22
22
|
* supersede map is a planning error, not something to paper over at
|
|
@@ -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
|
+
}
|