valent-pipeline 0.6.2 → 0.6.3
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/package.json +1 -1
- package/pipeline/orchestrators/claude-code/plan.workflow.js +82 -32
- package/pipeline/orchestrators/claude-code/sprint.workflow.js +18 -2
- package/skills/valent-run-epic-workflow/SKILL.md +14 -3
- package/skills/valent-run-project-workflow/SKILL.md +27 -14
- package/src/commands/resolve-eligible.js +9 -5
- package/src/lib/sprint.js +30 -11
package/package.json
CHANGED
|
@@ -148,6 +148,15 @@ if (!stories.length || !sprintId || typeof velocity !== 'number') {
|
|
|
148
148
|
throw new Error('args must include { stories:[{storyId,projectType}], sprintId, velocity }')
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
// A candidate arrives EITHER ungroomed (needs the full groom -> size pass below) OR already groomed
|
|
152
|
+
// — a leftover from a PRIOR sprint's grooming that this sprint only needs to PACK. The caller flags
|
|
153
|
+
// the latter `groomed: true` and carries its `profiles` from the backlog (resolveEligibleStories
|
|
154
|
+
// surfaces these as `groomedBuffer`). We groom only the ungroomed set; the buffer is folded straight
|
|
155
|
+
// into the pack + return so it executes WITHOUT being re-specced or re-gated. Skipping this is what
|
|
156
|
+
// stranded the buffer before: nothing drained the groomed overflow once a sprint over-groomed.
|
|
157
|
+
const toGroom = stories.filter((s) => !s.groomed)
|
|
158
|
+
const buffer = stories.filter((s) => s.groomed)
|
|
159
|
+
|
|
151
160
|
// --- per-agent model tiers ----------------------------------------------------
|
|
152
161
|
// Tiers come from pipeline-config.yaml `models` (a tier->roles map), passed in as
|
|
153
162
|
// args.models by the invoking skill — a Workflow script can't read files. We invert it
|
|
@@ -230,36 +239,44 @@ phase('Groom')
|
|
|
230
239
|
// story bounces off the profile gate and eats a re-tag + full re-review cycle. So derive once here,
|
|
231
240
|
// write the backlog in a SINGLE agent (one writer => no race on the shared YAML), and reuse the same
|
|
232
241
|
// profiles for the in-memory flow below so the backlog tag, UXA-skip, and sizing can never diverge.
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
)
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
)
|
|
258
|
-
|
|
242
|
+
// Tag only the ungroomed set — buffer stories already carry `testing_profiles` on the backlog (and
|
|
243
|
+
// in args), so re-deriving them is wasted work. A pure buffer-drain sprint (nothing to groom) skips
|
|
244
|
+
// this agent entirely.
|
|
245
|
+
const profileById = new Map()
|
|
246
|
+
if (toGroom.length) {
|
|
247
|
+
const tag = await agent(
|
|
248
|
+
[
|
|
249
|
+
'You are the grooming orchestrator performing **Step 0: Pre-Grooming Profile Tagging**, before any spec agent runs.',
|
|
250
|
+
'',
|
|
251
|
+
'For EACH story below: read `stories/{storyId}/story.md` (its ACs + scope) and derive its `testing_profiles` using these criteria (multiple may apply):',
|
|
252
|
+
'- `api` — owns API endpoints, backend routes, business logic, or database changes',
|
|
253
|
+
'- `ui` — owns UI components, pages, or visual elements',
|
|
254
|
+
'- `data-pipeline` — ETL, data transformation, or batch processing',
|
|
255
|
+
'- `mcp-server` — MCP server tools, handlers, or protocol work',
|
|
256
|
+
'- `library` — shared library/package (exports, packaging, versioning)',
|
|
257
|
+
'- `document-generation` — document/report template or generation pipeline work',
|
|
258
|
+
'- `iac` — infrastructure (Terraform, CloudFormation, Kubernetes, CI/CD)',
|
|
259
|
+
"Tag a profile only when the story OWNS that surface. A story that merely CONSUMES another story's API endpoint (no endpoint/DB change of its own) is NOT `api`.",
|
|
260
|
+
'',
|
|
261
|
+
`Then write \`testing_profiles: [...]\` onto each story's entry in \`${backlogPath}\` — preserve every other field and the item order. If an entry already has testing_profiles, only correct it when a required profile is missing.`,
|
|
262
|
+
'',
|
|
263
|
+
`Stories: ${JSON.stringify(toGroom.map((s) => s.storyId))}.`,
|
|
264
|
+
'',
|
|
265
|
+
'Return ONLY { stories: [{ storyId, testing_profiles:[...] }, ...] } as JSON, covering every story above.',
|
|
266
|
+
].join('\n'),
|
|
267
|
+
{ label: 'pre-groom-profile-tag', phase: 'Groom', schema: PROFILE_TAG_SCHEMA, model: modelFor('PERSIST') },
|
|
268
|
+
)
|
|
269
|
+
for (const s of tag.stories || []) {
|
|
270
|
+
profileById.set(s.storyId, Array.isArray(s.testing_profiles) ? s.testing_profiles : [])
|
|
271
|
+
}
|
|
272
|
+
log(`tagged testing_profiles on ${profileById.size}/${toGroom.length} stories before the readiness gate`)
|
|
273
|
+
}
|
|
274
|
+
if (buffer.length) log(`draining ${buffer.length} groomed buffer stor${buffer.length === 1 ? 'y' : 'ies'} (specced in a prior sprint — pack only)`)
|
|
259
275
|
|
|
260
276
|
// Pipelined: spec agents don't touch code, so stories flow assembly-line through the stages.
|
|
277
|
+
// Only the ungroomed set is groomed here; the already-groomed buffer skips straight to packing.
|
|
261
278
|
const groomed = await pipeline(
|
|
262
|
-
|
|
279
|
+
toGroom,
|
|
263
280
|
// Stage 1: REQS produces reqs-brief.md. Profiles are already derived + on the backlog (Step 0);
|
|
264
281
|
// we pass them in so REQS selects the right profile-applicable sections (draft-brief.md).
|
|
265
282
|
async (story) => {
|
|
@@ -314,12 +331,30 @@ const groomed = await pipeline(
|
|
|
314
331
|
buildPrompt({ role: target, promptFile: `${target.toLowerCase()}.md`, storyId: g.storyId, taskSubject: 'Address the READINESS rejection and rewrite the affected spec.' }),
|
|
315
332
|
{ label: `rework:${target.toLowerCase()}:${g.storyId}`, phase: 'Groom', schema: HANDOFF_SCHEMA, model: modelFor(target) },
|
|
316
333
|
)
|
|
334
|
+
// Cascade re-derivation downstream. Grooming is a derivation chain (REQS -> UXA -> QA-A): when an
|
|
335
|
+
// upstream spec is reworked, the specs derived from it are now stale and CONTRADICT the correction.
|
|
336
|
+
// Re-running only the rejection target leaves that staleness in place, so the next gate just rejects
|
|
337
|
+
// again on the downstream artifact — a guaranteed wasted reject/rework cycle per contract fix. Re-derive
|
|
338
|
+
// every dependent spec here, before re-gating, so the whole chain re-syncs in one pass.
|
|
339
|
+
const up = target.toUpperCase()
|
|
340
|
+
if (up === 'REQS' && g.profiles.includes('ui')) {
|
|
341
|
+
await agent(
|
|
342
|
+
buildPrompt({ role: 'UXA', promptFile: 'uxa.md', storyId: g.storyId, taskSubject: 'Re-derive uxa-spec.md from the reworked reqs-brief.md — the upstream brief changed during readiness rework, so re-sync the spec to it.' }),
|
|
343
|
+
{ label: `cascade:uxa:${g.storyId}`, phase: 'Groom', schema: HANDOFF_SCHEMA, model: modelFor('UXA') },
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
if (up === 'REQS' || up === 'UXA') {
|
|
347
|
+
await agent(
|
|
348
|
+
buildPrompt({ role: 'QA-A', promptFile: 'qa-a.md', storyId: g.storyId, taskSubject: 'Re-derive qa-test-spec.md from the reworked upstream spec — reqs-brief/uxa-spec changed during readiness rework, so re-sync the test spec to the corrected contract.' }),
|
|
349
|
+
{ label: `cascade:qa-a:${g.storyId}`, phase: 'Groom', schema: HANDOFF_SCHEMA, model: modelFor('QA-A') },
|
|
350
|
+
)
|
|
351
|
+
}
|
|
317
352
|
}
|
|
318
353
|
},
|
|
319
354
|
)
|
|
320
355
|
|
|
321
356
|
const readyStories = groomed.filter(Boolean).filter((g) => g.groomedStatus === 'groomed')
|
|
322
|
-
log(`groomed ${readyStories.length}/${
|
|
357
|
+
log(`groomed ${readyStories.length}/${toGroom.length} stories`)
|
|
323
358
|
|
|
324
359
|
phase('Size')
|
|
325
360
|
// Each story is sized by every estimator whose profile is present; story_points = the sum.
|
|
@@ -395,11 +430,26 @@ if (!validation.valid) {
|
|
|
395
430
|
// `groomed: true` — these stories were fully specced + passed the READINESS gate during grooming
|
|
396
431
|
// above, and their reqs-brief/uxa-spec/qa-test-spec are on disk. sprint.workflow.js reads this flag
|
|
397
432
|
// and SKIPS its Spec + Readiness phases, so execution doesn't redundantly re-spec and re-gate.
|
|
398
|
-
|
|
433
|
+
// Metadata for EVERY packable story: the ones groomed this run (sizedStories) AND the pre-existing
|
|
434
|
+
// groomed buffer (from args). sprint-pack reads the whole backlog, so `pack.sprint_stories` can name
|
|
435
|
+
// buffer stories too — they must be returned with their projectType/profiles or sprint.workflow.js
|
|
436
|
+
// has nothing to execute. (Building this only from sizedStories is exactly what silently dropped the
|
|
437
|
+
// buffer before: packed + tagged sprint-planned, but never handed downstream.)
|
|
438
|
+
const metaById = new Map(sizedStories.map((s) => [s.storyId, { projectType: s.projectType, profiles: s.profiles }]))
|
|
439
|
+
for (const b of buffer) {
|
|
440
|
+
if (!metaById.has(b.storyId)) {
|
|
441
|
+
metaById.set(b.storyId, { projectType: b.projectType, profiles: Array.isArray(b.profiles) ? b.profiles : [] })
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const missingMeta = pack.sprint_stories.filter((id) => !metaById.has(id))
|
|
445
|
+
if (missingMeta.length) {
|
|
446
|
+
log(`⚠ packed but NOT executable — no projectType/profiles passed for: ${missingMeta.join(', ')}. ` +
|
|
447
|
+
`These are groomed in the backlog but were not handed to plan as candidates or buffer; they will be ` +
|
|
448
|
+
`tagged sprint-planned yet skipped this sprint. Pass them (with profiles) via groomedBuffer.`)
|
|
449
|
+
}
|
|
399
450
|
const plannedStories = pack.sprint_stories
|
|
400
|
-
.
|
|
401
|
-
.
|
|
402
|
-
.map((s) => ({ storyId: s.storyId, projectType: s.projectType, profiles: s.profiles, groomed: true }))
|
|
451
|
+
.filter((id) => metaById.has(id))
|
|
452
|
+
.map((id) => ({ storyId: id, projectType: metaById.get(id).projectType, profiles: metaById.get(id).profiles, groomed: true }))
|
|
403
453
|
|
|
404
454
|
return {
|
|
405
455
|
sprintId,
|
|
@@ -636,12 +636,28 @@ async function runStory(story) {
|
|
|
636
636
|
|
|
637
637
|
phase('Readiness')
|
|
638
638
|
await runGate(storyId, 'READINESS', 'readiness.md', 'Validate the spec chain (reqs/uxa/qa) is implementation-ready.',
|
|
639
|
-
'Readiness', (verdict) => {
|
|
639
|
+
'Readiness', async (verdict) => {
|
|
640
640
|
const target = verdict.rejectionTarget || 'REQS'
|
|
641
|
-
|
|
641
|
+
await spawn(target, `${target.toLowerCase()}.md`, 'Address the READINESS rejection and rewrite the affected spec.', {
|
|
642
642
|
label: `rework:${target.toLowerCase()}:${storyId}`,
|
|
643
643
|
phase: 'Readiness',
|
|
644
644
|
})
|
|
645
|
+
// Cascade re-derivation downstream. Spec is a derivation chain (REQS -> UXA -> QA-A): when an
|
|
646
|
+
// upstream spec is reworked, the specs derived from it are now stale and contradict the correction.
|
|
647
|
+
// Re-running only the rejection target leaves that staleness in place, so the next gate just rejects
|
|
648
|
+
// again on the downstream artifact — a guaranteed wasted reject/rework cycle. Re-derive every dependent
|
|
649
|
+
// spec here, before re-gating, so the chain re-syncs in one pass. Mirrors plan.workflow.js.
|
|
650
|
+
const up = target.toUpperCase()
|
|
651
|
+
if (up === 'REQS' && has('uxa')) {
|
|
652
|
+
await spawn('UXA', 'uxa.md', 'Re-derive uxa-spec.md from the reworked reqs-brief.md — the upstream brief changed during readiness rework, so re-sync the spec to it.', {
|
|
653
|
+
label: `cascade:uxa:${storyId}`, phase: 'Readiness',
|
|
654
|
+
})
|
|
655
|
+
}
|
|
656
|
+
if (up === 'REQS' || up === 'UXA') {
|
|
657
|
+
await spawn('QA-A', 'qa-a.md', 'Re-derive qa-test-spec.md from the reworked upstream spec — reqs-brief/uxa-spec changed during readiness rework, so re-sync the test spec to the corrected contract.', {
|
|
658
|
+
label: `cascade:qa-a:${storyId}`, phase: 'Readiness',
|
|
659
|
+
})
|
|
660
|
+
}
|
|
645
661
|
})
|
|
646
662
|
}
|
|
647
663
|
|
|
@@ -56,7 +56,11 @@ Loop sprints until no pending epic stories with met dependencies remain. Each it
|
|
|
56
56
|
**ALWAYS** read `{epic_progress_path}` and `{backlog_path}` from disk at the top of each sprint. Never trust in-context memory.
|
|
57
57
|
|
|
58
58
|
#### 4b. Check for remaining work
|
|
59
|
-
Filter `{backlog_path}` by `{epic_id}
|
|
59
|
+
Filter `{backlog_path}` by `{epic_id}`, then split the unfinished stories two ways:
|
|
60
|
+
- **ungroomed** (`status: pending`) with met dependencies → need grooming.
|
|
61
|
+
- **groomed buffer** (`status: groomed`, not yet `sprint-planned`/`shipped`) → already specced + readiness-passed in a prior sprint that over-groomed; they only need **packing, not re-grooming**.
|
|
62
|
+
|
|
63
|
+
If **both** are empty, exit the loop → Step 5. Otherwise build this sprint's candidate list from both: each ungroomed id with its `projectType` (from config); each groomed-buffer id with its `projectType`, its `testing_profiles` (from the backlog entry), and `groomed: true`. (Surfacing the groomed buffer is essential — without it a sprint that grooms more than its velocity packs strands the overflow and the epic loop exits early with stories unbuilt.)
|
|
60
64
|
|
|
61
65
|
#### 4c. Plan the sprint
|
|
62
66
|
Invoke `plan.workflow.js` via the **Workflow tool**:
|
|
@@ -64,11 +68,18 @@ Invoke `plan.workflow.js` via the **Workflow tool**:
|
|
|
64
68
|
```js
|
|
65
69
|
Workflow({
|
|
66
70
|
scriptPath: '.valent-pipeline/orchestrators/claude-code/plan.workflow.js',
|
|
67
|
-
args: {
|
|
71
|
+
args: {
|
|
72
|
+
stories: [
|
|
73
|
+
...ungroomed.map(id => ({ storyId: id, projectType: '<project.type>' })),
|
|
74
|
+
...groomedBuffer.map(id => ({ storyId: id, projectType: '<project.type>', profiles: [/* backlog testing_profiles */], groomed: true })),
|
|
75
|
+
],
|
|
76
|
+
sprintId: '{epic_id}-sprint-{n}', velocity: {sprint.initial_velocity_points or current calibrated velocity},
|
|
77
|
+
models: <config.models or omit>, reasoning: <config.reasoning or omit>,
|
|
78
|
+
}
|
|
68
79
|
})
|
|
69
80
|
```
|
|
70
81
|
|
|
71
|
-
It grooms, sizes (calibrated Fibonacci), packs to velocity, and validates — returning `{ sprintId, stories: [{ storyId, projectType, profiles }, ...] }`. Record its `runId`.
|
|
82
|
+
It grooms only the ungroomed stories, sizes them (calibrated Fibonacci), packs the whole groomed backlog (buffer + newly groomed) to velocity, and validates — returning `{ sprintId, stories: [{ storyId, projectType, profiles, groomed }, ...], buffer_story_ids }`. **Always pass the groomed buffer** (with `groomed: true` + `profiles`); plan packs it without re-grooming, and logs `⚠ packed but NOT executable` for any packed story whose metadata wasn't supplied. Record its `runId`.
|
|
72
83
|
|
|
73
84
|
#### 4d. Execute the sprint
|
|
74
85
|
Feed the planned batch straight into `sprint.workflow.js`:
|
|
@@ -61,20 +61,20 @@ resolver instead of deciding by hand which `depends_on` are "met":
|
|
|
61
61
|
node .valent-pipeline/bin/cli.js resolve-eligible --backlog {backlog_path}
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
-
It emits `{ eligible
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
`blocked` / `blocked-on-user` / missing, or a blocking bug is unresolved.
|
|
64
|
+
It emits `{ eligible, groomedBuffer, blocked }`. **`eligible`** = ungroomed work whose whole
|
|
65
|
+
dependency chain is live — a prerequisite that is itself an eligible candidate is **INCLUDED in this
|
|
66
|
+
sprint**, not deferred (`plan.workflow.js` packs the chain together and `sprint.workflow.js` runs it
|
|
67
|
+
sequentially on a shared branch, so a chain ships in one sprint, capacity permitting). **`groomedBuffer`**
|
|
68
|
+
= stories already specced + readiness-passed in a PRIOR sprint that groomed more than its velocity
|
|
69
|
+
could pack; they only need **packing, not re-grooming**. A story is **blocked** (withheld) only when a
|
|
70
|
+
prerequisite is `cancelled` / `blocked` / `blocked-on-user` / missing, or a blocking bug is unresolved.
|
|
72
71
|
|
|
73
72
|
Interpret the output:
|
|
74
|
-
- `eligible` is non-empty →
|
|
75
|
-
`projectType` (from config)
|
|
76
|
-
|
|
77
|
-
- `eligible`
|
|
73
|
+
- `eligible` **or** `groomedBuffer` is non-empty → there is work this sprint; build the candidate list
|
|
74
|
+
for Step 4c. Pair each `eligible` id with its `projectType` (from config); pair each `groomedBuffer`
|
|
75
|
+
id with its `projectType` **and** its `testing_profiles` read from the backlog entry.
|
|
76
|
+
- both `eligible` and `groomedBuffer` empty, every remaining item `shipped`/`cancelled` → project complete, go to Step 5.
|
|
77
|
+
- both empty but `blocked` is non-empty → report the blocked stories with their reasons, go to Step 5.
|
|
78
78
|
|
|
79
79
|
#### 4c. Plan the sprint
|
|
80
80
|
Invoke `plan.workflow.js` via the **Workflow tool**:
|
|
@@ -82,11 +82,24 @@ Invoke `plan.workflow.js` via the **Workflow tool**:
|
|
|
82
82
|
```js
|
|
83
83
|
Workflow({
|
|
84
84
|
scriptPath: '.valent-pipeline/orchestrators/claude-code/plan.workflow.js',
|
|
85
|
-
args: {
|
|
85
|
+
args: {
|
|
86
|
+
stories: [
|
|
87
|
+
// ungroomed candidates — plan grooms, sizes, then packs these:
|
|
88
|
+
...eligible.map(id => ({ storyId: id, projectType: '<project.type>' })),
|
|
89
|
+
// groomed buffer — plan packs these as-is (NO re-groom). MUST carry profiles + groomed:true:
|
|
90
|
+
...groomedBuffer.map(id => ({ storyId: id, projectType: '<project.type>', profiles: [/* backlog testing_profiles */], groomed: true })),
|
|
91
|
+
],
|
|
92
|
+
sprintId: 'project-sprint-{n}', velocity: {sprint.initial_velocity_points or current calibrated velocity},
|
|
93
|
+
models: <config.models or omit>, reasoning: <config.reasoning or omit>,
|
|
94
|
+
}
|
|
86
95
|
})
|
|
87
96
|
```
|
|
88
97
|
|
|
89
|
-
|
|
98
|
+
**Always include the `groomedBuffer` stories** (each with `groomed: true` and its `profiles`) alongside
|
|
99
|
+
any ungroomed candidates — that is what drains the buffer. plan grooms only the ungroomed ones, packs
|
|
100
|
+
the whole groomed backlog up to velocity (buffer + newly groomed), and returns just the packed batch.
|
|
101
|
+
A buffer story omitted here is packed but silently skipped — plan now logs `⚠ packed but NOT executable`
|
|
102
|
+
if that happens. Returns `{ sprintId, stories: [{ storyId, projectType, profiles, groomed }, ...], buffer_story_ids }`. Record its `runId`.
|
|
90
103
|
|
|
91
104
|
#### 4d. Execute the sprint
|
|
92
105
|
Feed the planned batch straight into `sprint.workflow.js`:
|
|
@@ -16,12 +16,16 @@ function loadStructured(path) {
|
|
|
16
16
|
*
|
|
17
17
|
* Deterministic cross-epic candidate eligibility for the next sprint (src/lib/sprint.js). Replaces
|
|
18
18
|
* the hand-run "collect pending stories whose dependencies are met" step in the project skills.
|
|
19
|
-
* A
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* A story is eligible when its whole dependency chain is live (every prerequisite is shipped OR
|
|
20
|
+
* another eligible candidate), so a dependency chain can be groomed and packed into ONE sprint —
|
|
21
|
+
* packSprint orders the prerequisites first and the sprint executes them sequentially. Stories
|
|
22
|
+
* withheld by a dead/missing prerequisite or unresolved bug are reported.
|
|
23
23
|
*
|
|
24
|
-
* Emits JSON: { eligible
|
|
24
|
+
* Emits JSON: { eligible, groomedBuffer, blocked }. `eligible` = pending work that still needs
|
|
25
|
+
* grooming (feed to plan.workflow.js); `groomedBuffer` = already-specced stories left over from a
|
|
26
|
+
* prior sprint's grooming, ready to pack without re-grooming; `blocked` = [{ id, reason }]. Both
|
|
27
|
+
* id lists are priority-ordered. Surfacing `groomedBuffer` is what lets the project loop drain a
|
|
28
|
+
* groomed backlog across sprints instead of stalling once nothing is left `pending`.
|
|
25
29
|
*
|
|
26
30
|
* --backlog reads a backlog file and resolves its `items`. --stories reads an explicit array of
|
|
27
31
|
* items (JSON or YAML); both forms tolerate either an array or an `items:` list.
|
package/src/lib/sprint.js
CHANGED
|
@@ -143,24 +143,38 @@ export function packSprint(stories, velocity) {
|
|
|
143
143
|
* withheld story is itself withheld (the fixpoint below), so a dead prerequisite blocks its
|
|
144
144
|
* whole downstream chain.
|
|
145
145
|
*
|
|
146
|
+
* Candidate = remaining work eligible for the next sprint. That is BOTH ungroomed work (`pending`,
|
|
147
|
+
* which still needs grooming) AND already-groomed work waiting in the buffer (`groomed`, already
|
|
148
|
+
* specced+sized — it only needs packing). Before this, only `pending` counted: once a sprint
|
|
149
|
+
* groomed more stories than its velocity could pack, the overflow (now `groomed`) became invisible
|
|
150
|
+
* to the resolver and the project loop stalled with that work stranded. The eligible set is split
|
|
151
|
+
* on return so the caller can route each bucket correctly (groom the `eligible`, pack the
|
|
152
|
+
* `groomedBuffer`).
|
|
153
|
+
*
|
|
146
154
|
* @param {Array} items - backlog items: { id, status, depends_on|dependencies, blocked_by_bugs,
|
|
147
|
-
* priority }. `type` is irrelevant here — any item in
|
|
155
|
+
* priority }. `type` is irrelevant here — any item in a candidate status is a candidate.
|
|
148
156
|
* @param {object} [opts]
|
|
149
157
|
* - satisfiedStatuses: statuses that satisfy a dependency (default ['shipped'])
|
|
150
158
|
* - deadStatuses: statuses that permanently block a dependent (default
|
|
151
159
|
* ['cancelled','blocked','blocked-on-user'])
|
|
152
|
-
* -
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
160
|
+
* - candidateStatuses: statuses considered as candidates (default ['pending','groomed']).
|
|
161
|
+
* `candidateStatus` (a single string) is still accepted for back-compat and wins when given.
|
|
162
|
+
* - groomedStatus: the candidate status that means "already specced, pack-ready" (default
|
|
163
|
+
* 'groomed') — eligible items in this status are returned in `groomedBuffer`, not `eligible`.
|
|
164
|
+
* @returns {{ eligible: string[], groomedBuffer: string[], blocked: Array<{ id, reason }> }}
|
|
165
|
+
* `eligible` (still needs grooming) and `groomedBuffer` (pack-ready) are each ascending-priority
|
|
166
|
+
* ordered (lower number first; missing priority last, ties by id). A prerequisite in an
|
|
167
|
+
* in-progress status (neither shipped, dead, nor a still-eligible candidate) is treated as
|
|
168
|
+
* already-moving and does NOT block — only genuinely dead/missing/withheld-candidate deps do.
|
|
158
169
|
*/
|
|
159
170
|
export function resolveEligibleStories(items, opts = {}) {
|
|
160
171
|
const list = Array.isArray(items) ? items : [];
|
|
161
172
|
const satisfied = new Set(opts.satisfiedStatuses ?? ['shipped']);
|
|
162
173
|
const dead = new Set(opts.deadStatuses ?? ['cancelled', 'blocked', 'blocked-on-user']);
|
|
163
|
-
const
|
|
174
|
+
const groomedStatus = opts.groomedStatus ?? 'groomed';
|
|
175
|
+
const candidateStatuses = new Set(
|
|
176
|
+
opts.candidateStatus != null ? [opts.candidateStatus] : opts.candidateStatuses ?? ['pending', groomedStatus],
|
|
177
|
+
);
|
|
164
178
|
|
|
165
179
|
const byId = new Map(list.map((it) => [it.id, it]));
|
|
166
180
|
const blocked = []; // { id, reason }
|
|
@@ -186,7 +200,7 @@ export function resolveEligibleStories(items, opts = {}) {
|
|
|
186
200
|
// removal downstream until stable.
|
|
187
201
|
const eligible = new Set();
|
|
188
202
|
for (const it of list) {
|
|
189
|
-
if (it.status
|
|
203
|
+
if (!candidateStatuses.has(it.status)) continue;
|
|
190
204
|
const bug = bugBlocker(it);
|
|
191
205
|
if (bug) recordBlocked(it.id, `blocked by unresolved bug "${bug}"`);
|
|
192
206
|
else eligible.add(it.id);
|
|
@@ -202,7 +216,7 @@ export function resolveEligibleStories(items, opts = {}) {
|
|
|
202
216
|
let reason = null;
|
|
203
217
|
if (!dep) reason = `depends on "${depId}" which is missing from the backlog`;
|
|
204
218
|
else if (dead.has(dep.status)) reason = `depends on "${depId}" which is ${dep.status}`;
|
|
205
|
-
else if (dep.status
|
|
219
|
+
else if (candidateStatuses.has(dep.status) && !eligible.has(depId)) {
|
|
206
220
|
// Prerequisite is a candidate that has itself been withheld — chain is dead upstream.
|
|
207
221
|
reason = `depends on "${depId}" which is not eligible (its own prerequisites are unmet)`;
|
|
208
222
|
}
|
|
@@ -222,7 +236,12 @@ export function resolveEligibleStories(items, opts = {}) {
|
|
|
222
236
|
(a, b) => priorityOf(a) - priorityOf(b) || String(a).localeCompare(String(b)),
|
|
223
237
|
);
|
|
224
238
|
|
|
225
|
-
|
|
239
|
+
// Split the eligible set by grooming-readiness: already-groomed items go to the buffer (the
|
|
240
|
+
// caller packs them as-is), everything else still needs grooming. Both stay priority-ordered.
|
|
241
|
+
const groomedBuffer = eligibleIds.filter((id) => byId.get(id)?.status === groomedStatus);
|
|
242
|
+
const needsGrooming = eligibleIds.filter((id) => byId.get(id)?.status !== groomedStatus);
|
|
243
|
+
|
|
244
|
+
return { eligible: needsGrooming, groomedBuffer, blocked };
|
|
226
245
|
}
|
|
227
246
|
|
|
228
247
|
/** Sample standard deviation; 0 when fewer than 2 samples. */
|