mandrel 1.68.0 → 1.70.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 +1 -1
- package/.agents/docs/agentrc-reference.json +1 -2
- package/.agents/docs/configuration.md +2 -4
- package/.agents/docs/workflows.md +1 -1
- package/.agents/schemas/agentrc.schema.json +1 -5
- package/.agents/schemas/lifecycle/epic.automerge.end.schema.json +2 -1
- package/.agents/scripts/agents-update-preflight.js +235 -0
- package/.agents/scripts/apply-quality-bootstrap.js +79 -0
- package/.agents/scripts/audit-labels-bootstrap.js +52 -30
- package/.agents/scripts/audit-to-stories.js +54 -0
- package/.agents/scripts/bootstrap.js +13 -3
- package/.agents/scripts/epic-deliver-preflight.js +30 -13
- package/.agents/scripts/epic-deliver-prepare.js +40 -53
- package/.agents/scripts/epic-execute-record-wave.js +119 -133
- package/.agents/scripts/generate-config-docs.js +189 -94
- package/.agents/scripts/lib/audit-suite/findings.js +0 -4
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +99 -0
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +13 -5
- package/.agents/scripts/lib/baseline-snapshot.js +163 -4
- package/.agents/scripts/lib/baselines/refresh-service.js +13 -5
- package/.agents/scripts/lib/config/baselines.js +0 -20
- package/.agents/scripts/lib/config/explain.js +0 -2
- package/.agents/scripts/lib/config/limits.js +19 -8
- package/.agents/scripts/lib/config/temp-paths.js +0 -31
- package/.agents/scripts/lib/config-settings-schema.js +1 -2
- package/.agents/scripts/lib/crap-utils.js +281 -0
- package/.agents/scripts/lib/maintainability-utils.js +32 -9
- package/.agents/scripts/lib/orchestration/dispatch-engine.js +0 -2
- package/.agents/scripts/lib/orchestration/epic-cleanup.js +11 -7
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/cli.js +6 -6
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/context.js +11 -5
- package/.agents/scripts/lib/orchestration/epic-run-state-store.js +203 -110
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +38 -162
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +3 -4
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/transport.js +16 -13
- package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +10 -7
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +37 -24
- package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -4
- package/.agents/scripts/lib/orchestration/manifest-builder.js +6 -0
- package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +101 -70
- package/.agents/scripts/lib/orchestration/spec-renderer.js +42 -14
- package/.agents/scripts/lib/orchestration/ticket-lease.js +3 -0
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +6 -2
- package/.agents/scripts/lib/orchestration/wave-record-io.js +18 -77
- package/.agents/scripts/lib/orchestration/wave-record-notifications.js +78 -122
- package/.agents/scripts/lib/orchestration/wave-record-projection.js +21 -226
- package/.agents/scripts/lib/presentation/dispatch-manifest-render.js +18 -1
- package/.agents/scripts/lib/presentation/manifest-render-waves.js +77 -4
- package/.agents/scripts/lib/story-adjacency.js +14 -10
- package/.agents/scripts/lib/story-body/story-body.js +142 -65
- package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -3
- package/.agents/scripts/lib/test-tiers.js +13 -7
- package/.agents/scripts/lib/wave-runner/ready-set.js +295 -0
- package/.agents/scripts/lib/wave-runner/tick.js +446 -216
- package/.agents/scripts/lib/wave-runner/wave-runner-error.js +2 -1
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +226 -0
- package/.agents/scripts/lint-label-vocabulary.js +1 -1
- package/.agents/scripts/providers/github/issues.js +48 -0
- package/.agents/scripts/providers/github.js +1 -0
- package/.agents/scripts/stories-wave-tick.js +262 -161
- package/.agents/skills/core/epic-plan-consolidate/SKILL.md +6 -0
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +108 -101
- package/.agents/skills/skills.index.json +2 -2
- package/.agents/workflows/agents-update.md +205 -28
- package/.agents/workflows/deliver.md +12 -9
- package/.agents/workflows/helpers/deliver-epic.md +126 -90
- package/.agents/workflows/helpers/deliver-stories.md +131 -85
- package/.agents/workflows/helpers/plan-epic.md +13 -10
- package/.agents/workflows/plan.md +1 -1
- package/README.md +20 -0
- package/docs/CHANGELOG.md +46 -0
- package/lib/cli/registry.js +49 -6
- package/lib/cli/update.js +335 -332
- package/package.json +16 -11
- package/.agents/scripts/lib/wave-runner/wave-checkpoint.js +0 -91
|
@@ -8,23 +8,40 @@
|
|
|
8
8
|
*
|
|
9
9
|
* No top-level side effects — safe to import from tests without
|
|
10
10
|
* triggering GitHub I/O.
|
|
11
|
+
*
|
|
12
|
+
* Story #4157 — each projected story's `wave` is a **render-time
|
|
13
|
+
* dependency depth** derived from the manifest's `dependsOn` edges via the
|
|
14
|
+
* shared `deriveStoryDepths` lens (`manifest-render-waves.js`, built on
|
|
15
|
+
* `assignLayers`), not the persisted `earliestWave`. Scheduling no longer
|
|
16
|
+
* stamps a wave field onto the run checkpoint (Epic #4151 / Story #4155),
|
|
17
|
+
* so the rollup re-derives depth from the dependency graph at render time.
|
|
11
18
|
*/
|
|
12
19
|
|
|
20
|
+
import { deriveStoryDepths } from './manifest-render-waves.js';
|
|
21
|
+
|
|
13
22
|
/**
|
|
14
23
|
* Pure: project a full dispatch manifest into the `{ stories }` shape
|
|
15
24
|
* `renderManifest` accepts. Returns the canonical, non-ungrouped story
|
|
16
25
|
* rows used by the Epic-level dispatch-manifest comment.
|
|
17
26
|
*
|
|
27
|
+
* The `wave` field is the render-time dependency depth (Story #4157):
|
|
28
|
+
* `deriveStoryDepths` runs `assignLayers` over the entries' `dependsOn`
|
|
29
|
+
* edges, so a Story with no in-set dependency is wave 0 and a dependent
|
|
30
|
+
* sits one layer deeper than its deepest dependency. Entries the lens
|
|
31
|
+
* cannot place (e.g. a non-integer storyId that survives the sentinel
|
|
32
|
+
* filter) fall back to `-1`.
|
|
33
|
+
*
|
|
18
34
|
* @param {object} manifest
|
|
19
35
|
* @returns {{ storyId: number|string, wave: number, title: string }[]}
|
|
20
36
|
*/
|
|
21
37
|
export function projectStoriesFromManifest(manifest) {
|
|
22
38
|
const storyManifest = manifest?.storyManifest ?? [];
|
|
39
|
+
const depths = deriveStoryDepths(storyManifest);
|
|
23
40
|
return storyManifest
|
|
24
41
|
.filter((s) => s && s.storyId !== '__ungrouped__')
|
|
25
42
|
.map((s) => ({
|
|
26
43
|
storyId: s.storyId,
|
|
27
|
-
wave: s.
|
|
44
|
+
wave: depths.get(s.storyId) ?? -1,
|
|
28
45
|
title: s.storyTitle ?? s.storySlug ?? '',
|
|
29
46
|
}));
|
|
30
47
|
}
|
|
@@ -17,15 +17,81 @@
|
|
|
17
17
|
* Imports the small pure helpers from `manifest-helpers.js` — the
|
|
18
18
|
* formatter re-exports them so existing call-sites that read these
|
|
19
19
|
* names off `manifest-formatter.js` keep working.
|
|
20
|
+
*
|
|
21
|
+
* Story #4157 — the per-wave grouping key is now a **render-time
|
|
22
|
+
* dependency depth** derived from each Story entry's `dependsOn` edges via
|
|
23
|
+
* `assignLayers` (`lib/Graph.js`), not the persisted `earliestWave` field
|
|
24
|
+
* the planner stamped on the manifest. Scheduling no longer persists waves
|
|
25
|
+
* onto the run checkpoint (Epic #4151 / Story #4155), so the rollup
|
|
26
|
+
* re-derives depth from the dependency graph at the moment it renders. The
|
|
27
|
+
* shared `deriveStoryDepths` lens below is the single home for that
|
|
28
|
+
* derivation; `dispatch-manifest-render.js` imports it so both
|
|
29
|
+
* operator-facing surfaces group by the same render-time depths.
|
|
20
30
|
*/
|
|
21
31
|
|
|
32
|
+
import { assignLayers } from '../Graph.js';
|
|
22
33
|
import { AGENT_LABELS } from '../label-constants.js';
|
|
34
|
+
import { buildStoryAdjacency } from '../story-adjacency.js';
|
|
23
35
|
import {
|
|
24
36
|
deriveStorySymbol,
|
|
25
37
|
deriveWaveStatus,
|
|
26
38
|
waveHeadingText,
|
|
27
39
|
} from './manifest-helpers.js';
|
|
28
40
|
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Render-time dependency-depth lens (Story #4157)
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Derive a render-time dependency depth for every Story entry in a
|
|
47
|
+
* `storyManifest`, keyed by storyId.
|
|
48
|
+
*
|
|
49
|
+
* The depth is computed by feeding the entries' `dependsOn` edges through
|
|
50
|
+
* the canonical `buildStoryAdjacency` builder (the same one the dispatch
|
|
51
|
+
* pipeline and `stories-wave-tick.js` use) and then `assignLayers`
|
|
52
|
+
* (`lib/Graph.js`): a Story with no dependency edges is depth 0, and a
|
|
53
|
+
* Story sits one layer deeper than its deepest in-set dependency. This
|
|
54
|
+
* replaces the planner's persisted `earliestWave` as the grouping key so
|
|
55
|
+
* the rollup renders correctly from the per-Story checkpoint shape, which
|
|
56
|
+
* no longer carries a wave field (Epic #4151 / Story #4155).
|
|
57
|
+
*
|
|
58
|
+
* `buildStoryAdjacency` reads each entry's id from `id ?? number`, so the
|
|
59
|
+
* entries are adapted to expose `id: storyId` and `dependsOn`. Foreign
|
|
60
|
+
* edges (pointing outside the supplied entry set) are dropped — the
|
|
61
|
+
* default `dropForeign: true` — so the DAG stays closed over the rendered
|
|
62
|
+
* Stories and depth never deepens on a reference the rollup cannot show.
|
|
63
|
+
* The ungrouped sentinel (`storyId === '__ungrouped__'`) and any non-object
|
|
64
|
+
* entry are skipped; they never participate in the graph.
|
|
65
|
+
*
|
|
66
|
+
* Pure: no IO, no clock. Returns a `Map<storyId, depth>` covering exactly
|
|
67
|
+
* the graph-eligible entries.
|
|
68
|
+
*
|
|
69
|
+
* @param {object[]} storyManifest
|
|
70
|
+
* @returns {Map<number|string, number>}
|
|
71
|
+
*/
|
|
72
|
+
export function deriveStoryDepths(storyManifest) {
|
|
73
|
+
if (!Array.isArray(storyManifest)) return new Map();
|
|
74
|
+
const eligible = storyManifest.filter(
|
|
75
|
+
(s) =>
|
|
76
|
+
s !== null &&
|
|
77
|
+
typeof s === 'object' &&
|
|
78
|
+
s.storyId !== '__ungrouped__' &&
|
|
79
|
+
Number.isInteger(Number(s.storyId)),
|
|
80
|
+
);
|
|
81
|
+
const records = eligible.map((s) => ({
|
|
82
|
+
id: Number(s.storyId),
|
|
83
|
+
dependsOn: Array.isArray(s.dependsOn) ? s.dependsOn : [],
|
|
84
|
+
}));
|
|
85
|
+
const adjacency = buildStoryAdjacency(records);
|
|
86
|
+
const layers = assignLayers(adjacency);
|
|
87
|
+
|
|
88
|
+
const depths = new Map();
|
|
89
|
+
for (const s of eligible) {
|
|
90
|
+
depths.set(s.storyId, layers.get(Number(s.storyId)) ?? 0);
|
|
91
|
+
}
|
|
92
|
+
return depths;
|
|
93
|
+
}
|
|
94
|
+
|
|
29
95
|
// ---------------------------------------------------------------------------
|
|
30
96
|
// ---------------------------------------------------------------------------
|
|
31
97
|
|
|
@@ -90,14 +156,20 @@ function pickWaveTail(status, waveIdx, sortedWaves, storyCount) {
|
|
|
90
156
|
* (a Story is "done" when it carries `agent::done`) — the unit
|
|
91
157
|
* `deriveWaveStatus` consumes.
|
|
92
158
|
*
|
|
159
|
+
* Story #4157 — the bucket key is the render-time dependency depth from
|
|
160
|
+
* `depths` (keyed by storyId), not the persisted `earliestWave`. The
|
|
161
|
+
* ungrouped sentinel and any entry the lens could not place fall into the
|
|
162
|
+
* `-1` "Ungrouped" bucket so they still render.
|
|
163
|
+
*
|
|
93
164
|
* @param {object[]} waveStories
|
|
165
|
+
* @param {Map<number|string, number>} depths
|
|
94
166
|
* @returns {{ waveGroups: Map<number, object[]>, waveStats: Map<number, { total: number, done: number }> }}
|
|
95
167
|
*/
|
|
96
|
-
function groupStoriesByWave(waveStories) {
|
|
168
|
+
function groupStoriesByWave(waveStories, depths) {
|
|
97
169
|
const waveGroups = new Map();
|
|
98
170
|
const waveStats = new Map();
|
|
99
171
|
for (const story of waveStories) {
|
|
100
|
-
const w = story.
|
|
172
|
+
const w = depths.get(story.storyId) ?? -1;
|
|
101
173
|
if (!waveGroups.has(w)) {
|
|
102
174
|
waveGroups.set(w, []);
|
|
103
175
|
waveStats.set(w, { total: 0, done: 0 });
|
|
@@ -126,7 +198,8 @@ export function renderNestedWaveSections(storyManifest) {
|
|
|
126
198
|
validateWaveSection('story', s),
|
|
127
199
|
);
|
|
128
200
|
|
|
129
|
-
const
|
|
201
|
+
const depths = deriveStoryDepths(waveStories);
|
|
202
|
+
const { waveGroups, waveStats } = groupStoriesByWave(waveStories, depths);
|
|
130
203
|
const sortedWaves = [...waveGroups.keys()].sort((a, b) => a - b);
|
|
131
204
|
const lines = [];
|
|
132
205
|
|
|
@@ -230,4 +303,4 @@ function renderImplicitDepBullet(finding) {
|
|
|
230
303
|
|
|
231
304
|
// Test-only: surface the private predicate so the sibling unit test can
|
|
232
305
|
// exercise each branch without going through the full renderer.
|
|
233
|
-
export const __testables = { validateWaveSection };
|
|
306
|
+
export const __testables = { validateWaveSection, groupStoriesByWave };
|
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* lib/story-adjacency.js — the single story-level adjacency builder.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* `lib/Graph.js` kernel (`detectCycle` / `assignLayers` /
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* that step; the consumers are:
|
|
4
|
+
* Both Epic-path wave-computation wrappers bottom out in the shared
|
|
5
|
+
* `lib/Graph.js` kernel (`detectCycle` / `assignLayers` / `computeWaves`),
|
|
6
|
+
* but each historically re-implemented the step that turns a list of Story
|
|
7
|
+
* records into the `Map<storyId, number[]>` adjacency the kernel consumes.
|
|
8
|
+
* This module is now the one home for that step; the consumers are:
|
|
10
9
|
*
|
|
11
10
|
* - `lib/orchestration/epic-runner/phases/build-wave-dag.js`
|
|
12
11
|
* (`buildStoryDag` → `computeWaves`)
|
|
13
12
|
* - `lib/orchestration/dispatch-pipeline.js`
|
|
14
13
|
* (`buildStoryDispatchGraph` → `computeStoryWaves`)
|
|
15
|
-
* - `
|
|
14
|
+
* - `lib/wave-runner/ready-set.js` (`selectReadySet`, the path-agnostic
|
|
15
|
+
* continuous scheduler the standalone `stories-wave-tick.js` adapter
|
|
16
|
+
* and the Epic path both dispatch through)
|
|
17
|
+
* - `stories-wave-tick.js` (for cycle detection, before delegating
|
|
18
|
+
* selection to `selectReadySet`)
|
|
16
19
|
*
|
|
17
20
|
* Dependency source order (must stay aligned with manifest-builder.js so
|
|
18
21
|
* the dispatch manifest and runtime wave scheduling never disagree):
|
|
@@ -45,9 +48,10 @@ import { parseBlockedBy } from './dependency-parser.js';
|
|
|
45
48
|
* @param {boolean} [opts.dropForeign=true] When true (the default,
|
|
46
49
|
* matching the Epic-scoped wrappers), edges pointing at ids outside
|
|
47
50
|
* the supplied story set are dropped so the DAG stays closed over the
|
|
48
|
-
* scheduled set. `stories-wave-tick.js`
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
+
* scheduled set. The standalone path (`stories-wave-tick.js` and the
|
|
52
|
+
* `selectReadySet` core) passes `false` to preserve the operator-DAG
|
|
53
|
+
* contract, where a dependency on an id absent from the input is treated
|
|
54
|
+
* as not-yet-done and withholds the dependent until it completes.
|
|
51
55
|
* @returns {Map<number, number[]>}
|
|
52
56
|
*/
|
|
53
57
|
export function buildStoryAdjacency(stories, { dropForeign = true } = {}) {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* verify: string[], // exact commands / tier annotation
|
|
17
17
|
* references: PathEntry[], // read-only paths (optional)
|
|
18
18
|
* wide: { reason } | null,// declared-wide footprint (optional)
|
|
19
|
+
* reason_to_exist: string | null, // one-sentence cohesion reason (optional)
|
|
19
20
|
* depends_on: string[], // blocker story slugs or #ids
|
|
20
21
|
* estimated_test_files: number | null, // absent → null (informational)
|
|
21
22
|
* }
|
|
@@ -69,6 +70,7 @@ import { FILE_ASSUMPTION_VALUES } from '../orchestration/file-assumption-enum.js
|
|
|
69
70
|
* @property {string[]} verify - Exact commands with tier annotation.
|
|
70
71
|
* @property {PathEntry[]} references - Read-only paths (may be empty).
|
|
71
72
|
* @property {{ reason: string }|null} wide - Declared-wide footprint (reason), or null.
|
|
73
|
+
* @property {string|null} reason_to_exist - One-sentence cohesion reason ("why this Story exists"), or null.
|
|
72
74
|
* @property {string[]} depends_on - Blocking story slugs / issue refs.
|
|
73
75
|
* @property {number|null} estimated_test_files - Test surface count or null.
|
|
74
76
|
*/
|
|
@@ -246,10 +248,14 @@ const META_BLOCK_RE = /<!--\s*meta:\s*(\{[\s\S]*?\})\s*-->/;
|
|
|
246
248
|
* defaults instead of throwing.
|
|
247
249
|
*
|
|
248
250
|
* @param {string} markdown
|
|
249
|
-
* @returns {{ wide: { reason: string }|null, estimated_test_files: number|null }}
|
|
251
|
+
* @returns {{ wide: { reason: string }|null, reason_to_exist: string|null, estimated_test_files: number|null }}
|
|
250
252
|
*/
|
|
251
253
|
function extractMeta(markdown) {
|
|
252
|
-
const result = {
|
|
254
|
+
const result = {
|
|
255
|
+
wide: null,
|
|
256
|
+
reason_to_exist: null,
|
|
257
|
+
estimated_test_files: null,
|
|
258
|
+
};
|
|
253
259
|
const match = markdown.match(META_BLOCK_RE);
|
|
254
260
|
if (!match) return result;
|
|
255
261
|
|
|
@@ -263,12 +269,29 @@ function extractMeta(markdown) {
|
|
|
263
269
|
if (parsed === null || typeof parsed !== 'object') return result;
|
|
264
270
|
|
|
265
271
|
result.wide = normalizeWide(parsed.wide);
|
|
272
|
+
result.reason_to_exist = normalizeReasonToExist(parsed.reason_to_exist);
|
|
266
273
|
if (typeof parsed.estimated_test_files === 'number') {
|
|
267
274
|
result.estimated_test_files = parsed.estimated_test_files;
|
|
268
275
|
}
|
|
269
276
|
return result;
|
|
270
277
|
}
|
|
271
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Normalize a raw `reason_to_exist` value to a non-empty trimmed string or
|
|
281
|
+
* `null`. The field is the machine-checkable form of the cohesion rule
|
|
282
|
+
* ("one Story = one coherent change with one reason to exist"): the
|
|
283
|
+
* `epic-plan-consolidate` critic flags any Story whose body carries no
|
|
284
|
+
* non-empty reason. An empty or non-string value is treated as absent.
|
|
285
|
+
*
|
|
286
|
+
* @param {unknown} raw
|
|
287
|
+
* @returns {string|null}
|
|
288
|
+
*/
|
|
289
|
+
function normalizeReasonToExist(raw) {
|
|
290
|
+
if (typeof raw !== 'string') return null;
|
|
291
|
+
const reason = raw.trim();
|
|
292
|
+
return reason.length === 0 ? null : reason;
|
|
293
|
+
}
|
|
294
|
+
|
|
272
295
|
/**
|
|
273
296
|
* Normalize a raw `wide` declaration to the canonical `{ reason }` shape or
|
|
274
297
|
* `null`. A `wide` declaration is only honoured when it carries a non-empty
|
|
@@ -382,6 +405,7 @@ function parseLegacyStringBody(input, preamble, footer) {
|
|
|
382
405
|
verify: [],
|
|
383
406
|
references: [],
|
|
384
407
|
wide: null,
|
|
408
|
+
reason_to_exist: null,
|
|
385
409
|
depends_on: extractBlockedBy(footer),
|
|
386
410
|
estimated_test_files: null,
|
|
387
411
|
};
|
|
@@ -526,6 +550,7 @@ export function parse(input) {
|
|
|
526
550
|
const meta = extractMeta(input);
|
|
527
551
|
const estimated_test_files = meta.estimated_test_files;
|
|
528
552
|
const wide = meta.wide;
|
|
553
|
+
const reason_to_exist = meta.reason_to_exist;
|
|
529
554
|
if (estimated_test_files === null) {
|
|
530
555
|
warnings.push(
|
|
531
556
|
'test-surface-unestimated: estimated_test_files not present.',
|
|
@@ -539,6 +564,7 @@ export function parse(input) {
|
|
|
539
564
|
verify,
|
|
540
565
|
references,
|
|
541
566
|
wide,
|
|
567
|
+
reason_to_exist,
|
|
542
568
|
depends_on: dependsOn,
|
|
543
569
|
estimated_test_files,
|
|
544
570
|
};
|
|
@@ -597,6 +623,7 @@ function parseStructuredObject(obj) {
|
|
|
597
623
|
}
|
|
598
624
|
|
|
599
625
|
const wide = normalizeWide(obj.wide);
|
|
626
|
+
const reason_to_exist = normalizeReasonToExist(obj.reason_to_exist);
|
|
600
627
|
|
|
601
628
|
// depends_on: may be at top level or in body
|
|
602
629
|
const rawDeps = Array.isArray(obj.depends_on) ? obj.depends_on : [];
|
|
@@ -621,6 +648,7 @@ function parseStructuredObject(obj) {
|
|
|
621
648
|
verify,
|
|
622
649
|
references,
|
|
623
650
|
wide,
|
|
651
|
+
reason_to_exist,
|
|
624
652
|
depends_on,
|
|
625
653
|
estimated_test_files,
|
|
626
654
|
};
|
|
@@ -655,6 +683,108 @@ function serializePathEntry(entry) {
|
|
|
655
683
|
return JSON.stringify({ path: entry.path, assumption: entry.assumption });
|
|
656
684
|
}
|
|
657
685
|
|
|
686
|
+
/**
|
|
687
|
+
* Descriptor table for the human-readable Story-body sections, in canonical
|
|
688
|
+
* emit order (`## Goal`, `## Changes`, `## Acceptance`, `## Verify`,
|
|
689
|
+
* `## References`). Each descriptor reads one body field and returns the
|
|
690
|
+
* section's markdown block when the field is present and non-empty, or `null`
|
|
691
|
+
* to omit the section.
|
|
692
|
+
*
|
|
693
|
+
* Standardising the section ladder as a single data table makes adding a new
|
|
694
|
+
* optional section a one-line edit here rather than a new control-flow branch
|
|
695
|
+
* in {@link serialize}.
|
|
696
|
+
*
|
|
697
|
+
* @type {Array<{ field: string, render: (value: unknown) => string | null }>}
|
|
698
|
+
*/
|
|
699
|
+
const SERIALIZE_SECTIONS = [
|
|
700
|
+
{
|
|
701
|
+
field: 'goal',
|
|
702
|
+
render: (goal) =>
|
|
703
|
+
typeof goal === 'string' && goal.trim().length > 0
|
|
704
|
+
? `## Goal\n${goal.trim()}`
|
|
705
|
+
: null,
|
|
706
|
+
},
|
|
707
|
+
{
|
|
708
|
+
field: 'changes',
|
|
709
|
+
render: (changes) =>
|
|
710
|
+
Array.isArray(changes) && changes.length > 0
|
|
711
|
+
? `## Changes\n${changes.map((c) => `- ${serializePathEntry(c)}`).join('\n')}`
|
|
712
|
+
: null,
|
|
713
|
+
},
|
|
714
|
+
{
|
|
715
|
+
field: 'acceptance',
|
|
716
|
+
render: (acceptance) =>
|
|
717
|
+
Array.isArray(acceptance) && acceptance.length > 0
|
|
718
|
+
? `## Acceptance\n${acceptance.map((a) => `- [ ] ${a}`).join('\n')}`
|
|
719
|
+
: null,
|
|
720
|
+
},
|
|
721
|
+
{
|
|
722
|
+
field: 'verify',
|
|
723
|
+
render: (verify) =>
|
|
724
|
+
Array.isArray(verify) && verify.length > 0
|
|
725
|
+
? `## Verify\n${verify.map((v) => `- ${v}`).join('\n')}`
|
|
726
|
+
: null,
|
|
727
|
+
},
|
|
728
|
+
{
|
|
729
|
+
field: 'references',
|
|
730
|
+
render: (references) =>
|
|
731
|
+
Array.isArray(references) && references.length > 0
|
|
732
|
+
? `## References\n${references.map((r) => `- ${serializePathEntry(r)}`).join('\n')}`
|
|
733
|
+
: null,
|
|
734
|
+
},
|
|
735
|
+
];
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Build the trailing `<!-- meta: {...} -->` block carrying the fields that
|
|
739
|
+
* have no human-readable section (`wide`, `reason_to_exist`,
|
|
740
|
+
* `estimated_test_files`). Returns the empty string when no meta field is
|
|
741
|
+
* present so {@link serialize} appends nothing.
|
|
742
|
+
*
|
|
743
|
+
* Key insertion order (`wide` → `reason_to_exist` → `estimated_test_files`)
|
|
744
|
+
* is load-bearing: it fixes the serialized JSON byte sequence the parser's
|
|
745
|
+
* meta round-trip and the unit suite assert against.
|
|
746
|
+
*
|
|
747
|
+
* @param {StoryBody} body
|
|
748
|
+
* @returns {string}
|
|
749
|
+
*/
|
|
750
|
+
function serializeMetaBlock(body) {
|
|
751
|
+
const metaFields = {};
|
|
752
|
+
const wide = normalizeWide(body.wide);
|
|
753
|
+
if (wide !== null) {
|
|
754
|
+
metaFields.wide = wide;
|
|
755
|
+
}
|
|
756
|
+
const reasonToExist = normalizeReasonToExist(body.reason_to_exist);
|
|
757
|
+
if (reasonToExist !== null) {
|
|
758
|
+
metaFields.reason_to_exist = reasonToExist;
|
|
759
|
+
}
|
|
760
|
+
if (typeof body.estimated_test_files === 'number') {
|
|
761
|
+
metaFields.estimated_test_files = body.estimated_test_files;
|
|
762
|
+
}
|
|
763
|
+
if (Object.keys(metaFields).length === 0) return '';
|
|
764
|
+
return `\n\n<!-- meta: ${JSON.stringify(metaFields)} -->`;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Build the optional `---` footer block (`parent` / `Epic` / `blocked by`
|
|
769
|
+
* lines). Returns the empty string when `opts.includeFooter` is falsy.
|
|
770
|
+
*
|
|
771
|
+
* @param {StoryBody} body
|
|
772
|
+
* @param {SerializeOptions} opts
|
|
773
|
+
* @returns {string}
|
|
774
|
+
*/
|
|
775
|
+
function serializeFooter(body, opts) {
|
|
776
|
+
if (!opts.includeFooter) return '';
|
|
777
|
+
const footerLines = ['---'];
|
|
778
|
+
if (opts.footer?.parent) footerLines.push(`parent: #${opts.footer.parent}`);
|
|
779
|
+
if (opts.footer?.epic) footerLines.push(`Epic: #${opts.footer.epic}`);
|
|
780
|
+
if (Array.isArray(body.depends_on)) {
|
|
781
|
+
for (const dep of body.depends_on) {
|
|
782
|
+
footerLines.push(`blocked by ${dep}`);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return `\n\n${footerLines.join('\n')}`;
|
|
786
|
+
}
|
|
787
|
+
|
|
658
788
|
/**
|
|
659
789
|
* Serialize a structured {@link StoryBody} back to the canonical markdown
|
|
660
790
|
* format written to GitHub issue bodies.
|
|
@@ -663,8 +793,8 @@ function serializePathEntry(entry) {
|
|
|
663
793
|
* `## Goal`, `## Changes`, `## Acceptance`, `## Verify`, `## References`
|
|
664
794
|
* (omitted when empty).
|
|
665
795
|
*
|
|
666
|
-
* `wide` and `estimated_test_files` are emitted as a
|
|
667
|
-
* `<!-- meta -->` comment block so round-trips preserve them without
|
|
796
|
+
* `wide`, `reason_to_exist`, and `estimated_test_files` are emitted as a
|
|
797
|
+
* fenced `<!-- meta -->` comment block so round-trips preserve them without
|
|
668
798
|
* polluting the human-readable body.
|
|
669
799
|
*
|
|
670
800
|
* @param {StoryBody} body
|
|
@@ -679,69 +809,16 @@ export function serialize(body, opts = {}) {
|
|
|
679
809
|
}
|
|
680
810
|
|
|
681
811
|
const sections = [];
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
sections.push(`## Goal\n${body.goal.trim()}`);
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
// ## Changes
|
|
689
|
-
if (Array.isArray(body.changes) && body.changes.length > 0) {
|
|
690
|
-
const items = body.changes
|
|
691
|
-
.map((c) => `- ${serializePathEntry(c)}`)
|
|
692
|
-
.join('\n');
|
|
693
|
-
sections.push(`## Changes\n${items}`);
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
// ## Acceptance
|
|
697
|
-
if (Array.isArray(body.acceptance) && body.acceptance.length > 0) {
|
|
698
|
-
const items = body.acceptance.map((a) => `- [ ] ${a}`).join('\n');
|
|
699
|
-
sections.push(`## Acceptance\n${items}`);
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
// ## Verify
|
|
703
|
-
if (Array.isArray(body.verify) && body.verify.length > 0) {
|
|
704
|
-
const items = body.verify.map((v) => `- ${v}`).join('\n');
|
|
705
|
-
sections.push(`## Verify\n${items}`);
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
// ## References (only when non-empty)
|
|
709
|
-
if (Array.isArray(body.references) && body.references.length > 0) {
|
|
710
|
-
const items = body.references
|
|
711
|
-
.map((r) => `- ${serializePathEntry(r)}`)
|
|
712
|
-
.join('\n');
|
|
713
|
-
sections.push(`## References\n${items}`);
|
|
812
|
+
for (const descriptor of SERIALIZE_SECTIONS) {
|
|
813
|
+
const block = descriptor.render(body[descriptor.field]);
|
|
814
|
+
if (block !== null) sections.push(block);
|
|
714
815
|
}
|
|
715
816
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
if (wide !== null) {
|
|
722
|
-
metaFields.wide = wide;
|
|
723
|
-
}
|
|
724
|
-
if (typeof body.estimated_test_files === 'number') {
|
|
725
|
-
metaFields.estimated_test_files = body.estimated_test_files;
|
|
726
|
-
}
|
|
727
|
-
if (Object.keys(metaFields).length > 0) {
|
|
728
|
-
out += `\n\n<!-- meta: ${JSON.stringify(metaFields)} -->`;
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
// Footer
|
|
732
|
-
if (opts.includeFooter) {
|
|
733
|
-
const footerLines = ['---'];
|
|
734
|
-
if (opts.footer?.parent) footerLines.push(`parent: #${opts.footer.parent}`);
|
|
735
|
-
if (opts.footer?.epic) footerLines.push(`Epic: #${opts.footer.epic}`);
|
|
736
|
-
if (Array.isArray(body.depends_on)) {
|
|
737
|
-
for (const dep of body.depends_on) {
|
|
738
|
-
footerLines.push(`blocked by ${dep}`);
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
out += `\n\n${footerLines.join('\n')}`;
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
return out;
|
|
817
|
+
return (
|
|
818
|
+
sections.join('\n\n') +
|
|
819
|
+
serializeMetaBlock(body) +
|
|
820
|
+
serializeFooter(body, opts)
|
|
821
|
+
);
|
|
745
822
|
}
|
|
746
823
|
|
|
747
824
|
// ---------------------------------------------------------------------------
|
|
@@ -15,11 +15,28 @@ import {
|
|
|
15
15
|
* 2-tier is the only published hierarchy after Story #4041 removed the
|
|
16
16
|
* Feature tier: the prompt emits Stories only (direct Epic children) and
|
|
17
17
|
* asks the planner to carry acceptance/verify as top-level ticket arrays.
|
|
18
|
+
*
|
|
19
|
+
* **Single source of the prompt body (Story #4162).** This module is the sole
|
|
20
|
+
* carrier of the full decomposer system-prompt body. The
|
|
21
|
+
* `epic-plan-decompose-author` SKILL no longer embeds a second verbatim copy —
|
|
22
|
+
* it references this rendered prompt (delivered to the host LLM in the
|
|
23
|
+
* `systemPrompt` field of the authoring context envelope built by
|
|
24
|
+
* `epic-plan-decompose/phases/context.js`) instead, so the two surfaces cannot
|
|
25
|
+
* drift. A guard test (`tests/ticket-decomposer.test.js`) fails if the SKILL
|
|
26
|
+
* re-grows a full copy of the prompt preamble.
|
|
27
|
+
*
|
|
28
|
+
* **Token-budget sizing input (Story #4162).** `maxTokenBudget` is the real
|
|
29
|
+
* one-pass delivery envelope (the task-prompt hydration cap surfaced into the
|
|
30
|
+
* authoring envelope by `context.js`, Story #3875). It is threaded into the
|
|
31
|
+
* rendered prompt as a sizing input so the planner sizes Stories against the
|
|
32
|
+
* envelope a single agent can actually deliver in one pass, rather than leading
|
|
33
|
+
* with the file-count proxy alone.
|
|
18
34
|
*/
|
|
19
35
|
export function renderDecomposerSystemPrompt({
|
|
20
36
|
maxTickets = LIMITS_DEFAULTS.maxTickets,
|
|
37
|
+
maxTokenBudget = LIMITS_DEFAULTS.maxTokenBudget,
|
|
21
38
|
} = {}) {
|
|
22
|
-
return render2TierPrompt({ maxTickets });
|
|
39
|
+
return render2TierPrompt({ maxTickets, maxTokenBudget });
|
|
23
40
|
}
|
|
24
41
|
|
|
25
42
|
/**
|
|
@@ -28,7 +45,7 @@ export function renderDecomposerSystemPrompt({
|
|
|
28
45
|
* on the Story body so the executing agent has everything it needs in one
|
|
29
46
|
* ticket. Thematic grouping lives as prose in the Epic body / Tech Spec.
|
|
30
47
|
*/
|
|
31
|
-
function render2TierPrompt({ maxTickets }) {
|
|
48
|
+
function render2TierPrompt({ maxTickets, maxTokenBudget }) {
|
|
32
49
|
// Sizing thresholds are sourced from the single DEFAULT_TASK_SIZING constant
|
|
33
50
|
// (ticket-validator-sizing.js) so the prompt and the validator cannot drift.
|
|
34
51
|
const { softFiles, hardFiles, maxAcceptance, softAcceptanceCount } =
|
|
@@ -103,6 +120,7 @@ The serialized \`body\` string renders these markdown sections (in order):
|
|
|
103
120
|
- **changes** (in body string): Each entry is an object \`{ path, assumption }\` where \`assumption\` is one of \`creates | refactors-existing | deletes\`. Acceptable path shapes include explicit files (\`src/components/Foo.tsx\`), glob patterns (\`tests/e2e/*.spec.ts\`, \`**/*.astro\`), and module identifiers that resolve to files. Use \`refactors-existing\` for in-place edits to a file already on \`main\`; \`creates\` for net-new files; \`deletes\` for removals.
|
|
104
121
|
- **acceptance** (top-level array on the ticket object): Items MUST be observable from outside the agent. Acceptable shapes: a specific command exits 0, a file exists at a given path, a snapshot test matches, a \`data-testid\` resolves under a given selector, a row count in a fixture matches. UNACCEPTABLE: "verify by reading the diff", "looks good", "matches the spec" — push these down into a \`verify\` command instead.
|
|
105
122
|
- **verify** (top-level array on the ticket object): Each entry MUST name a testing tier in parentheses, drawn from \`unit\` / \`contract\` / \`e2e\` / \`validate\`. Example: \`npm run test -- src/x.test.ts (unit)\`, \`npm run validate (validate)\`. Stories with zero verify entries SHOULD fail validation; if a story is genuinely unverifiable in isolation (e.g., a copy edit auditor will eyeball), the literal entry \`manual:<reason>\` is allowed so the absence is intentional, not lazy. Manual entries without a reason are rejected.
|
|
123
|
+
- **reason to exist** (REQUIRED, encoded as the \`reason_to_exist\` field of the \`<!-- meta: {...} -->\` comment appended to the serialized body string — NOT a top-level ticket field): One sentence stating the single coherent reason this Story exists, distinct from its broader \`## Goal\` prose. Every Story MUST carry a non-empty \`reason_to_exist\`; it is the machine-checkable form of the cohesion rule (**one Story = one coherent change with one reason to exist**) and the \`epic-plan-consolidate\` critic flags any Story whose body carries no non-empty reason to exist. Encode it as \`<!-- meta: {"reason_to_exist": "..."} -->\`.
|
|
106
124
|
- **estimated_test_files** (optional, encoded in the \`<!-- meta: {...} -->\` comment appended to the serialized body string — NOT a top-level ticket field): Integer estimate of how many test files this Story creates or modifies. Omit when the number is not estimable. Informational only — it does not gate the decompose.
|
|
107
125
|
|
|
108
126
|
#### STORY SIZING — COHESION FIRST (the numeric ceiling is only a backstop):
|
|
@@ -111,7 +129,9 @@ The serialized \`body\` string renders these markdown sections (in order):
|
|
|
111
129
|
|
|
112
130
|
The primary question is **cohesion, not count**: *is this one coherent change with one reason to exist?* File count cannot tell a trivial ${softFiles}-file rename from a hard 3-file parser+caller+config change — so lead with the change's reason, not its size.
|
|
113
131
|
|
|
114
|
-
-
|
|
132
|
+
**Size against the real one-pass delivery envelope.** Each Story is delivered and self-verified by a single agent in one pass, whose context is capped by the delivery token budget \`maxTokenBudget = ${maxTokenBudget}\` tokens (the task-prompt hydration cap). Use that envelope — not the file count alone — as the leading sizing input: a Story is correctly sized when one agent can hold its full change, acceptance, and verification in a single pass within \`maxTokenBudget\`. The numeric file thresholds below are a coarse backstop on top of this envelope, not the primary signal.
|
|
133
|
+
|
|
134
|
+
- **One Story = one coherent change with one reason to exist.** If you cannot state that reason in a sentence, the Story is probably two Stories — or two Stories that should be one. State that sentence explicitly in the Story's \`reason_to_exist\` meta field (see STORY BODY RULES) so the consolidate critic can check it.
|
|
115
135
|
- ${singleConsumerRule}
|
|
116
136
|
- **Split independent, parallelizable work** into sibling Stories — but only when the pieces genuinely have separate reasons to exist.
|
|
117
137
|
- **Declare \`wide\` with a one-line reason when a change is legitimately broad** (a cohesive cutover that spans many files for one reason). Declaring \`wide\` lifts the hard file-width ceiling — see below.
|
|
@@ -33,20 +33,26 @@ const matchesIntegration = picomatch(INTEGRATION_INCLUDE, { dot: true });
|
|
|
33
33
|
* `tests` holds the framework's suite tree; `lib` holds the published CLI
|
|
34
34
|
* (under `lib/cli` and `lib/migrations`) whose tests are colocated in
|
|
35
35
|
* `__tests__` directories per the unit-tier convention in
|
|
36
|
-
* `rules/testing-standards.md`.
|
|
37
|
-
*
|
|
36
|
+
* `rules/testing-standards.md`. `.agents/scripts` holds the orchestration
|
|
37
|
+
* engine; some of its modules colocate tests in `__tests__` directories the
|
|
38
|
+
* same way (Story #4195). Without each root here, both the quick /
|
|
39
|
+
* integration walk and the full-tier glob set miss the colocated tests,
|
|
38
40
|
* leaving that coverage dark in `npm test`. The matching full-tier globs
|
|
39
41
|
* live in `FULL_TIER_GLOBS`.
|
|
40
42
|
*/
|
|
41
|
-
const TEST_WALK_ROOTS = ['tests', 'lib'];
|
|
43
|
+
const TEST_WALK_ROOTS = ['tests', 'lib', '.agents/scripts'];
|
|
42
44
|
|
|
43
45
|
/**
|
|
44
46
|
* Glob targets for the `full` tier — one per walk root in `TEST_WALK_ROOTS`.
|
|
45
|
-
* The `tests` glob is a flat recursive sweep; the `lib`
|
|
46
|
-
* `__tests__` subtrees so
|
|
47
|
-
* source modules themselves.
|
|
47
|
+
* The `tests` glob is a flat recursive sweep; the `lib` and `.agents/scripts`
|
|
48
|
+
* globs are scoped to `__tests__` subtrees so they only match colocated
|
|
49
|
+
* tests, never the shipped source modules themselves.
|
|
48
50
|
*/
|
|
49
|
-
const FULL_TIER_GLOBS = [
|
|
51
|
+
const FULL_TIER_GLOBS = [
|
|
52
|
+
'tests/**/*.test.js',
|
|
53
|
+
'lib/**/__tests__/**/*.test.js',
|
|
54
|
+
'.agents/scripts/**/__tests__/**/*.test.js',
|
|
55
|
+
];
|
|
50
56
|
|
|
51
57
|
/**
|
|
52
58
|
* @param {string} dir
|