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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "valent-pipeline",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "v3 multi-agent AI pipeline for software development lifecycle",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- const tag = await agent(
234
- [
235
- 'You are the grooming orchestrator performing **Step 0: Pre-Grooming Profile Tagging**, before any spec agent runs.',
236
- '',
237
- 'For EACH story below: read `stories/{storyId}/story.md` (its ACs + scope) and derive its `testing_profiles` using these criteria (multiple may apply):',
238
- '- `api` owns API endpoints, backend routes, business logic, or database changes',
239
- '- `ui` — owns UI components, pages, or visual elements',
240
- '- `data-pipeline` ETL, data transformation, or batch processing',
241
- '- `mcp-server` — MCP server tools, handlers, or protocol work',
242
- '- `library` shared library/package (exports, packaging, versioning)',
243
- '- `document-generation` — document/report template or generation pipeline work',
244
- '- `iac` — infrastructure (Terraform, CloudFormation, Kubernetes, CI/CD)',
245
- "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`.",
246
- '',
247
- `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.`,
248
- '',
249
- `Stories: ${JSON.stringify(stories.map((s) => s.storyId))}.`,
250
- '',
251
- 'Return ONLY { stories: [{ storyId, testing_profiles:[...] }, ...] } as JSON, covering every story above.',
252
- ].join('\n'),
253
- { label: 'pre-groom-profile-tag', phase: 'Groom', schema: PROFILE_TAG_SCHEMA, model: modelFor('PERSIST') },
254
- )
255
- const profileById = new Map(
256
- (tag.stories || []).map((s) => [s.storyId, Array.isArray(s.testing_profiles) ? s.testing_profiles : []]),
257
- )
258
- log(`tagged testing_profiles on ${profileById.size}/${stories.length} stories before the readiness gate`)
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
- stories,
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}/${stories.length} stories`)
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
- const sizedById = new Map(sizedStories.map((s) => [s.storyId, s]))
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
- .map((id) => sizedById.get(id))
401
- .filter(Boolean)
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
- return spawn(target, `${target.toLowerCase()}.md`, 'Address the READINESS rejection and rewrite the affected spec.', {
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}`. If no `pending` stories with met dependencies remain, exit the loop Step 5. Collect the eligible pending story IDs (with `projectType` from config and each story's `testing_profiles`) into a candidate list for this sprint.
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: { stories: [{ storyId, projectType }, ...candidates], sprintId: '{epic_id}-sprint-{n}', velocity: {sprint.initial_velocity_points or current calibrated velocity}, models: <config.models or omit>, reasoning: <config.reasoning or omit> }
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: [ids in priority order], blocked: [{ id, reason }] }`. A pending story is
65
- **eligible** when its whole dependency chain is live — a prerequisite that is itself a pending,
66
- eligible story is **INCLUDED in this sprint's candidate list**, not deferred to a later sprint.
67
- That is the point: `plan.workflow.js` packs the chain together (`sprint-pack` orders prerequisites
68
- before dependents and buffers anything over velocity), and `sprint.workflow.js` runs the batch
69
- sequentially on a shared branch so a dependency chain ships together in one sprint, capacity
70
- permitting. A story is **blocked** (withheld) only when a prerequisite is `cancelled` /
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 → these IDs are this sprint's candidate list. Pair each with its
75
- `projectType` (from config) and `testing_profiles` for Step 4c.
76
- - `eligible` is empty and every remaining item is `shipped`/`cancelled` project complete, go to Step 5.
77
- - `eligible` is empty but `blocked` is non-empty report the blocked stories with their reasons, go to Step 5.
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: { stories: [{ storyId, projectType }, ...candidates], sprintId: 'project-sprint-{n}', velocity: {sprint.initial_velocity_points or current calibrated velocity}, models: <config.models or omit>, reasoning: <config.reasoning or omit> }
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
- Returns `{ sprintId, stories: [{ storyId, projectType, profiles }, ...] }`. Record its `runId`.
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 pending story is eligible when its whole dependency chain is live (every prerequisite is
20
- * shipped OR another pending+eligible story), so a dependency chain can be groomed and packed
21
- * into ONE sprint — packSprint orders the prerequisites first and the sprint executes them
22
- * sequentially. Stories withheld by a dead/missing prerequisite or unresolved bug are reported.
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: [ids in priority order], blocked: [{ id, reason }] }.
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 `candidateStatus` is a candidate.
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
- * - candidateStatus: status of items considered as candidates (default 'pending')
153
- * @returns {{ eligible: string[], blocked: Array<{ id: string, reason: string }> }}
154
- * `eligible` is ascending-priority ordered (lower number first; missing priority last, ties by
155
- * id) so the downstream groom/size/pack sees the most important work first. A prerequisite in
156
- * an in-progress status (neither shipped, dead, nor still `pending`) is treated as
157
- * already-moving and does NOT block only genuinely dead/missing/pending-but-withheld deps do.
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 candidateStatus = opts.candidateStatus ?? 'pending';
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 !== candidateStatus) continue;
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 === candidateStatus && !eligible.has(depId)) {
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
- return { eligible: eligibleIds, blocked };
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. */