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
|
@@ -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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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;
|