mandrel 1.76.0 → 1.77.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/docs/configuration.md +2 -2
- package/.agents/schemas/agentrc.schema.json +1 -1
- package/.agents/schemas/dispatch-manifest.json +1 -1
- package/.agents/schemas/validation-evidence.schema.json +2 -1
- package/.agents/scripts/audit-to-stories.js +43 -1
- package/.agents/scripts/epic-deliver-prepare.js +31 -0
- package/.agents/scripts/evidence-gate.js +48 -12
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +141 -34
- package/.agents/scripts/lib/cli-args.js +6 -0
- package/.agents/scripts/lib/close-validation/runner.js +25 -8
- package/.agents/scripts/lib/config/temp-paths.js +1 -1
- package/.agents/scripts/lib/config/worktree-isolation.js +18 -3
- package/.agents/scripts/lib/config-resolver.js +4 -1
- package/.agents/scripts/lib/config-settings-schema-delivery.js +1 -1
- package/.agents/scripts/lib/git-branch-lifecycle.js +90 -0
- package/.agents/scripts/lib/orchestration/auto-merge-cwd.js +128 -0
- package/.agents/scripts/lib/orchestration/column-sync.js +88 -9
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +20 -2
- package/.agents/scripts/lib/orchestration/project-meta-cache.js +238 -0
- package/.agents/scripts/lib/orchestration/reassert-status-column.js +3 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +25 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +80 -14
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +74 -25
- package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +10 -1
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +48 -1
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +148 -4
- package/.agents/scripts/lib/orchestration/ticketing/transition.js +8 -1
- package/.agents/scripts/lib/story-body/story-body.js +76 -7
- package/.agents/scripts/lib/story-init/branch-initializer.js +29 -43
- package/.agents/scripts/lib/story-init/hierarchy-tracer.js +25 -4
- package/.agents/scripts/lib/story-init/task-graph-builder.js +22 -12
- package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -0
- package/.agents/scripts/lib/validation-evidence.js +63 -25
- package/.agents/scripts/lib/worktree/node-modules-strategy.js +239 -31
- package/.agents/scripts/resync-status-column.js +5 -0
- package/.agents/scripts/run-coverage.js +85 -45
- package/.agents/scripts/single-story-init.js +22 -29
- package/.agents/scripts/story-init.js +38 -63
- package/.agents/scripts/story-phase.js +46 -4
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
- package/.agents/workflows/helpers/acceptance-self-eval.md +27 -0
- package/.agents/workflows/helpers/deliver-epic.md +19 -2
- package/.agents/workflows/helpers/epic-deliver-story.md +50 -14
- package/.agents/workflows/helpers/single-story-deliver.md +12 -0
- package/docs/CHANGELOG.md +33 -0
- package/package.json +1 -1
|
@@ -30,6 +30,62 @@
|
|
|
30
30
|
* decomposer prompt and authoring SKILL.
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
+
import { parse as parseStoryBody } from '../story-body/story-body.js';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Normalize a Story's `body` to the structured object the sizing layers
|
|
37
|
+
* score, mirroring `validateAcFreshness` / `collectStoryAssumptionEntries`
|
|
38
|
+
* (Story #3302) and `resolveStructuredBody` in `task-body-validator.js`.
|
|
39
|
+
*
|
|
40
|
+
* The decomposer emits `body` as the canonical serialized **string**
|
|
41
|
+
* (`decomposer-prompts.js`), but the sizing layers historically read
|
|
42
|
+
* `story.body` only when it was already an object — so on the production
|
|
43
|
+
* string shape `changes` / `wide` fell through to empty and the `hardFiles`
|
|
44
|
+
* / unanchored-constant backstops emitted nothing. A defensive parse here
|
|
45
|
+
* restores parity:
|
|
46
|
+
* - **string body** → parsed via `parseStoryBody`; an unparseable string
|
|
47
|
+
* yields `null` (the gate degrades to "no structured signal", never
|
|
48
|
+
* throws mid-validation).
|
|
49
|
+
* - **object body** → returned verbatim (a caller may pass the
|
|
50
|
+
* pre-serialize shape directly; `parse` round-trips it).
|
|
51
|
+
* - **null / other** → `null`.
|
|
52
|
+
*
|
|
53
|
+
* @param {object} story
|
|
54
|
+
* @returns {object|null}
|
|
55
|
+
*/
|
|
56
|
+
function resolveStoryBody(story) {
|
|
57
|
+
const body = story?.body;
|
|
58
|
+
if (typeof body === 'string') {
|
|
59
|
+
if (body.trim().length === 0) return null;
|
|
60
|
+
try {
|
|
61
|
+
return parseStoryBody(body).body;
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (body !== null && typeof body === 'object') return body;
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resolve the acceptance-criteria array for a Story, preferring the
|
|
72
|
+
* authoritative top-level `story.acceptance` (the binding contract the
|
|
73
|
+
* validator already requires every Story to carry inline) over the
|
|
74
|
+
* structured body's `acceptance`. Reading the top-level array makes the
|
|
75
|
+
* acceptance ceiling correct regardless of body shape — a string body whose
|
|
76
|
+
* structured `acceptance` is only reachable after a parse, or an object body
|
|
77
|
+
* (Story #4271). Falls back to `resolveStoryBody(story).acceptance` only when
|
|
78
|
+
* the top-level array is absent.
|
|
79
|
+
*
|
|
80
|
+
* @param {object} story
|
|
81
|
+
* @returns {unknown[]}
|
|
82
|
+
*/
|
|
83
|
+
function resolveAcceptance(story) {
|
|
84
|
+
if (Array.isArray(story?.acceptance)) return story.acceptance;
|
|
85
|
+
const body = resolveStoryBody(story);
|
|
86
|
+
return Array.isArray(body?.acceptance) ? body.acceptance : [];
|
|
87
|
+
}
|
|
88
|
+
|
|
33
89
|
export const DEFAULT_TASK_SIZING = Object.freeze({
|
|
34
90
|
// Typical-Story warning thresholds (soft — emit advisory findings).
|
|
35
91
|
// Story #4162 raised `softFiles` 8 → 15: a capability-sized Story routinely
|
|
@@ -71,6 +127,39 @@ export const DELIVERABLE_GRANULARITY_GUIDANCE = Object.freeze({
|
|
|
71
127
|
'**Single-consumer merge rule.** A Story whose only consumer is one sibling Story should be **merged into that sibling** rather than emitted separately — a single-consumer downstream slice is not its own unit of work.',
|
|
72
128
|
});
|
|
73
129
|
|
|
130
|
+
/**
|
|
131
|
+
* `AUTHORING_ALTITUDE_GUIDANCE` is the **single source of truth** for the
|
|
132
|
+
* binding-vs-advisory authoring altitude (Epic #4131 F8) and the New-File
|
|
133
|
+
* Contract (Story #4272). It is stated ONCE here and consumed by BOTH the
|
|
134
|
+
* decomposer prompt template
|
|
135
|
+
* (`.agents/scripts/lib/templates/decomposer-prompts.js`, which interpolates
|
|
136
|
+
* the strings verbatim into the rendered system prompt) AND the authoring
|
|
137
|
+
* SKILL (`.agents/skills/core/epic-plan-decompose-author/SKILL.md`, whose
|
|
138
|
+
* prose mirrors these sentences). The SKILL cannot import JS, so the
|
|
139
|
+
* `ticket-decomposer` prompt test asserts the canonical phrasing on both
|
|
140
|
+
* surfaces — a divergent restatement fails that gate. This reuses the #3777
|
|
141
|
+
* single-source mechanism (one constant, two surfaces, drift-gated by tests).
|
|
142
|
+
*
|
|
143
|
+
* The altitude: `acceptance[]` / `verify[]` are the **binding contract** (the
|
|
144
|
+
* sole definition of "done"); `changes[]` / `references[]` are an **advisory
|
|
145
|
+
* implementation sketch** the executor MAY revise. Author acceptance to assert
|
|
146
|
+
* the **outcome** independent of file layout — never pin an incidental helper
|
|
147
|
+
* name or private path into an acceptance item. The advisory sketch is still
|
|
148
|
+
* validated (base-branch probes, New-File Contract) and never licenses
|
|
149
|
+
* skipping `acceptance[]` / `verify[]` or any `rules/security-baseline.md` MUST.
|
|
150
|
+
*/
|
|
151
|
+
export const AUTHORING_ALTITUDE_GUIDANCE = Object.freeze({
|
|
152
|
+
// The binding-vs-advisory altitude statement.
|
|
153
|
+
altitude:
|
|
154
|
+
'**Binding contract vs advisory sketch.** `acceptance[]` and `verify[]` are the Story\'s **binding contract** — the executor MUST satisfy them exactly, and they are the only definition of "done." `changes[]` and `references[]` are an **advisory implementation sketch**: your best prediction of the file footprint, which the executor MAY revise when the real codebase diverges from the sketch. Author `acceptance[]` / `verify[]` to assert the **outcome** independent of any one file layout — never pin an incidental implementation detail (an internal helper name, a private file path) into an acceptance item that the advisory `changes[]` is free to reshape; assert the observable behaviour instead.',
|
|
155
|
+
// The advisory-does-not-mean-unvalidated caveat.
|
|
156
|
+
advisoryCaveat:
|
|
157
|
+
"**Advisory does not mean unvalidated.** `changes[]` paths still pass the base-branch file-assumption probes (a `creates` against an existing path still fails), the New-File Contract still holds, and the executor's latitude to revise the approach never licenses skipping `acceptance[]` / `verify[]` or relaxing any `rules/security-baseline.md` MUST.",
|
|
158
|
+
// The New-File Contract.
|
|
159
|
+
newFileContract:
|
|
160
|
+
'**New-File Contract.** Any path named in a Story\'s `goal`, `acceptance`, or `verify` that does NOT already exist on `main` MUST also appear in that Story\'s `changes[]` with `assumption: "creates"`; otherwise the freshness validator rejects the decompose — even when the Story is the one authoring the file.',
|
|
161
|
+
});
|
|
162
|
+
|
|
74
163
|
/**
|
|
75
164
|
* Configuration-constant phrase patterns the `unanchored-constant` heuristic
|
|
76
165
|
* scans Story acceptance criteria for. Each entry matches the *kind* of
|
|
@@ -143,8 +232,11 @@ function makeUnanchoredConstant(slug, criterion) {
|
|
|
143
232
|
*/
|
|
144
233
|
function computeUnanchoredConstantFindings(story) {
|
|
145
234
|
const out = [];
|
|
146
|
-
|
|
147
|
-
|
|
235
|
+
// Read the authoritative top-level `story.acceptance` (the binding
|
|
236
|
+
// contract), falling back to the structured body's acceptance only when the
|
|
237
|
+
// top-level array is absent. This is correct regardless of body shape —
|
|
238
|
+
// string or object (Story #4271).
|
|
239
|
+
const acceptance = resolveAcceptance(story);
|
|
148
240
|
for (const item of acceptance) {
|
|
149
241
|
const criterion = String(item ?? '');
|
|
150
242
|
if (CONCRETE_VALUE_RE.test(criterion)) continue;
|
|
@@ -158,6 +250,47 @@ function computeUnanchoredConstantFindings(story) {
|
|
|
158
250
|
return out;
|
|
159
251
|
}
|
|
160
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Soft, advisory `missing-reason-to-exist` finding (Story #4273). Surfaces a
|
|
255
|
+
* Story whose body carries no non-empty `reason_to_exist` — the
|
|
256
|
+
* machine-checkable form of the cohesion rule (**one Story = one coherent
|
|
257
|
+
* change with one reason to exist**). `reason_to_exist` is marked REQUIRED by
|
|
258
|
+
* the decomposer prompt and is the field the `epic-plan-consolidate` critic
|
|
259
|
+
* gates on, but that critic is an honor-system LLM check with no runtime
|
|
260
|
+
* backstop. This deterministic finding is the cheap backstop.
|
|
261
|
+
*
|
|
262
|
+
* Severity is `soft` (not a hard reject) so existing `reason_to_exist`-less
|
|
263
|
+
* standalone / audit Stories are surfaced as an advisory nudge rather than
|
|
264
|
+
* blocked — matching the `unanchored-constant` finding's advisory contract.
|
|
265
|
+
*/
|
|
266
|
+
function makeMissingReasonToExist(slug) {
|
|
267
|
+
return {
|
|
268
|
+
kind: 'missing-reason-to-exist',
|
|
269
|
+
severity: 'soft',
|
|
270
|
+
ticketSlug: slug,
|
|
271
|
+
message:
|
|
272
|
+
'Story body carries no non-empty `reason_to_exist`. State the single coherent reason this Story exists in one sentence (the machine-checkable form of "one Story = one coherent change with one reason to exist"), encoded as the `reason_to_exist` field of the body meta comment.',
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Emit a soft `missing-reason-to-exist` finding when the Story body resolves
|
|
278
|
+
* to no non-empty `reason_to_exist`. The body parser
|
|
279
|
+
* (`story-body/story-body.js`) already normalizes `reason_to_exist` to a
|
|
280
|
+
* non-empty trimmed string or `null`, so reading `body.reason_to_exist` after
|
|
281
|
+
* `resolveStoryBody` is correct regardless of body shape — a serialized
|
|
282
|
+
* **string** body (the production decomposer shape) or an object body
|
|
283
|
+
* (Story #4271). A body that fails to parse resolves to `null` and trips the
|
|
284
|
+
* finding, which is the right advisory signal: the author should re-emit a
|
|
285
|
+
* parseable body carrying the field. One finding per Story.
|
|
286
|
+
*/
|
|
287
|
+
function computeMissingReasonToExistFinding(story) {
|
|
288
|
+
const body = resolveStoryBody(story);
|
|
289
|
+
const reason = body?.reason_to_exist;
|
|
290
|
+
const hasReason = typeof reason === 'string' && reason.trim().length > 0;
|
|
291
|
+
return hasReason ? [] : [makeMissingReasonToExist(story.slug)];
|
|
292
|
+
}
|
|
293
|
+
|
|
161
294
|
/**
|
|
162
295
|
* Returns true when a `changes[]` entry is a glob pattern. Handles both the
|
|
163
296
|
* canonical PathEntry object form `{ path, assumption }` and legacy strings.
|
|
@@ -253,8 +386,12 @@ function isDeclaredWide(wide) {
|
|
|
253
386
|
*/
|
|
254
387
|
function computeStorySizingFindings(story, sizing) {
|
|
255
388
|
const out = [];
|
|
256
|
-
|
|
257
|
-
|
|
389
|
+
// Story #4271: normalize the body so the canonical serialized **string**
|
|
390
|
+
// shape the decomposer emits is scored at parity with the pre-serialize
|
|
391
|
+
// object shape. The acceptance ceiling reads the authoritative top-level
|
|
392
|
+
// `story.acceptance` (the binding contract), not `body.acceptance`.
|
|
393
|
+
const body = resolveStoryBody(story);
|
|
394
|
+
const acceptance = resolveAcceptance(story);
|
|
258
395
|
const changes = Array.isArray(body?.changes) ? body.changes : [];
|
|
259
396
|
const declaredWide = isDeclaredWide(body?.wide ?? null);
|
|
260
397
|
|
|
@@ -263,6 +400,13 @@ function computeStorySizingFindings(story, sizing) {
|
|
|
263
400
|
// the numeric sizing layers below — purely an authoring nudge.
|
|
264
401
|
out.push(...computeUnanchoredConstantFindings(story));
|
|
265
402
|
|
|
403
|
+
// Soft, advisory: flag a Story body that carries no non-empty
|
|
404
|
+
// `reason_to_exist` (Story #4273). The decomposer prompt marks the field
|
|
405
|
+
// REQUIRED and the consolidate critic gates on it, but that critic has no
|
|
406
|
+
// runtime backstop — this deterministic finding is the cheap backstop.
|
|
407
|
+
// Independent of the numeric sizing layers below.
|
|
408
|
+
out.push(...computeMissingReasonToExistFinding(story));
|
|
409
|
+
|
|
266
410
|
// Acceptance ceiling + soft warn.
|
|
267
411
|
if (acceptance.length > sizing.maxAcceptance) {
|
|
268
412
|
out.push(
|
|
@@ -179,6 +179,7 @@ async function syncProjectStatusColumn(
|
|
|
179
179
|
ticketId,
|
|
180
180
|
newState,
|
|
181
181
|
_makeColumnSync,
|
|
182
|
+
config,
|
|
182
183
|
) {
|
|
183
184
|
try {
|
|
184
185
|
let sync;
|
|
@@ -191,10 +192,15 @@ async function syncProjectStatusColumn(
|
|
|
191
192
|
// The instance's `_meta` cache survives across label transitions
|
|
192
193
|
// so the invariant project metadata (projectId, fieldId, options)
|
|
193
194
|
// is only fetched once per process run. Story #3661.
|
|
195
|
+
//
|
|
196
|
+
// Story #4252 — `config` is threaded so the on-disk board-metadata
|
|
197
|
+
// cache lands under the project's configured tempRoot. It is read at
|
|
198
|
+
// construction only; the registry caches the first instance per
|
|
199
|
+
// provider, so a later transition's config is intentionally ignored.
|
|
194
200
|
if (!_columnSyncRegistry.has(provider)) {
|
|
195
201
|
_columnSyncRegistry.set(
|
|
196
202
|
provider,
|
|
197
|
-
new ColumnSync({ provider, logger: Logger }),
|
|
203
|
+
new ColumnSync({ provider, logger: Logger, config }),
|
|
198
204
|
);
|
|
199
205
|
}
|
|
200
206
|
sync = _columnSyncRegistry.get(provider);
|
|
@@ -363,6 +369,7 @@ export async function transitionTicketState(
|
|
|
363
369
|
ticketId,
|
|
364
370
|
newState,
|
|
365
371
|
opts._makeColumnSync,
|
|
372
|
+
opts.config,
|
|
366
373
|
);
|
|
367
374
|
|
|
368
375
|
// Automatically trigger upward cascade on every transition (Story
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* acceptance: string[], // observable criteria
|
|
16
16
|
* verify: string[], // exact commands / tier annotation
|
|
17
17
|
* references: PathEntry[], // read-only paths (optional)
|
|
18
|
+
* non_goals: string[], // negative-scope bullets (optional, advisory)
|
|
18
19
|
* wide: { reason } | null,// declared-wide footprint (optional)
|
|
19
20
|
* reason_to_exist: string | null, // one-sentence cohesion reason (optional)
|
|
20
21
|
* depends_on: string[], // blocker story slugs or #ids
|
|
@@ -69,6 +70,7 @@ import { FILE_ASSUMPTION_VALUES } from '../orchestration/file-assumption-enum.js
|
|
|
69
70
|
* @property {string[]} acceptance - Observable acceptance criteria.
|
|
70
71
|
* @property {string[]} verify - Exact commands with tier annotation.
|
|
71
72
|
* @property {PathEntry[]} references - Read-only paths (may be empty).
|
|
73
|
+
* @property {string[]} non_goals - Negative-scope bullets (advisory; may be empty).
|
|
72
74
|
* @property {{ reason: string }|null} wide - Declared-wide footprint (reason), or null.
|
|
73
75
|
* @property {string|null} reason_to_exist - One-sentence cohesion reason ("why this Story exists"), or null.
|
|
74
76
|
* @property {string[]} depends_on - Blocking story slugs / issue refs.
|
|
@@ -89,6 +91,7 @@ import { FILE_ASSUMPTION_VALUES } from '../orchestration/file-assumption-enum.js
|
|
|
89
91
|
* @property {boolean} hasAcceptanceSection - Whether a `## Acceptance` section was found.
|
|
90
92
|
* @property {boolean} hasVerifySection - Whether a `## Verify` section was found.
|
|
91
93
|
* @property {boolean} hasReferencesSection - Whether a `## References` section was found.
|
|
94
|
+
* @property {boolean} hasNonGoalsSection - Whether a `## Non-Goals` section was found.
|
|
92
95
|
* @property {boolean} isLegacyStringBody - True when no structured sections were found.
|
|
93
96
|
*/
|
|
94
97
|
|
|
@@ -125,13 +128,16 @@ export class StoryBodyParseError extends Error {
|
|
|
125
128
|
// Section heading map
|
|
126
129
|
// ---------------------------------------------------------------------------
|
|
127
130
|
|
|
128
|
-
// Heading text → body field name
|
|
131
|
+
// Heading text → body field name. Keys are normalized: lower-cased with `-`
|
|
132
|
+
// folded to `_` (see splitSections), so the hyphenated `## Non-Goals` heading
|
|
133
|
+
// maps to the `non_goals` field.
|
|
129
134
|
const HEADING_TO_FIELD = new Map([
|
|
130
135
|
['goal', 'goal'],
|
|
131
136
|
['changes', 'changes'],
|
|
132
137
|
['acceptance', 'acceptance'],
|
|
133
138
|
['verify', 'verify'],
|
|
134
139
|
['references', 'references'],
|
|
140
|
+
['non_goals', 'non_goals'],
|
|
135
141
|
]);
|
|
136
142
|
|
|
137
143
|
// ---------------------------------------------------------------------------
|
|
@@ -346,15 +352,53 @@ function splitSections(markdown) {
|
|
|
346
352
|
// Forms (Story #4227) render every field label as a level-3 heading
|
|
347
353
|
// (`### Goal`), not the level-2 the canonical serializer emits, so the
|
|
348
354
|
// parser accepts both levels. Any other heading depth is ignored.
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
355
|
+
//
|
|
356
|
+
// The token class is `[\w-]+` (not bare `\w+`) so a single hyphenated
|
|
357
|
+
// heading word — the canonical `## Non-Goals` negative-scope section —
|
|
358
|
+
// matches as one token. The captured name is normalized (lower-cased,
|
|
359
|
+
// `-` folded to `_`) before the HEADING_TO_FIELD lookup, so `Non-Goals`
|
|
360
|
+
// resolves to the `non_goals` field. Multi-word headings that contain a
|
|
361
|
+
// space (`## Out of Scope`, `## Agent Prompts`) still do NOT match this
|
|
362
|
+
// single-token shape — they fall through to the catch-all heading branch
|
|
363
|
+
// below, which closes the open section. The chosen canonical spelling is
|
|
364
|
+
// therefore the hyphenated single token `## Non-Goals`.
|
|
365
|
+
const fieldHeadingMatch = line.match(/^#{2,3}\s+([\w-]+)\s*$/i);
|
|
366
|
+
if (fieldHeadingMatch) {
|
|
367
|
+
const name = fieldHeadingMatch[1].toLowerCase().replace(/-/g, '_');
|
|
352
368
|
if (HEADING_TO_FIELD.has(name)) {
|
|
353
369
|
inPreamble = false;
|
|
354
370
|
currentSection = name;
|
|
355
371
|
if (!sections.has(currentSection)) sections.set(currentSection, []);
|
|
356
372
|
continue;
|
|
357
373
|
}
|
|
374
|
+
// A heading that matches the canonical `## Word` shape but is not a
|
|
375
|
+
// recognized field name (e.g. a trailing free-form `## Notes`) closes
|
|
376
|
+
// the currently-open section. Without this reset, the unknown heading
|
|
377
|
+
// and its bullets bleed into the previously-recognized section,
|
|
378
|
+
// silently corrupting `verify[]` / `acceptance[]`. We do NOT re-enter
|
|
379
|
+
// the preamble (`inPreamble` stays false), so a later recognized
|
|
380
|
+
// heading still registers normally; we only stop appending to the
|
|
381
|
+
// closed section. The heading line and its body are dropped from all
|
|
382
|
+
// sections. (Multi-word free-form headings like `## Out of Scope` —
|
|
383
|
+
// with internal spaces — do not match the `[\w-]+` single-token shape
|
|
384
|
+
// and reach this branch too. The hyphenated single-token canonical
|
|
385
|
+
// negative-scope heading is `## Non-Goals`, which IS recognized above.)
|
|
386
|
+
currentSection = null;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Any other markdown heading (`## …` / `### …`, single- or multi-word)
|
|
391
|
+
// that is NOT a canonical field heading TERMINATES the current structured
|
|
392
|
+
// section. Trailing extended content a producer appends after the
|
|
393
|
+
// canonical block — `audit-to-stories`'s `## Agent Prompts` / `## Context`
|
|
394
|
+
// / `## Sequencing` blocks, for instance — must not bleed into the last
|
|
395
|
+
// structured section's bullet list (Story #4270). Without this, those
|
|
396
|
+
// lines were silently absorbed into `verify[]` / `acceptance[]`. The
|
|
397
|
+
// heading and everything under it is dropped from structured parsing
|
|
398
|
+
// (it is extended, non-canonical markdown).
|
|
399
|
+
if (!inPreamble && /^#{1,6}\s+\S/.test(line)) {
|
|
400
|
+
currentSection = null;
|
|
401
|
+
continue;
|
|
358
402
|
}
|
|
359
403
|
|
|
360
404
|
// The trailing `<!-- meta: {...} -->` block is machine metadata, not
|
|
@@ -407,6 +451,7 @@ function parseLegacyStringBody(input, preamble, footer) {
|
|
|
407
451
|
acceptance: [],
|
|
408
452
|
verify: [],
|
|
409
453
|
references: [],
|
|
454
|
+
non_goals: [],
|
|
410
455
|
wide: null,
|
|
411
456
|
reason_to_exist: null,
|
|
412
457
|
depends_on: extractBlockedBy(footer),
|
|
@@ -421,6 +466,7 @@ function parseLegacyStringBody(input, preamble, footer) {
|
|
|
421
466
|
hasAcceptanceSection: false,
|
|
422
467
|
hasVerifySection: false,
|
|
423
468
|
hasReferencesSection: false,
|
|
469
|
+
hasNonGoalsSection: false,
|
|
424
470
|
isLegacyStringBody: true,
|
|
425
471
|
},
|
|
426
472
|
};
|
|
@@ -522,6 +568,7 @@ export function parse(input) {
|
|
|
522
568
|
const hasAcceptanceSection = sections.has('acceptance');
|
|
523
569
|
const hasVerifySection = sections.has('verify');
|
|
524
570
|
const hasReferencesSection = sections.has('references');
|
|
571
|
+
const hasNonGoalsSection = sections.has('non_goals');
|
|
525
572
|
|
|
526
573
|
// If no structured sections found, treat as legacy string body.
|
|
527
574
|
const isLegacyStringBody =
|
|
@@ -545,6 +592,7 @@ export function parse(input) {
|
|
|
545
592
|
sections.get('references') ?? [],
|
|
546
593
|
warnings,
|
|
547
594
|
);
|
|
595
|
+
const non_goals = parseTextListSection(sections.get('non_goals') ?? []);
|
|
548
596
|
const dependsOn = extractBlockedBy(footer);
|
|
549
597
|
|
|
550
598
|
// --- Recover wide / estimated_test_files from the meta block ---
|
|
@@ -566,6 +614,7 @@ export function parse(input) {
|
|
|
566
614
|
acceptance,
|
|
567
615
|
verify,
|
|
568
616
|
references,
|
|
617
|
+
non_goals,
|
|
569
618
|
wide,
|
|
570
619
|
reason_to_exist,
|
|
571
620
|
depends_on: dependsOn,
|
|
@@ -581,6 +630,7 @@ export function parse(input) {
|
|
|
581
630
|
hasAcceptanceSection,
|
|
582
631
|
hasVerifySection,
|
|
583
632
|
hasReferencesSection,
|
|
633
|
+
hasNonGoalsSection,
|
|
584
634
|
isLegacyStringBody: false,
|
|
585
635
|
},
|
|
586
636
|
};
|
|
@@ -625,6 +675,11 @@ function parseStructuredObject(obj) {
|
|
|
625
675
|
if (entry !== null) references.push(entry);
|
|
626
676
|
}
|
|
627
677
|
|
|
678
|
+
// non_goals (advisory negative-scope bullets)
|
|
679
|
+
const non_goals = Array.isArray(obj.non_goals)
|
|
680
|
+
? obj.non_goals.filter((n) => typeof n === 'string' && n.trim().length > 0)
|
|
681
|
+
: [];
|
|
682
|
+
|
|
628
683
|
const wide = normalizeWide(obj.wide);
|
|
629
684
|
const reason_to_exist = normalizeReasonToExist(obj.reason_to_exist);
|
|
630
685
|
|
|
@@ -650,6 +705,7 @@ function parseStructuredObject(obj) {
|
|
|
650
705
|
acceptance,
|
|
651
706
|
verify,
|
|
652
707
|
references,
|
|
708
|
+
non_goals,
|
|
653
709
|
wide,
|
|
654
710
|
reason_to_exist,
|
|
655
711
|
depends_on,
|
|
@@ -665,6 +721,7 @@ function parseStructuredObject(obj) {
|
|
|
665
721
|
hasAcceptanceSection: 'acceptance' in obj,
|
|
666
722
|
hasVerifySection: 'verify' in obj,
|
|
667
723
|
hasReferencesSection: 'references' in obj,
|
|
724
|
+
hasNonGoalsSection: 'non_goals' in obj,
|
|
668
725
|
isLegacyStringBody: false,
|
|
669
726
|
},
|
|
670
727
|
};
|
|
@@ -689,7 +746,7 @@ function serializePathEntry(entry) {
|
|
|
689
746
|
/**
|
|
690
747
|
* Descriptor table for the human-readable Story-body sections, in canonical
|
|
691
748
|
* emit order (`## Goal`, `## Changes`, `## Acceptance`, `## Verify`,
|
|
692
|
-
* `## References`). Each descriptor reads one body field and returns the
|
|
749
|
+
* `## References`, `## Non-Goals`). Each descriptor reads one body field and returns the
|
|
693
750
|
* section's markdown block when the field is present and non-empty, or `null`
|
|
694
751
|
* to omit the section.
|
|
695
752
|
*
|
|
@@ -735,6 +792,18 @@ const SERIALIZE_SECTIONS = [
|
|
|
735
792
|
? `## References\n${references.map((r) => `- ${serializePathEntry(r)}`).join('\n')}`
|
|
736
793
|
: null,
|
|
737
794
|
},
|
|
795
|
+
{
|
|
796
|
+
// Advisory negative-scope bullets. Rendered as the hyphenated canonical
|
|
797
|
+
// `## Non-Goals` heading (the spelling the parser's widened
|
|
798
|
+
// `[\w-]+` field-heading regex recognizes). Render-when-non-empty: an
|
|
799
|
+
// empty or absent `non_goals` emits nothing, so every pre-existing body
|
|
800
|
+
// round-trips byte-identically.
|
|
801
|
+
field: 'non_goals',
|
|
802
|
+
render: (nonGoals) =>
|
|
803
|
+
Array.isArray(nonGoals) && nonGoals.length > 0
|
|
804
|
+
? `## Non-Goals\n${nonGoals.map((n) => `- ${n}`).join('\n')}`
|
|
805
|
+
: null,
|
|
806
|
+
},
|
|
738
807
|
];
|
|
739
808
|
|
|
740
809
|
/**
|
|
@@ -793,8 +862,8 @@ function serializeFooter(body, opts) {
|
|
|
793
862
|
* format written to GitHub issue bodies.
|
|
794
863
|
*
|
|
795
864
|
* The output matches the section order the spec-renderer uses:
|
|
796
|
-
* `## Goal`, `## Changes`, `## Acceptance`, `## Verify`, `## References
|
|
797
|
-
* (omitted when empty).
|
|
865
|
+
* `## Goal`, `## Changes`, `## Acceptance`, `## Verify`, `## References`,
|
|
866
|
+
* `## Non-Goals` (each omitted when empty).
|
|
798
867
|
*
|
|
799
868
|
* `wide`, `reason_to_exist`, and `estimated_test_files` are emitted as a
|
|
800
869
|
* fenced `<!-- meta -->` comment block so round-trips preserve them without
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
classifyBranchSeed,
|
|
28
28
|
ensureEpicBranch,
|
|
29
29
|
ensureEpicBranchRef,
|
|
30
|
+
seedStoryBranchRef,
|
|
30
31
|
} from '../git-branch-lifecycle.js';
|
|
31
32
|
import { gitSpawn } from '../git-utils.js';
|
|
32
33
|
import { Logger } from '../Logger.js';
|
|
@@ -186,7 +187,10 @@ export function ensureStoryBranchSeed({
|
|
|
186
187
|
progress = defaultProgress(),
|
|
187
188
|
git,
|
|
188
189
|
}) {
|
|
189
|
-
const spawn =
|
|
190
|
+
const spawn =
|
|
191
|
+
git?.spawn != null
|
|
192
|
+
? (args) => git.spawn(...args)
|
|
193
|
+
: (args) => gitSpawn(mainCwd, ...args);
|
|
190
194
|
const existsLocally =
|
|
191
195
|
git?.existsLocally ?? ((b) => branchExistsLocally(b, mainCwd));
|
|
192
196
|
// `ensureStoryBranchSeed` is always called after `fetchMainRefs` in
|
|
@@ -195,49 +199,31 @@ export function ensureStoryBranchSeed({
|
|
|
195
199
|
const existsRemotely =
|
|
196
200
|
git?.existsRemotely ?? ((b) => branchExistsViaTrackingRef(b, mainCwd));
|
|
197
201
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
202
|
+
// The seed-action switch shell is single-homed in `seedStoryBranchRef`
|
|
203
|
+
// (Story #4255). The Epic path runs under concurrent wave dispatch, so it
|
|
204
|
+
// opts into `swallowCreateRace: true` to treat a lost probe→create race
|
|
205
|
+
// (`git branch` exits "already exists") as reuse (Story #3482) — the ref
|
|
206
|
+
// exists, which is exactly the post-condition this function guarantees.
|
|
207
|
+
// No `fetchError` is supplied: the fetch exit status is intentionally not
|
|
208
|
+
// inspected here (the worktree bootstrap re-checks the ref downstream).
|
|
209
|
+
seedStoryBranchRef({
|
|
210
|
+
storyBranch,
|
|
211
|
+
baseRef: epicBranch,
|
|
212
|
+
swallowCreateRace: true,
|
|
213
|
+
spawn,
|
|
214
|
+
existsLocally,
|
|
215
|
+
existsRemotely,
|
|
216
|
+
progress,
|
|
217
|
+
messages: {
|
|
218
|
+
reuse: (b) => `Reusing existing story branch ref: ${b} (no re-seed)`,
|
|
219
|
+
fetch: (b) => `Fetching remote story branch: ${b}`,
|
|
220
|
+
create: (b, ref) => `Creating story branch ref: ${b} from ${ref}`,
|
|
221
|
+
createRace: (b) =>
|
|
222
|
+
`Story branch ref ${b} already exists (created concurrently) — reusing.`,
|
|
223
|
+
createError: (b, ref, stderr) =>
|
|
224
|
+
`ensureStoryBranchSeed: failed to create ${b} from ${ref}: ${stderr}`,
|
|
225
|
+
},
|
|
201
226
|
});
|
|
202
|
-
if (action === 'none') {
|
|
203
|
-
// Story #3482 — a pre-existing `story-<id>` ref is reuse, not an error.
|
|
204
|
-
// The worktree bootstrap below seeds onto whatever the ref already points
|
|
205
|
-
// at (resuming a partially-implemented Story), so seeding is a no-op here.
|
|
206
|
-
progress(
|
|
207
|
-
'GIT',
|
|
208
|
-
`Reusing existing story branch ref: ${storyBranch} (no re-seed)`,
|
|
209
|
-
);
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
if (action === 'fetch') {
|
|
213
|
-
progress('GIT', `Fetching remote story branch: ${storyBranch}`);
|
|
214
|
-
spawn('fetch', 'origin', `${storyBranch}:${storyBranch}`);
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
progress(
|
|
218
|
-
'GIT',
|
|
219
|
-
`Creating story branch ref: ${storyBranch} from ${epicBranch}`,
|
|
220
|
-
);
|
|
221
|
-
const res = spawn('branch', storyBranch, epicBranch);
|
|
222
|
-
// Story #3482 — close the probe→create race: another concurrent
|
|
223
|
-
// dispatch (or a prior interrupted run) may have created the ref between
|
|
224
|
-
// our existence probe above and this `git branch` call. `git branch`
|
|
225
|
-
// exits non-zero with "already exists" in that window. Treat it as reuse
|
|
226
|
-
// rather than letting story-init abort — the ref exists, which is exactly
|
|
227
|
-
// the post-condition this function guarantees.
|
|
228
|
-
if (res.status !== 0) {
|
|
229
|
-
const stderr = res.stderr || res.stdout || '';
|
|
230
|
-
if (/already exists/i.test(stderr)) {
|
|
231
|
-
progress(
|
|
232
|
-
'GIT',
|
|
233
|
-
`Story branch ref ${storyBranch} already exists (created concurrently) — reusing.`,
|
|
234
|
-
);
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
237
|
-
throw new Error(
|
|
238
|
-
`ensureStoryBranchSeed: failed to create ${storyBranch} from ${epicBranch}: ${stderr}`,
|
|
239
|
-
);
|
|
240
|
-
}
|
|
241
227
|
}
|
|
242
228
|
|
|
243
229
|
function verifyWorkspaceSafe({
|
|
@@ -2,10 +2,19 @@ import { Logger } from '../Logger.js';
|
|
|
2
2
|
/**
|
|
3
3
|
* hierarchy-tracer.js — Stage 2 of the story-init pipeline.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Resolves the linked PRD and Tech Spec issue IDs for a Story's parent Epic.
|
|
6
|
+
*
|
|
7
|
+
* Story #4253: when both `prdId` and `techSpecId` are supplied as input
|
|
8
|
+
* (the `/deliver` fan-out resolves the immutable Epic once at the top of the
|
|
9
|
+
* run and threads the two ids down via `story-init.js --prd/--tech-spec`),
|
|
10
|
+
* this stage short-circuits and does NOT call `provider.getEpic`. The Epic
|
|
11
|
+
* issue is invariant for the lifetime of a delivery run, so the N per-Story
|
|
12
|
+
* `getEpic` round-trips collapse to one parent-side resolution.
|
|
13
|
+
*
|
|
14
|
+
* When the flags are absent (interactive / single-story use), the legacy
|
|
15
|
+
* `getEpic` resolution runs unchanged. Fetch failures are logged but
|
|
16
|
+
* non-fatal — the result simply reports `null` for whichever linkage could
|
|
17
|
+
* not be resolved, preserving the graceful degradation on a missing Epic.
|
|
9
18
|
*/
|
|
10
19
|
|
|
11
20
|
/**
|
|
@@ -14,12 +23,24 @@ import { Logger } from '../Logger.js';
|
|
|
14
23
|
* @param {object} [deps.logger]
|
|
15
24
|
* @param {object} deps.input
|
|
16
25
|
* @param {number} deps.input.epicId
|
|
26
|
+
* @param {number|null} [deps.input.prdId] Pre-resolved PRD id (from --prd).
|
|
27
|
+
* @param {number|null} [deps.input.techSpecId] Pre-resolved Tech Spec id
|
|
28
|
+
* (from --tech-spec). When both `prdId` and `techSpecId` are supplied,
|
|
29
|
+
* `getEpic` is skipped.
|
|
17
30
|
* @returns {Promise<{ prdId: number|null, techSpecId: number|null }>}
|
|
18
31
|
*/
|
|
19
32
|
export async function traceHierarchy({ provider, logger, input }) {
|
|
20
33
|
const { epicId } = input;
|
|
21
34
|
const warn = logger?.warn ?? ((msg) => Logger.error(msg));
|
|
22
35
|
|
|
36
|
+
// Short-circuit: the parent already resolved both linkages once and threaded
|
|
37
|
+
// them in, so there is nothing left to fetch. Skip the per-Story getEpic.
|
|
38
|
+
const suppliedPrdId = input.prdId ?? null;
|
|
39
|
+
const suppliedTechSpecId = input.techSpecId ?? null;
|
|
40
|
+
if (suppliedPrdId !== null && suppliedTechSpecId !== null) {
|
|
41
|
+
return { prdId: suppliedPrdId, techSpecId: suppliedTechSpecId };
|
|
42
|
+
}
|
|
43
|
+
|
|
23
44
|
let prdId = null;
|
|
24
45
|
let techSpecId = null;
|
|
25
46
|
try {
|
|
@@ -82,22 +82,32 @@ export async function buildTaskGraph({ provider, logger, input }) {
|
|
|
82
82
|
const warn = logger?.warn ?? ((msg) => Logger.error(msg));
|
|
83
83
|
const progress = logger?.progress ?? (() => {});
|
|
84
84
|
|
|
85
|
+
// Story #4251 — under the 2-tier hierarchy every Story is childless, so the
|
|
86
|
+
// `fetchChildTickets` call (a `getTicket` + empty sub-issues GraphQL query +
|
|
87
|
+
// a never-matching `/search/issues` fallback) is pure waste on every
|
|
88
|
+
// story-init. The Story body is already in scope, so detect the inline-
|
|
89
|
+
// acceptance 2-tier shape FIRST and short-circuit without any child fetch —
|
|
90
|
+
// sparing the most aggressively rate-limited GitHub endpoint exactly during
|
|
91
|
+
// wide wave fan-out. A body lacking inline acceptance still falls through to
|
|
92
|
+
// the legacy child-enumeration path below.
|
|
93
|
+
if (hasInlineAcceptance(storyBody)) {
|
|
94
|
+
progress(
|
|
95
|
+
'TASKS',
|
|
96
|
+
`Story #${storyId} has inline acceptance — no child Tasks expected (2-tier shape).`,
|
|
97
|
+
);
|
|
98
|
+
return { sortedTasks: [], mode: '2-tier' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Legacy / 4-tier fall-through: a body lacking inline acceptance still
|
|
102
|
+
// enumerates child Tasks for the topological sort below.
|
|
85
103
|
const tasks = await fetchChildTickets(provider, storyId);
|
|
86
104
|
|
|
87
|
-
const
|
|
88
|
-
const mode = tasks.length === 0 && inlineAcceptance ? '2-tier' : '4-tier';
|
|
105
|
+
const mode = '4-tier';
|
|
89
106
|
|
|
90
107
|
if (tasks.length === 0) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
`Story #${storyId} has inline acceptance — no child Tasks expected (2-tier shape).`,
|
|
95
|
-
);
|
|
96
|
-
} else {
|
|
97
|
-
warn(
|
|
98
|
-
`[story-init] Warning: Story #${storyId} has no child Tasks. The agent will need to work from the Story body directly.`,
|
|
99
|
-
);
|
|
100
|
-
}
|
|
108
|
+
warn(
|
|
109
|
+
`[story-init] Warning: Story #${storyId} has no child Tasks. The agent will need to work from the Story body directly.`,
|
|
110
|
+
);
|
|
101
111
|
}
|
|
102
112
|
|
|
103
113
|
const sortedTasks = sortTasksByDependencies(tasks);
|