@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,130 @@
1
+ #!/usr/bin/env node
2
+
3
+ // submodule-lock.js — serialize concurrent `git submodule update` calls
4
+ // across worktrees so they don't stomp each other's index.lock.
5
+ //
6
+ // Usage:
7
+ // submodule-lock.js acquire --submodule <name> [--project-root <path>]
8
+ // submodule-lock.js release --submodule <name> [--project-root <path>]
9
+ // submodule-lock.js check --submodule <name> [--project-root <path>]
10
+ //
11
+ // Lock path:
12
+ // <project-root>/.sprintpilot/submodule-locks/<slug>.lock
13
+ // (outside .git/ so git doesn't warn about foreign files)
14
+ //
15
+ // Thin wrapper over lock.js --file <lockPath>. Submodule names are
16
+ // slugified for filesystem safety (only [a-z0-9-] survive).
17
+
18
+ const fs = require('node:fs');
19
+ const path = require('node:path');
20
+ const { spawnSync } = require('node:child_process');
21
+
22
+ const { parseArgs } = require('../lib/runtime/args');
23
+ const log = require('../lib/runtime/log');
24
+
25
+ const VALID_ACTIONS = ['acquire', 'release', 'check'];
26
+ const LOCK_SCRIPT = path.join(__dirname, 'lock.js');
27
+
28
+ function help() {
29
+ log.out(
30
+ [
31
+ 'Usage:',
32
+ ' submodule-lock.js acquire --submodule <name> [--project-root <path>]',
33
+ ' submodule-lock.js release --submodule <name>',
34
+ ' submodule-lock.js check --submodule <name>',
35
+ ].join('\n'),
36
+ );
37
+ }
38
+
39
+ function slugify(name) {
40
+ return String(name)
41
+ .toLowerCase()
42
+ .replace(/[^a-z0-9]+/g, '-')
43
+ .replace(/^-+|-+$/g, '')
44
+ .slice(0, 64);
45
+ }
46
+
47
+ function lockPathFor(projectRoot, submodule) {
48
+ const slug = slugify(submodule);
49
+ if (!slug) throw new Error(`invalid submodule name: '${submodule}' slugifies to empty`);
50
+ return path.join(projectRoot, '.sprintpilot', 'submodule-locks', `${slug}.lock`);
51
+ }
52
+
53
+ function ensureDirFor(filePath) {
54
+ const dir = path.dirname(filePath);
55
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
56
+ }
57
+
58
+ function runLockScript(action, lockFile) {
59
+ const res = spawnSync(
60
+ process.execPath,
61
+ [LOCK_SCRIPT, action, '--file', lockFile, '--stale-minutes', '30'],
62
+ { encoding: 'utf8' },
63
+ );
64
+ return {
65
+ status: res.status === null ? 1 : res.status,
66
+ stdout: (res.stdout || '').trim(),
67
+ stderr: (res.stderr || '').trim(),
68
+ };
69
+ }
70
+
71
+ function acquire(projectRoot, submodule) {
72
+ const lockFile = lockPathFor(projectRoot, submodule);
73
+ ensureDirFor(lockFile);
74
+ return runLockScript('acquire', lockFile);
75
+ }
76
+
77
+ function release(projectRoot, submodule) {
78
+ const lockFile = lockPathFor(projectRoot, submodule);
79
+ return runLockScript('release', lockFile);
80
+ }
81
+
82
+ function check(projectRoot, submodule) {
83
+ const lockFile = lockPathFor(projectRoot, submodule);
84
+ return runLockScript('check', lockFile);
85
+ }
86
+
87
+ function main() {
88
+ const { opts, positional } = parseArgs(process.argv.slice(2));
89
+ if (opts.help || positional.length === 0) {
90
+ help();
91
+ process.exit(opts.help ? 0 : 1);
92
+ }
93
+ const action = positional[0];
94
+ if (!VALID_ACTIONS.includes(action)) {
95
+ log.error(`unknown action '${action}'. Valid: ${VALID_ACTIONS.join(', ')}`);
96
+ process.exit(1);
97
+ }
98
+ const submodule = opts.submodule;
99
+ if (!submodule) {
100
+ log.error('--submodule is required');
101
+ process.exit(1);
102
+ }
103
+ const projectRoot = opts['project-root'] || process.cwd();
104
+
105
+ let res;
106
+ try {
107
+ if (action === 'acquire') res = acquire(projectRoot, submodule);
108
+ else if (action === 'release') res = release(projectRoot, submodule);
109
+ else res = check(projectRoot, submodule);
110
+ } catch (e) {
111
+ log.error(e.message);
112
+ process.exit(1);
113
+ }
114
+ if (res.stdout) process.stdout.write(`${res.stdout}\n`);
115
+ if (res.stderr) process.stderr.write(`${res.stderr}\n`);
116
+ process.exit(res.status);
117
+ }
118
+
119
+ module.exports = {
120
+ VALID_ACTIONS,
121
+ slugify,
122
+ lockPathFor,
123
+ acquire,
124
+ release,
125
+ check,
126
+ };
127
+
128
+ if (require.main === module) {
129
+ main();
130
+ }
@@ -0,0 +1,362 @@
1
+ #!/usr/bin/env node
2
+
3
+ // summarize-timings.js — merge .timings/<story>.jsonl shards into a report.
4
+ //
5
+ // Usage:
6
+ // summarize-timings.js [--project-root <path>] [--format text|json|md]
7
+ // [--session-only] [--quiet]
8
+ //
9
+ // Behavior:
10
+ // Reads every .jsonl file under _bmad-output/implementation-artifacts/
11
+ // .timings/. Pairs start/end events by (story, phase) in LIFO order.
12
+ // Computes:
13
+ // - Wall-clock per story (min-start to max-end)
14
+ // - Per-phase aggregates: count, sum_ms, p50, p95, max
15
+ // - Hotspots: phases whose sum_ms consumes > 5% of total paired time
16
+ //
17
+ // Output:
18
+ // --format text (default) → stdout, human-readable table
19
+ // --format json → stdout, JSON dump
20
+ // --format md → markdown; also written to an artifact:
21
+ // default: .timings/summary-<YYYY-MM-DD>.md
22
+ // --session-only: .timings/summary-session-<ISO-ts>.md
23
+ //
24
+ // Hotspot threshold is fixed at 5% per the PR 2 contract.
25
+
26
+ const fs = require('node:fs');
27
+ const path = require('node:path');
28
+
29
+ const { parseArgs } = require('../lib/runtime/args');
30
+ const log = require('../lib/runtime/log');
31
+
32
+ const HOTSPOT_THRESHOLD = 0.05;
33
+
34
+ function help() {
35
+ log.out(
36
+ [
37
+ 'Usage:',
38
+ ' summarize-timings.js [--project-root <path>] [--format text|json|md]',
39
+ ' [--session-only] [--quiet]',
40
+ '',
41
+ 'Defaults: --format text, reads cwd.',
42
+ ' --session-only Writes artifact as summary-session-<ts>.md.',
43
+ ' --quiet Suppresses stdout for md format (still writes artifact).',
44
+ ].join('\n'),
45
+ );
46
+ }
47
+
48
+ function timingsDir(projectRoot) {
49
+ return path.join(projectRoot, '_bmad-output', 'implementation-artifacts', '.timings');
50
+ }
51
+
52
+ function readShards(projectRoot) {
53
+ const dir = timingsDir(projectRoot);
54
+ if (!fs.existsSync(dir)) return [];
55
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
56
+ const events = [];
57
+ for (const f of files) {
58
+ const full = path.join(dir, f);
59
+ const raw = fs.readFileSync(full, 'utf8');
60
+ for (const line of raw.split('\n')) {
61
+ const trimmed = line.trim();
62
+ if (!trimmed) continue;
63
+ let obj;
64
+ try {
65
+ obj = JSON.parse(trimmed);
66
+ } catch {
67
+ continue; // skip corrupt lines rather than abort the summary
68
+ }
69
+ if (!obj || typeof obj !== 'object') continue;
70
+ if (!obj.event || !obj.story || !obj.phase || !obj.ts) continue;
71
+ const ms = Date.parse(obj.ts);
72
+ if (Number.isNaN(ms)) continue;
73
+ events.push({ ...obj, _ms: ms });
74
+ }
75
+ }
76
+ // Stable sort by timestamp so pairing is deterministic.
77
+ events.sort((a, b) => a._ms - b._ms);
78
+ return events;
79
+ }
80
+
81
+ function pairEvents(events) {
82
+ // Returns { stories: { [story]: { first, last, phases: { [phase]: number[] } } },
83
+ // phaseAgg: { [phase]: number[] },
84
+ // onceCount: { [phase]: number },
85
+ // orphans: [{story, phase, event, ts}] }
86
+ const stories = {};
87
+ const phaseAgg = {};
88
+ const onceCount = {};
89
+ const openByStoryPhase = {}; // key = story::phase → stack of start ms
90
+
91
+ const ensureStory = (s) => {
92
+ if (!stories[s]) stories[s] = { first: null, last: null, phases: {} };
93
+ return stories[s];
94
+ };
95
+
96
+ for (const ev of events) {
97
+ const s = ensureStory(ev.story);
98
+ if (s.first === null || ev._ms < s.first) s.first = ev._ms;
99
+ if (s.last === null || ev._ms > s.last) s.last = ev._ms;
100
+
101
+ if (ev.event === 'once') {
102
+ onceCount[ev.phase] = (onceCount[ev.phase] || 0) + 1;
103
+ continue;
104
+ }
105
+ const key = `${ev.story}::${ev.phase}`;
106
+ if (ev.event === 'start') {
107
+ if (!openByStoryPhase[key]) openByStoryPhase[key] = [];
108
+ openByStoryPhase[key].push(ev._ms);
109
+ continue;
110
+ }
111
+ if (ev.event === 'end') {
112
+ const stack = openByStoryPhase[key];
113
+ if (!stack || stack.length === 0) continue; // orphan end — skip
114
+ const startMs = stack.pop();
115
+ const duration = ev._ms - startMs;
116
+ if (duration < 0) continue;
117
+ if (!s.phases[ev.phase]) s.phases[ev.phase] = [];
118
+ s.phases[ev.phase].push(duration);
119
+ if (!phaseAgg[ev.phase]) phaseAgg[ev.phase] = [];
120
+ phaseAgg[ev.phase].push(duration);
121
+ }
122
+ }
123
+
124
+ const orphans = [];
125
+ for (const key of Object.keys(openByStoryPhase)) {
126
+ const [story, phase] = key.split('::');
127
+ for (const startMs of openByStoryPhase[key]) {
128
+ orphans.push({ story, phase, event: 'start-without-end', ts: new Date(startMs).toISOString() });
129
+ }
130
+ }
131
+
132
+ return { stories, phaseAgg, onceCount, orphans };
133
+ }
134
+
135
+ function percentile(sorted, p) {
136
+ if (sorted.length === 0) return 0;
137
+ if (sorted.length === 1) return sorted[0];
138
+ // Nearest-rank; fine for our small N.
139
+ const idx = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1);
140
+ return sorted[Math.max(0, idx)];
141
+ }
142
+
143
+ function aggregate(paired) {
144
+ const phases = Object.keys(paired.phaseAgg).sort();
145
+ const rows = phases.map((phase) => {
146
+ const durations = [...paired.phaseAgg[phase]].sort((a, b) => a - b);
147
+ const sum = durations.reduce((acc, v) => acc + v, 0);
148
+ return {
149
+ phase,
150
+ count: durations.length,
151
+ sum_ms: sum,
152
+ p50_ms: percentile(durations, 50),
153
+ p95_ms: percentile(durations, 95),
154
+ max_ms: durations[durations.length - 1],
155
+ };
156
+ });
157
+ const totalPaired = rows.reduce((acc, r) => acc + r.sum_ms, 0);
158
+ const withPct = rows.map((r) => ({
159
+ ...r,
160
+ pct_of_total: totalPaired > 0 ? r.sum_ms / totalPaired : 0,
161
+ }));
162
+ withPct.sort((a, b) => b.sum_ms - a.sum_ms);
163
+ const hotspots = withPct.filter((r) => r.pct_of_total > HOTSPOT_THRESHOLD);
164
+
165
+ const stories = Object.keys(paired.stories).sort().map((key) => {
166
+ const s = paired.stories[key];
167
+ const wall_ms = s.first !== null && s.last !== null ? s.last - s.first : 0;
168
+ const phaseSum = Object.values(s.phases)
169
+ .flat()
170
+ .reduce((acc, v) => acc + v, 0);
171
+ return { story: key, wall_ms, phase_sum_ms: phaseSum, phase_count: Object.keys(s.phases).length };
172
+ });
173
+
174
+ return {
175
+ total_paired_ms: totalPaired,
176
+ phases: withPct,
177
+ hotspots,
178
+ stories,
179
+ once_markers: paired.onceCount,
180
+ orphans: paired.orphans,
181
+ };
182
+ }
183
+
184
+ function fmtMs(ms) {
185
+ if (ms < 1000) return `${ms}ms`;
186
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
187
+ return `${(ms / 60_000).toFixed(1)}m`;
188
+ }
189
+
190
+ function fmtPct(p) {
191
+ return `${(p * 100).toFixed(1)}%`;
192
+ }
193
+
194
+ function renderText(report) {
195
+ const lines = [];
196
+ lines.push('Sprintpilot phase-timing summary');
197
+ lines.push(`Total paired phase time: ${fmtMs(report.total_paired_ms)}`);
198
+ lines.push('');
199
+ lines.push('Per-story wall-clock:');
200
+ if (report.stories.length === 0) {
201
+ lines.push(' (no data)');
202
+ } else {
203
+ for (const s of report.stories) {
204
+ lines.push(
205
+ ` ${s.story} wall=${fmtMs(s.wall_ms)} phase-sum=${fmtMs(s.phase_sum_ms)} phases=${s.phase_count}`,
206
+ );
207
+ }
208
+ }
209
+ lines.push('');
210
+ lines.push('Phase aggregates (sorted by sum):');
211
+ if (report.phases.length === 0) {
212
+ lines.push(' (no paired start/end events)');
213
+ } else {
214
+ lines.push(' phase count sum p50 p95 max %');
215
+ for (const r of report.phases) {
216
+ lines.push(
217
+ ` ${r.phase.padEnd(40)} ${String(r.count).padStart(5)} ${fmtMs(r.sum_ms).padStart(6)} ${fmtMs(r.p50_ms).padStart(6)} ${fmtMs(r.p95_ms).padStart(6)} ${fmtMs(r.max_ms).padStart(6)} ${fmtPct(r.pct_of_total).padStart(6)}`,
218
+ );
219
+ }
220
+ }
221
+ lines.push('');
222
+ if (report.hotspots.length > 0) {
223
+ lines.push(`Hotspots (> ${HOTSPOT_THRESHOLD * 100}% of total):`);
224
+ for (const h of report.hotspots) {
225
+ lines.push(` ${h.phase} ${fmtPct(h.pct_of_total)} ${fmtMs(h.sum_ms)}`);
226
+ }
227
+ } else {
228
+ lines.push('Hotspots: none above threshold');
229
+ }
230
+ if (Object.keys(report.once_markers).length > 0) {
231
+ lines.push('');
232
+ lines.push('Once markers:');
233
+ for (const [phase, count] of Object.entries(report.once_markers)) {
234
+ lines.push(` ${phase} ×${count}`);
235
+ }
236
+ }
237
+ if (report.orphans.length > 0) {
238
+ lines.push('');
239
+ lines.push(`Orphaned starts (no matching end): ${report.orphans.length}`);
240
+ }
241
+ return `${lines.join('\n')}\n`;
242
+ }
243
+
244
+ function renderMarkdown(report) {
245
+ const lines = [];
246
+ lines.push('# Sprintpilot phase-timing summary');
247
+ lines.push('');
248
+ lines.push(`**Total paired phase time:** ${fmtMs(report.total_paired_ms)}`);
249
+ lines.push('');
250
+ lines.push('## Per-story wall-clock');
251
+ lines.push('');
252
+ if (report.stories.length === 0) {
253
+ lines.push('_No data._');
254
+ } else {
255
+ lines.push('| Story | Wall | Phase sum | # phases |');
256
+ lines.push('|---|---|---|---|');
257
+ for (const s of report.stories) {
258
+ lines.push(`| ${s.story} | ${fmtMs(s.wall_ms)} | ${fmtMs(s.phase_sum_ms)} | ${s.phase_count} |`);
259
+ }
260
+ }
261
+ lines.push('');
262
+ lines.push('## Phase aggregates');
263
+ lines.push('');
264
+ if (report.phases.length === 0) {
265
+ lines.push('_No paired start/end events._');
266
+ } else {
267
+ lines.push('| Phase | Count | Sum | p50 | p95 | Max | % total |');
268
+ lines.push('|---|---:|---:|---:|---:|---:|---:|');
269
+ for (const r of report.phases) {
270
+ lines.push(
271
+ `| \`${r.phase}\` | ${r.count} | ${fmtMs(r.sum_ms)} | ${fmtMs(r.p50_ms)} | ${fmtMs(r.p95_ms)} | ${fmtMs(r.max_ms)} | ${fmtPct(r.pct_of_total)} |`,
272
+ );
273
+ }
274
+ }
275
+ lines.push('');
276
+ lines.push(`## Hotspots (> ${HOTSPOT_THRESHOLD * 100}% of total)`);
277
+ lines.push('');
278
+ if (report.hotspots.length === 0) {
279
+ lines.push('_None above threshold._');
280
+ } else {
281
+ for (const h of report.hotspots) {
282
+ lines.push(`- \`${h.phase}\` — ${fmtPct(h.pct_of_total)} (${fmtMs(h.sum_ms)})`);
283
+ }
284
+ }
285
+ if (Object.keys(report.once_markers).length > 0) {
286
+ lines.push('');
287
+ lines.push('## Once markers');
288
+ lines.push('');
289
+ for (const [phase, count] of Object.entries(report.once_markers)) {
290
+ lines.push(`- \`${phase}\` ×${count}`);
291
+ }
292
+ }
293
+ if (report.orphans.length > 0) {
294
+ lines.push('');
295
+ lines.push(`_Orphaned starts (no matching end): ${report.orphans.length}_`);
296
+ }
297
+ return `${lines.join('\n')}\n`;
298
+ }
299
+
300
+ function artifactPath(projectRoot, sessionOnly) {
301
+ const dir = timingsDir(projectRoot);
302
+ if (sessionOnly) {
303
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
304
+ return path.join(dir, `summary-session-${ts}.md`);
305
+ }
306
+ const date = new Date().toISOString().slice(0, 10);
307
+ return path.join(dir, `summary-${date}.md`);
308
+ }
309
+
310
+ function main() {
311
+ const { opts } = parseArgs(process.argv.slice(2), { booleanFlags: ['session-only', 'quiet'] });
312
+ if (opts.help) {
313
+ help();
314
+ process.exit(0);
315
+ }
316
+ const projectRoot = opts['project-root'] || process.cwd();
317
+ const format = opts.format || 'text';
318
+ const sessionOnly = opts['session-only'] === true;
319
+ const quiet = opts.quiet === true;
320
+
321
+ const events = readShards(projectRoot);
322
+ const paired = pairEvents(events);
323
+ const report = aggregate(paired);
324
+
325
+ if (format === 'json') {
326
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
327
+ return;
328
+ }
329
+ if (format === 'md') {
330
+ const body = renderMarkdown(report);
331
+ const out = artifactPath(projectRoot, sessionOnly);
332
+ fs.mkdirSync(path.dirname(out), { recursive: true });
333
+ fs.writeFileSync(out, body);
334
+ if (!quiet) process.stdout.write(`${body}\nWrote: ${out}\n`);
335
+ else process.stdout.write(`${out}\n`);
336
+ return;
337
+ }
338
+ if (format === 'text') {
339
+ process.stdout.write(renderText(report));
340
+ return;
341
+ }
342
+ log.error(`unknown --format '${format}'. Valid: text, json, md`);
343
+ process.exit(1);
344
+ }
345
+
346
+ module.exports = {
347
+ HOTSPOT_THRESHOLD,
348
+ timingsDir,
349
+ readShards,
350
+ pairEvents,
351
+ aggregate,
352
+ percentile,
353
+ renderText,
354
+ renderMarkdown,
355
+ artifactPath,
356
+ fmtMs,
357
+ fmtPct,
358
+ };
359
+
360
+ if (require.main === module) {
361
+ main();
362
+ }
@@ -120,6 +120,17 @@ function main() {
120
120
  worktreeCleaned = v === true || String(v).toLowerCase() === 'true' ? 'true' : 'false';
121
121
  }
