valent-pipeline 0.5.1 → 0.5.2

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/bin/cli.js CHANGED
@@ -133,6 +133,17 @@ program
133
133
  await sprintPackCmd(options);
134
134
  });
135
135
 
136
+ // resolve-eligible command (meta-loop: cross-epic candidate eligibility)
137
+ program
138
+ .command('resolve-eligible')
139
+ .description('Deterministically resolve which pending backlog items are eligible for the next sprint (dependency chains stay together)')
140
+ .option('--backlog <path>', 'Backlog file (YAML/JSON); resolves its `items`')
141
+ .option('--stories <path>', 'Explicit item array (YAML/JSON); overrides --backlog')
142
+ .action(async (options) => {
143
+ const { resolveEligibleCmd } = await import('../src/commands/resolve-eligible.js');
144
+ await resolveEligibleCmd(options);
145
+ });
146
+
136
147
  // calibrate command (meta-loop: estimation-accuracy arithmetic)
137
148
  program
138
149
  .command('calibrate')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "valent-pipeline",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "v3 multi-agent AI pipeline for software development lifecycle",
5
5
  "type": "module",
6
6
  "bin": {
@@ -332,10 +332,15 @@ if (!validation.valid) {
332
332
  throw new Error(`sprint ${sprintId} plan failed validation: ${(validation.errors || []).join('; ')}`)
333
333
  }
334
334
 
335
- // Shaped to feed straight into sprint.workflow.js.
336
- const packedSet = new Set(pack.sprint_stories)
337
- const plannedStories = sizedStories
338
- .filter((s) => packedSet.has(s.storyId))
335
+ // Shaped to feed straight into sprint.workflow.js. Order by `pack.sprint_stories`, NOT by
336
+ // grooming/candidate order: sprint-pack emits stories in dependency-safe order (every prerequisite
337
+ // before its dependent) and sprint.workflow.js runs the batch SEQUENTIALLY in array order on a
338
+ // shared branch — so a dependent must not precede its prerequisite. This is what lets a dependency
339
+ // chain ship together in one sprint.
340
+ const sizedById = new Map(sizedStories.map((s) => [s.storyId, s]))
341
+ const plannedStories = pack.sprint_stories
342
+ .map((id) => sizedById.get(id))
343
+ .filter(Boolean)
339
344
  .map((s) => ({ storyId: s.storyId, projectType: s.projectType, profiles: s.profiles }))
340
345
 
341
346
  return {
@@ -52,12 +52,27 @@ Loop sprints until all stories are shipped, blocked, or cancelled. Each iteratio
52
52
  **ALWAYS** read `{epic_progress_path}` and `{backlog_path}` from disk at the top of each sprint.
53
53
 
54
54
  #### 4b. Check for remaining work (cross-epic)
55
- Read `{backlog_path}` (all epics). If no `pending` stories with met dependencies remain:
56
- - all remaining `shipped`/`cancelled` project complete, go to Step 5;
57
- - remaining `blocked`/`blocked-on-user` → report blockers, go to Step 5;
58
- - remaining `pending` but all with unmet `depends_on` → circular/missing prerequisite, report and stop.
55
+ **Do not hand-walk the dependency graph.** Candidate eligibility is deterministic call the
56
+ resolver instead of deciding by hand which `depends_on` are "met":
59
57
 
60
- Otherwise collect the eligible pending story IDs across ALL epics whose dependencies are met (with `projectType` from config and each story's `testing_profiles`) as this sprint's candidate list.
58
+ ```
59
+ node .valent-pipeline/bin/cli.js resolve-eligible --backlog {backlog_path}
60
+ ```
61
+
62
+ It emits `{ eligible: [ids in priority order], blocked: [{ id, reason }] }`. A pending story is
63
+ **eligible** when its whole dependency chain is live — a prerequisite that is itself a pending,
64
+ eligible story is **INCLUDED in this sprint's candidate list**, not deferred to a later sprint.
65
+ That is the point: `plan.workflow.js` packs the chain together (`sprint-pack` orders prerequisites
66
+ before dependents and buffers anything over velocity), and `sprint.workflow.js` runs the batch
67
+ sequentially on a shared branch — so a dependency chain ships together in one sprint, capacity
68
+ permitting. A story is **blocked** (withheld) only when a prerequisite is `cancelled` /
69
+ `blocked` / `blocked-on-user` / missing, or a blocking bug is unresolved.
70
+
71
+ Interpret the output:
72
+ - `eligible` is non-empty → these IDs are this sprint's candidate list. Pair each with its
73
+ `projectType` (from config) and `testing_profiles` for Step 4c.
74
+ - `eligible` is empty and every remaining item is `shipped`/`cancelled` → project complete, go to Step 5.
75
+ - `eligible` is empty but `blocked` is non-empty → report the blocked stories with their reasons, go to Step 5.
61
76
 
62
77
  #### 4c. Plan the sprint
63
78
  Invoke `plan.workflow.js` via the **Workflow tool**:
@@ -0,0 +1,49 @@
1
+ import { readFileSync, existsSync } from 'fs';
2
+ import { isAbsolute, join, extname } from 'path';
3
+ import { parse as parseYaml } from 'yaml';
4
+ import { resolveEligibleStories } from '../lib/sprint.js';
5
+
6
+ /** Read a JSON or YAML file (by extension) and return the parsed object. */
7
+ function loadStructured(path) {
8
+ const abs = isAbsolute(path) ? path : join(process.cwd(), path);
9
+ if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
10
+ const raw = readFileSync(abs, 'utf-8');
11
+ return extname(abs).toLowerCase() === '.json' ? JSON.parse(raw) : parseYaml(raw);
12
+ }
13
+
14
+ /**
15
+ * `valent-pipeline resolve-eligible (--backlog <path> | --stories <path>)`
16
+ *
17
+ * Deterministic cross-epic candidate eligibility for the next sprint (src/lib/sprint.js). Replaces
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.
23
+ *
24
+ * Emits JSON: { eligible: [ids in priority order], blocked: [{ id, reason }] }.
25
+ *
26
+ * --backlog reads a backlog file and resolves its `items`. --stories reads an explicit array of
27
+ * items (JSON or YAML); both forms tolerate either an array or an `items:` list.
28
+ */
29
+ export async function resolveEligibleCmd(options) {
30
+ let items;
31
+ try {
32
+ const src = options.stories || options.backlog;
33
+ if (!src) throw new Error('Either --backlog <path> or --stories <path> is required.');
34
+ const data = loadStructured(src);
35
+ items = Array.isArray(data) ? data : data.items;
36
+ } catch (err) {
37
+ console.error(`Error: ${err.message}`);
38
+ process.exit(1);
39
+ }
40
+
41
+ if (!Array.isArray(items)) {
42
+ console.error('Error: no item list found (expected an array or an `items:` list).');
43
+ process.exit(1);
44
+ }
45
+
46
+ const result = resolveEligibleStories(items);
47
+ console.log(JSON.stringify(result, null, 2));
48
+ process.exit(0);
49
+ }
package/src/lib/sprint.js CHANGED
@@ -97,6 +97,107 @@ export function packSprint(stories, velocity) {
97
97
  };
98
98
  }
99
99
 
100
+ /**
101
+ * Cross-epic candidate eligibility for the next sprint — which pending backlog items can be
102
+ * groomed now. A deterministic replacement for the hand-run "collect pending stories whose
103
+ * dependencies are met" step in the project skills (`valent-run-project*` Step 4b).
104
+ *
105
+ * The OLD rule only surfaced a story whose prerequisites were ALREADY shipped, so a dependency
106
+ * chain trickled in one story per sprint (A ships, then B becomes eligible, then C...). This
107
+ * instead surfaces a pending story whenever its whole dependency chain is LIVE — every
108
+ * prerequisite is either already shipped or another pending story that is itself eligible.
109
+ * `packSprint` then auto-includes those prerequisites in dependency-safe order and the sprint
110
+ * executes them sequentially, so a chain can run together in ONE sprint (up to the velocity
111
+ * budget; overflow falls to the groomed buffer).
112
+ *
113
+ * Only items whose dependency chain hits a DEAD prerequisite (cancelled / blocked /
114
+ * blocked-on-user, or missing from the backlog) or a blocking bug that is not yet resolved are
115
+ * withheld — reported in `blocked` with the offending ref. Removal propagates: a dependent of a
116
+ * withheld story is itself withheld (the fixpoint below), so a dead prerequisite blocks its
117
+ * whole downstream chain.
118
+ *
119
+ * @param {Array} items - backlog items: { id, status, depends_on|dependencies, blocked_by_bugs,
120
+ * priority }. `type` is irrelevant here — any item in `candidateStatus` is a candidate.
121
+ * @param {object} [opts]
122
+ * - satisfiedStatuses: statuses that satisfy a dependency (default ['shipped'])
123
+ * - deadStatuses: statuses that permanently block a dependent (default
124
+ * ['cancelled','blocked','blocked-on-user'])
125
+ * - candidateStatus: status of items considered as candidates (default 'pending')
126
+ * @returns {{ eligible: string[], blocked: Array<{ id: string, reason: string }> }}
127
+ * `eligible` is ascending-priority ordered (lower number first; missing priority last, ties by
128
+ * id) so the downstream groom/size/pack sees the most important work first. A prerequisite in
129
+ * an in-progress status (neither shipped, dead, nor still `pending`) is treated as
130
+ * already-moving and does NOT block — only genuinely dead/missing/pending-but-withheld deps do.
131
+ */
132
+ export function resolveEligibleStories(items, opts = {}) {
133
+ const list = Array.isArray(items) ? items : [];
134
+ const satisfied = new Set(opts.satisfiedStatuses ?? ['shipped']);
135
+ const dead = new Set(opts.deadStatuses ?? ['cancelled', 'blocked', 'blocked-on-user']);
136
+ const candidateStatus = opts.candidateStatus ?? 'pending';
137
+
138
+ const byId = new Map(list.map((it) => [it.id, it]));
139
+ const blocked = []; // { id, reason }
140
+ const blockedIds = new Set();
141
+ const recordBlocked = (id, reason) => {
142
+ if (blockedIds.has(id)) return;
143
+ blockedIds.add(id);
144
+ blocked.push({ id, reason });
145
+ };
146
+
147
+ // A blocking bug is cleared when its referenced bug item is shipped (update-backlog-status.md)
148
+ // or no longer present in the backlog (the entry was removed). Otherwise the dependent waits.
149
+ const bugBlocker = (it) => {
150
+ for (const bugId of it.blocked_by_bugs ?? []) {
151
+ const bug = byId.get(bugId);
152
+ if (bug && !satisfied.has(bug.status)) return bugId;
153
+ }
154
+ return null;
155
+ };
156
+
157
+ // Seed the eligible set with every candidate that isn't bug-blocked, then iterate to a fixpoint:
158
+ // drop any whose dependency chain reaches a dead/missing/withheld prerequisite, propagating the
159
+ // removal downstream until stable.
160
+ const eligible = new Set();
161
+ for (const it of list) {
162
+ if (it.status !== candidateStatus) continue;
163
+ const bug = bugBlocker(it);
164
+ if (bug) recordBlocked(it.id, `blocked by unresolved bug "${bug}"`);
165
+ else eligible.add(it.id);
166
+ }
167
+
168
+ let changed = true;
169
+ while (changed) {
170
+ changed = false;
171
+ for (const id of [...eligible]) {
172
+ const it = byId.get(id);
173
+ for (const depId of depsOf(it)) {
174
+ const dep = byId.get(depId);
175
+ let reason = null;
176
+ if (!dep) reason = `depends on "${depId}" which is missing from the backlog`;
177
+ else if (dead.has(dep.status)) reason = `depends on "${depId}" which is ${dep.status}`;
178
+ else if (dep.status === candidateStatus && !eligible.has(depId)) {
179
+ // Prerequisite is a candidate that has itself been withheld — chain is dead upstream.
180
+ reason = `depends on "${depId}" which is not eligible (its own prerequisites are unmet)`;
181
+ }
182
+ // else: dep is satisfied or in an in-progress status, or a still-eligible candidate -> OK.
183
+ if (reason) {
184
+ eligible.delete(id);
185
+ recordBlocked(id, reason);
186
+ changed = true;
187
+ break;
188
+ }
189
+ }
190
+ }
191
+ }
192
+
193
+ const priorityOf = (id) => byId.get(id)?.priority ?? Infinity;
194
+ const eligibleIds = [...eligible].sort(
195
+ (a, b) => priorityOf(a) - priorityOf(b) || String(a).localeCompare(String(b)),
196
+ );
197
+
198
+ return { eligible: eligibleIds, blocked };
199
+ }
200
+
100
201
  /** Sample standard deviation; 0 when fewer than 2 samples. */
101
202
  function stdev(values) {
102
203
  if (values.length < 2) return 0;