@ikunin/sprintpilot 2.2.31 → 2.3.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/README.md +232 -413
- package/_Sprintpilot/Sprintpilot.md +76 -6
- package/_Sprintpilot/bin/autopilot.js +734 -68
- package/_Sprintpilot/lib/orchestrator/action-ledger.js +208 -0
- package/_Sprintpilot/lib/orchestrator/adapt.js +93 -15
- package/_Sprintpilot/lib/orchestrator/profile-rules.js +7 -16
- package/_Sprintpilot/lib/orchestrator/sprint-plan.js +488 -0
- package/_Sprintpilot/lib/orchestrator/state-store.js +9 -5
- package/_Sprintpilot/lib/orchestrator/user-command-applier.js +78 -0
- package/_Sprintpilot/lib/orchestrator/user-commands.js +114 -0
- package/_Sprintpilot/lib/orchestrator/verify.js +10 -17
- package/_Sprintpilot/manifest.yaml +4 -1
- package/_Sprintpilot/modules/autopilot/profiles/_base.yaml +18 -4
- package/_Sprintpilot/modules/git/config.yaml +15 -9
- package/_Sprintpilot/modules/ma/config.yaml +29 -27
- package/_Sprintpilot/scripts/dispatch-layer.js +12 -15
- package/_Sprintpilot/scripts/infer-dependencies.js +706 -254
- package/_Sprintpilot/scripts/log-timing.js +6 -10
- package/_Sprintpilot/scripts/merge-shards.js +21 -23
- package/_Sprintpilot/scripts/post-green-gates.js +3 -1
- package/_Sprintpilot/scripts/resolve-dag.js +452 -280
- package/_Sprintpilot/scripts/sprint-plan.js +1068 -0
- package/_Sprintpilot/scripts/state-shard.js +13 -5
- package/_Sprintpilot/scripts/summarize-timings.js +2 -3
- package/_Sprintpilot/skills/sprint-autopilot-on/SKILL.md +30 -2
- package/_Sprintpilot/skills/sprint-autopilot-on/workflow.orchestrator.md +36 -10
- package/_Sprintpilot/skills/sprintpilot-dependency-graph/SKILL.md +63 -0
- package/_Sprintpilot/skills/sprintpilot-dependency-graph/workflow.md +227 -0
- package/_Sprintpilot/skills/sprintpilot-plan-sprint/SKILL.md +67 -0
- package/_Sprintpilot/skills/sprintpilot-plan-sprint/workflow.md +435 -0
- package/_Sprintpilot/skills/sprintpilot-sprint-progress/SKILL.md +53 -0
- package/_Sprintpilot/skills/sprintpilot-sprint-progress/workflow.md +169 -0
- package/lib/commands/install.js +186 -10
- package/package.json +1 -1
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
// _Sprintpilot/lib/orchestrator/sprint-plan.js — orchestrator-side
|
|
2
|
+
// wrappers around the sprint-plan.js script. This module:
|
|
3
|
+
//
|
|
4
|
+
// - knows about autopilot/profile/config concerns (auto_plan_on_start
|
|
5
|
+
// opt-in gate, --no-auto-plan CLI flag);
|
|
6
|
+
// - composes the plan-aware story queue from sprint-plan.yaml;
|
|
7
|
+
// - drives one-shot legacy-file migration on first cmdStart;
|
|
8
|
+
// - computes plan staleness for the auto-derive trigger.
|
|
9
|
+
//
|
|
10
|
+
// It does NOT execute any LLM call; auto-derive emits a `invoke_skill`
|
|
11
|
+
// action that the LLM session handles. By design this layer stays
|
|
12
|
+
// host-agnostic and unit-testable.
|
|
13
|
+
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const { spawnSync } = require('node:child_process');
|
|
17
|
+
|
|
18
|
+
const sprintPlanScript = require('../../scripts/sprint-plan.js');
|
|
19
|
+
|
|
20
|
+
const REPO_BIN = path.join(__dirname, '..', '..', 'scripts');
|
|
21
|
+
const INFER_SCRIPT = path.join(REPO_BIN, 'infer-dependencies.js');
|
|
22
|
+
|
|
23
|
+
// Plan-status values that mean "do not run this story" (queue resolver
|
|
24
|
+
// drops these). 'pending' is the only state the autopilot picks up.
|
|
25
|
+
const NON_PENDING_PLAN_STATUSES = new Set(['done', 'skipped', 'excluded']);
|
|
26
|
+
|
|
27
|
+
// Reasons surfaced by planStaleness().
|
|
28
|
+
const STALENESS_REASONS = {
|
|
29
|
+
missing: 'missing',
|
|
30
|
+
added_stories: 'added_stories',
|
|
31
|
+
removed_stories: 'removed_stories',
|
|
32
|
+
migration_needed: 'migration_needed',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Path to the legacy dependencies.yaml file (pre-v2.3.0). Used by the
|
|
36
|
+
// migration trigger; never read by the live DAG resolver.
|
|
37
|
+
function legacyDependenciesPath(projectRoot) {
|
|
38
|
+
return path.join(projectRoot, '_Sprintpilot', 'sprints', 'dependencies.yaml');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------
|
|
42
|
+
// Reading sprint-status (minimal pull — we only need the keys here)
|
|
43
|
+
// ---------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
function sprintStatusPath(projectRoot) {
|
|
46
|
+
return path.join(
|
|
47
|
+
projectRoot,
|
|
48
|
+
'_bmad-output',
|
|
49
|
+
'implementation-artifacts',
|
|
50
|
+
'sprint-status.yaml',
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Parse story keys (and their bmad status) out of sprint-status.yaml.
|
|
55
|
+
// Mirrors the pull logic in resolve-dag.js#readStoriesFromStatus — we
|
|
56
|
+
// duplicate here to keep the orchestrator helper independent of the
|
|
57
|
+
// strategy layer (which reads from sprint-plan.yaml).
|
|
58
|
+
function readSprintStatusKeys(projectRoot) {
|
|
59
|
+
const file = sprintStatusPath(projectRoot);
|
|
60
|
+
if (!fs.existsSync(file)) return { exists: false, ordered: [], byKey: {} };
|
|
61
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
62
|
+
const ordered = [];
|
|
63
|
+
const byKey = {};
|
|
64
|
+
const lines = raw.split(/\r?\n/);
|
|
65
|
+
let inStories = false;
|
|
66
|
+
let storyIndent = null;
|
|
67
|
+
for (const rawLine of lines) {
|
|
68
|
+
const trimmed = rawLine.trimEnd();
|
|
69
|
+
if (/^(development_status|stories):\s*$/.test(trimmed)) {
|
|
70
|
+
inStories = true;
|
|
71
|
+
storyIndent = null;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (inStories && /^\S/.test(trimmed)) {
|
|
75
|
+
inStories = false;
|
|
76
|
+
storyIndent = null;
|
|
77
|
+
}
|
|
78
|
+
if (!inStories) continue;
|
|
79
|
+
const m = trimmed.match(/^([\t ]+)([A-Za-z0-9][A-Za-z0-9-]*):\s*(\S+)?/);
|
|
80
|
+
if (!m) continue;
|
|
81
|
+
if (storyIndent === null) storyIndent = m[1];
|
|
82
|
+
else if (m[1] !== storyIndent) continue;
|
|
83
|
+
const status = m[3] ? m[3].replace(/^["']|["']$/g, '') : null;
|
|
84
|
+
if (byKey[m[2]] === undefined) {
|
|
85
|
+
ordered.push(m[2]);
|
|
86
|
+
byKey[m[2]] = { key: m[2], status };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { exists: true, ordered, byKey };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------
|
|
93
|
+
// Staleness detection
|
|
94
|
+
// ---------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
// Compute whether the current sprint-plan.yaml needs regeneration.
|
|
97
|
+
// Returns:
|
|
98
|
+
// { stale: false } — plan is fresh OR no plan exists yet
|
|
99
|
+
// { stale: true, reason: 'missing' } — plan absent AND legacy file absent
|
|
100
|
+
// { stale: true, reason: 'migration_needed' } — legacy file present, plan absent
|
|
101
|
+
// { stale: true, reason: 'added_stories', missing_keys } — sprint-status has stories not in plan
|
|
102
|
+
// { stale: true, reason: 'removed_stories', removed_keys } — plan stories absent from sprint-status
|
|
103
|
+
function planStaleness({ projectRoot }) {
|
|
104
|
+
const planResult = sprintPlanScript.read({ projectRoot });
|
|
105
|
+
|
|
106
|
+
// Plan present but corrupt — not "stale" per se; callers handle this
|
|
107
|
+
// via the corrupt-recovery user_prompt. We surface it as a sentinel.
|
|
108
|
+
if (planResult && typeof planResult === 'object' && 'error' in planResult) {
|
|
109
|
+
return { stale: true, reason: 'corrupt', error: planResult.error, message: planResult.message };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const legacyExists = fs.existsSync(legacyDependenciesPath(projectRoot));
|
|
113
|
+
|
|
114
|
+
if (planResult === null) {
|
|
115
|
+
if (legacyExists) {
|
|
116
|
+
return { stale: true, reason: STALENESS_REASONS.migration_needed };
|
|
117
|
+
}
|
|
118
|
+
return { stale: true, reason: STALENESS_REASONS.missing };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Plan exists — compare against sprint-status keys.
|
|
122
|
+
const ss = readSprintStatusKeys(projectRoot);
|
|
123
|
+
if (!ss.exists) {
|
|
124
|
+
// No sprint-status to compare against. Plan stands alone.
|
|
125
|
+
return { stale: false };
|
|
126
|
+
}
|
|
127
|
+
const planStoryKeys = new Set(
|
|
128
|
+
(planResult.stories || []).map((s) => s && s.key).filter((k) => typeof k === 'string'),
|
|
129
|
+
);
|
|
130
|
+
const ssSet = new Set(ss.ordered);
|
|
131
|
+
|
|
132
|
+
const missingFromPlan = ss.ordered.filter((k) => !planStoryKeys.has(k));
|
|
133
|
+
if (missingFromPlan.length > 0) {
|
|
134
|
+
return {
|
|
135
|
+
stale: true,
|
|
136
|
+
reason: STALENESS_REASONS.added_stories,
|
|
137
|
+
missing_keys: missingFromPlan,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const removedFromStatus = [...planStoryKeys].filter((k) => !ssSet.has(k));
|
|
141
|
+
if (removedFromStatus.length > 0) {
|
|
142
|
+
return {
|
|
143
|
+
stale: true,
|
|
144
|
+
reason: STALENESS_REASONS.removed_stories,
|
|
145
|
+
removed_keys: removedFromStatus,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return { stale: false };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------
|
|
152
|
+
// Migration trigger
|
|
153
|
+
// ---------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
// One-shot upgrade path: if a legacy `_Sprintpilot/sprints/dependencies.yaml`
|
|
156
|
+
// exists, invoke `infer-dependencies.js migrate` to import it into
|
|
157
|
+
// sprint-plan.yaml. Idempotent — subsequent calls are no-ops since
|
|
158
|
+
// migrate archives the legacy file on success.
|
|
159
|
+
//
|
|
160
|
+
// Returns the parsed JSON output from migrate, or { skipped: true }
|
|
161
|
+
// when no legacy file is present.
|
|
162
|
+
function bootstrapMigrationIfNeeded({ projectRoot }) {
|
|
163
|
+
if (!fs.existsSync(legacyDependenciesPath(projectRoot))) {
|
|
164
|
+
return { skipped: true, reason: 'no_legacy_file' };
|
|
165
|
+
}
|
|
166
|
+
const r = spawnSync('node', [INFER_SCRIPT, 'migrate', '--project-root', projectRoot], {
|
|
167
|
+
encoding: 'utf8',
|
|
168
|
+
});
|
|
169
|
+
let parsed = null;
|
|
170
|
+
try {
|
|
171
|
+
parsed = JSON.parse(r.stdout);
|
|
172
|
+
} catch {
|
|
173
|
+
parsed = null;
|
|
174
|
+
}
|
|
175
|
+
if (r.status !== 0) {
|
|
176
|
+
return {
|
|
177
|
+
migrated: false,
|
|
178
|
+
reason: 'migrate_failed',
|
|
179
|
+
status: r.status,
|
|
180
|
+
stdout: r.stdout,
|
|
181
|
+
stderr: r.stderr,
|
|
182
|
+
parsed,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return parsed || { migrated: false, reason: 'unparseable_migrate_output' };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------
|
|
189
|
+
// Plan-aware queue composition
|
|
190
|
+
// ---------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
// Build an ordered story queue from sprint-plan.yaml's pending entries.
|
|
193
|
+
// Returns null when no usable plan exists (caller falls back to
|
|
194
|
+
// sprint-status order). Empty array means plan exists but has no pending
|
|
195
|
+
// stories (queue is exhausted).
|
|
196
|
+
//
|
|
197
|
+
// Ordering: by `priority` ascending. Stories without a priority sink to
|
|
198
|
+
// the end in their array-position order.
|
|
199
|
+
function composePlanQueue({ projectRoot }) {
|
|
200
|
+
const plan = sprintPlanScript.read({ projectRoot });
|
|
201
|
+
if (plan === null) return null;
|
|
202
|
+
if (plan && typeof plan === 'object' && 'error' in plan) return null;
|
|
203
|
+
if (!Array.isArray(plan.stories) || plan.stories.length === 0) {
|
|
204
|
+
return null; // no curation done yet — fall through to legacy
|
|
205
|
+
}
|
|
206
|
+
const pending = plan.stories.filter(
|
|
207
|
+
(s) => s && s.key && !NON_PENDING_PLAN_STATUSES.has(s.plan_status),
|
|
208
|
+
);
|
|
209
|
+
pending.sort((a, b) => {
|
|
210
|
+
const pa = typeof a.priority === 'number' ? a.priority : Number.MAX_SAFE_INTEGER;
|
|
211
|
+
const pb = typeof b.priority === 'number' ? b.priority : Number.MAX_SAFE_INTEGER;
|
|
212
|
+
return pa - pb;
|
|
213
|
+
});
|
|
214
|
+
return pending.map((s) => s.key);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Detect "plan exhausted" — every entry in plan.stories[] has a terminal
|
|
218
|
+
// plan_status (done / skipped / excluded). Returns:
|
|
219
|
+
// { exhausted: true, plan_id, total, terminal_counts }
|
|
220
|
+
// { exhausted: false, reason: '<short tag>' }
|
|
221
|
+
// Reasons:
|
|
222
|
+
// - 'no_plan' — no sprint-plan.yaml on disk
|
|
223
|
+
// - 'corrupt_plan' — file exists but is unreadable
|
|
224
|
+
// - 'empty_stories' — plan.stories is [] (skill didn't curate yet)
|
|
225
|
+
// - 'has_pending' — at least one story has plan_status='pending'
|
|
226
|
+
//
|
|
227
|
+
// Distinct from `plan_fresh` in shouldAutoDerive: exhaustion means the
|
|
228
|
+
// plan WAS curated and every story finished. Caller archives the plan
|
|
229
|
+
// and emits a `plan_exhausted` user_prompt halt.
|
|
230
|
+
function planExhausted({ projectRoot }) {
|
|
231
|
+
const plan = sprintPlanScript.read({ projectRoot });
|
|
232
|
+
if (plan === null) return { exhausted: false, reason: 'no_plan' };
|
|
233
|
+
if (plan && typeof plan === 'object' && 'error' in plan) {
|
|
234
|
+
return { exhausted: false, reason: 'corrupt_plan' };
|
|
235
|
+
}
|
|
236
|
+
if (!Array.isArray(plan.stories) || plan.stories.length === 0) {
|
|
237
|
+
return { exhausted: false, reason: 'empty_stories' };
|
|
238
|
+
}
|
|
239
|
+
const terminal_counts = { done: 0, skipped: 0, excluded: 0 };
|
|
240
|
+
let hasPending = false;
|
|
241
|
+
for (const s of plan.stories) {
|
|
242
|
+
if (!s || !s.key) continue;
|
|
243
|
+
if (s.plan_status === 'pending') {
|
|
244
|
+
hasPending = true;
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
if (s.plan_status in terminal_counts) {
|
|
248
|
+
terminal_counts[s.plan_status] += 1;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (hasPending) return { exhausted: false, reason: 'has_pending' };
|
|
252
|
+
return {
|
|
253
|
+
exhausted: true,
|
|
254
|
+
plan_id: plan.plan_id,
|
|
255
|
+
total: plan.stories.length,
|
|
256
|
+
terminal_counts,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Check whether a persisted current_story is plan-terminal. Returns a
|
|
261
|
+
// reason string when the story exists in the plan with terminal
|
|
262
|
+
// plan_status, else null. Used by composeRuntimeState reconciliation
|
|
263
|
+
// alongside the existing sprint-status-based persistedStoryRejectionReason.
|
|
264
|
+
//
|
|
265
|
+
// Distinct from refreshBmadStatus's eager transition — that flow runs
|
|
266
|
+
// for stories whose BMAD status is terminal. This handles the case
|
|
267
|
+
// where the USER manually marked plan_status='skipped' / 'excluded' but
|
|
268
|
+
// sprint-status hasn't caught up yet.
|
|
269
|
+
function planRejectionReason(story_key, { projectRoot }) {
|
|
270
|
+
if (typeof story_key !== 'string' || !story_key) return null;
|
|
271
|
+
const plan = sprintPlanScript.read({ projectRoot });
|
|
272
|
+
if (plan === null) return null;
|
|
273
|
+
if (plan && typeof plan === 'object' && 'error' in plan) return null;
|
|
274
|
+
if (!Array.isArray(plan.stories)) return null;
|
|
275
|
+
const entry = plan.stories.find((s) => s && s.key === story_key);
|
|
276
|
+
if (!entry) return null;
|
|
277
|
+
if (entry.plan_status === 'done') return `sprint-plan.yaml plan_status='done'`;
|
|
278
|
+
if (entry.plan_status === 'skipped') return `sprint-plan.yaml plan_status='skipped'`;
|
|
279
|
+
if (entry.plan_status === 'excluded') return `sprint-plan.yaml plan_status='excluded'`;
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ---------------------------------------------------------------
|
|
284
|
+
// refreshBmadStatus wrapper (best-effort)
|
|
285
|
+
// ---------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
// Refresh the plan's bmad_status cache from sprint-status.yaml. Returns
|
|
288
|
+
// the result envelope from sprint-plan.js#refreshBmadStatus. Failures
|
|
289
|
+
// are non-fatal — the caller logs and proceeds. (We never want a stale
|
|
290
|
+
// status cache to wedge cmdStart.)
|
|
291
|
+
function refreshIfPlanExists({ projectRoot }) {
|
|
292
|
+
try {
|
|
293
|
+
return sprintPlanScript.refreshBmadStatus({ projectRoot });
|
|
294
|
+
} catch (e) {
|
|
295
|
+
return { wrote: false, reason: 'refresh_failed', message: e.message };
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---------------------------------------------------------------
|
|
300
|
+
// Auto-derive gating
|
|
301
|
+
// ---------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
// Decide whether cmdStart should emit an `invoke_skill` action for
|
|
304
|
+
// /sprintpilot-plan-sprint based on:
|
|
305
|
+
// - whether the plan is stale,
|
|
306
|
+
// - whether the user opted into auto-derive (config or env),
|
|
307
|
+
// - whether the user explicitly disabled it via --no-auto-plan,
|
|
308
|
+
// - whether explicit --stories / --epic flags overrode planning.
|
|
309
|
+
//
|
|
310
|
+
// Returns { auto_derive: bool, reason: '<short tag>' }.
|
|
311
|
+
function shouldAutoDerive({ projectRoot, profile, opts }) {
|
|
312
|
+
if (opts && opts['no-auto-plan']) {
|
|
313
|
+
return { auto_derive: false, reason: 'no_auto_plan_flag' };
|
|
314
|
+
}
|
|
315
|
+
if (opts && (Array.isArray(opts.stories) ? opts.stories.length > 0 : opts.stories)) {
|
|
316
|
+
return { auto_derive: false, reason: 'explicit_stories_flag' };
|
|
317
|
+
}
|
|
318
|
+
if (opts && opts.epic !== undefined && opts.epic !== null) {
|
|
319
|
+
return { auto_derive: false, reason: 'explicit_epic_flag' };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const staleness = planStaleness({ projectRoot });
|
|
323
|
+
// Plan exists and is stale → ALWAYS re-derive (the user already adopted
|
|
324
|
+
// the plan workflow; we keep it fresh). Spread staleness first so the
|
|
325
|
+
// explicit `reason` (with `stale_` prefix) wins.
|
|
326
|
+
if (staleness.stale && staleness.reason !== STALENESS_REASONS.missing &&
|
|
327
|
+
staleness.reason !== STALENESS_REASONS.migration_needed) {
|
|
328
|
+
return { ...staleness, auto_derive: true, reason: `stale_${staleness.reason}` };
|
|
329
|
+
}
|
|
330
|
+
// Plan missing (greenfield) → only auto-derive if user opted in via config.
|
|
331
|
+
// Default config (per user direction) is auto_plan_on_start: false →
|
|
332
|
+
// greenfield runs in sprint-status order without LLM invocation.
|
|
333
|
+
if (staleness.stale && staleness.reason === STALENESS_REASONS.missing) {
|
|
334
|
+
const enabled = profile && profile.auto_plan_on_start === true;
|
|
335
|
+
if (enabled) {
|
|
336
|
+
return { auto_derive: true, reason: 'opt_in_missing' };
|
|
337
|
+
}
|
|
338
|
+
return { auto_derive: false, reason: 'greenfield_default_no_auto_plan' };
|
|
339
|
+
}
|
|
340
|
+
// Migration needed → migrate is NOT auto-derive (no LLM). The migration
|
|
341
|
+
// bootstrap runs separately; if after migration the plan is fresh, no
|
|
342
|
+
// derive needed.
|
|
343
|
+
if (staleness.stale && staleness.reason === STALENESS_REASONS.migration_needed) {
|
|
344
|
+
return { auto_derive: false, reason: 'migration_only' };
|
|
345
|
+
}
|
|
346
|
+
return { auto_derive: false, reason: 'plan_fresh' };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ---------------------------------------------------------------
|
|
350
|
+
// DAG validation (Phase 5 — reorder_queue)
|
|
351
|
+
// ---------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
// Collect transitive upstreams of `story_key` from a plan. Walks both
|
|
354
|
+
// plan.dependencies.stories[*].depends_on (intra-epic edges) AND
|
|
355
|
+
// plan.cross_epic_deps (cross-boundary edges). Returns a Set of keys
|
|
356
|
+
// (excluding the story itself).
|
|
357
|
+
function collectUpstreams(story_key, plan) {
|
|
358
|
+
const upstreams = new Set();
|
|
359
|
+
if (!plan || !plan.dependencies || !plan.dependencies.stories) return upstreams;
|
|
360
|
+
const intra = plan.dependencies.stories;
|
|
361
|
+
const cross = Array.isArray(plan.cross_epic_deps) ? plan.cross_epic_deps : [];
|
|
362
|
+
|
|
363
|
+
// `visited` tracks which keys we've already walked (to avoid re-walking
|
|
364
|
+
// shared subtrees and to break cycles). `upstreams` is the result Set —
|
|
365
|
+
// we only add a key to it when it's discovered as a real upstream
|
|
366
|
+
// (excluding the starting story_key itself).
|
|
367
|
+
const visited = new Set();
|
|
368
|
+
const visit = (key) => {
|
|
369
|
+
if (visited.has(key)) return;
|
|
370
|
+
visited.add(key);
|
|
371
|
+
const direct = intra[key]?.depends_on;
|
|
372
|
+
if (Array.isArray(direct)) {
|
|
373
|
+
for (const up of direct) {
|
|
374
|
+
if (up !== story_key) {
|
|
375
|
+
upstreams.add(up);
|
|
376
|
+
visit(up);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// cross_epic_deps semantics: from_story depends on to_story.
|
|
381
|
+
// So an edge with from_story === key adds to_story as upstream.
|
|
382
|
+
for (const edge of cross) {
|
|
383
|
+
if (!edge) continue;
|
|
384
|
+
if (edge.from_story === key && typeof edge.to_story === 'string') {
|
|
385
|
+
const up = edge.to_story;
|
|
386
|
+
if (up !== story_key) {
|
|
387
|
+
upstreams.add(up);
|
|
388
|
+
visit(up);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
visit(story_key);
|
|
395
|
+
return upstreams;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Is a story in a plan-terminal state (done / skipped / excluded)?
|
|
399
|
+
function isPlanTerminal(story_key, plan) {
|
|
400
|
+
if (!plan || !Array.isArray(plan.stories)) return false;
|
|
401
|
+
const entry = plan.stories.find((s) => s && s.key === story_key);
|
|
402
|
+
if (!entry) return false;
|
|
403
|
+
return entry.plan_status === 'done' || entry.plan_status === 'skipped' || entry.plan_status === 'excluded';
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Is a story terminal in sprint-status.yaml (done/skipped/wont_do/etc)?
|
|
407
|
+
function isTerminalInSprintStatus(story_key, projectRoot) {
|
|
408
|
+
const ss = readSprintStatusKeys(projectRoot);
|
|
409
|
+
if (!ss.exists) return false;
|
|
410
|
+
const entry = ss.byKey[story_key];
|
|
411
|
+
if (!entry) return false;
|
|
412
|
+
const TERMINAL = new Set([
|
|
413
|
+
'done',
|
|
414
|
+
'skipped',
|
|
415
|
+
'wont_do',
|
|
416
|
+
"won't_do",
|
|
417
|
+
'cancelled',
|
|
418
|
+
'canceled',
|
|
419
|
+
'deferred',
|
|
420
|
+
'abandoned',
|
|
421
|
+
]);
|
|
422
|
+
return entry.status ? TERMINAL.has(String(entry.status).toLowerCase()) : false;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Validate a proposed reorder against the plan's DAG. For each story in
|
|
426
|
+
// `proposedOrder`, every transitive upstream must be either:
|
|
427
|
+
// - positioned BEFORE the story in proposedOrder, OR
|
|
428
|
+
// - plan-terminal (done/skipped/excluded), OR
|
|
429
|
+
// - terminal in sprint-status.yaml.
|
|
430
|
+
// Returns { valid: bool, violations: [{story, upstream, suggestion}] }.
|
|
431
|
+
// Each violation includes a suggestion ("insert <upstream> before <story>")
|
|
432
|
+
// so the user_prompt can guide the user.
|
|
433
|
+
function validateOrdering(proposedOrder, plan, { projectRoot } = {}) {
|
|
434
|
+
if (!Array.isArray(proposedOrder)) {
|
|
435
|
+
return { valid: false, violations: [{ reason: 'order must be an array' }] };
|
|
436
|
+
}
|
|
437
|
+
const indexOf = Object.create(null);
|
|
438
|
+
for (let i = 0; i < proposedOrder.length; i++) {
|
|
439
|
+
indexOf[proposedOrder[i]] = i;
|
|
440
|
+
}
|
|
441
|
+
const violations = [];
|
|
442
|
+
for (const story of proposedOrder) {
|
|
443
|
+
const ups = collectUpstreams(story, plan);
|
|
444
|
+
for (const up of ups) {
|
|
445
|
+
const planTerminal = isPlanTerminal(up, plan);
|
|
446
|
+
const ssTerminal = projectRoot ? isTerminalInSprintStatus(up, projectRoot) : false;
|
|
447
|
+
const positionedBefore = up in indexOf && indexOf[up] < indexOf[story];
|
|
448
|
+
if (!planTerminal && !ssTerminal && !positionedBefore) {
|
|
449
|
+
violations.push({
|
|
450
|
+
story,
|
|
451
|
+
upstream: up,
|
|
452
|
+
suggestion: `insert ${up} before ${story}`,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return { valid: violations.length === 0, violations };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ---------------------------------------------------------------
|
|
461
|
+
// Sentinel file for the first-time auto-plan prompt (Phase 3 stub).
|
|
462
|
+
// The full sentinel UX lives in Phase 4.5 wiring; this module exposes
|
|
463
|
+
// just the path so cmdStart can probe it.
|
|
464
|
+
// ---------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
function autoPlanFirstSeenSentinelPath(projectRoot) {
|
|
467
|
+
return path.join(projectRoot, '.sprintpilot', '.auto-plan-first-seen');
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
module.exports = {
|
|
471
|
+
NON_PENDING_PLAN_STATUSES,
|
|
472
|
+
STALENESS_REASONS,
|
|
473
|
+
legacyDependenciesPath,
|
|
474
|
+
sprintStatusPath,
|
|
475
|
+
readSprintStatusKeys,
|
|
476
|
+
planStaleness,
|
|
477
|
+
bootstrapMigrationIfNeeded,
|
|
478
|
+
composePlanQueue,
|
|
479
|
+
refreshIfPlanExists,
|
|
480
|
+
shouldAutoDerive,
|
|
481
|
+
planExhausted,
|
|
482
|
+
planRejectionReason,
|
|
483
|
+
collectUpstreams,
|
|
484
|
+
isPlanTerminal,
|
|
485
|
+
isTerminalInSprintStatus,
|
|
486
|
+
validateOrdering,
|
|
487
|
+
autoPlanFirstSeenSentinelPath,
|
|
488
|
+
};
|
|
@@ -32,6 +32,13 @@ const CRITICAL_KEYS = new Set([
|
|
|
32
32
|
// story_key; adapt.advanceState pops the head when a story completes.
|
|
33
33
|
// When empty, the orchestrator falls back to resolveNextStoryKey.
|
|
34
34
|
'story_queue',
|
|
35
|
+
// v2.3.0 — verify-loop trackers. These must write through immediately
|
|
36
|
+
// so a crash between verify rejections doesn't reset the
|
|
37
|
+
// consecutive-identical counter. Without write-through, the
|
|
38
|
+
// budget-exhaustion halt would emit the generic prompt instead of
|
|
39
|
+
// the loop-hint enriched prompt, defeating the loop-detection UX.
|
|
40
|
+
'last_verify_issues_signature',
|
|
41
|
+
'consecutive_identical_rejections',
|
|
35
42
|
]);
|
|
36
43
|
|
|
37
44
|
// In-memory pending buffer. Process-scoped — flushed at story boundary or
|
|
@@ -106,11 +113,8 @@ function readStateFile(fs, filePath) {
|
|
|
106
113
|
// - item-scalar
|
|
107
114
|
// - item-key: item-value
|
|
108
115
|
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
// dropping every `- item` entry. Hand-edited state files (or any
|
|
112
|
-
// roundtrip through a tool that emits block-form YAML) lost their
|
|
113
|
-
// `story_queue`, leaving the autopilot's queue mysteriously empty.
|
|
116
|
+
// dumpYaml emits inline JSON for arrays; the block-form path handles
|
|
117
|
+
// hand edits and tools that emit `- item` lines.
|
|
114
118
|
function parseYamlNarrow(text) {
|
|
115
119
|
if (!text) return {};
|
|
116
120
|
const lines = text.split(/\r?\n/);
|
|
@@ -36,6 +36,8 @@ function applyOne(state, profile, cmd) {
|
|
|
36
36
|
retry_count_this_phase: 0,
|
|
37
37
|
verify_reject_count: 0,
|
|
38
38
|
consecutive_test_failures: 0,
|
|
39
|
+
last_verify_issues_signature: null,
|
|
40
|
+
consecutive_identical_rejections: 0,
|
|
39
41
|
};
|
|
40
42
|
effects.push({
|
|
41
43
|
kind: 'state_transition',
|
|
@@ -64,11 +66,16 @@ function applyOne(state, profile, cmd) {
|
|
|
64
66
|
// looping on a stuck transition. Phase is unchanged. Also clears
|
|
65
67
|
// any pending_alternative — `force_continue` is the explicit "no,
|
|
66
68
|
// keep the planned action" answer to a propose_alternative prompt.
|
|
69
|
+
// v2.3.0: also reset verify-loop trackers so the next reject starts
|
|
70
|
+
// fresh — user explicitly accepted that the prior issues are
|
|
71
|
+
// resolved out of band.
|
|
67
72
|
newState = {
|
|
68
73
|
...state,
|
|
69
74
|
retry_count_this_phase: 0,
|
|
70
75
|
verify_reject_count: 0,
|
|
71
76
|
consecutive_test_failures: 0,
|
|
77
|
+
last_verify_issues_signature: null,
|
|
78
|
+
consecutive_identical_rejections: 0,
|
|
72
79
|
pending_alternative: undefined,
|
|
73
80
|
};
|
|
74
81
|
effects.push({
|
|
@@ -95,6 +102,14 @@ function applyOne(state, profile, cmd) {
|
|
|
95
102
|
// Mark the change so audit can detect it.
|
|
96
103
|
changed_via_user_command: true,
|
|
97
104
|
};
|
|
105
|
+
// v2.3.0 — also clear verify-loop trackers. The profile change
|
|
106
|
+
// shifts retry/verify budgets, so prior consecutive-identical
|
|
107
|
+
// counts shouldn't influence the new profile's halt threshold.
|
|
108
|
+
newState = {
|
|
109
|
+
...state,
|
|
110
|
+
last_verify_issues_signature: null,
|
|
111
|
+
consecutive_identical_rejections: 0,
|
|
112
|
+
};
|
|
98
113
|
effects.push({
|
|
99
114
|
kind: 'profile_escalated', // reuse the ledger kind
|
|
100
115
|
from: profile.name,
|
|
@@ -145,6 +160,12 @@ function applyOne(state, profile, cmd) {
|
|
|
145
160
|
pending_alternative: undefined,
|
|
146
161
|
retry_count_this_phase: 0,
|
|
147
162
|
verify_reject_count: 0,
|
|
163
|
+
// v2.3.0 — accepting an alternative supersedes the prior planned
|
|
164
|
+
// action, so prior verify-loop accumulator should reset too.
|
|
165
|
+
// The next reject under the new action is treated as a fresh
|
|
166
|
+
// signal-identity baseline.
|
|
167
|
+
last_verify_issues_signature: null,
|
|
168
|
+
consecutive_identical_rejections: 0,
|
|
148
169
|
};
|
|
149
170
|
effects.push({
|
|
150
171
|
kind: 'dispatch_action',
|
|
@@ -186,6 +207,8 @@ function applyOne(state, profile, cmd) {
|
|
|
186
207
|
retry_count_this_phase: 0,
|
|
187
208
|
verify_reject_count: 0,
|
|
188
209
|
consecutive_test_failures: 0,
|
|
210
|
+
last_verify_issues_signature: null,
|
|
211
|
+
consecutive_identical_rejections: 0,
|
|
189
212
|
// current_epic intentionally preserved — retro skill needs it.
|
|
190
213
|
};
|
|
191
214
|
effects.push({
|
|
@@ -198,6 +221,61 @@ function applyOne(state, profile, cmd) {
|
|
|
198
221
|
});
|
|
199
222
|
break;
|
|
200
223
|
|
|
224
|
+
// v2.3.0 — plan-aware mid-flight commands. Each emits a side-effect
|
|
225
|
+
// record that the CLI dispatcher handles by calling sprint-plan.js
|
|
226
|
+
// primitives. DAG-aware validation lives in the dispatcher (it needs
|
|
227
|
+
// the live plan file). State mutations are minimal here; only the
|
|
228
|
+
// replan_sprint flow touches state to schedule the halt.
|
|
229
|
+
case 'reorder_queue':
|
|
230
|
+
effects.push({
|
|
231
|
+
kind: 'plan_reorder',
|
|
232
|
+
order: cmd.order,
|
|
233
|
+
reason: cmd.reason || null,
|
|
234
|
+
});
|
|
235
|
+
break;
|
|
236
|
+
|
|
237
|
+
case 'add_to_sprint':
|
|
238
|
+
effects.push({
|
|
239
|
+
kind: 'plan_add_stories',
|
|
240
|
+
story_keys: cmd.story_keys,
|
|
241
|
+
position: cmd.position !== undefined ? cmd.position : 'end',
|
|
242
|
+
issue_ids: cmd.issue_ids || null,
|
|
243
|
+
reason: cmd.reason || null,
|
|
244
|
+
});
|
|
245
|
+
break;
|
|
246
|
+
|
|
247
|
+
case 'remove_from_sprint':
|
|
248
|
+
effects.push({
|
|
249
|
+
kind: 'plan_remove_stories',
|
|
250
|
+
story_keys: cmd.story_keys,
|
|
251
|
+
mark_status: cmd.mark_status || 'skipped',
|
|
252
|
+
reason: cmd.reason || null,
|
|
253
|
+
});
|
|
254
|
+
break;
|
|
255
|
+
|
|
256
|
+
case 'replan_sprint':
|
|
257
|
+
// Set replan_requested in state so the next cmdStart picks it up
|
|
258
|
+
// and emits the invoke_skill action. Halt now so the autopilot
|
|
259
|
+
// stops at the current story boundary; the user (or the LLM
|
|
260
|
+
// session) restarts to drive the skill.
|
|
261
|
+
newState = {
|
|
262
|
+
...state,
|
|
263
|
+
replan_requested: {
|
|
264
|
+
reason: cmd.reason || null,
|
|
265
|
+
requested_at: new Date().toISOString(),
|
|
266
|
+
},
|
|
267
|
+
halt_requested: {
|
|
268
|
+
reason: cmd.reason || 'user_replan_sprint',
|
|
269
|
+
requested_at: new Date().toISOString(),
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
effects.push({
|
|
273
|
+
kind: 'halt',
|
|
274
|
+
reason: 'user_replan_sprint',
|
|
275
|
+
details: cmd.reason || null,
|
|
276
|
+
});
|
|
277
|
+
break;
|
|
278
|
+
|
|
201
279
|
default:
|
|
202
280
|
effects.push({ kind: 'state_transition', reason: 'unknown_user_command', cmd });
|
|
203
281
|
}
|