@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.
Files changed (35) hide show
  1. package/README.md +48 -1
  2. package/_Sprintpilot/Sprintpilot.md +14 -1
  3. package/_Sprintpilot/manifest.yaml +1 -1
  4. package/_Sprintpilot/modules/autopilot/config.yaml +22 -0
  5. package/_Sprintpilot/modules/autopilot/profiles/_base.yaml +45 -0
  6. package/_Sprintpilot/modules/autopilot/profiles/large.yaml +22 -0
  7. package/_Sprintpilot/modules/autopilot/profiles/legacy.yaml +35 -0
  8. package/_Sprintpilot/modules/autopilot/profiles/medium.yaml +5 -0
  9. package/_Sprintpilot/modules/autopilot/profiles/nano.yaml +35 -0
  10. package/_Sprintpilot/modules/autopilot/profiles/small.yaml +5 -0
  11. package/_Sprintpilot/modules/git/config.yaml +8 -0
  12. package/_Sprintpilot/modules/ma/config.yaml +42 -0
  13. package/_Sprintpilot/scripts/agent-adapter.js +247 -0
  14. package/_Sprintpilot/scripts/cached-read.js +238 -0
  15. package/_Sprintpilot/scripts/check-prereqs.js +139 -0
  16. package/_Sprintpilot/scripts/dispatch-layer.js +192 -0
  17. package/_Sprintpilot/scripts/git-portable.js +219 -0
  18. package/_Sprintpilot/scripts/infer-dependencies.js +594 -0
  19. package/_Sprintpilot/scripts/inject-tasks-section.js +279 -0
  20. package/_Sprintpilot/scripts/list-remaining-stories.js +295 -0
  21. package/_Sprintpilot/scripts/log-timing.js +425 -0
  22. package/_Sprintpilot/scripts/mark-done-stories-tasks.js +254 -0
  23. package/_Sprintpilot/scripts/merge-shards.js +339 -0
  24. package/_Sprintpilot/scripts/preflight-merge.js +235 -0
  25. package/_Sprintpilot/scripts/resolve-dag.js +559 -0
  26. package/_Sprintpilot/scripts/resolve-profile.js +355 -0
  27. package/_Sprintpilot/scripts/state-shard.js +602 -0
  28. package/_Sprintpilot/scripts/submodule-lock.js +130 -0
  29. package/_Sprintpilot/scripts/summarize-timings.js +362 -0
  30. package/_Sprintpilot/scripts/sync-status.js +13 -0
  31. package/_Sprintpilot/scripts/with-retry.js +145 -0
  32. package/_Sprintpilot/skills/sprint-autopilot-on/workflow.md +572 -42
  33. package/bin/sprintpilot.js +4 -0
  34. package/lib/commands/install.js +157 -1
  35. 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
+ }