122
122
 
123
+ // Epic-granularity metadata (PR 5). When the autopilot runs with
124
+ // git.granularity=epic, every story in the epic shares one branch.
125
+ // We record epic_id + granularity on every block so downstream code
126
+ // can "find the branch for this epic" by scanning the file.
127
+ const epicId = opts['epic-id'];
128
+ const granularity = opts.granularity || 'story';
129
+ if (!['story', 'epic'].includes(granularity)) {
130
+ log.error(`invalid --granularity '${granularity}': must be story|epic`);
131
+ process.exit(1);
132
+ }
133
+
123
134
  const fields = [
124
135
  { key: 'branch', value: branch },
125
136
  { key: 'worktree', value: worktree },
@@ -130,6 +141,8 @@ function main() {
130
141
  { key: 'merge_status', value: mergeStatus },
131
142
  { key: 'pr_url', value: prUrl },
132
143
  { key: 'worktree_cleaned', value: worktreeCleaned, raw: true },
144
+ { key: 'epic_id', value: epicId },
145
+ { key: 'granularity', value: granularity === 'story' ? undefined : granularity },
133
146
  ];
134
147
 
135
148
  const block = buildBlock(story, fields);
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env node
2
+
3
+ // with-retry.js — run a command with jittered-backoff retries on
4
+ // transient git ref-lock failures.
5
+ //
6
+ // Usage:
7
+ // with-retry.js -- <command> [args...]
8
+ // with-retry.js --attempts 3 --min-ms 500 --max-ms 2000 -- <command> [args...]
9
+ // with-retry.js --pattern '<regex>' -- <command> [args...]
10
+ //
11
+ // Retry trigger:
12
+ // stderr is scanned for the default ref-lock regex (case-insensitive):
13
+ // cannot lock ref|Unable to create.*\.lock|Reference already exists|failed to lock|lock.ref
14
+ // Custom regex via --pattern. Any command that matches the pattern AND
15
+ // exits non-zero is retried up to --attempts times with jittered backoff
16
+ // in [--min-ms, --max-ms]. All other non-zero exits are returned as-is
17
+ // (no blind retry — safeguards against hiding real failures).
18
+ //
19
+ // Exit code: the last attempt's exit code. stdout + stderr are forwarded
20
+ // verbatim on each attempt.
21
+
22
+ const { spawnSync } = require('node:child_process');
23
+
24
+ const { parseArgs } = require('../lib/runtime/args');
25
+ const log = require('../lib/runtime/log');
26
+
27
+ const DEFAULT_ATTEMPTS = 3;
28
+ const DEFAULT_MIN_MS = 500;
29
+ const DEFAULT_MAX_MS = 2000;
30
+ const DEFAULT_REF_LOCK_PATTERN = /cannot lock ref|Unable to create.*\.lock|Reference already exists|failed to lock|lock\.ref/i;
31
+
32
+ function help() {
33
+ log.out(
34
+ [
35
+ 'Usage: with-retry.js [options] -- <command> [args...]',
36
+ '',
37
+ 'Options:',
38
+ ' --attempts N Max attempts (default 3, min 1).',
39
+ ' --min-ms N Backoff lower bound (default 500).',
40
+ ' --max-ms N Backoff upper bound (default 2000).',
41
+ ' --pattern REGEX Override the retry-trigger regex (case-insensitive).',
42
+ ' --no-shell Always use execFile semantics (implicit — no shell).',
43
+ '',
44
+ 'Retries only when stderr matches the pattern AND exit code is non-zero.',
45
+ ].join('\n'),
46
+ );
47
+ }
48
+
49
+ function jitteredDelay(minMs, maxMs) {
50
+ const lo = Math.max(0, minMs);
51
+ const hi = Math.max(lo, maxMs);
52
+ return lo + Math.floor(Math.random() * (hi - lo + 1));
53
+ }
54
+
55
+ function sleepSync(ms) {
56
+ // Node's child_process lacks a portable sleep; use spawnSync to block.
57
+ if (ms <= 0) return;
58
+ spawnSync(process.execPath, ['-e', `setTimeout(()=>process.exit(0), ${ms})`], {
59
+ stdio: 'ignore',
60
+ });
61
+ }
62
+
63
+ function shouldRetry(stderr, pattern) {
64
+ if (!stderr) return false;
65
+ return pattern.test(String(stderr));
66
+ }
67
+
68
+ function runOnce(cmd, args, inherit = false) {
69
+ const res = spawnSync(cmd, args, {
70
+ stdio: inherit ? 'inherit' : 'pipe',
71
+ encoding: 'utf8',
72
+ });
73
+ return {
74
+ status: res.status,
75
+ signal: res.signal,
76
+ stdout: res.stdout || '',
77
+ stderr: res.stderr || '',
78
+ error: res.error,
79
+ };
80
+ }
81
+
82
+ function runWithRetry({ cmd, args, attempts = DEFAULT_ATTEMPTS, minMs = DEFAULT_MIN_MS, maxMs = DEFAULT_MAX_MS, pattern = DEFAULT_REF_LOCK_PATTERN, onAttempt = null }) {
83
+ const actualAttempts = Math.max(1, attempts | 0);
84
+ let last = null;
85
+ for (let i = 0; i < actualAttempts; i++) {
86
+ const res = runOnce(cmd, args);
87
+ last = res;
88
+ if (typeof onAttempt === 'function') onAttempt({ attempt: i + 1, ...res });
89
+ if (res.status === 0) return { ...res, attempts: i + 1 };
90
+ if (!shouldRetry(res.stderr, pattern)) return { ...res, attempts: i + 1 };
91
+ if (i + 1 >= actualAttempts) break;
92
+ sleepSync(jitteredDelay(minMs, maxMs));
93
+ }
94
+ return { ...last, attempts: actualAttempts };
95
+ }
96
+
97
+ function splitAtSeparator(argv) {
98
+ const idx = argv.indexOf('--');
99
+ if (idx === -1) return { flags: argv, cmdArgs: [] };
100
+ return { flags: argv.slice(0, idx), cmdArgs: argv.slice(idx + 1) };
101
+ }
102
+
103
+ function main() {
104
+ const { flags, cmdArgs } = splitAtSeparator(process.argv.slice(2));
105
+ const { opts } = parseArgs(flags);
106
+ if (opts.help || cmdArgs.length === 0) {
107
+ help();
108
+ process.exit(opts.help ? 0 : 1);
109
+ }
110
+ const attempts = opts.attempts !== undefined ? Number.parseInt(String(opts.attempts), 10) : DEFAULT_ATTEMPTS;
111
+ const minMs = opts['min-ms'] !== undefined ? Number.parseInt(String(opts['min-ms']), 10) : DEFAULT_MIN_MS;
112
+ const maxMs = opts['max-ms'] !== undefined ? Number.parseInt(String(opts['max-ms']), 10) : DEFAULT_MAX_MS;
113
+ let pattern = DEFAULT_REF_LOCK_PATTERN;
114
+ if (opts.pattern) {
115
+ try {
116
+ pattern = new RegExp(String(opts.pattern), 'i');
117
+ } catch (e) {
118
+ log.error(`invalid --pattern regex: ${e.message}`);
119
+ process.exit(1);
120
+ }
121
+ }
122
+ const [cmd, ...rest] = cmdArgs;
123
+ const res = runWithRetry({ cmd, args: rest, attempts, minMs, maxMs, pattern });
124
+ process.stdout.write(res.stdout);
125
+ process.stderr.write(res.stderr);
126
+ if (res.attempts > 1) {
127
+ process.stderr.write(`with-retry: ${res.attempts} attempts, final exit ${res.status}\n`);
128
+ }
129
+ process.exit(res.status === null ? 1 : res.status);
130
+ }
131
+
132
+ module.exports = {
133
+ DEFAULT_ATTEMPTS,
134
+ DEFAULT_MIN_MS,
135
+ DEFAULT_MAX_MS,
136
+ DEFAULT_REF_LOCK_PATTERN,
137
+ shouldRetry,
138
+ jitteredDelay,
139
+ runWithRetry,
140
+ splitAtSeparator,
141
+ };
142
+
143
+ if (require.main === module) {
144
+ main();
145
+ }