mandrel 2.1.0 → 2.3.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/agents/acceptance-critic.md +11 -2
- package/.agents/agents/story-worker.md +4 -2
- package/.agents/docs/SDLC.md +11 -4
- package/.agents/docs/configuration.md +1 -1
- package/.agents/docs/quality-gates.md +3 -3
- package/.agents/rules/gherkin-standards.md +10 -0
- package/.agents/schemas/acceptance-eval-verdict.schema.json +2 -2
- package/.agents/schemas/agentrc.schema.json +1 -1
- package/.agents/scripts/acceptance-eval.js +2 -2
- package/.agents/scripts/lib/config/acceptance-eval.js +2 -2
- package/.agents/scripts/lib/config-settings-schema-delivery.js +3 -3
- package/.agents/scripts/lib/orchestration/change-set.js +103 -0
- package/.agents/scripts/lib/orchestration/code-review.js +24 -35
- package/.agents/scripts/lib/orchestration/plan-context.js +2 -9
- package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +17 -16
- package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +28 -15
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +0 -25
- package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +230 -0
- package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +1 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +1 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +97 -255
- package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +191 -0
- package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +120 -0
- package/.agents/scripts/lib/story-body/story-body.js +75 -8
- package/.agents/scripts/lib/templates/decomposer-prompts.js +8 -13
- package/.agents/scripts/lib/wave-runner/live-probe.js +315 -0
- package/.agents/scripts/plan-context.js +0 -1
- package/.agents/scripts/plan-critics.js +203 -0
- package/.agents/scripts/quality-preview.js +13 -6
- package/.agents/scripts/stories-wave-tick.js +307 -55
- package/.agents/workflows/deliver.md +50 -15
- package/.agents/workflows/helpers/acceptance-self-eval.md +14 -5
- package/.agents/workflows/helpers/code-quality-guardrails.md +7 -4
- package/.agents/workflows/helpers/code-review.md +2 -2
- package/.agents/workflows/helpers/deliver-story.md +22 -6
- package/.agents/workflows/plan.md +55 -0
- package/bin/mandrel.js +0 -0
- package/docs/CHANGELOG.md +30 -0
- package/lib/cli/update.js +83 -34
- package/lib/migrations/index.js +6 -1
- package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +154 -0
- package/package.json +2 -2
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* phases/local-lens-review.js — the Story-scope local-lens pass.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `phases/code-review.js` (Story #4603) so the review spine and
|
|
5
|
+
* the lens pass each carry one reason to change. The spine
|
|
6
|
+
* (`runStoryReviewCore`) owns the single per-close-run diff enumeration and the
|
|
7
|
+
* `runCodeReview` invocation; this module owns lens selection + materialization.
|
|
8
|
+
*
|
|
9
|
+
* Shift-left tier (Epic #4405): local concerns are cheap to decide on a single
|
|
10
|
+
* Story's diff, so the maker-blind Story-scope review runs its matched local
|
|
11
|
+
* lenses here, inside the story-close subprocess, rather than paying a deeper
|
|
12
|
+
* pass at Epic close.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
runAuditSuite,
|
|
17
|
+
selectLocalLenses,
|
|
18
|
+
} from '../../../audit-suite/index.js';
|
|
19
|
+
import { gitSpawn } from '../../../git-utils.js';
|
|
20
|
+
import { computeChangeSet } from '../../change-set.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The review depth the Story-scope local-lens pass runs at. Fixed for this
|
|
24
|
+
* tier — it is not risk-scaled like the code-review pillar depth.
|
|
25
|
+
*
|
|
26
|
+
* Module-local: it is an implementation detail of {@link runLocalLensReview}
|
|
27
|
+
* (it rides out on the returned envelope's `depth` field), not a public seam.
|
|
28
|
+
* Tests assert the observable `'light'` on that envelope rather than importing
|
|
29
|
+
* the constant, so it stays off the public surface (Story #4603).
|
|
30
|
+
*/
|
|
31
|
+
const STORY_SCOPE_LENS_DEPTH = 'light';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Enumerate the files changed in the `baseRef...headRef` diff. Thin adapter over
|
|
35
|
+
* the shared {@link computeChangeSet} enumerator (Story #4593) that flattens its
|
|
36
|
+
* `files: string[]|null` envelope to this phase's historical `[]`-on-failure
|
|
37
|
+
* contract: the lens roster treats "nothing changed" and "diff unknown"
|
|
38
|
+
* identically, because an unknown diff matches no `filePatterns` and therefore
|
|
39
|
+
* adds no lens work either way.
|
|
40
|
+
*
|
|
41
|
+
* Best-effort and total — never throws, mirroring the advisory posture of the
|
|
42
|
+
* surrounding review phase. Retained as the self-enumeration fallback for
|
|
43
|
+
* {@link runLocalLensReview} when no change set is injected.
|
|
44
|
+
*
|
|
45
|
+
* @param {{
|
|
46
|
+
* baseRef: string,
|
|
47
|
+
* headRef: string,
|
|
48
|
+
* gitSpawnFn?: import('../../change-set.js').GitSpawnFn,
|
|
49
|
+
* }} args
|
|
50
|
+
* Module-local (Story #4603): a private fallback of {@link runLocalLensReview},
|
|
51
|
+
* exercised through that public entry point rather than imported directly, so it
|
|
52
|
+
* adds no public export a production path fails to reach.
|
|
53
|
+
*
|
|
54
|
+
* @returns {string[]} Changed file paths, or `[]` on any failure.
|
|
55
|
+
*/
|
|
56
|
+
function enumerateChangedFiles({ baseRef, headRef, gitSpawnFn = gitSpawn }) {
|
|
57
|
+
return computeChangeSet({ baseRef, headRef, gitSpawnFn }).files ?? [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve the change set the lens roster reads, honouring all THREE injection
|
|
62
|
+
* states (Story #4603 — the fix for #4593's single-enumeration leak).
|
|
63
|
+
*
|
|
64
|
+
* The distinction between `null` and `undefined` is load-bearing and mirrors
|
|
65
|
+
* the sibling contract in `orchestration/code-review.js#resolveInjectedChangedFiles`:
|
|
66
|
+
*
|
|
67
|
+
* - **array** — the caller's change set; use it verbatim.
|
|
68
|
+
* - **`null`** — the caller (`runStoryReviewCore`) already tried and the
|
|
69
|
+
* diff is unenumerable. Re-running git here would only fail
|
|
70
|
+
* again, so degrade straight to the fail-safe empty roster.
|
|
71
|
+
* - **`undefined`** — nobody enumerated (standalone callers), so the shared
|
|
72
|
+
* enumerator runs as the fallback.
|
|
73
|
+
*
|
|
74
|
+
* The prior `Array.isArray()` discriminator collapsed `null` and `undefined`
|
|
75
|
+
* into one branch and re-spawned git on the unenumerable path, contradicting the
|
|
76
|
+
* spine's documented "the one enumeration per close run" invariant.
|
|
77
|
+
*
|
|
78
|
+
* Module-local (Story #4603): a private detail of {@link runLocalLensReview}.
|
|
79
|
+
* The three-state contract is asserted through that public entry point (does an
|
|
80
|
+
* injected `null` re-spawn git? does `undefined` self-enumerate?), so it needs
|
|
81
|
+
* no public export — keeping the fix from re-introducing the very kind of
|
|
82
|
+
* production-dead public symbol this Story's ratchet root-cause is about.
|
|
83
|
+
*
|
|
84
|
+
* @param {{
|
|
85
|
+
* changedFiles: string[]|null|undefined,
|
|
86
|
+
* baseRef: string,
|
|
87
|
+
* headRef: string,
|
|
88
|
+
* gitSpawnFn?: import('../../change-set.js').GitSpawnFn,
|
|
89
|
+
* }} args
|
|
90
|
+
* @returns {string[]}
|
|
91
|
+
*/
|
|
92
|
+
function resolveLensChangeSet({
|
|
93
|
+
changedFiles,
|
|
94
|
+
baseRef,
|
|
95
|
+
headRef,
|
|
96
|
+
gitSpawnFn = gitSpawn,
|
|
97
|
+
}) {
|
|
98
|
+
if (changedFiles === undefined) {
|
|
99
|
+
return enumerateChangedFiles({ baseRef, headRef, gitSpawnFn });
|
|
100
|
+
}
|
|
101
|
+
return changedFiles ?? [];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Run the Story-scope local-lens pass: select the LOCAL-tier lenses whose
|
|
106
|
+
* `filePatterns` match the actual Story diff (`baseRef...headRef`) and
|
|
107
|
+
* materialize their lens-prompt bodies at `light` depth. Called only from
|
|
108
|
+
* `runStoryReviewCore`, never in the delivering child's (maker's) context, so a
|
|
109
|
+
* maker never grades its own work.
|
|
110
|
+
*
|
|
111
|
+
* A diff that matches no local lens adds **no** lens work: the roster is empty
|
|
112
|
+
* and `runAuditSuite` is never invoked. Best-effort and total — a git or
|
|
113
|
+
* materialization failure degrades to `{ skipped: true, lenses: [] }` and is
|
|
114
|
+
* logged via `progress`, matching the advisory posture the review phase already
|
|
115
|
+
* takes for provider/transport failures.
|
|
116
|
+
*
|
|
117
|
+
* Story #4593 — `changedFiles` is injected by `runStoryReviewCore`, which
|
|
118
|
+
* computes the change set once per close run and hands the same list to this
|
|
119
|
+
* pass and to `runCodeReview`. Self-enumeration is the **fallback only**, kept
|
|
120
|
+
* for standalone callers that supply no list; see {@link resolveLensChangeSet}
|
|
121
|
+
* for the three-state contract.
|
|
122
|
+
*
|
|
123
|
+
* @param {{
|
|
124
|
+
* baseRef: string,
|
|
125
|
+
* headRef: string,
|
|
126
|
+
* changedFiles?: string[]|null,
|
|
127
|
+
* progress: (tag: string, msg: string) => void,
|
|
128
|
+
* progressTag?: string,
|
|
129
|
+
* gitSpawnFn?: import('../../change-set.js').GitSpawnFn,
|
|
130
|
+
* selectLocalLensesFn?: typeof selectLocalLenses,
|
|
131
|
+
* runAuditSuiteFn?: typeof runAuditSuite,
|
|
132
|
+
* }} args
|
|
133
|
+
* @returns {Promise<{
|
|
134
|
+
* depth: 'light',
|
|
135
|
+
* lenses: string[],
|
|
136
|
+
* skipped: boolean,
|
|
137
|
+
* materialized: object|null,
|
|
138
|
+
* }>}
|
|
139
|
+
*/
|
|
140
|
+
export async function runLocalLensReview({
|
|
141
|
+
baseRef,
|
|
142
|
+
headRef,
|
|
143
|
+
changedFiles: injectedChangedFiles,
|
|
144
|
+
progress,
|
|
145
|
+
progressTag = 'CODE-REVIEW',
|
|
146
|
+
gitSpawnFn = gitSpawn,
|
|
147
|
+
selectLocalLensesFn = selectLocalLenses,
|
|
148
|
+
runAuditSuiteFn = runAuditSuite,
|
|
149
|
+
}) {
|
|
150
|
+
const empty = {
|
|
151
|
+
depth: STORY_SCOPE_LENS_DEPTH,
|
|
152
|
+
lenses: [],
|
|
153
|
+
skipped: true,
|
|
154
|
+
materialized: null,
|
|
155
|
+
};
|
|
156
|
+
try {
|
|
157
|
+
const changedFiles = resolveLensChangeSet({
|
|
158
|
+
changedFiles: injectedChangedFiles,
|
|
159
|
+
baseRef,
|
|
160
|
+
headRef,
|
|
161
|
+
gitSpawnFn,
|
|
162
|
+
});
|
|
163
|
+
const lenses = selectLocalLensesFn({ changedFiles });
|
|
164
|
+
if (lenses.length === 0) {
|
|
165
|
+
progress(
|
|
166
|
+
progressTag,
|
|
167
|
+
'No local lens matched the Story diff — skipping the lens pass.',
|
|
168
|
+
);
|
|
169
|
+
return empty;
|
|
170
|
+
}
|
|
171
|
+
const materialized = await runAuditSuiteFn({ auditWorkflows: lenses });
|
|
172
|
+
progress(
|
|
173
|
+
progressTag,
|
|
174
|
+
`Ran ${lenses.length} local lens(es) at ${STORY_SCOPE_LENS_DEPTH} depth: ${lenses.join(', ')}.`,
|
|
175
|
+
);
|
|
176
|
+
return {
|
|
177
|
+
depth: STORY_SCOPE_LENS_DEPTH,
|
|
178
|
+
lenses,
|
|
179
|
+
skipped: false,
|
|
180
|
+
materialized,
|
|
181
|
+
};
|
|
182
|
+
} catch (err) {
|
|
183
|
+
// The lens pass is advisory: a git or materialization failure must not
|
|
184
|
+
// fail the close. Log and degrade to a skipped envelope.
|
|
185
|
+
progress(
|
|
186
|
+
progressTag,
|
|
187
|
+
`⚠️ local lens pass failed (continuing without it): ${err?.message ?? err}`,
|
|
188
|
+
);
|
|
189
|
+
return empty;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* phases/review-core.js — the shared Story-scope review spine.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `phases/code-review.js` (Story #4603). `runStoryReviewCore` is
|
|
5
|
+
* the one implementation both close paths call `runCodeReview` through — the
|
|
6
|
+
* epic-attached phase (`code-review.js#runStoryCodeReview`) and the standalone
|
|
7
|
+
* v2 path (`single-story-close/phases/code-review.js#runStoryScopeReview`) —
|
|
8
|
+
* so it belongs to neither and lives here rather than inside one path's phase
|
|
9
|
+
* file (Story #3653 established the shared-spine contract).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { gitSpawn } from '../../../git-utils.js';
|
|
13
|
+
import { computeChangeSet } from '../../change-set.js';
|
|
14
|
+
import { runCodeReview } from '../../code-review.js';
|
|
15
|
+
import { runLocalLensReview } from './local-lens-review.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Invoke `runCodeReviewFn` with the canonical Story-scope envelope and return
|
|
19
|
+
* the raw result.
|
|
20
|
+
*
|
|
21
|
+
* The caller is responsible for error handling and result interpretation —
|
|
22
|
+
* this function propagates throws rather than swallowing them, because the
|
|
23
|
+
* two callers have different advisory postures:
|
|
24
|
+
*
|
|
25
|
+
* - Epic-attached close: swallows throws (non-blocking advisory, same as
|
|
26
|
+
* `refresh.js`).
|
|
27
|
+
* - Standalone close: propagates throws (a review failure stops the close).
|
|
28
|
+
*
|
|
29
|
+
* Review depth is not passed in: `runCodeReview` derives it from the changed
|
|
30
|
+
* files — their sensitive-path intersection plus their count (Story #4542, which
|
|
31
|
+
* retired the planner-authored risk envelope this spine used to forward). Depth
|
|
32
|
+
* remains an **input-only** signal: it tells the provider how thorough to be and
|
|
33
|
+
* never alters the review's output envelope or the posted structured-comment
|
|
34
|
+
* body.
|
|
35
|
+
*
|
|
36
|
+
* Story #4593 — this spine is the **single injection point** for the change set.
|
|
37
|
+
* It enumerates `baseRef...headRef` exactly once via {@link computeChangeSet}
|
|
38
|
+
* and threads the resulting list into both the local-lens pass and
|
|
39
|
+
* `runCodeReview`, which otherwise each enumerated the diff for themselves. Both
|
|
40
|
+
* consumers ultimately route through `deriveChangeLevel`, so feeding them one
|
|
41
|
+
* list is what makes the lens roster and the review depth provably agree about
|
|
42
|
+
* what changed — even when a commit lands between the two calls.
|
|
43
|
+
*
|
|
44
|
+
* Story #4603 — the invariant now holds on the failure path too: an unenumerable
|
|
45
|
+
* diff injects an explicit `null`, and both consumers distinguish that from
|
|
46
|
+
* `undefined` ("nobody enumerated") rather than re-spawning git.
|
|
47
|
+
*
|
|
48
|
+
* @param {{
|
|
49
|
+
* storyId: number|string,
|
|
50
|
+
* baseRef: string,
|
|
51
|
+
* headRef: string,
|
|
52
|
+
* commentTargetId?: number|null,
|
|
53
|
+
* provider: object,
|
|
54
|
+
* progress: (tag: string, msg: string) => void,
|
|
55
|
+
* progressTag?: string,
|
|
56
|
+
* gitSpawnFn?: import('../../change-set.js').GitSpawnFn,
|
|
57
|
+
* computeChangeSetFn?: typeof computeChangeSet,
|
|
58
|
+
* runCodeReviewFn?: typeof runCodeReview,
|
|
59
|
+
* runLocalLensReviewFn?: typeof runLocalLensReview,
|
|
60
|
+
* }} args
|
|
61
|
+
* @returns {Promise<object>} Raw result envelope from `runCodeReview`, augmented
|
|
62
|
+
* with a `localLensReview` field carrying the Story-scope local-lens pass
|
|
63
|
+
* outcome (Epic #4405, Story #4409) and the `changeSet` this run computed
|
|
64
|
+
* (Story #4593).
|
|
65
|
+
*/
|
|
66
|
+
export async function runStoryReviewCore({
|
|
67
|
+
storyId,
|
|
68
|
+
baseRef,
|
|
69
|
+
headRef,
|
|
70
|
+
commentTargetId = null,
|
|
71
|
+
provider,
|
|
72
|
+
progress,
|
|
73
|
+
progressTag = 'CODE-REVIEW',
|
|
74
|
+
gitSpawnFn = gitSpawn,
|
|
75
|
+
computeChangeSetFn = computeChangeSet,
|
|
76
|
+
runCodeReviewFn = runCodeReview,
|
|
77
|
+
runLocalLensReviewFn = runLocalLensReview,
|
|
78
|
+
}) {
|
|
79
|
+
const storyIdNum = Number(storyId);
|
|
80
|
+
|
|
81
|
+
// The one enumeration per close run. Every consumer below is injected from
|
|
82
|
+
// this list; none of them re-derives the diff. `files` is `null` when the
|
|
83
|
+
// diff is unenumerable — an explicit "already tried" signal both consumers
|
|
84
|
+
// honour without retrying (Story #4603).
|
|
85
|
+
const changeSet = computeChangeSetFn({ baseRef, headRef, gitSpawnFn });
|
|
86
|
+
|
|
87
|
+
const opts = {
|
|
88
|
+
scope: 'story',
|
|
89
|
+
ticketId: storyIdNum,
|
|
90
|
+
baseRef,
|
|
91
|
+
headRef,
|
|
92
|
+
provider,
|
|
93
|
+
changedFiles: changeSet.files,
|
|
94
|
+
gitSpawnFn,
|
|
95
|
+
logger: {
|
|
96
|
+
info: (m) => progress(progressTag, m),
|
|
97
|
+
warn: (m) => progress(progressTag, `⚠️ ${m}`),
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
if (commentTargetId != null) {
|
|
101
|
+
opts.commentTargetId = commentTargetId;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Shift-left local-lens pass (Epic #4405). Runs matched local lenses at
|
|
105
|
+
// `light` depth against the actual Story diff, inside this close-subprocess
|
|
106
|
+
// spine so the maker never grades its own work. Advisory — it never blocks
|
|
107
|
+
// the close and its outcome rides on the returned envelope for downstream
|
|
108
|
+
// consumers.
|
|
109
|
+
const localLensReview = await runLocalLensReviewFn({
|
|
110
|
+
baseRef,
|
|
111
|
+
headRef,
|
|
112
|
+
changedFiles: changeSet.files,
|
|
113
|
+
progress,
|
|
114
|
+
progressTag,
|
|
115
|
+
gitSpawnFn,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const result = await runCodeReviewFn(opts);
|
|
119
|
+
return { ...result, localLensReview, changeSet };
|
|
120
|
+
}
|
|
@@ -165,13 +165,35 @@ function stripListMarker(line) {
|
|
|
165
165
|
return line.replace(/^-\s+(?:\[\s*[xX ]?\s*\]\s+)?/, '').trim();
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
// Humanized PathEntry bullet (Story #4600): `path` — assumption. This is the
|
|
169
|
+
// shape serialize() now emits for `## Changes` / `## References`; the legacy
|
|
170
|
+
// inline-JSON object bullet remains accepted at parse time indefinitely (live
|
|
171
|
+
// issue bodies are never rewritten).
|
|
172
|
+
const HUMANIZED_PATH_ENTRY_RE = /^`([^`]+)`\s+—\s+(\S+)$/;
|
|
173
|
+
|
|
174
|
+
// AC-<n> presentation prefix on acceptance checkboxes (Story #4600). The
|
|
175
|
+
// numbering is a stable 1-based human handle only — parse() strips it so the
|
|
176
|
+
// top-level acceptance[] machine contract round-trips byte-identical.
|
|
177
|
+
const AC_PREFIX_RE = /^AC-\d+:\s+/;
|
|
178
|
+
|
|
179
|
+
// Visible wide-rationale line (Story #4600): `> **Wide:** <reason>` rendered
|
|
180
|
+
// under `## Goal`. Presentation only — the `<!-- meta -->` block stays the
|
|
181
|
+
// canonical machine carrier, so the parser skips this line wherever it
|
|
182
|
+
// appears (same treatment as the authored-provenance marker).
|
|
183
|
+
const WIDE_MARKER_LINE_RE = /^>\s*\*\*Wide:\*\*/;
|
|
184
|
+
|
|
168
185
|
/**
|
|
169
186
|
* Parse a single `changes` / `references` bullet into a `PathEntry`.
|
|
170
187
|
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
188
|
+
* Accepted markdown shapes (both parsed indefinitely — live issue bodies
|
|
189
|
+
* are never rewritten):
|
|
190
|
+
* - Humanized bullet (canonical serialize() output since Story #4600):
|
|
191
|
+
* `` `src/x.js` — refactors-existing ``
|
|
192
|
+
* - Legacy inline-JSON object bullet:
|
|
193
|
+
* `- { "path": "...", "assumption": "..." }`
|
|
194
|
+
*
|
|
195
|
+
* A structured object entry (from a parsed JSON body that was never
|
|
196
|
+
* serialized to markdown) arrives as-is and is validated directly.
|
|
175
197
|
*
|
|
176
198
|
* @param {string|object} raw
|
|
177
199
|
* @param {string[]} warnings
|
|
@@ -197,6 +219,21 @@ function parsePathEntry(raw, warnings) {
|
|
|
197
219
|
const str = typeof raw === 'string' ? raw.trim() : String(raw).trim();
|
|
198
220
|
if (str.length === 0) return null;
|
|
199
221
|
|
|
222
|
+
// Humanized bullet shape (the canonical serialize() output since
|
|
223
|
+
// Story #4600): `path` — assumption.
|
|
224
|
+
const humanized = str.match(HUMANIZED_PATH_ENTRY_RE);
|
|
225
|
+
if (humanized) {
|
|
226
|
+
const path = humanized[1].trim();
|
|
227
|
+
if (path.length > 0 && FILE_ASSUMPTION_VALUES.includes(humanized[2])) {
|
|
228
|
+
return { path, assumption: humanized[2] };
|
|
229
|
+
}
|
|
230
|
+
// Recognized the humanized shape but the fields are invalid: fail closed.
|
|
231
|
+
throw new StoryBodyParseError(
|
|
232
|
+
`changes/references entry is a humanized bullet but not a valid PathEntry: ${str.slice(0, 120)}`,
|
|
233
|
+
{ field: 'changes', raw: str },
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
200
237
|
// Try to detect inline JSON object shape: `{ "path": "...", "assumption": "..." }`
|
|
201
238
|
if (str.startsWith('{')) {
|
|
202
239
|
try {
|
|
@@ -433,6 +470,14 @@ function splitSections(markdown) {
|
|
|
433
470
|
continue;
|
|
434
471
|
}
|
|
435
472
|
|
|
473
|
+
// The visible `> **Wide:** <reason>` rationale line is presentation only
|
|
474
|
+
// (Story #4600): the meta block remains the canonical carrier for
|
|
475
|
+
// `wide.reason`, so this line must not bleed into the goal (or any other)
|
|
476
|
+
// section. Skip it wherever it appears.
|
|
477
|
+
if (WIDE_MARKER_LINE_RE.test(line)) {
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
|
|
436
481
|
if (inPreamble) {
|
|
437
482
|
preambleLines.push(line);
|
|
438
483
|
} else if (currentSection !== null) {
|
|
@@ -637,7 +682,11 @@ export function parse(input) {
|
|
|
637
682
|
sections.get('changes') ?? [],
|
|
638
683
|
warnings,
|
|
639
684
|
);
|
|
640
|
-
|
|
685
|
+
// The AC-<n> checkbox prefix is presentation-only (Story #4600): strip it
|
|
686
|
+
// so acceptance[] round-trips byte-identical to the authored array.
|
|
687
|
+
const acceptance = parseTextListSection(sections.get('acceptance') ?? []).map(
|
|
688
|
+
(a) => a.replace(AC_PREFIX_RE, ''),
|
|
689
|
+
);
|
|
641
690
|
const verify = parseTextListSection(sections.get('verify') ?? []);
|
|
642
691
|
const references = parsePathEntrySection(
|
|
643
692
|
sections.get('references') ?? [],
|
|
@@ -820,8 +869,10 @@ function parseStructuredObject(obj) {
|
|
|
820
869
|
*/
|
|
821
870
|
function serializePathEntry(entry) {
|
|
822
871
|
if (typeof entry === 'string') return entry;
|
|
823
|
-
// Canonical object form: render as
|
|
824
|
-
|
|
872
|
+
// Canonical object form (Story #4600): render as a human-readable bullet —
|
|
873
|
+
// path in backticks, em-dash, assumption. parsePathEntry recognizes this
|
|
874
|
+
// shape (and the legacy inline-JSON shape) for round-trip fidelity.
|
|
875
|
+
return `\`${entry.path}\` — ${entry.assumption}`;
|
|
825
876
|
}
|
|
826
877
|
|
|
827
878
|
/**
|
|
@@ -845,6 +896,18 @@ const SERIALIZE_SECTIONS = [
|
|
|
845
896
|
? `## Goal\n${goal.trim()}`
|
|
846
897
|
: null,
|
|
847
898
|
},
|
|
899
|
+
{
|
|
900
|
+
// Visible wide-rationale line (Story #4600), rendered directly under
|
|
901
|
+
// `## Goal`. Presentation only: the `<!-- meta -->` block remains the
|
|
902
|
+
// canonical machine carrier and the parser skips this line, so `wide`
|
|
903
|
+
// round-trips through the meta block alone. Absent/invalid wide emits
|
|
904
|
+
// nothing, keeping every non-wide body byte-identical to before.
|
|
905
|
+
field: 'wide',
|
|
906
|
+
render: (wide) => {
|
|
907
|
+
const normalized = normalizeWide(wide);
|
|
908
|
+
return normalized === null ? null : `> **Wide:** ${normalized.reason}`;
|
|
909
|
+
},
|
|
910
|
+
},
|
|
848
911
|
{
|
|
849
912
|
// Optional v2 intra-Story delivery slice plan. Single-token `## Slicing`
|
|
850
913
|
// heading (recognized by the `[\w-]+` field-heading regex). Verbatim text
|
|
@@ -873,10 +936,14 @@ const SERIALIZE_SECTIONS = [
|
|
|
873
936
|
: null,
|
|
874
937
|
},
|
|
875
938
|
{
|
|
939
|
+
// Each checkbox carries a stable 1-based `AC-<n>:` handle (Story #4600)
|
|
940
|
+
// so humans and reviewers can reference criteria by number. The prefix
|
|
941
|
+
// is presentation-only — parse() strips it, and validators compare the
|
|
942
|
+
// top-level acceptance[] array, so numbering never affects gating.
|
|
876
943
|
field: 'acceptance',
|
|
877
944
|
render: (acceptance) =>
|
|
878
945
|
Array.isArray(acceptance) && acceptance.length > 0
|
|
879
|
-
? `## Acceptance\n${acceptance.map((a) => `- [ ] ${a}`).join('\n')}`
|
|
946
|
+
? `## Acceptance\n${acceptance.map((a, i) => `- [ ] AC-${i + 1}: ${a}`).join('\n')}`
|
|
880
947
|
: null,
|
|
881
948
|
},
|
|
882
949
|
{
|
|
@@ -26,9 +26,8 @@ import {
|
|
|
26
26
|
*/
|
|
27
27
|
export function renderDecomposerSystemPrompt({
|
|
28
28
|
maxTickets = LIMITS_DEFAULTS.maxTickets,
|
|
29
|
-
epicId = null,
|
|
30
29
|
} = {}) {
|
|
31
|
-
return render2TierPrompt({ maxTickets
|
|
30
|
+
return render2TierPrompt({ maxTickets });
|
|
32
31
|
}
|
|
33
32
|
|
|
34
33
|
/**
|
|
@@ -37,7 +36,7 @@ export function renderDecomposerSystemPrompt({
|
|
|
37
36
|
* on the Story body so the executing agent has everything it needs in one
|
|
38
37
|
* ticket. Thematic grouping lives as prose in the Epic body / Tech Spec.
|
|
39
38
|
*/
|
|
40
|
-
function render2TierPrompt({ maxTickets
|
|
39
|
+
function render2TierPrompt({ maxTickets }) {
|
|
41
40
|
// v2 Stage 3: default-single — emit one Story unless the split policy clears.
|
|
42
41
|
// Capacity thresholds are sourced from the single DEFAULT_MODEL_CAPACITY
|
|
43
42
|
// constant (ticket-validator-sizing.js) so the prompt and the validator
|
|
@@ -64,13 +63,6 @@ function render2TierPrompt({ maxTickets, epicId = null }) {
|
|
|
64
63
|
advisoryCaveat,
|
|
65
64
|
newFileContract,
|
|
66
65
|
} = AUTHORING_ALTITUDE_GUIDANCE;
|
|
67
|
-
// The namespaced AC-tag token the wave-0 BDD scaffold section below must
|
|
68
|
-
// require on every scaffolded scenario (Story #4301). When the Epic ID is
|
|
69
|
-
// known at render time, interpolate the concrete tag so the author has no
|
|
70
|
-
// placeholder to get wrong; otherwise fall back to the documented pattern.
|
|
71
|
-
const acTagExample = Number.isInteger(epicId)
|
|
72
|
-
? `@epic-${epicId}-ac-1`
|
|
73
|
-
: '@epic-<id>-ac-N';
|
|
74
66
|
return `You are an expert Senior Project Manager and Orchestrator.
|
|
75
67
|
Your job is to turn a plan seed / Tech Spec into a Story ticket array for an AI Agent to execute.
|
|
76
68
|
|
|
@@ -154,6 +146,10 @@ The serialized \`body\` string renders these markdown sections (in order):
|
|
|
154
146
|
- **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.
|
|
155
147
|
- **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": "..."} -->\`.
|
|
156
148
|
- **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.
|
|
149
|
+
- **Observed-behavior claims open with \`Current state (verified <date>)\`.** Any Spec claim about how the codebase behaves today MUST open with that preamble (e.g. \`Current state (verified 2026-07-17): …\`) so a reader can tell a verified observation from an assumption, and can tell when the observation went stale.
|
|
150
|
+
- **Intent-then-proxy acceptance shape.** When an acceptance item verifies through a proxy check (a grep, a file-exists probe, an exit-code test), state the intent clause before the proxy check — what outcome the check stands in for — so the proxy never becomes the goal (e.g. "the workflow names hygiene findings as re-author input: \`grep -n "textHygiene" …\` exits 0").
|
|
151
|
+
- **Slicing checkpoints are one line each.** Each \`## Slicing\` checkpoint is a single line naming the checkpoint; implementation detail lives in \`## Spec\`, never duplicated into Slicing. A Slicing section outweighing its Spec is a defect the text-hygiene lint flags.
|
|
152
|
+
- **Bodies record decisions, never questions to the operator.** Never persist an open question ("Flag if…", "TBD", "confirm with the operator") into a Story body — the executing sub-agent is non-interactive and cannot answer it. Resolve the unknown before authoring, or restate it as a declarative Key Assumption the agent can act on.
|
|
157
153
|
- **non_goals** (OPTIONAL, in body string as the \`## Non-Goals\` section): A short list of capabilities or changes this Story explicitly does NOT deliver — an advisory negative-scope bound that fences the executing agent away from adjacent work. It is **advisory and NON-GATING**: the validator does not require, count, or reject on it, and an absent or empty section renders nothing. Use the EXACT single-word hyphenated heading spelling \`## Non-Goals\` (a space-separated heading like \`## Out of Scope\` is NOT recognized by the parser and will be dropped). Reach for it when a Story's negative boundary is non-obvious from its \`acceptance[]\` alone; omit it otherwise.
|
|
158
154
|
|
|
159
155
|
#### AUTHORING ALTITUDE — BINDING ACCEPTANCE vs ADVISORY CHANGES:
|
|
@@ -229,9 +225,8 @@ When the Acceptance Spec contains **one or more \`Disposition: new\` rows**, you
|
|
|
229
225
|
- **goal**: contains the literal token \`bdd-scaffold\` (e.g. "bdd-scaffold: create the @skip-tagged feature files the implementation Stories verify against").
|
|
230
226
|
- **depends_on**: EMPTY (\`[]\`) — it runs first, in wave 0.
|
|
231
227
|
- **changes**: one entry per distinct \`.feature\` file named in a \`new\` row, each \`{ "path": "<feature file path>", "assumption": "creates" }\`.
|
|
232
|
-
- **acceptance**: MUST assert (a) every new \`.feature\` file exists
|
|
233
|
-
- **
|
|
234
|
-
- **verify**: a grep/validate command (tier \`validate\`), NOT an e2e runner — verifying that a file exists with the required tags needs no browser/playwright run. Example: \`grep -rL '@skip' tests/features/<area>/*.feature (validate)\` paired with an existence check, AND a check that every new AC ID's namespaced tag (\`${acTagExample}\`) appears in the scaffolded files, e.g. \`grep -q '${acTagExample}' tests/features/<area>/<file>.feature (validate)\` for each new AC row.
|
|
228
|
+
- **acceptance**: MUST assert (a) every new \`.feature\` file exists AND (b) every new scenario within them carries an \`@skip\` tag. Keep these observable (a grep/validate command exits 0, a file exists at a path).
|
|
229
|
+
- **verify**: a grep/validate command (tier \`validate\`), NOT an e2e runner — verifying that a file exists with the required tags needs no browser/playwright run. Example: \`grep -rL '@skip' tests/features/<area>/*.feature (validate)\` paired with an existence check.
|
|
235
230
|
- Each implementation Story whose \`verify[]\` references one of these scaffolded \`.feature\` paths MUST \`depends_on\` the scaffold Story (so the scaffold lands in an earlier wave). Omitting the link trips the soft \`missing-bdd-scaffold\` validator finding.
|
|
236
231
|
|
|
237
232
|
When the Acceptance Spec contains **zero \`new\`-disposition rows** (every row is \`updated\` or \`unchanged\`), do NOT emit a scaffold Story — there is nothing to create.
|