@ikunin/sprintpilot 1.0.5 → 2.0.5
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 +48 -1
- 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 +425 -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 +572 -42
- package/bin/sprintpilot.js +4 -0
- package/lib/commands/install.js +157 -1
- package/package.json +1 -1
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// log-timing.js — append a timing event to a per-story JSONL shard.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// log-timing.js <action> --story <key> --phase <name> [--meta <json>]
|
|
7
|
+
//
|
|
8
|
+
// Actions:
|
|
9
|
+
// start Emit {event:"start", story, phase, ts:<iso8601>}
|
|
10
|
+
// end Emit {event:"end", story, phase, ts:<iso8601>}
|
|
11
|
+
// once Emit a single-event marker (for things like health-check-run)
|
|
12
|
+
// mark Single-call replacement for start/end pairs. Reads a tiny
|
|
13
|
+
// marker file (.timings/.mark.json), computes the duration
|
|
14
|
+
// since the previous mark, emits one duration record for the
|
|
15
|
+
// PREVIOUS phase, and writes a new marker for the current
|
|
16
|
+
// phase. Designed for LLM-driven workflows where the agent
|
|
17
|
+
// may forget to call `end` after a long skill — `mark` only
|
|
18
|
+
// needs to be called ONCE per phase transition.
|
|
19
|
+
//
|
|
20
|
+
// Output path:
|
|
21
|
+
// <project-root>/_bmad-output/implementation-artifacts/.timings/<story>.jsonl
|
|
22
|
+
// Append-only, one writer per story-key = one sub-agent in the autopilot
|
|
23
|
+
// model. Each line is JSON, < LINE_MAX_BYTES so a single write() is
|
|
24
|
+
// atomic on POSIX (PIPE_BUF >= 4096 on every supported platform).
|
|
25
|
+
//
|
|
26
|
+
// No-op contract:
|
|
27
|
+
// If the resolved profile has autopilot.phase_timings !== true, the
|
|
28
|
+
// script silently returns without creating files. This is the
|
|
29
|
+
// rollback path for PR 2 and the permanent behavior for the `legacy`
|
|
30
|
+
// profile.
|
|
31
|
+
|
|
32
|
+
const fs = require('node:fs');
|
|
33
|
+
const path = require('node:path');
|
|
34
|
+
|
|
35
|
+
const { parseArgs } = require('../lib/runtime/args');
|
|
36
|
+
const log = require('../lib/runtime/log');
|
|
37
|
+
|
|
38
|
+
// Path-traversal guard: a story key is part of the filename, so it must
|
|
39
|
+
// not contain slashes, dots, or other shell-significant characters.
|
|
40
|
+
const STORY_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
41
|
+
// Phase names follow a dotted-namespace convention (skill.bmad-dev-story).
|
|
42
|
+
const PHASE_RE = /^[a-z][a-z0-9-.]*$/;
|
|
43
|
+
const META_MAX_BYTES = 2048;
|
|
44
|
+
const LINE_MAX_BYTES = 4096; // POSIX PIPE_BUF floor — single write() is atomic
|
|
45
|
+
const VALID_ACTIONS = ['start', 'end', 'once', 'mark'];
|
|
46
|
+
// MARKER_FILE: per-story marker file template. The actual file is
|
|
47
|
+
// `.mark.<story>.json` so concurrent writers for different stories never
|
|
48
|
+
// race on the same path. Pre-2.0.5 used a single global `.mark.json`
|
|
49
|
+
// which corrupted timing data when sub-agents in the same project root
|
|
50
|
+
// marked phases concurrently (e.g. parallel story dispatch). Kept as
|
|
51
|
+
// `.mark.json` only as a back-compat constant; runtime always uses the
|
|
52
|
+
// per-story path via `markerPath(root, story)`.
|
|
53
|
+
const MARKER_FILE = '.mark.json';
|
|
54
|
+
|
|
55
|
+
function help() {
|
|
56
|
+
log.out(
|
|
57
|
+
[
|
|
58
|
+
'Usage:',
|
|
59
|
+
' log-timing.js <action> --story <key> --phase <name> [--meta <json>]',
|
|
60
|
+
'',
|
|
61
|
+
'Actions: start | end | once',
|
|
62
|
+
'',
|
|
63
|
+
'Options:',
|
|
64
|
+
' --project-root <path> Defaults to cwd',
|
|
65
|
+
' --story <key> Matches /^[a-z0-9][a-z0-9-]*$/',
|
|
66
|
+
' --phase <name> Matches /^[a-z][a-z0-9-.]*$/',
|
|
67
|
+
' --meta <json> Inline JSON, serialized < 2KB',
|
|
68
|
+
].join('\n'),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function validateStory(s) {
|
|
73
|
+
if (s === undefined || s === null || s === '') {
|
|
74
|
+
return { ok: false, error: '--story is required' };
|
|
75
|
+
}
|
|
76
|
+
if (!STORY_RE.test(s)) {
|
|
77
|
+
return { ok: false, error: `invalid --story '${s}': must match ${STORY_RE}` };
|
|
78
|
+
}
|
|
79
|
+
return { ok: true, value: s };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function validatePhase(s) {
|
|
83
|
+
if (s === undefined || s === null || s === '') {
|
|
84
|
+
return { ok: false, error: '--phase is required' };
|
|
85
|
+
}
|
|
86
|
+
if (!PHASE_RE.test(s)) {
|
|
87
|
+
return { ok: false, error: `invalid --phase '${s}': must match ${PHASE_RE}` };
|
|
88
|
+
}
|
|
89
|
+
return { ok: true, value: s };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function validateAction(a) {
|
|
93
|
+
if (!VALID_ACTIONS.includes(a)) {
|
|
94
|
+
return { ok: false, error: `invalid action '${a}': must be one of ${VALID_ACTIONS.join(', ')}` };
|
|
95
|
+
}
|
|
96
|
+
return { ok: true, value: a };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function validateMeta(metaJson) {
|
|
100
|
+
if (metaJson === undefined) return { ok: true, value: undefined };
|
|
101
|
+
let parsed;
|
|
102
|
+
try {
|
|
103
|
+
parsed = JSON.parse(metaJson);
|
|
104
|
+
} catch (e) {
|
|
105
|
+
return { ok: false, error: `--meta is not valid JSON: ${e.message}` };
|
|
106
|
+
}
|
|
107
|
+
const serialized = JSON.stringify(parsed);
|
|
108
|
+
const bytes = Buffer.byteLength(serialized, 'utf8');
|
|
109
|
+
if (bytes > META_MAX_BYTES) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
error: `--meta exceeds ${META_MAX_BYTES} bytes after serialization (got ${bytes})`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return { ok: true, value: parsed };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function timingsDir(projectRoot) {
|
|
119
|
+
return path.join(projectRoot, '_bmad-output', 'implementation-artifacts', '.timings');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function readPhaseTimingSetting(projectRoot) {
|
|
123
|
+
const profilesDir = path.join(projectRoot, '_Sprintpilot', 'modules', 'autopilot', 'profiles');
|
|
124
|
+
if (!fs.existsSync(profilesDir)) return false;
|
|
125
|
+
const pick = (raw) => {
|
|
126
|
+
const m = raw.match(/^[ \t]*phase_timings:[ \t]*(true|false)[ \t]*(?:#.*)?$/m);
|
|
127
|
+
return m ? m[1] === 'true' : null;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const cfgPath = path.join(projectRoot, '_Sprintpilot', 'modules', 'autopilot', 'config.yaml');
|
|
131
|
+
if (fs.existsSync(cfgPath)) {
|
|
132
|
+
const override = pick(fs.readFileSync(cfgPath, 'utf8'));
|
|
133
|
+
if (override !== null) return override;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let profileName = 'medium';
|
|
137
|
+
if (fs.existsSync(cfgPath)) {
|
|
138
|
+
const raw = fs.readFileSync(cfgPath, 'utf8');
|
|
139
|
+
const m = raw.match(/^[ \t]*complexity_profile:[ \t]*["']?([a-zA-Z_-]+)["']?[ \t]*(?:#.*)?$/m);
|
|
140
|
+
if (m) profileName = m[1];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const profileFile = path.join(profilesDir, `${profileName}.yaml`);
|
|
144
|
+
if (fs.existsSync(profileFile)) {
|
|
145
|
+
const val = pick(fs.readFileSync(profileFile, 'utf8'));
|
|
146
|
+
if (val !== null) return val;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Non-legacy profiles inherit from _base. Legacy stands alone — if its
|
|
150
|
+
// own file didn't declare the key (shouldn't happen), we fall through
|
|
151
|
+
// to `false` (fail-safe — never write silently).
|
|
152
|
+
if (profileName !== 'legacy') {
|
|
153
|
+
const baseFile = path.join(profilesDir, '_base.yaml');
|
|
154
|
+
if (fs.existsSync(baseFile)) {
|
|
155
|
+
const val = pick(fs.readFileSync(baseFile, 'utf8'));
|
|
156
|
+
if (val !== null) return val;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isEnabled(projectRoot) {
|
|
164
|
+
try {
|
|
165
|
+
return readPhaseTimingSetting(projectRoot) === true;
|
|
166
|
+
} catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function appendLine(projectRoot, story, entry) {
|
|
172
|
+
const dir = timingsDir(projectRoot);
|
|
173
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
174
|
+
const line = JSON.stringify(entry);
|
|
175
|
+
const bytes = Buffer.byteLength(`${line}\n`, 'utf8');
|
|
176
|
+
if (bytes > LINE_MAX_BYTES) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`timing line exceeds ${LINE_MAX_BYTES} bytes (${bytes}); refusing to write a non-atomic record`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
const file = path.join(dir, `${story}.jsonl`);
|
|
182
|
+
fs.appendFileSync(file, `${line}\n`);
|
|
183
|
+
return file;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function buildEntry(action, story, phase, meta) {
|
|
187
|
+
const entry = {
|
|
188
|
+
event: action,
|
|
189
|
+
story,
|
|
190
|
+
phase,
|
|
191
|
+
ts: new Date().toISOString(),
|
|
192
|
+
};
|
|
193
|
+
if (meta !== undefined) entry.meta = meta;
|
|
194
|
+
return entry;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------
|
|
198
|
+
// `mark` — single-call timing
|
|
199
|
+
// ---------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
function markerPath(projectRoot, story) {
|
|
202
|
+
if (!story) throw new Error('markerPath requires a story key');
|
|
203
|
+
return path.join(timingsDir(projectRoot), `.mark.${story}.json`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function readMarker(projectRoot, story) {
|
|
207
|
+
const file = markerPath(projectRoot, story);
|
|
208
|
+
let raw;
|
|
209
|
+
try {
|
|
210
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
211
|
+
} catch (e) {
|
|
212
|
+
if (e.code === 'ENOENT') return null;
|
|
213
|
+
// EACCES / EISDIR / other I/O — surface to stderr so silent corruption
|
|
214
|
+
// doesn't masquerade as "first mark of session".
|
|
215
|
+
log.error(`timing marker read failed (${file}): ${e.message}`);
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
const parsed = JSON.parse(raw);
|
|
220
|
+
if (
|
|
221
|
+
parsed &&
|
|
222
|
+
typeof parsed === 'object' &&
|
|
223
|
+
typeof parsed.story === 'string' &&
|
|
224
|
+
typeof parsed.phase === 'string' &&
|
|
225
|
+
typeof parsed.ts === 'string'
|
|
226
|
+
) {
|
|
227
|
+
return parsed;
|
|
228
|
+
}
|
|
229
|
+
} catch (e) {
|
|
230
|
+
log.error(`timing marker corrupt (${file}): ${e.message} — treating as absent`);
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function writeMarker(projectRoot, story, marker) {
|
|
236
|
+
const dir = timingsDir(projectRoot);
|
|
237
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
238
|
+
const file = markerPath(projectRoot, story);
|
|
239
|
+
// Atomic-ish: write tmp + rename. Marker is small, single-line JSON.
|
|
240
|
+
// Tmp filename includes story + pid + random suffix to avoid collisions
|
|
241
|
+
// between concurrent same-process writers (rare in normal use, common in
|
|
242
|
+
// parallel test runs) and PID-reuse.
|
|
243
|
+
const tmp = `${file}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
|
|
244
|
+
try {
|
|
245
|
+
fs.writeFileSync(tmp, JSON.stringify(marker));
|
|
246
|
+
fs.renameSync(tmp, file);
|
|
247
|
+
} catch (e) {
|
|
248
|
+
// Clean up tmp on rename failure so we don't leak orphan files.
|
|
249
|
+
try {
|
|
250
|
+
fs.unlinkSync(tmp);
|
|
251
|
+
} catch {
|
|
252
|
+
/* ignore — tmp may not exist */
|
|
253
|
+
}
|
|
254
|
+
throw e;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function clearMarker(projectRoot, story) {
|
|
259
|
+
const file = markerPath(projectRoot, story);
|
|
260
|
+
try {
|
|
261
|
+
fs.unlinkSync(file);
|
|
262
|
+
} catch {
|
|
263
|
+
/* ignore */
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* mark: single-call timing API.
|
|
269
|
+
*
|
|
270
|
+
* Emits a duration record for THIS story's PREVIOUS phase (if any),
|
|
271
|
+
* covering the interval since the previous mark for the same story key,
|
|
272
|
+
* then writes a new marker for the current phase. The very first mark
|
|
273
|
+
* for a given story emits no duration record — there's no "previous
|
|
274
|
+
* phase" yet for that story.
|
|
275
|
+
*
|
|
276
|
+
* Pre-2.0.5 used a single global marker file shared across stories,
|
|
277
|
+
* which under parallel dispatch (sub-agents marking different stories
|
|
278
|
+
* concurrently against the same project root) raced on a single file —
|
|
279
|
+
* one rename clobbered the other and durations were attributed to the
|
|
280
|
+
* wrong (story, phase). Per-story markers eliminate the race entirely:
|
|
281
|
+
* each story has its own marker file `.mark.<story>.json`.
|
|
282
|
+
*
|
|
283
|
+
* Use phase = "_end" to close THIS story's last open phase without
|
|
284
|
+
* starting a new one (e.g. at sprint-complete time, or per-story
|
|
285
|
+
* cleanup). `_end` only touches the marker for the named story; other
|
|
286
|
+
* stories' markers are untouched.
|
|
287
|
+
*
|
|
288
|
+
* Order of operations is interrupt-safe: the new marker is written
|
|
289
|
+
* BEFORE the duration record is appended. If the process is killed
|
|
290
|
+
* between the marker rename and the duration append, we lose one
|
|
291
|
+
* duration record but the next mark will read the new marker (not the
|
|
292
|
+
* stale prev) and won't double-count.
|
|
293
|
+
*
|
|
294
|
+
* Wall-clock skew: durations are clamped at 0 with a `clock_skew: true`
|
|
295
|
+
* flag in the entry so aggregators don't get poisoned by NTP backsteps
|
|
296
|
+
* or DST transitions.
|
|
297
|
+
*
|
|
298
|
+
* Returns { duration_ms, prev_phase } so callers can log/inspect.
|
|
299
|
+
*/
|
|
300
|
+
function markPhase(projectRoot, story, phase, meta) {
|
|
301
|
+
const now = new Date();
|
|
302
|
+
const prev = readMarker(projectRoot, story);
|
|
303
|
+
|
|
304
|
+
// Build the duration entry from prev (if any) before mutating marker
|
|
305
|
+
// state. We append AFTER writing the new marker, so an interrupt
|
|
306
|
+
// between the two yields one missed record (acceptable) rather than a
|
|
307
|
+
// stale marker that would double-count on the next call.
|
|
308
|
+
let durationEntry = null;
|
|
309
|
+
let durationMs = null;
|
|
310
|
+
let prevPhase = null;
|
|
311
|
+
if (prev) {
|
|
312
|
+
const prevTs = Date.parse(prev.ts);
|
|
313
|
+
if (!Number.isNaN(prevTs)) {
|
|
314
|
+
const rawDelta = now.getTime() - prevTs;
|
|
315
|
+
durationMs = Math.max(0, rawDelta);
|
|
316
|
+
prevPhase = prev.phase;
|
|
317
|
+
durationEntry = {
|
|
318
|
+
event: 'duration',
|
|
319
|
+
story: prev.story,
|
|
320
|
+
phase: prev.phase,
|
|
321
|
+
started: prev.ts,
|
|
322
|
+
ended: now.toISOString(),
|
|
323
|
+
duration_ms: durationMs,
|
|
324
|
+
};
|
|
325
|
+
if (rawDelta < 0) durationEntry.clock_skew = true;
|
|
326
|
+
if (prev.meta !== undefined) durationEntry.meta = prev.meta;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// 1. Commit the marker state transition first.
|
|
331
|
+
if (phase === '_end') {
|
|
332
|
+
clearMarker(projectRoot, story);
|
|
333
|
+
} else {
|
|
334
|
+
const marker = { story, phase, ts: now.toISOString() };
|
|
335
|
+
if (meta !== undefined) marker.meta = meta;
|
|
336
|
+
writeMarker(projectRoot, story, marker);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// 2. Append the duration record after the marker is committed. If
|
|
340
|
+
// this throws, the marker is already correct for the next mark.
|
|
341
|
+
if (durationEntry !== null) {
|
|
342
|
+
appendLine(projectRoot, prev.story, durationEntry);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return { duration_ms: durationMs, prev_phase: prevPhase };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function main() {
|
|
349
|
+
const { opts, positional } = parseArgs(process.argv.slice(2));
|
|
350
|
+
if (opts.help || positional.length === 0) {
|
|
351
|
+
help();
|
|
352
|
+
process.exit(opts.help ? 0 : 1);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const action = validateAction(positional[0]);
|
|
356
|
+
if (!action.ok) {
|
|
357
|
+
log.error(action.error);
|
|
358
|
+
process.exit(1);
|
|
359
|
+
}
|
|
360
|
+
const story = validateStory(opts.story);
|
|
361
|
+
if (!story.ok) {
|
|
362
|
+
log.error(story.error);
|
|
363
|
+
process.exit(1);
|
|
364
|
+
}
|
|
365
|
+
// `mark _end` is a sentinel that closes the last open phase without
|
|
366
|
+
// starting a new one. Skip the regex check for it; everything else
|
|
367
|
+
// must match PHASE_RE.
|
|
368
|
+
const phase =
|
|
369
|
+
action.value === 'mark' && opts.phase === '_end'
|
|
370
|
+
? { ok: true, value: '_end' }
|
|
371
|
+
: validatePhase(opts.phase);
|
|
372
|
+
if (!phase.ok) {
|
|
373
|
+
log.error(phase.error);
|
|
374
|
+
process.exit(1);
|
|
375
|
+
}
|
|
376
|
+
const meta = validateMeta(opts.meta);
|
|
377
|
+
if (!meta.ok) {
|
|
378
|
+
log.error(meta.error);
|
|
379
|
+
process.exit(1);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const projectRoot = opts['project-root'] || process.cwd();
|
|
383
|
+
if (!isEnabled(projectRoot)) return;
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
if (action.value === 'mark') {
|
|
387
|
+
const r = markPhase(projectRoot, story.value, phase.value, meta.value);
|
|
388
|
+
// Emit a brief JSON line so callers can log the duration if useful.
|
|
389
|
+
// Stdout is intentionally separate from the per-story shard.
|
|
390
|
+
process.stdout.write(`${JSON.stringify({ marked: phase.value, prev_phase: r.prev_phase, duration_ms: r.duration_ms })}\n`);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
appendLine(projectRoot, story.value, buildEntry(action.value, story.value, phase.value, meta.value));
|
|
394
|
+
} catch (e) {
|
|
395
|
+
log.error(`timing write failed: ${e.message}`);
|
|
396
|
+
process.exit(1);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
module.exports = {
|
|
401
|
+
STORY_RE,
|
|
402
|
+
PHASE_RE,
|
|
403
|
+
META_MAX_BYTES,
|
|
404
|
+
LINE_MAX_BYTES,
|
|
405
|
+
MARKER_FILE,
|
|
406
|
+
VALID_ACTIONS,
|
|
407
|
+
validateStory,
|
|
408
|
+
validatePhase,
|
|
409
|
+
validateAction,
|
|
410
|
+
validateMeta,
|
|
411
|
+
timingsDir,
|
|
412
|
+
markerPath,
|
|
413
|
+
readMarker,
|
|
414
|
+
writeMarker,
|
|
415
|
+
clearMarker,
|
|
416
|
+
markPhase,
|
|
417
|
+
readPhaseTimingSetting,
|
|
418
|
+
isEnabled,
|
|
419
|
+
appendLine,
|
|
420
|
+
buildEntry,
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
if (require.main === module) {
|
|
424
|
+
main();
|
|
425
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// mark-done-stories-tasks.js — final pass that replaces `- [ ]` with `- [x]`
|
|
4
|
+
// in every story file whose status is "done" in sprint-status.yaml.
|
|
5
|
+
//
|
|
6
|
+
// Usage:
|
|
7
|
+
// mark-done-stories-tasks.js --status-file <path> [--project-root <path>]
|
|
8
|
+
// [--output-folder <path>]
|
|
9
|
+
//
|
|
10
|
+
// Rationale: bmad-dev-story is supposed to check off its Tasks/Subtasks as
|
|
11
|
+
// it implements each story, and workflow.md's step 7 has an explicit
|
|
12
|
+
// "Mark all task checkboxes complete" action. Both are LLM-executed
|
|
13
|
+
// instructions that the autopilot sometimes skips in long sessions. This
|
|
14
|
+
// helper is the final deterministic safety net — it runs from step 10's
|
|
15
|
+
// critical path and brings the filesystem state in line with sprint-status.
|
|
16
|
+
//
|
|
17
|
+
// Story file lookup: honors BMad's `output_folder` config
|
|
18
|
+
// (_bmad/bmm/config.yaml). Resolves in this order:
|
|
19
|
+
// <output-folder>/stories/story-<key>.md
|
|
20
|
+
// <output-folder>/implementation-artifacts/story-<key>.md
|
|
21
|
+
// <output-folder>/stories/<key>.md
|
|
22
|
+
// <output-folder>/implementation-artifacts/<key>.md
|
|
23
|
+
//
|
|
24
|
+
// Checkbox replacement is fenced-code-aware: `- [ ]` inside triple-backtick
|
|
25
|
+
// or triple-tilde blocks is NOT rewritten, because story templates commonly
|
|
26
|
+
// show example task lists that must round-trip verbatim.
|
|
27
|
+
//
|
|
28
|
+
// Writes are durable-atomic: write to tmp, fsync tmp, rename, fsync parent
|
|
29
|
+
// directory. A crash between rename and flush cannot leave a zero-byte
|
|
30
|
+
// story file.
|
|
31
|
+
|
|
32
|
+
const fs = require('node:fs');
|
|
33
|
+
const path = require('node:path');
|
|
34
|
+
|
|
35
|
+
const { parseArgs } = require('../lib/runtime/args');
|
|
36
|
+
const log = require('../lib/runtime/log');
|
|
37
|
+
const { parseStatuses, isDone } = require('./list-remaining-stories.js');
|
|
38
|
+
const timing = require('./log-timing.js');
|
|
39
|
+
|
|
40
|
+
// Best-effort auto-emit: surfaces a timing event so that callers don't
|
|
41
|
+
// have to wrap every CRITICAL invocation in start/end pairs the LLM
|
|
42
|
+
// might skip. Silent no-op if phase_timings is disabled or anything
|
|
43
|
+
// throws — never affects the script's primary outcome.
|
|
44
|
+
function emitTimingEvent(projectRoot, phase, meta) {
|
|
45
|
+
try {
|
|
46
|
+
if (!timing.isEnabled(projectRoot)) return;
|
|
47
|
+
timing.appendLine(projectRoot, 'sprint', timing.buildEntry('once', 'sprint', phase, meta));
|
|
48
|
+
} catch {
|
|
49
|
+
/* ignore */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function help() {
|
|
54
|
+
log.out(
|
|
55
|
+
[
|
|
56
|
+
'Usage:',
|
|
57
|
+
' mark-done-stories-tasks.js --status-file <path> [--project-root <path>]',
|
|
58
|
+
' [--output-folder <path>]',
|
|
59
|
+
'',
|
|
60
|
+
'For every story with status="done" (case-insensitive), replaces every',
|
|
61
|
+
'`- [ ]` with `- [x]` in its story markdown file. Fenced code blocks',
|
|
62
|
+
'are preserved verbatim. Emits JSON summary to stdout.',
|
|
63
|
+
].join('\n'),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Read BMad's output_folder from _bmad/bmm/config.yaml if present. Returns
|
|
68
|
+
// the folder name (relative to projectRoot) or null if not configurable.
|
|
69
|
+
function readOutputFolder(projectRoot) {
|
|
70
|
+
const cfg = path.join(projectRoot, '_bmad', 'bmm', 'config.yaml');
|
|
71
|
+
if (!fs.existsSync(cfg)) return null;
|
|
72
|
+
try {
|
|
73
|
+
const body = fs.readFileSync(cfg, 'utf8');
|
|
74
|
+
const m = body.match(/^output_folder\s*:\s*(\S+)/m);
|
|
75
|
+
if (!m) return null;
|
|
76
|
+
return m[1].replace(/^["']|["']$/g, '').trim();
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function findStoryFile(projectRoot, storyKey, outputFolder) {
|
|
83
|
+
const folder = outputFolder || readOutputFolder(projectRoot) || '_bmad-output';
|
|
84
|
+
const candidates = [
|
|
85
|
+
path.join(projectRoot, folder, 'stories', `story-${storyKey}.md`),
|
|
86
|
+
path.join(projectRoot, folder, 'implementation-artifacts', `story-${storyKey}.md`),
|
|
87
|
+
path.join(projectRoot, folder, 'stories', `${storyKey}.md`),
|
|
88
|
+
path.join(projectRoot, folder, 'implementation-artifacts', `${storyKey}.md`),
|
|
89
|
+
];
|
|
90
|
+
for (const c of candidates) {
|
|
91
|
+
if (fs.existsSync(c)) return c;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Line-by-line replacement that tracks fenced code blocks. Inside ``` or
|
|
97
|
+
// ~~~ blocks the original line is emitted verbatim. Outside, `- [ ]` (or
|
|
98
|
+
// `* [ ]`) at any indent is rewritten to `- [x]`.
|
|
99
|
+
//
|
|
100
|
+
// Fence detection: a line whose first non-whitespace characters are ```
|
|
101
|
+
// or ~~~ toggles the fenced state. Info strings after the opener (e.g.
|
|
102
|
+
// ```ts) are allowed. Only the fence character is significant.
|
|
103
|
+
function markAllTasksChecked(body) {
|
|
104
|
+
const lines = String(body).split('\n');
|
|
105
|
+
const out = new Array(lines.length);
|
|
106
|
+
let inFence = false;
|
|
107
|
+
let fenceChar = null;
|
|
108
|
+
const fenceOpenRe = /^\s*(`{3,}|~{3,})/;
|
|
109
|
+
const taskRe = /^(\s*[-*]\s*)\[ \](\s*)/;
|
|
110
|
+
|
|
111
|
+
for (let i = 0; i < lines.length; i++) {
|
|
112
|
+
const line = lines[i];
|
|
113
|
+
const fence = line.match(fenceOpenRe);
|
|
114
|
+
if (fence) {
|
|
115
|
+
const ch = fence[1][0];
|
|
116
|
+
if (!inFence) {
|
|
117
|
+
inFence = true;
|
|
118
|
+
fenceChar = ch;
|
|
119
|
+
} else if (fenceChar === ch) {
|
|
120
|
+
inFence = false;
|
|
121
|
+
fenceChar = null;
|
|
122
|
+
}
|
|
123
|
+
out[i] = line;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (inFence) {
|
|
127
|
+
out[i] = line;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
out[i] = line.replace(taskRe, (_m, pre, post) => `${pre}[x]${post}`);
|
|
131
|
+
}
|
|
132
|
+
return out.join('\n');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Durable-atomic write: tmp → fsync(tmp) → rename → fsync(parent dir).
|
|
136
|
+
// Preserves file mode of the existing target if possible.
|
|
137
|
+
function atomicWrite(file, body) {
|
|
138
|
+
const dir = path.dirname(file);
|
|
139
|
+
const tmp = path.join(
|
|
140
|
+
dir,
|
|
141
|
+
`.${path.basename(file)}.tmp.${process.pid}.${process.hrtime.bigint().toString(36)}`,
|
|
142
|
+
);
|
|
143
|
+
let mode;
|
|
144
|
+
try {
|
|
145
|
+
mode = fs.statSync(file).mode;
|
|
146
|
+
} catch {
|
|
147
|
+
/* new file — leave mode unset */
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const fd = fs.openSync(tmp, 'w', mode ?? 0o644);
|
|
151
|
+
try {
|
|
152
|
+
fs.writeFileSync(fd, body);
|
|
153
|
+
try {
|
|
154
|
+
fs.fsyncSync(fd);
|
|
155
|
+
} catch {
|
|
156
|
+
/* fsync not supported on this fs — best-effort */
|
|
157
|
+
}
|
|
158
|
+
} finally {
|
|
159
|
+
fs.closeSync(fd);
|
|
160
|
+
}
|
|
161
|
+
fs.renameSync(tmp, file);
|
|
162
|
+
// Best-effort directory fsync so the rename itself is durable. Skipped on
|
|
163
|
+
// Windows where fs.openSync(<dir>, 'r') throws EISDIR/EPERM — there's no
|
|
164
|
+
// documented way to fsync a directory handle. The rename itself is still
|
|
165
|
+
// atomic on NTFS, just not flushed to disk on power loss the way fsync
|
|
166
|
+
// would guarantee on POSIX.
|
|
167
|
+
if (process.platform !== 'win32') {
|
|
168
|
+
try {
|
|
169
|
+
const dfd = fs.openSync(dir, 'r');
|
|
170
|
+
try {
|
|
171
|
+
fs.fsyncSync(dfd);
|
|
172
|
+
} finally {
|
|
173
|
+
fs.closeSync(dfd);
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
/* directory fsync unsupported on some filesystems — ignore */
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function main() {
|
|
182
|
+
const { opts } = parseArgs(process.argv.slice(2));
|
|
183
|
+
if (opts.help) {
|
|
184
|
+
help();
|
|
185
|
+
process.exit(0);
|
|
186
|
+
}
|
|
187
|
+
const statusFile = opts['status-file'];
|
|
188
|
+
if (!statusFile) {
|
|
189
|
+
log.error('--status-file is required');
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
if (!fs.existsSync(statusFile)) {
|
|
193
|
+
log.error(`status file missing: ${statusFile}`);
|
|
194
|
+
process.exit(2);
|
|
195
|
+
}
|
|
196
|
+
const projectRoot = opts['project-root'] || process.cwd();
|
|
197
|
+
const outputFolder = opts['output-folder'] || null;
|
|
198
|
+
|
|
199
|
+
let raw;
|
|
200
|
+
try {
|
|
201
|
+
raw = fs.readFileSync(statusFile, 'utf8');
|
|
202
|
+
} catch (e) {
|
|
203
|
+
log.error(`cannot read ${statusFile}: ${e.message}`);
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const stories = parseStatuses(raw);
|
|
208
|
+
const doneKeys = Object.keys(stories).filter((k) => isDone(stories[k].status));
|
|
209
|
+
|
|
210
|
+
const summary = {
|
|
211
|
+
done_stories: doneKeys.length,
|
|
212
|
+
marked: [],
|
|
213
|
+
missing_files: [],
|
|
214
|
+
unchanged: [],
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
for (const key of doneKeys) {
|
|
218
|
+
const file = findStoryFile(projectRoot, key, outputFolder);
|
|
219
|
+
if (!file) {
|
|
220
|
+
summary.missing_files.push(key);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const body = fs.readFileSync(file, 'utf8');
|
|
224
|
+
const marked = markAllTasksChecked(body);
|
|
225
|
+
if (marked === body) {
|
|
226
|
+
summary.unchanged.push(key);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
atomicWrite(file, marked);
|
|
231
|
+
summary.marked.push({ key, file });
|
|
232
|
+
} catch (e) {
|
|
233
|
+
log.warn(`failed to mark ${file}: ${e.message}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
emitTimingEvent(projectRoot, 'cleanup.mark-done-tasks', {
|
|
238
|
+
done_stories: summary.done_stories,
|
|
239
|
+
marked: summary.marked.length,
|
|
240
|
+
missing_files: summary.missing_files.length,
|
|
241
|
+
});
|
|
242
|
+
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
module.exports = {
|
|
246
|
+
findStoryFile,
|
|
247
|
+
markAllTasksChecked,
|
|
248
|
+
atomicWrite,
|
|
249
|
+
readOutputFolder,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
if (require.main === module) {
|
|
253
|
+
main();
|
|
254
|
+
}
|