openplanr 2.1.1 → 2.2.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/CHANGELOG.md +5 -0
- package/dist/cli/commands/planning-artifacts.d.ts.map +1 -1
- package/dist/cli/commands/planning-artifacts.js +6 -18
- package/dist/cli/commands/planning-artifacts.js.map +1 -1
- package/dist/cli/commands/sprint-refinement.d.ts +5 -0
- package/dist/cli/commands/sprint-refinement.d.ts.map +1 -0
- package/dist/cli/commands/sprint-refinement.js +141 -0
- package/dist/cli/commands/sprint-refinement.js.map +1 -0
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +4 -2
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/commands/update.js +5 -8
- package/dist/cli/commands/update.js.map +1 -1
- package/dist/dashboard/.vite/manifest.json +1 -1
- package/dist/dashboard/assets/{index-BCvntXFj.js → index-B74ddyXA.js} +2 -2
- package/dist/dashboard/assets/{index-BCvntXFj.js.map → index-B74ddyXA.js.map} +1 -1
- package/dist/dashboard/dashboard-manifest.json +8 -8
- package/dist/dashboard/index.html +1 -1
- package/dist/models/sprint-refinement-schema.d.ts +122 -0
- package/dist/models/sprint-refinement-schema.d.ts.map +1 -0
- package/dist/models/sprint-refinement-schema.js +147 -0
- package/dist/models/sprint-refinement-schema.js.map +1 -0
- package/dist/models/types.d.ts +8 -0
- package/dist/models/types.d.ts.map +1 -1
- package/dist/services/delivery-status-service.d.ts +6 -1
- package/dist/services/delivery-status-service.d.ts.map +1 -1
- package/dist/services/delivery-status-service.js +28 -1
- package/dist/services/delivery-status-service.js.map +1 -1
- package/dist/services/git-service.d.ts +19 -0
- package/dist/services/git-service.d.ts.map +1 -1
- package/dist/services/git-service.js +41 -0
- package/dist/services/git-service.js.map +1 -1
- package/dist/services/graph-service.d.ts.map +1 -1
- package/dist/services/graph-service.js +3 -1
- package/dist/services/graph-service.js.map +1 -1
- package/dist/services/sprint-refinement-service.d.ts +162 -0
- package/dist/services/sprint-refinement-service.d.ts.map +1 -0
- package/dist/services/sprint-refinement-service.js +645 -0
- package/dist/services/sprint-refinement-service.js.map +1 -0
- package/dist/templates/sprints/refinement.md.hbs +94 -0
- package/dist/templates/sprints/sprint-tasks.md.hbs +16 -0
- package/dist/templates/sprints/sprint.md.hbs +17 -12
- package/dist/utils/constants.d.ts +2 -0
- package/dist/utils/constants.d.ts.map +1 -1
- package/dist/utils/constants.js +6 -1
- package/dist/utils/constants.js.map +1 -1
- package/docs/CLI.md +26 -2
- package/lib/host-packages/adapter-registry.json +1 -1
- package/lib/host-packages/capability-map.json +16 -0
- package/lib/host-packages/claude/openplanr/.openplanr-content.json +27 -7
- package/lib/host-packages/claude/openplanr/skills/design/scripts/runtime/packages/protocol/package.json +1 -1
- package/lib/host-packages/claude/openplanr/skills/design-loop/scripts/runtime/packages/protocol/package.json +1 -1
- package/lib/host-packages/claude/openplanr/skills/design-review/scripts/runtime/packages/protocol/package.json +1 -1
- package/lib/host-packages/claude/openplanr/skills/openplanr/SKILL.md +1 -0
- package/lib/host-packages/claude/openplanr/skills/plan/scripts/runtime/packages/protocol/package.json +1 -1
- package/lib/host-packages/claude/openplanr/skills/sprint/SKILL.md +181 -0
- package/lib/host-packages/claude/openplanr/skills/sprint/openplanr.skill.json +59 -0
- package/lib/host-packages/claude/openplanr/skills/sprint/references/refinement-contract.md +154 -0
- package/lib/host-packages/claude/openplanr/skills/sprint/references/sprint-formats.md +146 -0
- package/lib/host-packages/claude/openplanr/skills/sprint/schemas/refinement.schema.json +140 -0
- package/lib/host-packages/cursor/openplanr/.openplanr-content.json +24 -8
- package/lib/host-packages/cursor/openplanr/manifest.json +2 -1
- package/lib/host-packages/cursor/openplanr/rules/planr-design/scripts/runtime/packages/protocol/package.json +1 -1
- package/lib/host-packages/cursor/openplanr/rules/planr-design-loop/scripts/runtime/packages/protocol/package.json +1 -1
- package/lib/host-packages/cursor/openplanr/rules/planr-design-review/scripts/runtime/packages/protocol/package.json +1 -1
- package/lib/host-packages/cursor/openplanr/rules/planr-openplanr.mdc +1 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-plan/scripts/runtime/packages/protocol/package.json +1 -1
- package/lib/host-packages/cursor/openplanr/rules/planr-sprint/references/refinement-contract.md +154 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-sprint/references/sprint-formats.md +146 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-sprint/schemas/refinement.schema.json +140 -0
- package/lib/host-packages/cursor/openplanr/rules/planr-sprint.mdc +180 -0
- package/lib/host-packages/openai/openplanr/.openplanr-content.json +31 -7
- package/lib/host-packages/openai/openplanr/skills/design/scripts/runtime/packages/protocol/package.json +1 -1
- package/lib/host-packages/openai/openplanr/skills/design-loop/scripts/runtime/packages/protocol/package.json +1 -1
- package/lib/host-packages/openai/openplanr/skills/design-review/scripts/runtime/packages/protocol/package.json +1 -1
- package/lib/host-packages/openai/openplanr/skills/openplanr/SKILL.md +1 -0
- package/lib/host-packages/openai/openplanr/skills/plan/scripts/runtime/packages/protocol/package.json +1 -1
- package/lib/host-packages/openai/openplanr/skills/sprint/SKILL.md +181 -0
- package/lib/host-packages/openai/openplanr/skills/sprint/agents/openai.yaml +6 -0
- package/lib/host-packages/openai/openplanr/skills/sprint/openplanr.skill.json +59 -0
- package/lib/host-packages/openai/openplanr/skills/sprint/references/refinement-contract.md +154 -0
- package/lib/host-packages/openai/openplanr/skills/sprint/references/sprint-formats.md +146 -0
- package/lib/host-packages/openai/openplanr/skills/sprint/schemas/refinement.schema.json +140 -0
- package/package.json +2 -2
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage behind `planr sprint refinement|diff|close|apply` and the batched
|
|
3
|
+
* sprint body: validates the refinement document a host skill produces,
|
|
4
|
+
* renders the sprint tasks and the refinement note, records leftovers on
|
|
5
|
+
* close and writes approved status changes back to the artifacts.
|
|
6
|
+
*/
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { REFINEMENT_BUCKETS, REFINEMENT_SCHEMA_VERSION, refinementDocumentSchema, sprintBatchesInputSchema, toSchemaDiagnostics, } from '../models/sprint-refinement-schema.js';
|
|
9
|
+
import { isValidStatus, VALID_STATUSES } from '../utils/constants.js';
|
|
10
|
+
import { fileExists, readFile } from '../utils/fs.js';
|
|
11
|
+
import { findArtifactTypeById, getArtifactDir, listArtifacts, readArtifact, resolveArtifactFilename, updateArtifact, updateArtifactFields, } from './artifact-service.js';
|
|
12
|
+
import { atomicWriteFile } from './atomic-write-service.js';
|
|
13
|
+
import { commitPaths, GitUnavailableError } from './git-service.js';
|
|
14
|
+
import { renderTemplate } from './template-service.js';
|
|
15
|
+
const CHECKBOX_LINE = /^(\s*)- \[(x| )\]\s+\*{0,2}([A-Z]+-\d+)\*{0,2}(?:\s+(.*))?$/u;
|
|
16
|
+
const DONE_STATES = new Set([
|
|
17
|
+
'done',
|
|
18
|
+
'closed',
|
|
19
|
+
'completed',
|
|
20
|
+
'promoted',
|
|
21
|
+
'superseded',
|
|
22
|
+
'shipped',
|
|
23
|
+
'released',
|
|
24
|
+
]);
|
|
25
|
+
const TASKS_HEADING = '## Tasks';
|
|
26
|
+
const IMPLICIT_BATCH_TITLE = 'In progress';
|
|
27
|
+
function today() {
|
|
28
|
+
return new Date().toISOString().slice(0, 10);
|
|
29
|
+
}
|
|
30
|
+
function relativePath(projectDir, filePath) {
|
|
31
|
+
return path.relative(projectDir, filePath).split(path.sep).join('/');
|
|
32
|
+
}
|
|
33
|
+
function stringList(value) {
|
|
34
|
+
return Array.isArray(value) ? value.map(String).filter(Boolean) : [];
|
|
35
|
+
}
|
|
36
|
+
function optionalString(value) {
|
|
37
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
38
|
+
}
|
|
39
|
+
/** A bounded failure the CLI renders as `{ ok: false, code, problem, recovery, diagnostics }`. */
|
|
40
|
+
export class SprintRefinementError extends Error {
|
|
41
|
+
code;
|
|
42
|
+
recovery;
|
|
43
|
+
details;
|
|
44
|
+
constructor(code, problem, recovery, extra = {}) {
|
|
45
|
+
super(problem, extra.cause === undefined ? undefined : { cause: extra.cause });
|
|
46
|
+
this.name = code;
|
|
47
|
+
this.code = code;
|
|
48
|
+
this.recovery = recovery;
|
|
49
|
+
if (extra.diagnostics?.length)
|
|
50
|
+
this.details = { diagnostics: extra.diagnostics };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function sprintError(code, problem, recovery, extra = {}) {
|
|
54
|
+
return new SprintRefinementError(code, problem, recovery, extra);
|
|
55
|
+
}
|
|
56
|
+
function inputError(field, problem, recovery) {
|
|
57
|
+
return sprintError('E_PLANNING_INPUT_INVALID', problem, recovery, {
|
|
58
|
+
diagnostics: [{ path: `$.${field}`, rule: 'sprint:input', detail: problem }],
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/** Sprint task lines: `- [ ] **BL-012** title · effort · [view](...)`; the bold marks are optional. */
|
|
62
|
+
export function parseSprintCheckboxes(content) {
|
|
63
|
+
const lines = [];
|
|
64
|
+
content.split('\n').forEach((line, lineIndex) => {
|
|
65
|
+
const match = CHECKBOX_LINE.exec(line);
|
|
66
|
+
if (!match)
|
|
67
|
+
return;
|
|
68
|
+
lines.push({ id: match[3], done: match[2] === 'x', title: (match[4] ?? '').trim(), lineIndex });
|
|
69
|
+
});
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
|
72
|
+
export function refinementPaths(config, sprintId) {
|
|
73
|
+
const dir = path.posix.join(getArtifactDir(config, 'sprint'), sprintId);
|
|
74
|
+
return { dir, json: `${dir}/refinement.json`, note: `${dir}/refinement.md` };
|
|
75
|
+
}
|
|
76
|
+
function yamlValue(value) {
|
|
77
|
+
if (Array.isArray(value))
|
|
78
|
+
return `[${value.map((entry) => yamlValue(entry)).join(', ')}]`;
|
|
79
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
80
|
+
return String(value);
|
|
81
|
+
return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Upsert frontmatter fields in place, keeping the file's own formatting.
|
|
85
|
+
* Arrays are written in flow form; an existing block sequence is replaced whole.
|
|
86
|
+
*/
|
|
87
|
+
export function upsertFrontmatter(raw, fields) {
|
|
88
|
+
const openIdx = raw.indexOf('---');
|
|
89
|
+
const closeIdx = raw.indexOf('\n---', openIdx + 3);
|
|
90
|
+
if (openIdx === -1 || closeIdx === -1)
|
|
91
|
+
throw new Error('The artifact has no valid frontmatter.');
|
|
92
|
+
const lines = raw.slice(openIdx, closeIdx).split('\n');
|
|
93
|
+
const body = raw.slice(closeIdx);
|
|
94
|
+
for (const [key, value] of Object.entries({ ...fields, updated: today() })) {
|
|
95
|
+
if (value === undefined)
|
|
96
|
+
continue;
|
|
97
|
+
const line = `${key}: ${yamlValue(value)}`;
|
|
98
|
+
const keyPattern = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:`, 'u');
|
|
99
|
+
const at = lines.findIndex((entry, index) => index > 0 && keyPattern.test(entry));
|
|
100
|
+
if (at === -1) {
|
|
101
|
+
lines.push(line);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
let end = at + 1;
|
|
105
|
+
while (end < lines.length && /^(\s+\S|- )/u.test(lines[end]))
|
|
106
|
+
end++;
|
|
107
|
+
lines.splice(at, end - at, line);
|
|
108
|
+
}
|
|
109
|
+
return `${lines.join('\n')}${body}`;
|
|
110
|
+
}
|
|
111
|
+
/** Replace the content under a level-2 heading, or append the section when the heading is absent. */
|
|
112
|
+
export function replaceSection(markdown, heading, content) {
|
|
113
|
+
const lines = markdown.split('\n');
|
|
114
|
+
const start = lines.findIndex((line) => line.trim() === heading);
|
|
115
|
+
if (start === -1)
|
|
116
|
+
return `${markdown.trimEnd()}\n\n${heading}\n\n${content}\n`;
|
|
117
|
+
let end = start + 1;
|
|
118
|
+
while (end < lines.length && !/^## /u.test(lines[end]))
|
|
119
|
+
end++;
|
|
120
|
+
lines.splice(start + 1, end - start - 1, '', ...content.split('\n'), '');
|
|
121
|
+
return lines.join('\n');
|
|
122
|
+
}
|
|
123
|
+
export function parseRefinementDocument(value, expectedSprintId) {
|
|
124
|
+
const parsed = refinementDocumentSchema.safeParse(value);
|
|
125
|
+
if (!parsed.success) {
|
|
126
|
+
throw sprintError('E_SPRINT_REFINEMENT_INVALID', 'The refinement document does not match the refinement contract.', 'Fix the listed paths and re-run; the contract is documented under planr sprint refinement --help.', { diagnostics: toSchemaDiagnostics(parsed.error) });
|
|
127
|
+
}
|
|
128
|
+
if (expectedSprintId && parsed.data.sprintId !== expectedSprintId) {
|
|
129
|
+
throw sprintError('E_SPRINT_REFINEMENT_INVALID', `The refinement document targets ${parsed.data.sprintId}, not ${expectedSprintId}.`, 'Set sprintId to the sprint being refined.', {
|
|
130
|
+
diagnostics: [
|
|
131
|
+
{
|
|
132
|
+
path: '$.sprintId',
|
|
133
|
+
rule: 'refinement:sprint-mismatch',
|
|
134
|
+
detail: `expected ${expectedSprintId}`,
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return parsed.data;
|
|
140
|
+
}
|
|
141
|
+
export async function readRefinement(projectDir, config, sprintId) {
|
|
142
|
+
const relative = refinementPaths(config, sprintId).json;
|
|
143
|
+
const file = path.join(projectDir, relative);
|
|
144
|
+
if (!(await fileExists(file)))
|
|
145
|
+
return null;
|
|
146
|
+
let raw;
|
|
147
|
+
try {
|
|
148
|
+
raw = JSON.parse(await readFile(file));
|
|
149
|
+
}
|
|
150
|
+
catch (cause) {
|
|
151
|
+
throw sprintError('E_SPRINT_REFINEMENT_INVALID', `${relative} is not valid JSON.`, 'Restore the file from git or re-run planr sprint refinement.', { cause });
|
|
152
|
+
}
|
|
153
|
+
return parseRefinementDocument(raw, sprintId);
|
|
154
|
+
}
|
|
155
|
+
async function requireRefinement(projectDir, config, sprintId) {
|
|
156
|
+
const document = await readRefinement(projectDir, config, sprintId);
|
|
157
|
+
if (!document) {
|
|
158
|
+
throw sprintError('E_SPRINT_REFINEMENT_MISSING', `${sprintId} has no refinement document at ${refinementPaths(config, sprintId).json}.`, 'Run planr sprint refinement <id> --data <refinement.json> first.');
|
|
159
|
+
}
|
|
160
|
+
return document;
|
|
161
|
+
}
|
|
162
|
+
async function requireSprint(projectDir, config, sprintId) {
|
|
163
|
+
const sprint = await readArtifact(projectDir, config, 'sprint', sprintId);
|
|
164
|
+
if (!sprint) {
|
|
165
|
+
throw sprintError('E_SPRINT_NOT_FOUND', `Sprint ${sprintId} was not found.`, 'Run planr sprint list to see the available sprints.');
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
data: sprint.data,
|
|
169
|
+
content: sprint.content,
|
|
170
|
+
filePath: sprint.filePath,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
async function resolveItem(projectDir, config, id) {
|
|
174
|
+
const type = findArtifactTypeById(id);
|
|
175
|
+
if (!type || type === 'sprint')
|
|
176
|
+
return null;
|
|
177
|
+
const artifact = await readArtifact(projectDir, config, type, id);
|
|
178
|
+
if (!artifact)
|
|
179
|
+
return null;
|
|
180
|
+
const filename = await resolveArtifactFilename(projectDir, config, type, id);
|
|
181
|
+
const dir = path.posix.relative(getArtifactDir(config, 'sprint'), getArtifactDir(config, type));
|
|
182
|
+
return { type, title: optionalString(artifact.data.title), link: `${dir}/${filename}.md` };
|
|
183
|
+
}
|
|
184
|
+
async function renderTasksSection(config, batches, taskIds) {
|
|
185
|
+
const rendered = await renderTemplate('sprints/sprint-tasks.md.hbs', { batches, taskIds }, config.templateOverrides);
|
|
186
|
+
return rendered.trimEnd();
|
|
187
|
+
}
|
|
188
|
+
function optionalDate(value, field) {
|
|
189
|
+
if (value === undefined || value === null || value === '')
|
|
190
|
+
return undefined;
|
|
191
|
+
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/u.test(value))
|
|
192
|
+
return value;
|
|
193
|
+
throw inputError(field, `Sprint ${field} must be an ISO date (YYYY-MM-DD), not ${JSON.stringify(value)}.`, `Provide ${field} as YYYY-MM-DD or omit it.`);
|
|
194
|
+
}
|
|
195
|
+
function optionalPositive(value, field) {
|
|
196
|
+
if (value === undefined || value === null || value === '')
|
|
197
|
+
return undefined;
|
|
198
|
+
const number = typeof value === 'number' ? value : Number(value);
|
|
199
|
+
if (Number.isFinite(number) && number > 0)
|
|
200
|
+
return number;
|
|
201
|
+
throw inputError(field, `Sprint ${field} must be a positive number, not ${JSON.stringify(value)}.`, `Provide ${field} as a positive number or omit it.`);
|
|
202
|
+
}
|
|
203
|
+
function addDays(isoDate, days) {
|
|
204
|
+
const date = new Date(`${isoDate}T00:00:00Z`);
|
|
205
|
+
date.setUTCDate(date.getUTCDate() + days);
|
|
206
|
+
return date.toISOString().slice(0, 10);
|
|
207
|
+
}
|
|
208
|
+
function daysBetween(from, to) {
|
|
209
|
+
const ms = Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`);
|
|
210
|
+
return Math.max(0, Math.round(ms / 86_400_000));
|
|
211
|
+
}
|
|
212
|
+
/** Template data for `planr sprint create`, including the rendered `## Tasks` section. */
|
|
213
|
+
export async function prepareSprintCreation(projectDir, config, input, durationOption) {
|
|
214
|
+
const duration = String(durationOption ?? input.duration ?? '2w');
|
|
215
|
+
const weeks = /^(\d+)w$/u.exec(duration);
|
|
216
|
+
if (!weeks)
|
|
217
|
+
throw new Error('Sprint duration must use the form 1w, 2w, and so on.');
|
|
218
|
+
const status = input.status === undefined ? 'active' : String(input.status);
|
|
219
|
+
if (status !== 'planned' && status !== 'active') {
|
|
220
|
+
throw inputError('status', `A new sprint is planned or active, not "${status}".`, 'Use planr sprint close <id> to close a sprint.');
|
|
221
|
+
}
|
|
222
|
+
const releaseCut = optionalDate(input.releaseCut, 'releaseCut');
|
|
223
|
+
const refinedAt = optionalDate(input.refinedAt, 'refinedAt');
|
|
224
|
+
const capacityDays = optionalPositive(input.capacityDays, 'capacityDays');
|
|
225
|
+
const startDate = optionalDate(input.startDate, 'startDate') ?? today();
|
|
226
|
+
const explicitEnd = optionalDate(input.endDate, 'endDate') ?? releaseCut;
|
|
227
|
+
const endDate = explicitEnd ?? addDays(startDate, Number(weeks[1]) * 7);
|
|
228
|
+
const batches = [];
|
|
229
|
+
if (input.batches !== undefined) {
|
|
230
|
+
const parsed = sprintBatchesInputSchema.safeParse(input.batches);
|
|
231
|
+
if (!parsed.success) {
|
|
232
|
+
throw sprintError('E_PLANNING_INPUT_INVALID', 'Sprint batches must be a list of { title, effortDays?, items[{ id, title?, effort?, link? }] }.', 'Fix the listed paths and re-run.', { diagnostics: toSchemaDiagnostics(parsed.error) });
|
|
233
|
+
}
|
|
234
|
+
for (const batch of parsed.data) {
|
|
235
|
+
const items = [];
|
|
236
|
+
for (const item of batch.items) {
|
|
237
|
+
const resolved = await resolveItem(projectDir, config, item.id);
|
|
238
|
+
if (!resolved) {
|
|
239
|
+
throw inputError('batches', `Sprint item ${item.id} was not found.`, 'Create the artifact first (planr backlog add, planr quick create) or remove it from the batch.');
|
|
240
|
+
}
|
|
241
|
+
items.push({
|
|
242
|
+
id: item.id,
|
|
243
|
+
title: item.title ?? resolved.title ?? item.id,
|
|
244
|
+
effort: item.effort,
|
|
245
|
+
link: item.link ?? resolved.link,
|
|
246
|
+
checked: false,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
batches.push({ title: batch.title, effortDays: batch.effortDays, items });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const taskIds = batches.length > 0
|
|
253
|
+
? [...new Set(batches.flatMap((batch) => batch.items.map((item) => item.id)))]
|
|
254
|
+
: input.taskIds;
|
|
255
|
+
return {
|
|
256
|
+
...input,
|
|
257
|
+
name: input.title,
|
|
258
|
+
duration: explicitEnd ? `${daysBetween(startDate, endDate)}d` : duration,
|
|
259
|
+
status,
|
|
260
|
+
startDate,
|
|
261
|
+
endDate,
|
|
262
|
+
taskIds,
|
|
263
|
+
releaseCut,
|
|
264
|
+
capacityDays,
|
|
265
|
+
refinedAt,
|
|
266
|
+
tasksSection: await renderTasksSection(config, batches, taskIds),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function effectiveBatches(document) {
|
|
270
|
+
if (document.batches.length > 0)
|
|
271
|
+
return document.batches;
|
|
272
|
+
if (document.buckets.inProgress.length === 0)
|
|
273
|
+
return [];
|
|
274
|
+
return [{ title: IMPLICIT_BATCH_TITLE, itemIds: document.buckets.inProgress }];
|
|
275
|
+
}
|
|
276
|
+
async function batchesFromDocument(projectDir, config, document, checked) {
|
|
277
|
+
const byId = new Map(document.items.map((item) => [item.id, item]));
|
|
278
|
+
const batches = [];
|
|
279
|
+
for (const batch of effectiveBatches(document)) {
|
|
280
|
+
const items = [];
|
|
281
|
+
for (const id of batch.itemIds) {
|
|
282
|
+
const item = byId.get(id);
|
|
283
|
+
if (!item)
|
|
284
|
+
continue;
|
|
285
|
+
const resolved = await resolveItem(projectDir, config, id);
|
|
286
|
+
items.push({
|
|
287
|
+
id,
|
|
288
|
+
title: item.title,
|
|
289
|
+
effort: item.effort,
|
|
290
|
+
link: resolved?.link,
|
|
291
|
+
checked: checked.has(id),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
batches.push({ title: batch.title, effortDays: batch.effortDays, items });
|
|
295
|
+
}
|
|
296
|
+
return batches;
|
|
297
|
+
}
|
|
298
|
+
function itemRow(item) {
|
|
299
|
+
const parts = [`**${item.id}** ${item.title}`, item.effort, item.reason];
|
|
300
|
+
if (item.bucket === 'blocked') {
|
|
301
|
+
if (item.blockedBy.length)
|
|
302
|
+
parts.push(`blocked by ${item.blockedBy.join('; ')}`);
|
|
303
|
+
if (item.unblockQuestion)
|
|
304
|
+
parts.push(`unblock: ${item.unblockQuestion}`);
|
|
305
|
+
}
|
|
306
|
+
if (item.evidence)
|
|
307
|
+
parts.push(`evidence: ${item.evidence}`);
|
|
308
|
+
if (item.targetStatus)
|
|
309
|
+
parts.push(`→ ${item.targetStatus}`);
|
|
310
|
+
if (item.targetPriority)
|
|
311
|
+
parts.push(`priority → ${item.targetPriority}`);
|
|
312
|
+
return parts.join(' · ');
|
|
313
|
+
}
|
|
314
|
+
function cell(value) {
|
|
315
|
+
return value.replace(/\|/g, '\\|');
|
|
316
|
+
}
|
|
317
|
+
function noteView(document) {
|
|
318
|
+
const byId = new Map(document.items.map((item) => [item.id, item]));
|
|
319
|
+
const rows = (ids) => ids.flatMap((id) => {
|
|
320
|
+
const item = byId.get(id);
|
|
321
|
+
return item ? [itemRow(item)] : [];
|
|
322
|
+
});
|
|
323
|
+
const { inputs } = document;
|
|
324
|
+
return {
|
|
325
|
+
sprintId: document.sprintId,
|
|
326
|
+
refinedAt: document.refinedAt,
|
|
327
|
+
inputs: {
|
|
328
|
+
capacity: inputs.capacityDays ? `${inputs.capacityDays} engineer-days` : 'not fitted',
|
|
329
|
+
releaseCut: inputs.releaseCut ?? 'none',
|
|
330
|
+
gitRevision: inputs.gitRevision ? `\`${inputs.gitRevision}\`` : 'not verified against code',
|
|
331
|
+
previousSprintId: inputs.previousSprintId ?? 'none',
|
|
332
|
+
operateCycleId: inputs.operateCycleId ?? 'none',
|
|
333
|
+
sources: inputs.sources.length ? inputs.sources.join(', ') : 'none',
|
|
334
|
+
defaulted: inputs.defaulted.length ? inputs.defaulted.join(', ') : 'none',
|
|
335
|
+
notes: inputs.notes,
|
|
336
|
+
},
|
|
337
|
+
items: document.items.map((item) => ({
|
|
338
|
+
id: item.id,
|
|
339
|
+
score: String(item.score),
|
|
340
|
+
evidenceDate: item.evidenceDate ?? '—',
|
|
341
|
+
stale: item.stale ? 'yes' : 'no',
|
|
342
|
+
blockedBy: item.blockedBy.length ? cell(item.blockedBy.join('; ')) : '—',
|
|
343
|
+
effort: item.effort,
|
|
344
|
+
bucket: item.bucket,
|
|
345
|
+
})),
|
|
346
|
+
refuted: document.refuted,
|
|
347
|
+
batches: effectiveBatches(document).map((batch) => ({
|
|
348
|
+
title: batch.title,
|
|
349
|
+
effortDays: batch.effortDays,
|
|
350
|
+
rows: rows(batch.itemIds),
|
|
351
|
+
})),
|
|
352
|
+
planNext: rows(document.buckets.planNext),
|
|
353
|
+
blocked: rows(document.buckets.blocked),
|
|
354
|
+
closeOrDemote: rows(document.buckets.closeOrDemote),
|
|
355
|
+
leftovers: document.leftovers?.map((leftover) => `**${leftover.id}** · ${leftover.reason}`),
|
|
356
|
+
applied: document.applied
|
|
357
|
+
? {
|
|
358
|
+
at: document.applied.at,
|
|
359
|
+
updates: document.applied.updates.map((update) => `**${update.id}** · ${Object.entries(update.fields)
|
|
360
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
361
|
+
.join(', ')}`),
|
|
362
|
+
}
|
|
363
|
+
: undefined,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
async function writeRefinement(projectDir, config, document) {
|
|
367
|
+
const paths = refinementPaths(config, document.sprintId);
|
|
368
|
+
await atomicWriteFile(path.join(projectDir, paths.json), `${JSON.stringify(document, null, 2)}\n`);
|
|
369
|
+
const note = await renderTemplate('sprints/refinement.md.hbs', noteView(document), config.templateOverrides);
|
|
370
|
+
await atomicWriteFile(path.join(projectDir, paths.note), note);
|
|
371
|
+
return { refinementPath: paths.json, notePath: paths.note };
|
|
372
|
+
}
|
|
373
|
+
/** `planr sprint refinement <id> --data`: store the document and fill the sprint from it. */
|
|
374
|
+
export async function recordRefinement(projectDir, config, sprintId, input) {
|
|
375
|
+
const sprint = await requireSprint(projectDir, config, sprintId);
|
|
376
|
+
if (String(sprint.data.status ?? '').toLowerCase() === 'closed') {
|
|
377
|
+
throw sprintError('E_SPRINT_CLOSED', `${sprintId} is closed and cannot be refined.`, 'Create a new sprint with planr sprint create and refine that one.');
|
|
378
|
+
}
|
|
379
|
+
const document = parseRefinementDocument(input, sprintId);
|
|
380
|
+
const checked = new Set(parseSprintCheckboxes(sprint.content)
|
|
381
|
+
.filter((line) => line.done)
|
|
382
|
+
.map((line) => line.id));
|
|
383
|
+
const batches = await batchesFromDocument(projectDir, config, document, checked);
|
|
384
|
+
const taskIds = [...new Set(effectiveBatches(document).flatMap((batch) => batch.itemIds))];
|
|
385
|
+
const tasksSection = await renderTasksSection(config, batches, taskIds);
|
|
386
|
+
const fields = {
|
|
387
|
+
refinedAt: document.refinedAt,
|
|
388
|
+
taskIds,
|
|
389
|
+
capacityDays: document.inputs.capacityDays,
|
|
390
|
+
releaseCut: document.inputs.releaseCut,
|
|
391
|
+
};
|
|
392
|
+
const raw = await readFile(sprint.filePath);
|
|
393
|
+
const next = replaceSection(upsertFrontmatter(raw, fields), TASKS_HEADING, tasksSection);
|
|
394
|
+
await updateArtifact(projectDir, config, 'sprint', sprintId, next);
|
|
395
|
+
const written = await writeRefinement(projectDir, config, document);
|
|
396
|
+
const counts = Object.fromEntries(REFINEMENT_BUCKETS.map((bucket) => [bucket, document.buckets[bucket].length]));
|
|
397
|
+
return {
|
|
398
|
+
id: sprintId,
|
|
399
|
+
refinedAt: document.refinedAt,
|
|
400
|
+
artifactPath: relativePath(projectDir, sprint.filePath),
|
|
401
|
+
...written,
|
|
402
|
+
counts: { ...counts, refuted: document.refuted.length },
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
/** `planr sprint diff <from> <to>`: bucket moves, additions, removals and score changes. */
|
|
406
|
+
export function diffRefinements(from, to) {
|
|
407
|
+
const before = new Map(from.items.map((item) => [item.id, item]));
|
|
408
|
+
const after = new Map(to.items.map((item) => [item.id, item]));
|
|
409
|
+
const byId = (a, b) => a.id.localeCompare(b.id, undefined, { numeric: true });
|
|
410
|
+
const moved = [];
|
|
411
|
+
let unchanged = 0;
|
|
412
|
+
for (const [id, item] of before) {
|
|
413
|
+
const next = after.get(id);
|
|
414
|
+
if (!next)
|
|
415
|
+
continue;
|
|
416
|
+
if (next.bucket === item.bucket)
|
|
417
|
+
unchanged++;
|
|
418
|
+
else
|
|
419
|
+
moved.push({
|
|
420
|
+
id,
|
|
421
|
+
from: item.bucket,
|
|
422
|
+
to: next.bucket,
|
|
423
|
+
scoreFrom: item.score,
|
|
424
|
+
scoreTo: next.score,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
const added = [...after.values()]
|
|
428
|
+
.filter((item) => !before.has(item.id))
|
|
429
|
+
.map((item) => ({ id: item.id, bucket: item.bucket }));
|
|
430
|
+
const removed = [...before.values()]
|
|
431
|
+
.filter((item) => !after.has(item.id))
|
|
432
|
+
.map((item) => ({ id: item.id, bucket: item.bucket }));
|
|
433
|
+
return {
|
|
434
|
+
from: { sprintId: from.sprintId, refinedAt: from.refinedAt },
|
|
435
|
+
to: { sprintId: to.sprintId, refinedAt: to.refinedAt },
|
|
436
|
+
moved: moved.sort(byId),
|
|
437
|
+
added: added.sort(byId),
|
|
438
|
+
removed: removed.sort(byId),
|
|
439
|
+
unchanged,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
function emptyRefinement(sprintId, refinedAt) {
|
|
443
|
+
return {
|
|
444
|
+
schemaVersion: REFINEMENT_SCHEMA_VERSION,
|
|
445
|
+
sprintId,
|
|
446
|
+
refinedAt,
|
|
447
|
+
inputs: { sources: [], defaulted: ['refinement'] },
|
|
448
|
+
items: [],
|
|
449
|
+
buckets: { inProgress: [], planNext: [], blocked: [], closeOrDemote: [] },
|
|
450
|
+
batches: [],
|
|
451
|
+
refuted: [],
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
/** `planr sprint close <id>`: mark the sprint closed and record what was not finished. */
|
|
455
|
+
export async function closeSprint(projectDir, config, sprintId) {
|
|
456
|
+
const sprint = await requireSprint(projectDir, config, sprintId);
|
|
457
|
+
if (String(sprint.data.status ?? '').toLowerCase() === 'closed') {
|
|
458
|
+
throw sprintError('E_SPRINT_ALREADY_CLOSED', `${sprintId} is already closed.`, 'Nothing to do; open the next sprint with planr sprint create.');
|
|
459
|
+
}
|
|
460
|
+
const checkboxes = new Map(parseSprintCheckboxes(sprint.content).map((line) => [line.id, line]));
|
|
461
|
+
const leftovers = [];
|
|
462
|
+
const warnings = [];
|
|
463
|
+
for (const id of stringList(sprint.data.taskIds)) {
|
|
464
|
+
const line = checkboxes.get(id);
|
|
465
|
+
if (line) {
|
|
466
|
+
if (!line.done)
|
|
467
|
+
leftovers.push({ id, reason: 'unchecked' });
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
const type = findArtifactTypeById(id);
|
|
471
|
+
if (!type || type === 'sprint') {
|
|
472
|
+
warnings.push(`${id}: not an artifact id; not carried forward.`);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
const artifact = await readArtifact(projectDir, config, type, id);
|
|
476
|
+
if (!artifact) {
|
|
477
|
+
warnings.push(`${id}: artifact not found; not carried forward.`);
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
if (!DONE_STATES.has(String(artifact.data.status ?? '').toLowerCase()))
|
|
481
|
+
leftovers.push({ id, reason: 'not-done' });
|
|
482
|
+
}
|
|
483
|
+
const closedAt = today();
|
|
484
|
+
const document = (await readRefinement(projectDir, config, sprintId)) ?? emptyRefinement(sprintId, closedAt);
|
|
485
|
+
const raw = await readFile(sprint.filePath);
|
|
486
|
+
await updateArtifact(projectDir, config, 'sprint', sprintId, upsertFrontmatter(raw, { status: 'closed', closedAt }));
|
|
487
|
+
const written = await writeRefinement(projectDir, config, { ...document, leftovers });
|
|
488
|
+
return {
|
|
489
|
+
id: sprintId,
|
|
490
|
+
closedAt,
|
|
491
|
+
leftovers,
|
|
492
|
+
warnings,
|
|
493
|
+
artifactPath: relativePath(projectDir, sprint.filePath),
|
|
494
|
+
...written,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
/** `planr sprint apply <id>`: write the approved status changes, then optionally commit them. */
|
|
498
|
+
export async function applyRefinement(projectDir, config, sprintId, options = {}) {
|
|
499
|
+
const sprint = await requireSprint(projectDir, config, sprintId);
|
|
500
|
+
const document = await requireRefinement(projectDir, config, sprintId);
|
|
501
|
+
const updates = [];
|
|
502
|
+
const skipped = [];
|
|
503
|
+
const invalid = [];
|
|
504
|
+
for (const [index, item] of document.items.entries()) {
|
|
505
|
+
const fields = {};
|
|
506
|
+
if (item.targetStatus)
|
|
507
|
+
fields.status = item.targetStatus;
|
|
508
|
+
if (item.targetPriority)
|
|
509
|
+
fields.priority = item.targetPriority;
|
|
510
|
+
if (item.bucket === 'blocked' && item.blockedBy.length)
|
|
511
|
+
fields.blockedBy = item.blockedBy.join('; ');
|
|
512
|
+
if (Object.keys(fields).length === 0)
|
|
513
|
+
continue;
|
|
514
|
+
const type = findArtifactTypeById(item.id);
|
|
515
|
+
if (!type || type === 'sprint') {
|
|
516
|
+
skipped.push({ id: item.id, reason: 'not a writable artifact id' });
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
if (fields.priority && type !== 'backlog') {
|
|
520
|
+
skipped.push({
|
|
521
|
+
id: item.id,
|
|
522
|
+
reason: `priority applies to backlog items only; ${type} keeps its priority`,
|
|
523
|
+
});
|
|
524
|
+
delete fields.priority;
|
|
525
|
+
if (Object.keys(fields).length === 0)
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
const artifact = await readArtifact(projectDir, config, type, item.id);
|
|
529
|
+
if (!artifact) {
|
|
530
|
+
skipped.push({ id: item.id, reason: 'artifact not found' });
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (fields.status && !options.force && !isValidStatus(type, fields.status)) {
|
|
534
|
+
invalid.push({
|
|
535
|
+
path: `$.items[${index}].targetStatus`,
|
|
536
|
+
rule: 'refinement:status-vocabulary',
|
|
537
|
+
detail: `"${fields.status}" is not a ${type} status (${VALID_STATUSES[type]?.join(', ')})`,
|
|
538
|
+
});
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
updates.push({
|
|
542
|
+
id: item.id,
|
|
543
|
+
type,
|
|
544
|
+
artifactPath: relativePath(projectDir, artifact.filePath),
|
|
545
|
+
fields,
|
|
546
|
+
from: {
|
|
547
|
+
status: optionalString(artifact.data.status),
|
|
548
|
+
priority: optionalString(artifact.data.priority),
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
if (invalid.length) {
|
|
553
|
+
throw sprintError('E_SPRINT_APPLY_STATUS_INVALID', 'Some target statuses are outside the repository status vocabulary.', 'Use each type’s vocabulary from planr update --help, or pass --force.', { diagnostics: invalid });
|
|
554
|
+
}
|
|
555
|
+
const message = `chore(planr): refine backlog for ${sprintId}`;
|
|
556
|
+
const paths = refinementPaths(config, sprintId);
|
|
557
|
+
const base = {
|
|
558
|
+
id: sprintId,
|
|
559
|
+
message,
|
|
560
|
+
updates,
|
|
561
|
+
skipped,
|
|
562
|
+
artifactPath: relativePath(projectDir, sprint.filePath),
|
|
563
|
+
refinementPath: paths.json,
|
|
564
|
+
notePath: paths.note,
|
|
565
|
+
};
|
|
566
|
+
if (options.dryRun)
|
|
567
|
+
return { ...base, dryRun: true };
|
|
568
|
+
if (!options.yes) {
|
|
569
|
+
throw sprintError('E_SPRINT_APPLY_CONFIRMATION_REQUIRED', 'Applying a refinement changes artifact statuses and needs explicit confirmation.', 'Re-run with --yes after the approval question, or use --dry-run to preview the changes.');
|
|
570
|
+
}
|
|
571
|
+
for (const update of updates) {
|
|
572
|
+
await updateArtifactFields(projectDir, config, update.type, update.id, update.fields);
|
|
573
|
+
}
|
|
574
|
+
await writeRefinement(projectDir, config, {
|
|
575
|
+
...document,
|
|
576
|
+
applied: {
|
|
577
|
+
at: new Date().toISOString(),
|
|
578
|
+
updates: updates.map((update) => ({
|
|
579
|
+
id: update.id,
|
|
580
|
+
type: update.type,
|
|
581
|
+
fields: update.fields,
|
|
582
|
+
})),
|
|
583
|
+
},
|
|
584
|
+
});
|
|
585
|
+
let commit;
|
|
586
|
+
if (options.commit) {
|
|
587
|
+
const commitList = [
|
|
588
|
+
...new Set([
|
|
589
|
+
base.artifactPath,
|
|
590
|
+
paths.json,
|
|
591
|
+
paths.note,
|
|
592
|
+
...updates.map((u) => u.artifactPath),
|
|
593
|
+
]),
|
|
594
|
+
];
|
|
595
|
+
try {
|
|
596
|
+
commit = await commitPaths(projectDir, commitList, message);
|
|
597
|
+
}
|
|
598
|
+
catch (cause) {
|
|
599
|
+
if (cause instanceof GitUnavailableError) {
|
|
600
|
+
throw sprintError('E_SPRINT_GIT_UNAVAILABLE', `The status changes were written but not committed: ${cause.message.trim()}`, 'Commit the changed files by hand, or re-run without --commit.', { cause });
|
|
601
|
+
}
|
|
602
|
+
throw sprintError('E_SPRINT_COMMIT_FAILED', `The status changes were written but git commit failed: ${cause instanceof Error ? cause.message.trim() : String(cause)}`, 'Resolve the git error (identity, hooks, signing) and commit the listed paths by hand.', { cause });
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return { ...base, dryRun: false, commit };
|
|
606
|
+
}
|
|
607
|
+
/** The newest sprint whose status is `active`, with its checkbox progress. */
|
|
608
|
+
export async function findActiveSprint(projectDir, config) {
|
|
609
|
+
const rows = await listArtifacts(projectDir, config, 'sprint');
|
|
610
|
+
const active = [];
|
|
611
|
+
for (const row of rows) {
|
|
612
|
+
const sprint = await readArtifact(projectDir, config, 'sprint', row.id);
|
|
613
|
+
if (!sprint)
|
|
614
|
+
continue;
|
|
615
|
+
const data = sprint.data;
|
|
616
|
+
if (String(data.status ?? '').toLowerCase() !== 'active')
|
|
617
|
+
continue;
|
|
618
|
+
active.push({
|
|
619
|
+
id: row.id,
|
|
620
|
+
sprint: { data, content: sprint.content, filePath: sprint.filePath },
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
if (active.length === 0)
|
|
624
|
+
return null;
|
|
625
|
+
active.sort((a, b) => b.id.localeCompare(a.id, undefined, { numeric: true }));
|
|
626
|
+
const { id, sprint } = active[0];
|
|
627
|
+
const taskIds = stringList(sprint.data.taskIds);
|
|
628
|
+
const checkboxes = parseSprintCheckboxes(sprint.content);
|
|
629
|
+
const progress = checkboxes.length > 0
|
|
630
|
+
? { done: checkboxes.filter((line) => line.done).length, total: checkboxes.length }
|
|
631
|
+
: { done: 0, total: taskIds.length };
|
|
632
|
+
const capacity = sprint.data.capacityDays;
|
|
633
|
+
return {
|
|
634
|
+
id,
|
|
635
|
+
name: optionalString(sprint.data.name) ?? id,
|
|
636
|
+
status: 'active',
|
|
637
|
+
releaseCut: optionalString(sprint.data.releaseCut),
|
|
638
|
+
capacityDays: typeof capacity === 'number' ? capacity : undefined,
|
|
639
|
+
refinedAt: optionalString(sprint.data.refinedAt),
|
|
640
|
+
taskIds,
|
|
641
|
+
progress,
|
|
642
|
+
artifactPath: relativePath(projectDir, sprint.filePath),
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
//# sourceMappingURL=sprint-refinement-service.js.map
|