@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,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
|
+
}
|