@ikunin/sprintpilot 1.0.5 → 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.
Files changed (34) hide show
  1. package/_Sprintpilot/Sprintpilot.md +14 -1
  2. package/_Sprintpilot/manifest.yaml +1 -1
  3. package/_Sprintpilot/modules/autopilot/config.yaml +22 -0
  4. package/_Sprintpilot/modules/autopilot/profiles/_base.yaml +45 -0
  5. package/_Sprintpilot/modules/autopilot/profiles/large.yaml +22 -0
  6. package/_Sprintpilot/modules/autopilot/profiles/legacy.yaml +35 -0
  7. package/_Sprintpilot/modules/autopilot/profiles/medium.yaml +5 -0
  8. package/_Sprintpilot/modules/autopilot/profiles/nano.yaml +35 -0
  9. package/_Sprintpilot/modules/autopilot/profiles/small.yaml +5 -0
  10. package/_Sprintpilot/modules/git/config.yaml +8 -0
  11. package/_Sprintpilot/modules/ma/config.yaml +42 -0
  12. package/_Sprintpilot/scripts/agent-adapter.js +247 -0
  13. package/_Sprintpilot/scripts/cached-read.js +238 -0
  14. package/_Sprintpilot/scripts/check-prereqs.js +139 -0
  15. package/_Sprintpilot/scripts/dispatch-layer.js +192 -0
  16. package/_Sprintpilot/scripts/git-portable.js +219 -0
  17. package/_Sprintpilot/scripts/infer-dependencies.js +594 -0
  18. package/_Sprintpilot/scripts/inject-tasks-section.js +279 -0
  19. package/_Sprintpilot/scripts/list-remaining-stories.js +295 -0
  20. package/_Sprintpilot/scripts/log-timing.js +360 -0
  21. package/_Sprintpilot/scripts/mark-done-stories-tasks.js +254 -0
  22. package/_Sprintpilot/scripts/merge-shards.js +339 -0
  23. package/_Sprintpilot/scripts/preflight-merge.js +235 -0
  24. package/_Sprintpilot/scripts/resolve-dag.js +559 -0
  25. package/_Sprintpilot/scripts/resolve-profile.js +355 -0
  26. package/_Sprintpilot/scripts/state-shard.js +602 -0
  27. package/_Sprintpilot/scripts/submodule-lock.js +130 -0
  28. package/_Sprintpilot/scripts/summarize-timings.js +362 -0
  29. package/_Sprintpilot/scripts/sync-status.js +13 -0
  30. package/_Sprintpilot/scripts/with-retry.js +145 -0
  31. package/_Sprintpilot/skills/sprint-autopilot-on/workflow.md +572 -42
  32. package/bin/sprintpilot.js +4 -0
  33. package/lib/commands/install.js +157 -1
  34. package/package.json +1 -1
