@ikunin/sprintpilot 1.0.4 → 2.0.4
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/_Sprintpilot/Sprintpilot.md +14 -1
- package/_Sprintpilot/manifest.yaml +1 -1
- package/_Sprintpilot/modules/autopilot/config.yaml +22 -0
- package/_Sprintpilot/modules/autopilot/profiles/_base.yaml +45 -0
- package/_Sprintpilot/modules/autopilot/profiles/large.yaml +22 -0
- package/_Sprintpilot/modules/autopilot/profiles/legacy.yaml +35 -0
- package/_Sprintpilot/modules/autopilot/profiles/medium.yaml +5 -0
- package/_Sprintpilot/modules/autopilot/profiles/nano.yaml +35 -0
- package/_Sprintpilot/modules/autopilot/profiles/small.yaml +5 -0
- package/_Sprintpilot/modules/git/config.yaml +8 -0
- package/_Sprintpilot/modules/ma/config.yaml +42 -0
- package/_Sprintpilot/scripts/agent-adapter.js +247 -0
- package/_Sprintpilot/scripts/cached-read.js +238 -0
- package/_Sprintpilot/scripts/check-prereqs.js +139 -0
- package/_Sprintpilot/scripts/dispatch-layer.js +192 -0
- package/_Sprintpilot/scripts/git-portable.js +219 -0
- package/_Sprintpilot/scripts/infer-dependencies.js +594 -0
- package/_Sprintpilot/scripts/inject-tasks-section.js +279 -0
- package/_Sprintpilot/scripts/list-remaining-stories.js +295 -0
- package/_Sprintpilot/scripts/log-timing.js +360 -0
- package/_Sprintpilot/scripts/mark-done-stories-tasks.js +254 -0
- package/_Sprintpilot/scripts/merge-shards.js +339 -0
- package/_Sprintpilot/scripts/preflight-merge.js +235 -0
- package/_Sprintpilot/scripts/resolve-dag.js +559 -0
- package/_Sprintpilot/scripts/resolve-profile.js +355 -0
- package/_Sprintpilot/scripts/state-shard.js +602 -0
- package/_Sprintpilot/scripts/submodule-lock.js +130 -0
- package/_Sprintpilot/scripts/summarize-timings.js +362 -0
- package/_Sprintpilot/scripts/sync-status.js +13 -0
- package/_Sprintpilot/scripts/with-retry.js +145 -0
- package/_Sprintpilot/skills/sprint-autopilot-on/workflow.md +673 -540
- package/_Sprintpilot/skills/sprintpilot-update/workflow.md +2 -1
- package/_Sprintpilot/templates/epic-retrospective.md +24 -0
- package/_Sprintpilot/templates/sprint-report.txt +60 -0
- package/bin/sprintpilot.js +4 -0
- package/lib/commands/install.js +157 -1
- package/package.json +1 -1
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// inject-tasks-section.js — deterministic fallback for bmad-create-story
|
|
4
|
+
// when it emits a story file without a `## Tasks` / `## Subtasks` section.
|
|
5
|
+
//
|
|
6
|
+
// Usage:
|
|
7
|
+
// inject-tasks-section.js --story-file <path> [--ac-section "Acceptance Criteria"]
|
|
8
|
+
//
|
|
9
|
+
// Behavior (idempotent):
|
|
10
|
+
// 1. If the story file already has a `## Tasks` or `## Subtasks` section
|
|
11
|
+
// with at least one `- [ ]` checkbox, do nothing. Exit 0.
|
|
12
|
+
// 2. Otherwise, locate the `## Acceptance Criteria` section. Extract
|
|
13
|
+
// every AC entry — numbered list item (`1. foo`), bullet (`- foo`),
|
|
14
|
+
// or `**AC-N:** foo` — ONLY within that section (bounded by the next
|
|
15
|
+
// `##` heading or EOF).
|
|
16
|
+
// 3. Append a new `## Tasks / Subtasks` section to the story file with
|
|
17
|
+
// one `- [ ] <AC summary>` bullet per AC entry.
|
|
18
|
+
//
|
|
19
|
+
// Exits:
|
|
20
|
+
// 0 — section already present or appended successfully
|
|
21
|
+
// 1 — story file missing or AC section cannot be located (nothing to do)
|
|
22
|
+
//
|
|
23
|
+
// Why this exists: the previous LLM-prose instruction in workflow.md was
|
|
24
|
+
// under-specified — it told the agent to "read every `N. ...` line" with
|
|
25
|
+
// no section boundary, which regularly scraped lines from Dev Notes or
|
|
26
|
+
// body prose. This script pins the scan exactly to the AC section.
|
|
27
|
+
|
|
28
|
+
const fs = require('node:fs');
|
|
29
|
+
const path = require('node:path');
|
|
30
|
+
|
|
31
|
+
const { parseArgs } = require('../lib/runtime/args');
|
|
32
|
+
const log = require('../lib/runtime/log');
|
|
33
|
+
const { atomicWrite } = require('./mark-done-stories-tasks.js');
|
|
34
|
+
const timing = require('./log-timing.js');
|
|
35
|
+
|
|
36
|
+
// Derive a STORY_RE-compatible story key from a story-file path. BMad
|
|
37
|
+
// emits files like `story-1-1-foo.md` or `1-1-foo.md`; either form
|
|
38
|
+
// reduces to the key `1-1-foo`. Returns null if no key is recoverable.
|
|
39
|
+
function storyKeyFromFile(storyFile) {
|
|
40
|
+
const base = path.basename(storyFile).replace(/\.md$/i, '');
|
|
41
|
+
const stripped = base.replace(/^story-/i, '').toLowerCase();
|
|
42
|
+
if (/^[a-z0-9][a-z0-9-]*$/.test(stripped)) return stripped;
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function emitTimingEvent(projectRoot, story, phase, meta) {
|
|
47
|
+
try {
|
|
48
|
+
if (!story) return;
|
|
49
|
+
if (!timing.isEnabled(projectRoot)) return;
|
|
50
|
+
timing.appendLine(projectRoot, story, timing.buildEntry('once', story, phase, meta));
|
|
51
|
+
} catch {
|
|
52
|
+
/* ignore — timing is best-effort */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function help() {
|
|
57
|
+
log.out(
|
|
58
|
+
[
|
|
59
|
+
'Usage:',
|
|
60
|
+
' inject-tasks-section.js --story-file <path> [--ac-section "Acceptance Criteria"]',
|
|
61
|
+
'',
|
|
62
|
+
'Ensures the story file has a `## Tasks / Subtasks` section with at',
|
|
63
|
+
'least one `- [ ]` checkbox. If the section is missing, appends one',
|
|
64
|
+
'derived 1:1 from the `## Acceptance Criteria` section. Idempotent.',
|
|
65
|
+
].join('\n'),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Locate `## Tasks` or `## Subtasks` (case-insensitive, any depth >= 2)
|
|
70
|
+
// and check whether the section contains at least one `- [ ]` checkbox.
|
|
71
|
+
// Returns { found: boolean, hasCheckbox: boolean }.
|
|
72
|
+
//
|
|
73
|
+
// Fence-aware: lines inside ``` or ~~~ fenced blocks are treated as
|
|
74
|
+
// content (not headers, not checkboxes). Without this, an example code
|
|
75
|
+
// block elsewhere in the file containing `## Tasks` or `- [ ]` would
|
|
76
|
+
// give a false positive — same fix mark-done-stories-tasks.js makes.
|
|
77
|
+
function inspectTasksSection(body) {
|
|
78
|
+
const lines = body.split('\n');
|
|
79
|
+
const headerRe = /^(#{2,})\s+(tasks|subtasks)(\s*\/\s*(tasks|subtasks))?\s*$/i;
|
|
80
|
+
const fenceRe = /^\s*(`{3,}|~{3,})/;
|
|
81
|
+
let inSection = false;
|
|
82
|
+
let headerLevel = 0;
|
|
83
|
+
let found = false;
|
|
84
|
+
let hasCheckbox = false;
|
|
85
|
+
let inFence = false;
|
|
86
|
+
let fenceChar = null;
|
|
87
|
+
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
const fence = line.match(fenceRe);
|
|
90
|
+
if (fence) {
|
|
91
|
+
const ch = fence[1][0];
|
|
92
|
+
if (!inFence) {
|
|
93
|
+
inFence = true;
|
|
94
|
+
fenceChar = ch;
|
|
95
|
+
} else if (fenceChar === ch) {
|
|
96
|
+
inFence = false;
|
|
97
|
+
fenceChar = null;
|
|
98
|
+
}
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (inFence) continue;
|
|
102
|
+
|
|
103
|
+
const h = line.match(/^(#{1,6})\s+.*$/);
|
|
104
|
+
if (h) {
|
|
105
|
+
if (inSection && h[1].length <= headerLevel) {
|
|
106
|
+
// left the section
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
const match = line.match(headerRe);
|
|
110
|
+
if (match) {
|
|
111
|
+
inSection = true;
|
|
112
|
+
found = true;
|
|
113
|
+
headerLevel = match[1].length;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (inSection && /^\s*[-*]\s*\[ \]/.test(line)) {
|
|
118
|
+
hasCheckbox = true;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { found, hasCheckbox };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Extract AC entries from the `## Acceptance Criteria` section.
|
|
126
|
+
// An entry is one of:
|
|
127
|
+
// 1. <text>
|
|
128
|
+
// - <text>
|
|
129
|
+
// * <text>
|
|
130
|
+
// **AC-N:** <text>
|
|
131
|
+
// Scanning is strictly bounded to within the AC section.
|
|
132
|
+
function extractAcceptanceCriteria(body, sectionName) {
|
|
133
|
+
const lines = body.split('\n');
|
|
134
|
+
const headerRe = new RegExp(
|
|
135
|
+
`^(#{2,})\\s+${sectionName.replace(/[.*+?^${}()|[\\\]]/g, '\\$&')}\\s*$`,
|
|
136
|
+
'i',
|
|
137
|
+
);
|
|
138
|
+
const fenceRe = /^\s*(`{3,}|~{3,})/;
|
|
139
|
+
let inSection = false;
|
|
140
|
+
let sectionHeaderLevel = 0;
|
|
141
|
+
let inFence = false;
|
|
142
|
+
let fenceChar = null;
|
|
143
|
+
const entries = [];
|
|
144
|
+
|
|
145
|
+
const acEntryRes = [
|
|
146
|
+
/^\s*\d+\.\s+(.+?)\s*$/, // 1. foo
|
|
147
|
+
/^\s*[-*]\s+(?:\*\*AC-\d+:?\*\*\s*)?(.+?)\s*$/, // - foo or - **AC-1:** foo
|
|
148
|
+
/^\s*\*\*AC-\d+:?\*\*\s*(.+?)\s*$/, // **AC-1:** foo
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
for (const line of lines) {
|
|
152
|
+
const fence = line.match(fenceRe);
|
|
153
|
+
if (fence) {
|
|
154
|
+
const ch = fence[1][0];
|
|
155
|
+
if (!inFence) {
|
|
156
|
+
inFence = true;
|
|
157
|
+
fenceChar = ch;
|
|
158
|
+
} else if (fenceChar === ch) {
|
|
159
|
+
inFence = false;
|
|
160
|
+
fenceChar = null;
|
|
161
|
+
}
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (inFence) continue; // example code can include AC-like lines verbatim
|
|
165
|
+
|
|
166
|
+
const h = line.match(/^(#{1,6})\s+.*$/);
|
|
167
|
+
if (h) {
|
|
168
|
+
if (inSection && h[1].length <= sectionHeaderLevel) {
|
|
169
|
+
inSection = false;
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
const match = line.match(headerRe);
|
|
173
|
+
if (match) {
|
|
174
|
+
inSection = true;
|
|
175
|
+
sectionHeaderLevel = match[1].length;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (!inSection) continue;
|
|
180
|
+
if (/^\s*$/.test(line)) continue;
|
|
181
|
+
for (const re of acEntryRes) {
|
|
182
|
+
const m = line.match(re);
|
|
183
|
+
if (m && m[1]) {
|
|
184
|
+
const text = m[1].replace(/\s+/g, ' ').trim();
|
|
185
|
+
if (text) entries.push(text);
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return entries;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function buildTasksSection(entries) {
|
|
195
|
+
const lines = ['', '## Tasks / Subtasks', ''];
|
|
196
|
+
if (entries.length === 0) {
|
|
197
|
+
lines.push('- [ ] Implement story per Acceptance Criteria');
|
|
198
|
+
} else {
|
|
199
|
+
for (const e of entries) {
|
|
200
|
+
lines.push(`- [ ] ${e}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
lines.push('');
|
|
204
|
+
return lines.join('\n');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function main() {
|
|
208
|
+
const { opts } = parseArgs(process.argv.slice(2));
|
|
209
|
+
if (opts.help) {
|
|
210
|
+
help();
|
|
211
|
+
process.exit(0);
|
|
212
|
+
}
|
|
213
|
+
const storyFile = opts['story-file'];
|
|
214
|
+
if (!storyFile) {
|
|
215
|
+
log.error('--story-file is required');
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
if (!fs.existsSync(storyFile)) {
|
|
219
|
+
log.error(`story file missing: ${storyFile}`);
|
|
220
|
+
process.exit(1);
|
|
221
|
+
}
|
|
222
|
+
const acSectionName = opts['ac-section'] || 'Acceptance Criteria';
|
|
223
|
+
const projectRoot = opts['project-root'] || process.cwd();
|
|
224
|
+
const storyKey = storyKeyFromFile(storyFile);
|
|
225
|
+
|
|
226
|
+
const body = fs.readFileSync(storyFile, 'utf8');
|
|
227
|
+
const info = inspectTasksSection(body);
|
|
228
|
+
if (info.found && info.hasCheckbox) {
|
|
229
|
+
emitTimingEvent(projectRoot, storyKey, 'story.inject-tasks', { action: 'skip' });
|
|
230
|
+
process.stdout.write(
|
|
231
|
+
`${JSON.stringify({ action: 'skip', reason: 'tasks-section-present' })}\n`,
|
|
232
|
+
);
|
|
233
|
+
process.exit(0);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const entries = extractAcceptanceCriteria(body, acSectionName);
|
|
237
|
+
const section = buildTasksSection(entries);
|
|
238
|
+
|
|
239
|
+
let newBody;
|
|
240
|
+
if (info.found && !info.hasCheckbox) {
|
|
241
|
+
// Section exists but has no checkboxes — append checkboxes inside it
|
|
242
|
+
// by inserting after the header line.
|
|
243
|
+
const lines = body.split('\n');
|
|
244
|
+
const headerRe = /^(#{2,})\s+(tasks|subtasks)(\s*\/\s*(tasks|subtasks))?\s*$/i;
|
|
245
|
+
for (let i = 0; i < lines.length; i++) {
|
|
246
|
+
if (headerRe.test(lines[i])) {
|
|
247
|
+
const injection = entries.length === 0
|
|
248
|
+
? ['', '- [ ] Implement story per Acceptance Criteria', '']
|
|
249
|
+
: ['', ...entries.map((e) => `- [ ] ${e}`), ''];
|
|
250
|
+
lines.splice(i + 1, 0, ...injection);
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
newBody = lines.join('\n');
|
|
255
|
+
} else {
|
|
256
|
+
newBody = body.replace(/\s*$/, '') + '\n' + section;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
atomicWrite(path.resolve(storyFile), newBody);
|
|
260
|
+
const action = info.found ? 'checkboxes-added' : 'section-appended';
|
|
261
|
+
emitTimingEvent(projectRoot, storyKey, 'story.inject-tasks', { action, entries: entries.length });
|
|
262
|
+
process.stdout.write(
|
|
263
|
+
`${JSON.stringify({
|
|
264
|
+
action,
|
|
265
|
+
entries: entries.length,
|
|
266
|
+
})}\n`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
module.exports = {
|
|
271
|
+
inspectTasksSection,
|
|
272
|
+
extractAcceptanceCriteria,
|
|
273
|
+
buildTasksSection,
|
|
274
|
+
storyKeyFromFile,
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
if (require.main === module) {
|
|
278
|
+
main();
|
|
279
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// list-remaining-stories.js — deterministic compute of stories_remaining.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// list-remaining-stories.js --status-file <path> [--format json|csv|lines|envelope]
|
|
7
|
+
//
|
|
8
|
+
// Reads BMad's sprint-status.yaml and returns the set of story keys whose
|
|
9
|
+
// status is NOT "done" (case-insensitive). Handles every BMad-observed
|
|
10
|
+
// shape: dict form, inline form, block form, list form, quoted keys,
|
|
11
|
+
// 2/4/tab indent.
|
|
12
|
+
//
|
|
13
|
+
// Formats:
|
|
14
|
+
// json (default) — `["1-1-a","1-2-b"]`. Legacy form; always a bare array.
|
|
15
|
+
// csv — `1-1-a,1-2-b`
|
|
16
|
+
// lines — `1-1-a\n1-2-b`
|
|
17
|
+
// envelope — `{"remaining":[...],"state":"sprint-in-progress"}`
|
|
18
|
+
// state is one of: pre-planning | sprint-in-progress |
|
|
19
|
+
// sprint-complete | parse-error. This is the shape the
|
|
20
|
+
// autopilot workflow consumes because exit codes alone
|
|
21
|
+
// cannot disambiguate "all done" vs "not planned yet".
|
|
22
|
+
//
|
|
23
|
+
// Exit codes (still emitted for backward compatibility with shell callers):
|
|
24
|
+
// 0 — sprint-in-progress or sprint-complete
|
|
25
|
+
// 1 — parse error (invalid YAML-like content)
|
|
26
|
+
// 2 — pre-planning (status file missing)
|
|
27
|
+
//
|
|
28
|
+
// On every exit path — including errors — a well-formed value is printed
|
|
29
|
+
// on stdout in the requested format. Callers never need to consult stderr
|
|
30
|
+
// or exit-code shell probes to get a usable value.
|
|
31
|
+
|
|
32
|
+
const fs = require('node:fs');
|
|
33
|
+
|
|
34
|
+
const { parseArgs } = require('../lib/runtime/args');
|
|
35
|
+
const log = require('../lib/runtime/log');
|
|
36
|
+
|
|
37
|
+
const VALID_FORMATS = ['json', 'csv', 'lines', 'envelope'];
|
|
38
|
+
|
|
39
|
+
function help() {
|
|
40
|
+
log.out(
|
|
41
|
+
[
|
|
42
|
+
'Usage:',
|
|
43
|
+
' list-remaining-stories.js --status-file <path> [--format json|csv|lines|envelope]',
|
|
44
|
+
'',
|
|
45
|
+
'Emits story keys whose status is NOT "done" (case-insensitive).',
|
|
46
|
+
'Any status other than the literal token `done` — including null,',
|
|
47
|
+
'backlog, ready-for-dev, in-progress, review, draft, or typos — is',
|
|
48
|
+
'treated as "not done".',
|
|
49
|
+
'',
|
|
50
|
+
'Exits: 0 on success (sprint-in-progress or sprint-complete); 1 on',
|
|
51
|
+
'parse failure; 2 when the status file is missing. On every exit',
|
|
52
|
+
'path a well-formed value is still written to stdout.',
|
|
53
|
+
].join('\n'),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Normalize a scalar value: strip surrounding quotes, trim inner whitespace.
|
|
58
|
+
function stripQuotes(v) {
|
|
59
|
+
if (v == null) return v;
|
|
60
|
+
let s = String(v).trim();
|
|
61
|
+
if (s.length >= 2) {
|
|
62
|
+
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
63
|
+
s = s.slice(1, -1);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return s.trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Status comparison is case-insensitive and whitespace-insensitive so
|
|
70
|
+
// `Done`, `"done "`, and `done` all collapse to the same class. Anything
|
|
71
|
+
// other than the literal token `done` is classified as not-done.
|
|
72
|
+
function isDone(status) {
|
|
73
|
+
if (status == null) return false;
|
|
74
|
+
return String(status).trim().toLowerCase() === 'done';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function leadingIndent(line) {
|
|
78
|
+
const m = line.match(/^[ \t]*/);
|
|
79
|
+
return m ? m[0].length : 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Parse a `key: value` line where key may be quoted. Returns
|
|
83
|
+
// {key, value} or null. Handles trailing `# comment`.
|
|
84
|
+
function parseKV(content) {
|
|
85
|
+
const m = content.match(
|
|
86
|
+
/^["']?([A-Za-z0-9][A-Za-z0-9_.\-]*)["']?\s*:\s*(\S[^#]*?)?(?:\s*#.*)?\s*$/,
|
|
87
|
+
);
|
|
88
|
+
if (!m) return null;
|
|
89
|
+
return { key: m[1], value: m[2] ? stripQuotes(m[2].trim()) : '' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// parseStatuses — indent-agnostic, shape-tolerant scanner for BMad's
|
|
93
|
+
// sprint-status.yaml.
|
|
94
|
+
//
|
|
95
|
+
// Recognized block headers at any indent:
|
|
96
|
+
// development_status:
|
|
97
|
+
// stories:
|
|
98
|
+
//
|
|
99
|
+
// Inside a block, children are detected by indent strictly greater than
|
|
100
|
+
// the block header's indent. The indent of the first child fixes the
|
|
101
|
+
// "item indent" for the block; deeper-indented lines are treated as
|
|
102
|
+
// fields of the current story.
|
|
103
|
+
//
|
|
104
|
+
// Supported per-item shapes:
|
|
105
|
+
// <key>: <status> — dict inline
|
|
106
|
+
// "<key>": <status> — dict inline (quoted)
|
|
107
|
+
// <key>: — dict block (status: on next line)
|
|
108
|
+
// status: <value>
|
|
109
|
+
// - <key>: <status> — list inline
|
|
110
|
+
// - id: <key> — list block (id|key|name as the story id field)
|
|
111
|
+
// status: <value>
|
|
112
|
+
//
|
|
113
|
+
// Returns {storyKey: {status: string|null}}.
|
|
114
|
+
function parseStatuses(raw) {
|
|
115
|
+
const lines = String(raw || '').split(/\r?\n/);
|
|
116
|
+
const stories = {};
|
|
117
|
+
|
|
118
|
+
let inBlock = false;
|
|
119
|
+
let blockIndent = -1;
|
|
120
|
+
let itemIndent = -1;
|
|
121
|
+
let isListBlock = false;
|
|
122
|
+
let currentKey = null;
|
|
123
|
+
let currentKeyIndent = -1;
|
|
124
|
+
|
|
125
|
+
for (let i = 0; i < lines.length; i++) {
|
|
126
|
+
const rawLine = lines[i].replace(/\s+$/, '');
|
|
127
|
+
if (!rawLine.trim()) continue;
|
|
128
|
+
if (/^\s*#/.test(rawLine)) continue;
|
|
129
|
+
|
|
130
|
+
const indent = leadingIndent(rawLine);
|
|
131
|
+
const content = rawLine.slice(indent);
|
|
132
|
+
|
|
133
|
+
// Exit the stories block when we return to its indent or shallower.
|
|
134
|
+
if (inBlock && indent <= blockIndent) {
|
|
135
|
+
inBlock = false;
|
|
136
|
+
itemIndent = -1;
|
|
137
|
+
isListBlock = false;
|
|
138
|
+
currentKey = null;
|
|
139
|
+
currentKeyIndent = -1;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!inBlock) {
|
|
143
|
+
const h = content.match(/^(development_status|stories)\s*:\s*$/);
|
|
144
|
+
if (h) {
|
|
145
|
+
inBlock = true;
|
|
146
|
+
blockIndent = indent;
|
|
147
|
+
itemIndent = -1;
|
|
148
|
+
isListBlock = false;
|
|
149
|
+
currentKey = null;
|
|
150
|
+
currentKeyIndent = -1;
|
|
151
|
+
}
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Lazily fix the direct-child indent and the block flavor on the first
|
|
156
|
+
// non-blank child line.
|
|
157
|
+
if (itemIndent < 0) {
|
|
158
|
+
itemIndent = indent;
|
|
159
|
+
isListBlock = content.startsWith('- ') || content === '-';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (indent === itemIndent) {
|
|
163
|
+
if (isListBlock) {
|
|
164
|
+
// `- key: value` | `- id: key` | `- key: value # comment`
|
|
165
|
+
const listM = content.match(/^-\s+(.+)$/);
|
|
166
|
+
if (!listM) {
|
|
167
|
+
// bare `-` with children below; rare but tolerate.
|
|
168
|
+
currentKey = null;
|
|
169
|
+
currentKeyIndent = indent;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const kv = parseKV(listM[1]);
|
|
173
|
+
if (!kv) continue;
|
|
174
|
+
// If the first field inside the list item is an id-like field, the
|
|
175
|
+
// story key is its value; otherwise the field name IS the story key
|
|
176
|
+
// and the field value is its inline status.
|
|
177
|
+
if (kv.key === 'id' || kv.key === 'key' || kv.key === 'name') {
|
|
178
|
+
currentKey = kv.value || null;
|
|
179
|
+
if (currentKey) stories[currentKey] = stories[currentKey] ?? { status: null };
|
|
180
|
+
} else {
|
|
181
|
+
currentKey = kv.key;
|
|
182
|
+
stories[currentKey] = { status: kv.value || null };
|
|
183
|
+
}
|
|
184
|
+
currentKeyIndent = indent;
|
|
185
|
+
} else {
|
|
186
|
+
const kv = parseKV(content);
|
|
187
|
+
if (!kv) continue;
|
|
188
|
+
currentKey = kv.key;
|
|
189
|
+
stories[currentKey] = { status: kv.value || null };
|
|
190
|
+
currentKeyIndent = indent;
|
|
191
|
+
}
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Deeper than itemIndent → a field of the current list/dict item.
|
|
196
|
+
if (currentKey != null && indent > currentKeyIndent) {
|
|
197
|
+
const kv = parseKV(content);
|
|
198
|
+
if (kv && kv.key === 'status') {
|
|
199
|
+
stories[currentKey].status = kv.value || null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return stories;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function remainingFrom(stories) {
|
|
208
|
+
const out = [];
|
|
209
|
+
for (const key of Object.keys(stories)) {
|
|
210
|
+
if (isDone(stories[key].status)) continue;
|
|
211
|
+
out.push(key);
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function emit(list, format, state) {
|
|
217
|
+
if (format === 'envelope') {
|
|
218
|
+
process.stdout.write(`${JSON.stringify({ remaining: list, state })}\n`);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (format === 'csv') {
|
|
222
|
+
process.stdout.write(list.join(',') + (list.length ? '\n' : ''));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (format === 'lines') {
|
|
226
|
+
if (list.length === 0) return;
|
|
227
|
+
process.stdout.write(list.join('\n') + '\n');
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
// json (default)
|
|
231
|
+
process.stdout.write(`${JSON.stringify(list)}\n`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function main() {
|
|
235
|
+
const { opts } = parseArgs(process.argv.slice(2));
|
|
236
|
+
if (opts.help) {
|
|
237
|
+
help();
|
|
238
|
+
process.exit(0);
|
|
239
|
+
}
|
|
240
|
+
const file = opts['status-file'];
|
|
241
|
+
if (!file) {
|
|
242
|
+
log.error('--status-file is required');
|
|
243
|
+
emit([], opts.format || 'json', 'parse-error');
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
const format = opts.format || 'json';
|
|
247
|
+
if (!VALID_FORMATS.includes(format)) {
|
|
248
|
+
log.error(`invalid --format '${format}'. Valid: ${VALID_FORMATS.join(', ')}`);
|
|
249
|
+
process.exit(1);
|
|
250
|
+
}
|
|
251
|
+
if (!fs.existsSync(file)) {
|
|
252
|
+
// Pre-planning — status file not yet created.
|
|
253
|
+
emit([], format, 'pre-planning');
|
|
254
|
+
process.exit(2);
|
|
255
|
+
}
|
|
256
|
+
let raw;
|
|
257
|
+
try {
|
|
258
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
259
|
+
} catch (e) {
|
|
260
|
+
log.error(`failed to read ${file}: ${e.message}`);
|
|
261
|
+
emit([], format, 'parse-error');
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
264
|
+
let stories;
|
|
265
|
+
try {
|
|
266
|
+
stories = parseStatuses(raw);
|
|
267
|
+
} catch (e) {
|
|
268
|
+
log.error(`failed to parse ${file}: ${e.message}`);
|
|
269
|
+
emit([], format, 'parse-error');
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
const remaining = remainingFrom(stories);
|
|
273
|
+
const storyCount = Object.keys(stories).length;
|
|
274
|
+
let state;
|
|
275
|
+
if (storyCount === 0) {
|
|
276
|
+
state = 'pre-planning';
|
|
277
|
+
} else if (remaining.length === 0) {
|
|
278
|
+
state = 'sprint-complete';
|
|
279
|
+
} else {
|
|
280
|
+
state = 'sprint-in-progress';
|
|
281
|
+
}
|
|
282
|
+
emit(remaining, format, state);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
module.exports = {
|
|
286
|
+
VALID_FORMATS,
|
|
287
|
+
parseStatuses,
|
|
288
|
+
remainingFrom,
|
|
289
|
+
stripQuotes,
|
|
290
|
+
isDone,
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
if (require.main === module) {
|
|
294
|
+
main();
|
|
295
|
+
}
|