@@ -0,0 +1,360 @@
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
+ const MARKER_FILE = '.mark.json';
47
+
48
+ function help() {
49
+ log.out(
50
+ [
51
+ 'Usage:',
52
+ ' log-timing.js <action> --story <key> --phase <name> [--meta <json>]',
53
+ '',
54
+ 'Actions: start | end | once',
55
+ '',
56
+ 'Options:',
57
+ ' --project-root <path> Defaults to cwd',
58
+ ' --story <key> Matches /^[a-z0-9][a-z0-9-]*$/',
59
+ ' --phase <name> Matches /^[a-z][a-z0-9-.]*$/',
60
+ ' --meta <json> Inline JSON, serialized < 2KB',
61
+ ].join('\n'),
62
+ );
63
+ }
64
+
65
+ function validateStory(s) {
66
+ if (s === undefined || s === null || s === '') {
67
+ return { ok: false, error: '--story is required' };
68
+ }
69
+ if (!STORY_RE.test(s)) {
70
+ return { ok: false, error: `invalid --story '${s}': must match ${STORY_RE}` };
71
+ }
72
+ return { ok: true, value: s };
73
+ }
74
+
75
+ function validatePhase(s) {
76
+ if (s === undefined || s === null || s === '') {
77
+ return { ok: false, error: '--phase is required' };
78
+ }
79
+ if (!PHASE_RE.test(s)) {
80
+ return { ok: false, error: `invalid --phase '${s}': must match ${PHASE_RE}` };
81
+ }
82
+ return { ok: true, value: s };
83
+ }
84
+
85
+ function validateAction(a) {
86
+ if (!VALID_ACTIONS.includes(a)) {
87
+ return { ok: false, error: `invalid action '${a}': must be one of ${VALID_ACTIONS.join(', ')}` };
88
+ }
89
+ return { ok: true, value: a };
90
+ }
91
+
92
+ function validateMeta(metaJson) {
93
+ if (metaJson === undefined) return { ok: true, value: undefined };
94
+ let parsed;
95
+ try {
96
+ parsed = JSON.parse(metaJson);
97
+ } catch (e) {
98
+ return { ok: false, error: `--meta is not valid JSON: ${e.message}` };
99
+ }
100
+ const serialized = JSON.stringify(parsed);
101
+ const bytes = Buffer.byteLength(serialized, 'utf8');
102
+ if (bytes > META_MAX_BYTES) {
103
+ return {
104
+ ok: false,
105
+ error: `--meta exceeds ${META_MAX_BYTES} bytes after serialization (got ${bytes})`,
106
+ };
107
+ }
108
+ return { ok: true, value: parsed };
109
+ }
110
+
111
+ function timingsDir(projectRoot) {
112
+ return path.join(projectRoot, '_bmad-output', 'implementation-artifacts', '.timings');
113
+ }
114
+
115
+ function readPhaseTimingSetting(projectRoot) {
116
+ const profilesDir = path.join(projectRoot, '_Sprintpilot', 'modules', 'autopilot', 'profiles');
117
+ if (!fs.existsSync(profilesDir)) return false;
118
+ const pick = (raw) => {
119
+ const m = raw.match(/^[ \t]*phase_timings:[ \t]*(true|false)[ \t]*(?:#.*)?$/m);
120
+ return m ? m[1] === 'true' : null;
121
+ };
122
+
123
+ const cfgPath = path.join(projectRoot, '_Sprintpilot', 'modules', 'autopilot', 'config.yaml');
124
+ if (fs.existsSync(cfgPath)) {
125
+ const override = pick(fs.readFileSync(cfgPath, 'utf8'));
126
+ if (override !== null) return override;
127
+ }
128
+
129
+ let profileName = 'medium';
130
+ if (fs.existsSync(cfgPath)) {
131
+ const raw = fs.readFileSync(cfgPath, 'utf8');
132
+ const m = raw.match(/^[ \t]*complexity_profile:[ \t]*["']?([a-zA-Z_-]+)["']?[ \t]*(?:#.*)?$/m);
133
+ if (m) profileName = m[1];
134
+ }
135
+
136
+ const profileFile = path.join(profilesDir, `${profileName}.yaml`);
137
+ if (fs.existsSync(profileFile)) {
138
+ const val = pick(fs.readFileSync(profileFile, 'utf8'));
139
+ if (val !== null) return val;
140
+ }
141
+
142
+ // Non-legacy profiles inherit from _base. Legacy stands alone — if its
143
+ // own file didn't declare the key (shouldn't happen), we fall through
144
+ // to `false` (fail-safe — never write silently).
145
+ if (profileName !== 'legacy') {
146
+ const baseFile = path.join(profilesDir, '_base.yaml');
147
+ if (fs.existsSync(baseFile)) {
148
+ const val = pick(fs.readFileSync(baseFile, 'utf8'));
149
+ if (val !== null) return val;
150
+ }
151
+ }
152
+
153
+ return false;
154
+ }
155
+
156
+ function isEnabled(projectRoot) {
157
+ try {
158
+ return readPhaseTimingSetting(projectRoot) === true;
159
+ } catch {
160
+ return false;
161
+ }
162
+ }
163
+
164
+ function appendLine(projectRoot, story, entry) {
165
+ const dir = timingsDir(projectRoot);
166
+ fs.mkdirSync(dir, { recursive: true });
167
+ const line = JSON.stringify(entry);
168
+ const bytes = Buffer.byteLength(`${line}\n`, 'utf8');
169
+ if (bytes > LINE_MAX_BYTES) {
170
+ throw new Error(
171
+ `timing line exceeds ${LINE_MAX_BYTES} bytes (${bytes}); refusing to write a non-atomic record`,
172
+ );
173
+ }
174
+ const file = path.join(dir, `${story}.jsonl`);
175
+ fs.appendFileSync(file, `${line}\n`);
176
+ return file;
177
+ }
178
+
179
+ function buildEntry(action, story, phase, meta) {
180
+ const entry = {
181
+ event: action,
182
+ story,
183
+ phase,
184
+ ts: new Date().toISOString(),
185
+ };
186
+ if (meta !== undefined) entry.meta = meta;
187
+ return entry;
188
+ }
189
+
190
+ // ---------------------------------------------------------------
191
+ // `mark` — single-call timing
192
+ // ---------------------------------------------------------------
193
+
194
+ function markerPath(projectRoot) {
195
+ return path.join(timingsDir(projectRoot), MARKER_FILE);
196
+ }
197
+
198
+ function readMarker(projectRoot) {
199
+ const file = markerPath(projectRoot);
200
+ if (!fs.existsSync(file)) return null;
201
+ try {
202
+ const raw = fs.readFileSync(file, 'utf8');
203
+ const parsed = JSON.parse(raw);
204
+ if (
205
+ parsed &&
206
+ typeof parsed === 'object' &&
207
+ typeof parsed.story === 'string' &&
208
+ typeof parsed.phase === 'string' &&
209
+ typeof parsed.ts === 'string'
210
+ ) {
211
+ return parsed;
212
+ }
213
+ } catch {
214
+ /* corrupt marker — treat as absent */
215
+ }
216
+ return null;
217
+ }
218
+
219
+ function writeMarker(projectRoot, marker) {
220
+ const dir = timingsDir(projectRoot);
221
+ fs.mkdirSync(dir, { recursive: true });
222
+ const file = markerPath(projectRoot);
223
+ // Atomic-ish: write tmp + rename. Marker is small, single-line JSON.
224
+ const tmp = `${file}.tmp.${process.pid}`;
225
+ fs.writeFileSync(tmp, JSON.stringify(marker));
226
+ fs.renameSync(tmp, file);
227
+ }
228
+
229
+ function clearMarker(projectRoot) {
230
+ const file = markerPath(projectRoot);
231
+ try {
232
+ fs.unlinkSync(file);
233
+ } catch {
234
+ /* ignore */
235
+ }
236
+ }
237
+
238
+ /**
239
+ * mark: single-call timing API.
240
+ *
241
+ * Emits a duration record for the PREVIOUS phase (if any) covering the
242
+ * interval since the previous mark, then writes a new marker for the
243
+ * current phase. The very first mark in a session emits no duration
244
+ * record — there's no "previous phase" yet.
245
+ *
246
+ * Use phase = "_end" to close the last open phase without starting a new
247
+ * one (e.g. at sprint-complete time).
248
+ *
249
+ * Returns { duration_ms, prev_phase } so callers can log/inspect.
250
+ */
251
+ function markPhase(projectRoot, story, phase, meta) {
252
+ const now = new Date();
253
+ const prev = readMarker(projectRoot);
254
+ let durationMs = null;
255
+ let prevPhase = null;
256
+ if (prev) {
257
+ const prevTs = Date.parse(prev.ts);
258
+ if (!Number.isNaN(prevTs)) {
259
+ durationMs = now.getTime() - prevTs;
260
+ prevPhase = prev.phase;
261
+ const durationEntry = {
262
+ event: 'duration',
263
+ story: prev.story,
264
+ phase: prev.phase,
265
+ started: prev.ts,
266
+ ended: now.toISOString(),
267
+ duration_ms: durationMs,
268
+ };
269
+ if (prev.meta !== undefined) durationEntry.meta = prev.meta;
270
+ appendLine(projectRoot, prev.story, durationEntry);
271
+ }
272
+ }
273
+ if (phase === '_end') {
274
+ clearMarker(projectRoot);
275
+ } else {
276
+ const marker = { story, phase, ts: now.toISOString() };
277
+ if (meta !== undefined) marker.meta = meta;
278
+ writeMarker(projectRoot, marker);
279
+ }
280
+ return { duration_ms: durationMs, prev_phase: prevPhase };
281
+ }
282
+
283
+ function main() {
284
+ const { opts, positional } = parseArgs(process.argv.slice(2));
285
+ if (opts.help || positional.length === 0) {
286
+ help();
287
+ process.exit(opts.help ? 0 : 1);
288
+ }
289
+
290
+ const action = validateAction(positional[0]);
291
+ if (!action.ok) {
292
+ log.error(action.error);
293
+ process.exit(1);
294
+ }
295
+ const story = validateStory(opts.story);
296
+ if (!story.ok) {
297
+ log.error(story.error);
298
+ process.exit(1);
299
+ }
300
+ // `mark _end` is a sentinel that closes the last open phase without
301
+ // starting a new one. Skip the regex check for it; everything else
302
+ // must match PHASE_RE.
303
+ const phase =
304
+ action.value === 'mark' && opts.phase === '_end'
305
+ ? { ok: true, value: '_end' }
306
+ : validatePhase(opts.phase);
307
+ if (!phase.ok) {
308
+ log.error(phase.error);
309
+ process.exit(1);
310
+ }
311
+ const meta = validateMeta(opts.meta);
312
+ if (!meta.ok) {
313
+ log.error(meta.error);
314
+ process.exit(1);
315
+ }
316
+
317
+ const projectRoot = opts['project-root'] || process.cwd();
318
+ if (!isEnabled(projectRoot)) return;
319
+
320
+ try {
321
+ if (action.value === 'mark') {
322
+ const r = markPhase(projectRoot, story.value, phase.value, meta.value);
323
+ // Emit a brief JSON line so callers can log the duration if useful.
324
+ // Stdout is intentionally separate from the per-story shard.
325
+ process.stdout.write(`${JSON.stringify({ marked: phase.value, prev_phase: r.prev_phase, duration_ms: r.duration_ms })}\n`);
326
+ return;
327
+ }
328
+ appendLine(projectRoot, story.value, buildEntry(action.value, story.value, phase.value, meta.value));
329
+ } catch (e) {
330
+ log.error(`timing write failed: ${e.message}`);
331
+ process.exit(1);
332
+ }
333
+ }
334
+
335
+ module.exports = {
336
+ STORY_RE,
337
+ PHASE_RE,
338
+ META_MAX_BYTES,
339
+ LINE_MAX_BYTES,
340
+ MARKER_FILE,
341
+ VALID_ACTIONS,
342
+ validateStory,
343
+ validatePhase,
344
+ validateAction,
345
+ validateMeta,
346
+ timingsDir,
347
+ markerPath,
348
+ readMarker,
349
+ writeMarker,
350
+ clearMarker,
351
+ markPhase,
352
+ readPhaseTimingSetting,
353
+ isEnabled,
354
+ appendLine,
355
+ buildEntry,
356
+ };
357
+
358
+ if (require.main === module) {
359
+ main();
360
+ }
@@ -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
+ }