@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,238 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// cached-read.js — TTL + mtime-aware file cache for the autopilot loop.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// cached-read.js read --file <path> [--ttl <ms>] [--cache-root <path>]
|
|
7
|
+
// cached-read.js invalidate --file <path> [--cache-root <path>]
|
|
8
|
+
// cached-read.js clear [--cache-root <path>]
|
|
9
|
+
// cached-read.js stats [--cache-root <path>]
|
|
10
|
+
//
|
|
11
|
+
// Rationale (PR 8 / M5):
|
|
12
|
+
// workflow.md re-reads sprint-status.yaml, git-status.yaml, and
|
|
13
|
+
// decision-log.yaml at many step boundaries. A single loop iteration
|
|
14
|
+
// can read each one 5+ times. This helper memoizes the reads to a
|
|
15
|
+
// per-project cache directory, respecting TTL AND source-file mtime
|
|
16
|
+
// so a write always invalidates the cache even if the caller forgets
|
|
17
|
+
// to call `invalidate` explicitly.
|
|
18
|
+
//
|
|
19
|
+
// Cache layout:
|
|
20
|
+
// <cache-root>/.cache/cached-reads/<sha256(file)>.json
|
|
21
|
+
// { source, mtime_ms, cached_at, body }
|
|
22
|
+
//
|
|
23
|
+
// Consumer gate:
|
|
24
|
+
// Callers should gate use of this script on `autopilot.cache_shared_reads`
|
|
25
|
+
// via resolve-profile.js. When the flag is false, read the file directly.
|
|
26
|
+
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
const crypto = require('node:crypto');
|
|
30
|
+
|
|
31
|
+
const { parseArgs } = require('../lib/runtime/args');
|
|
32
|
+
const log = require('../lib/runtime/log');
|
|
33
|
+
|
|
34
|
+
const DEFAULT_TTL_MS = 60_000;
|
|
35
|
+
const VALID_ACTIONS = ['read', 'invalidate', 'clear', 'stats'];
|
|
36
|
+
|
|
37
|
+
function help() {
|
|
38
|
+
log.out(
|
|
39
|
+
[
|
|
40
|
+
'Usage:',
|
|
41
|
+
' cached-read.js read --file <path> [--ttl <ms>] [--cache-root <path>]',
|
|
42
|
+
' cached-read.js invalidate --file <path> [--cache-root <path>]',
|
|
43
|
+
' cached-read.js clear [--cache-root <path>]',
|
|
44
|
+
' cached-read.js stats [--cache-root <path>]',
|
|
45
|
+
'',
|
|
46
|
+
`Default TTL: ${DEFAULT_TTL_MS}ms. Source-file mtime always invalidates.`,
|
|
47
|
+
].join('\n'),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function cacheDir(cacheRoot) {
|
|
52
|
+
return path.join(cacheRoot, '.cache', 'cached-reads');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function keyFor(filePath) {
|
|
56
|
+
return crypto.createHash('sha256').update(path.resolve(filePath)).digest('hex').slice(0, 32);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function cacheEntryPath(cacheRoot, filePath) {
|
|
60
|
+
return path.join(cacheDir(cacheRoot), `${keyFor(filePath)}.json`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readFileStat(filePath) {
|
|
64
|
+
try {
|
|
65
|
+
const stat = fs.statSync(filePath);
|
|
66
|
+
return { exists: true, mtime_ms: stat.mtimeMs };
|
|
67
|
+
} catch {
|
|
68
|
+
return { exists: false, mtime_ms: 0 };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readFromCache(cacheRoot, filePath, ttlMs) {
|
|
73
|
+
const entryFile = cacheEntryPath(cacheRoot, filePath);
|
|
74
|
+
if (!fs.existsSync(entryFile)) return { hit: false, reason: 'miss' };
|
|
75
|
+
let entry;
|
|
76
|
+
try {
|
|
77
|
+
entry = JSON.parse(fs.readFileSync(entryFile, 'utf8'));
|
|
78
|
+
} catch {
|
|
79
|
+
return { hit: false, reason: 'corrupt' };
|
|
80
|
+
}
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
// ttlMs=0 means "always miss" (bypass); >= (not >) ensures that.
|
|
83
|
+
if (now - entry.cached_at >= ttlMs) return { hit: false, reason: 'ttl-expired' };
|
|
84
|
+
const srcStat = readFileStat(filePath);
|
|
85
|
+
if (!srcStat.exists) return { hit: false, reason: 'source-gone' };
|
|
86
|
+
if (srcStat.mtime_ms > entry.mtime_ms) return { hit: false, reason: 'source-newer' };
|
|
87
|
+
return { hit: true, body: entry.body, entry };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function writeToCache(cacheRoot, filePath, body) {
|
|
91
|
+
const dir = cacheDir(cacheRoot);
|
|
92
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
93
|
+
const srcStat = readFileStat(filePath);
|
|
94
|
+
const entry = {
|
|
95
|
+
source: path.resolve(filePath),
|
|
96
|
+
mtime_ms: srcStat.mtime_ms,
|
|
97
|
+
cached_at: Date.now(),
|
|
98
|
+
body,
|
|
99
|
+
};
|
|
100
|
+
const file = cacheEntryPath(cacheRoot, filePath);
|
|
101
|
+
const tmp = `${file}.tmp.${process.pid}.${process.hrtime.bigint().toString(36)}`;
|
|
102
|
+
fs.writeFileSync(tmp, JSON.stringify(entry));
|
|
103
|
+
fs.renameSync(tmp, file);
|
|
104
|
+
return file;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function readThrough(cacheRoot, filePath, ttlMs) {
|
|
108
|
+
const hit = readFromCache(cacheRoot, filePath, ttlMs);
|
|
109
|
+
if (hit.hit) return { body: hit.body, hit: true, source: filePath };
|
|
110
|
+
// Cache miss — read through.
|
|
111
|
+
if (!fs.existsSync(filePath)) {
|
|
112
|
+
return { body: null, hit: false, source: filePath, reason: hit.reason || 'missing' };
|
|
113
|
+
}
|
|
114
|
+
const body = fs.readFileSync(filePath, 'utf8');
|
|
115
|
+
writeToCache(cacheRoot, filePath, body);
|
|
116
|
+
return { body, hit: false, source: filePath, reason: hit.reason };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function invalidate(cacheRoot, filePath) {
|
|
120
|
+
const file = cacheEntryPath(cacheRoot, filePath);
|
|
121
|
+
try {
|
|
122
|
+
fs.unlinkSync(file);
|
|
123
|
+
return { cleared: true };
|
|
124
|
+
} catch {
|
|
125
|
+
return { cleared: false };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function clearAll(cacheRoot) {
|
|
130
|
+
const dir = cacheDir(cacheRoot);
|
|
131
|
+
if (!fs.existsSync(dir)) return { cleared: 0 };
|
|
132
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
|
|
133
|
+
let cleared = 0;
|
|
134
|
+
for (const f of files) {
|
|
135
|
+
try {
|
|
136
|
+
fs.unlinkSync(path.join(dir, f));
|
|
137
|
+
cleared++;
|
|
138
|
+
} catch {
|
|
139
|
+
/* best effort */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { cleared };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function stats(cacheRoot) {
|
|
146
|
+
const dir = cacheDir(cacheRoot);
|
|
147
|
+
if (!fs.existsSync(dir)) return { entries: 0, oldest_age_ms: null, newest_age_ms: null };
|
|
148
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
|
|
149
|
+
const now = Date.now();
|
|
150
|
+
let oldest = Infinity;
|
|
151
|
+
let newest = 0;
|
|
152
|
+
for (const f of files) {
|
|
153
|
+
try {
|
|
154
|
+
const entry = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
|
|
155
|
+
const age = now - entry.cached_at;
|
|
156
|
+
if (age < oldest) oldest = age;
|
|
157
|
+
if (age > newest) newest = age;
|
|
158
|
+
} catch {
|
|
159
|
+
/* skip corrupt */
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
entries: files.length,
|
|
164
|
+
oldest_age_ms: files.length ? oldest : null,
|
|
165
|
+
newest_age_ms: files.length ? newest : null,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function main() {
|
|
170
|
+
const { opts, positional } = parseArgs(process.argv.slice(2));
|
|
171
|
+
if (opts.help || positional.length === 0) {
|
|
172
|
+
help();
|
|
173
|
+
process.exit(opts.help ? 0 : 1);
|
|
174
|
+
}
|
|
175
|
+
const action = positional[0];
|
|
176
|
+
if (!VALID_ACTIONS.includes(action)) {
|
|
177
|
+
log.error(`unknown action '${action}'. Valid: ${VALID_ACTIONS.join(', ')}`);
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
const cacheRoot = opts['cache-root'] || process.cwd();
|
|
181
|
+
const filePath = opts.file;
|
|
182
|
+
const ttlMs = opts.ttl !== undefined ? Number.parseInt(String(opts.ttl), 10) : DEFAULT_TTL_MS;
|
|
183
|
+
if (Number.isNaN(ttlMs) || ttlMs < 0) {
|
|
184
|
+
log.error(`invalid --ttl '${opts.ttl}': must be a non-negative integer (ms)`);
|
|
185
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (action === 'read') {
|
|
189
|
+
if (!filePath) {
|
|
190
|
+
log.error('--file is required for read');
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
const out = readThrough(cacheRoot, filePath, ttlMs);
|
|
194
|
+
if (out.body === null) {
|
|
195
|
+
log.error(`source missing: ${filePath}`);
|
|
196
|
+
process.exit(2);
|
|
197
|
+
}
|
|
198
|
+
process.stdout.write(out.body);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (action === 'invalidate') {
|
|
202
|
+
if (!filePath) {
|
|
203
|
+
log.error('--file is required for invalidate');
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
const r = invalidate(cacheRoot, filePath);
|
|
207
|
+
process.stdout.write(`${JSON.stringify(r)}\n`);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (action === 'clear') {
|
|
211
|
+
const r = clearAll(cacheRoot);
|
|
212
|
+
process.stdout.write(`${JSON.stringify(r)}\n`);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (action === 'stats') {
|
|
216
|
+
const r = stats(cacheRoot);
|
|
217
|
+
process.stdout.write(`${JSON.stringify(r)}\n`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = {
|
|
222
|
+
DEFAULT_TTL_MS,
|
|
223
|
+
VALID_ACTIONS,
|
|
224
|
+
cacheDir,
|
|
225
|
+
keyFor,
|
|
226
|
+
cacheEntryPath,
|
|
227
|
+
readFileStat,
|
|
228
|
+
readFromCache,
|
|
229
|
+
writeToCache,
|
|
230
|
+
readThrough,
|
|
231
|
+
invalidate,
|
|
232
|
+
clearAll,
|
|
233
|
+
stats,
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
if (require.main === module) {
|
|
237
|
+
main();
|
|
238
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// check-prereqs.js — verify Sprintpilot v2's environment prerequisites.
|
|
4
|
+
//
|
|
5
|
+
// Exit codes:
|
|
6
|
+
// 0 — all prereqs met (may include a warning for degraded mode)
|
|
7
|
+
// 1 — hard failure (user must resolve before continuing)
|
|
8
|
+
//
|
|
9
|
+
// Checks:
|
|
10
|
+
// - Node >= 18 (package.json engines)
|
|
11
|
+
// - Git >= 2.18 required for submodule --jobs / --reference (PR 10).
|
|
12
|
+
// Git 2.5.0–2.17 works in degraded mode (no submodule speedup) —
|
|
13
|
+
// emits a warning on stderr but exits 0.
|
|
14
|
+
|
|
15
|
+
const { execFileSync } = require('node:child_process');
|
|
16
|
+
const { parseArgs } = require('../lib/runtime/args');
|
|
17
|
+
const log = require('../lib/runtime/log');
|
|
18
|
+
|
|
19
|
+
const MIN_NODE = [18, 0, 0];
|
|
20
|
+
const MIN_GIT_STRICT = [2, 18, 0]; // submodule --jobs / --reference
|
|
21
|
+
const MIN_GIT_SOFT = [2, 5, 0]; // worktree basics
|
|
22
|
+
|
|
23
|
+
function help() {
|
|
24
|
+
log.out('Usage: check-prereqs.js [--min-git <semver>]');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseSemver(str) {
|
|
28
|
+
// Accept "2.18.0", "2.18", "git version 2.39.3 (Apple Git-145)", etc.
|
|
29
|
+
const m = String(str).match(/(\d+)\.(\d+)(?:\.(\d+))?/);
|
|
30
|
+
if (!m) return null;
|
|
31
|
+
return [
|
|
32
|
+
Number.parseInt(m[1], 10),
|
|
33
|
+
Number.parseInt(m[2], 10),
|
|
34
|
+
m[3] ? Number.parseInt(m[3], 10) : 0,
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function cmp(a, b) {
|
|
39
|
+
for (let i = 0; i < 3; i++) {
|
|
40
|
+
if (a[i] > b[i]) return 1;
|
|
41
|
+
if (a[i] < b[i]) return -1;
|
|
42
|
+
}
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function fmt(v) {
|
|
47
|
+
return `${v[0]}.${v[1]}.${v[2]}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function checkNode() {
|
|
51
|
+
const v = parseSemver(process.version);
|
|
52
|
+
if (!v) {
|
|
53
|
+
log.error(`unable to parse node version '${process.version}'`);
|
|
54
|
+
return { ok: false };
|
|
55
|
+
}
|
|
56
|
+
if (cmp(v, MIN_NODE) < 0) {
|
|
57
|
+
log.error(`node ${fmt(v)} is too old; need >= ${fmt(MIN_NODE)}. Upgrade node.`);
|
|
58
|
+
return { ok: false };
|
|
59
|
+
}
|
|
60
|
+
return { ok: true, version: fmt(v) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readGitVersion() {
|
|
64
|
+
// execFileSync (not exec) — no shell, no injection surface.
|
|
65
|
+
try {
|
|
66
|
+
const out = execFileSync('git', ['--version'], {
|
|
67
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
68
|
+
encoding: 'utf8',
|
|
69
|
+
}).trim();
|
|
70
|
+
return out;
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function checkGit(minStrictArg) {
|
|
77
|
+
const raw = readGitVersion();
|
|
78
|
+
if (!raw) {
|
|
79
|
+
log.error('git not found on PATH. Install git >= 2.18 (or 2.5 for degraded mode).');
|
|
80
|
+
return { ok: false };
|
|
81
|
+
}
|
|
82
|
+
const v = parseSemver(raw);
|
|
83
|
+
if (!v) {
|
|
84
|
+
log.warn(`unable to parse git version string: ${raw}`);
|
|
85
|
+
return { ok: true, version: raw, degraded: true };
|
|
86
|
+
}
|
|
87
|
+
const minStrict = minStrictArg ? parseSemver(minStrictArg) : MIN_GIT_STRICT;
|
|
88
|
+
if (cmp(v, minStrict) >= 0) {
|
|
89
|
+
return { ok: true, version: fmt(v), degraded: false };
|
|
90
|
+
}
|
|
91
|
+
if (cmp(v, MIN_GIT_SOFT) >= 0) {
|
|
92
|
+
log.warn(
|
|
93
|
+
`git ${fmt(v)} is below recommended ${fmt(minStrict)}. Degraded mode: submodule speedups disabled (PR 10 features).`,
|
|
94
|
+
);
|
|
95
|
+
return { ok: true, version: fmt(v), degraded: true };
|
|
96
|
+
}
|
|
97
|
+
log.error(
|
|
98
|
+
`git ${fmt(v)} is too old; need >= ${fmt(MIN_GIT_SOFT)} minimum (${fmt(minStrict)} recommended).`,
|
|
99
|
+
);
|
|
100
|
+
return { ok: false, version: fmt(v) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function main() {
|
|
104
|
+
const { opts } = parseArgs(process.argv.slice(2));
|
|
105
|
+
if (opts.help) {
|
|
106
|
+
help();
|
|
107
|
+
process.exit(0);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const node = checkNode();
|
|
111
|
+
if (!node.ok) process.exit(1);
|
|
112
|
+
|
|
113
|
+
const git = checkGit(opts['min-git']);
|
|
114
|
+
if (!git.ok) process.exit(1);
|
|
115
|
+
|
|
116
|
+
// Summary on stdout for scripting consumers.
|
|
117
|
+
const summary = {
|
|
118
|
+
node: node.version,
|
|
119
|
+
git: git.version,
|
|
120
|
+
git_degraded: git.degraded || false,
|
|
121
|
+
};
|
|
122
|
+
process.stdout.write(JSON.stringify(summary) + '\n');
|
|
123
|
+
process.exit(0);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = {
|
|
127
|
+
checkNode,
|
|
128
|
+
checkGit,
|
|
129
|
+
parseSemver,
|
|
130
|
+
cmp,
|
|
131
|
+
fmt,
|
|
132
|
+
MIN_NODE,
|
|
133
|
+
MIN_GIT_STRICT,
|
|
134
|
+
MIN_GIT_SOFT,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
if (require.main === module) {
|
|
138
|
+
main();
|
|
139
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// dispatch-layer.js — orchestrator for parallel intra-epic story execution.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// dispatch-layer.js --layer <key,key,...> [--max-parallel <n>]
|
|
7
|
+
// [--project-root <path>] [--branch-prefix <str>]
|
|
8
|
+
// [--base-branch <br>] [--dry-run]
|
|
9
|
+
//
|
|
10
|
+
// Responsibilities:
|
|
11
|
+
// 1. For each story in --layer (respecting --max-parallel concurrency),
|
|
12
|
+
// create the story's worktree and branch. Worktree creation itself
|
|
13
|
+
// happens synchronously (cheap after PR 10); actual sub-agent spawn
|
|
14
|
+
// is delegated to the host agent via a plan file the host reads.
|
|
15
|
+
// 2. Emit a plan.json to the project at
|
|
16
|
+
// _bmad-output/implementation-artifacts/.layer-plan.json
|
|
17
|
+
// that the host workflow then consumes — invoking N sub-agents, one
|
|
18
|
+
// per story, pointing each at its worktree + branch.
|
|
19
|
+
// 3. When the host reports back (all sub-agents complete), the top-level
|
|
20
|
+
// workflow invokes `merge-shards.js --archive --layer <id>` to merge
|
|
21
|
+
// each story's state shard into the authoritative project YAML.
|
|
22
|
+
//
|
|
23
|
+
// This script itself does NOT call an LLM. Host-specific multi-agent
|
|
24
|
+
// dispatch is up to workflow.md (gated on agent-adapter.js's confidence).
|
|
25
|
+
|
|
26
|
+
const fs = require('node:fs');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
const { spawnSync } = require('node:child_process');
|
|
29
|
+
|
|
30
|
+
const { parseArgs } = require('../lib/runtime/args');
|
|
31
|
+
const log = require('../lib/runtime/log');
|
|
32
|
+
|
|
33
|
+
const STORY_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
34
|
+
const PLAN_FILENAME = '.layer-plan.json';
|
|
35
|
+
|
|
36
|
+
function help() {
|
|
37
|
+
log.out(
|
|
38
|
+
[
|
|
39
|
+
'Usage:',
|
|
40
|
+
' dispatch-layer.js --layer <key,key,...> [options]',
|
|
41
|
+
'',
|
|
42
|
+
'Options:',
|
|
43
|
+
' --max-parallel N Upper bound on concurrent sub-agents (default 2).',
|
|
44
|
+
' --project-root P Defaults to cwd.',
|
|
45
|
+
' --branch-prefix S Branch name prefix (default story/).',
|
|
46
|
+
' --base-branch B Branch point (default main).',
|
|
47
|
+
' --dry-run Compute the plan but do not create worktrees.',
|
|
48
|
+
].join('\n'),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseLayer(raw) {
|
|
53
|
+
if (!raw) return { ok: false, error: '--layer is required' };
|
|
54
|
+
const keys = String(raw).split(',').map((s) => s.trim()).filter(Boolean);
|
|
55
|
+
for (const k of keys) {
|
|
56
|
+
if (!STORY_RE.test(k)) {
|
|
57
|
+
return { ok: false, error: `invalid story key '${k}': must match ${STORY_RE}` };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (keys.length === 0) {
|
|
61
|
+
return { ok: false, error: '--layer must contain at least one story key' };
|
|
62
|
+
}
|
|
63
|
+
return { ok: true, value: keys };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function planLayer({ keys, maxParallel, projectRoot, branchPrefix, baseBranch }) {
|
|
67
|
+
const effectiveParallel = Math.max(1, Math.min(maxParallel | 0, keys.length));
|
|
68
|
+
const worktrees = keys.map((key) => ({
|
|
69
|
+
story: key,
|
|
70
|
+
worktree: path.join(projectRoot, '.worktrees', key),
|
|
71
|
+
branch: `${branchPrefix}${key}`,
|
|
72
|
+
base_branch: baseBranch,
|
|
73
|
+
}));
|
|
74
|
+
return {
|
|
75
|
+
version: 1,
|
|
76
|
+
created_at: new Date().toISOString(),
|
|
77
|
+
effective_parallel: effectiveParallel,
|
|
78
|
+
max_parallel: maxParallel,
|
|
79
|
+
stories: worktrees,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function writePlan(projectRoot, plan) {
|
|
84
|
+
const dir = path.join(projectRoot, '_bmad-output', 'implementation-artifacts');
|
|
85
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
86
|
+
const file = path.join(dir, PLAN_FILENAME);
|
|
87
|
+
const tmp = `${file}.tmp.${process.pid}.${process.hrtime.bigint().toString(36)}`;
|
|
88
|
+
fs.writeFileSync(tmp, JSON.stringify(plan, null, 2));
|
|
89
|
+
fs.renameSync(tmp, file);
|
|
90
|
+
return file;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function createWorktree({ projectRoot, worktree, branch, baseBranch }) {
|
|
94
|
+
// Try -b first, fall back to checkout-existing-branch if already present.
|
|
95
|
+
const args = ['worktree', 'add', worktree, '-b', branch];
|
|
96
|
+
if (baseBranch) args.push(baseBranch);
|
|
97
|
+
const first = spawnSync('git', ['-C', projectRoot, ...args], {
|
|
98
|
+
encoding: 'utf8',
|
|
99
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
100
|
+
});
|
|
101
|
+
if (first.status === 0) return { created: true, retried: false, stderr: first.stderr || '' };
|
|
102
|
+
// Retry without -b (branch exists).
|
|
103
|
+
const second = spawnSync(
|
|
104
|
+
'git',
|
|
105
|
+
['-C', projectRoot, 'worktree', 'add', worktree, branch],
|
|
106
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
|
|
107
|
+
);
|
|
108
|
+
return {
|
|
109
|
+
created: second.status === 0,
|
|
110
|
+
retried: true,
|
|
111
|
+
stderr: (first.stderr || '') + (second.stderr || ''),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function dispatch({ keys, maxParallel, projectRoot, branchPrefix, baseBranch, dryRun }) {
|
|
116
|
+
const plan = planLayer({ keys, maxParallel, projectRoot, branchPrefix, baseBranch });
|
|
117
|
+
const results = {
|
|
118
|
+
plan_file: null,
|
|
119
|
+
effective_parallel: plan.effective_parallel,
|
|
120
|
+
stories: [],
|
|
121
|
+
dry_run: !!dryRun,
|
|
122
|
+
};
|
|
123
|
+
if (!dryRun) {
|
|
124
|
+
for (const entry of plan.stories) {
|
|
125
|
+
const out = createWorktree({
|
|
126
|
+
projectRoot,
|
|
127
|
+
worktree: entry.worktree,
|
|
128
|
+
branch: entry.branch,
|
|
129
|
+
baseBranch: entry.base_branch,
|
|
130
|
+
});
|
|
131
|
+
results.stories.push({ story: entry.story, worktree: entry.worktree, branch: entry.branch, ...out });
|
|
132
|
+
}
|
|
133
|
+
results.plan_file = writePlan(projectRoot, plan);
|
|
134
|
+
} else {
|
|
135
|
+
results.stories = plan.stories.map((e) => ({
|
|
136
|
+
story: e.story,
|
|
137
|
+
worktree: e.worktree,
|
|
138
|
+
branch: e.branch,
|
|
139
|
+
created: false,
|
|
140
|
+
retried: false,
|
|
141
|
+
stderr: '(dry-run)',
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
return results;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function main() {
|
|
148
|
+
const { opts } = parseArgs(process.argv.slice(2), { booleanFlags: ['dry-run'] });
|
|
149
|
+
if (opts.help) {
|
|
150
|
+
help();
|
|
151
|
+
process.exit(0);
|
|
152
|
+
}
|
|
153
|
+
const layer = parseLayer(opts.layer);
|
|
154
|
+
if (!layer.ok) {
|
|
155
|
+
log.error(layer.error);
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
const maxParallel = opts['max-parallel'] !== undefined ? Number.parseInt(String(opts['max-parallel']), 10) : 2;
|
|
159
|
+
if (Number.isNaN(maxParallel) || maxParallel < 1) {
|
|
160
|
+
log.error(`invalid --max-parallel '${opts['max-parallel']}': must be a positive integer`);
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
const projectRoot = opts['project-root'] || process.cwd();
|
|
164
|
+
const branchPrefix = opts['branch-prefix'] !== undefined ? String(opts['branch-prefix']) : 'story/';
|
|
165
|
+
const baseBranch = opts['base-branch'] !== undefined ? String(opts['base-branch']) : 'main';
|
|
166
|
+
const dryRun = opts['dry-run'] === true;
|
|
167
|
+
|
|
168
|
+
const result = dispatch({
|
|
169
|
+
keys: layer.value,
|
|
170
|
+
maxParallel,
|
|
171
|
+
projectRoot,
|
|
172
|
+
branchPrefix,
|
|
173
|
+
baseBranch,
|
|
174
|
+
dryRun,
|
|
175
|
+
});
|
|
176
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
177
|
+
const allCreated = dryRun || result.stories.every((s) => s.created);
|
|
178
|
+
process.exit(allCreated ? 0 : 1);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = {
|
|
182
|
+
STORY_RE,
|
|
183
|
+
PLAN_FILENAME,
|
|
184
|
+
parseLayer,
|
|
185
|
+
planLayer,
|
|
186
|
+
writePlan,
|
|
187
|
+
dispatch,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
if (require.main === module) {
|
|
191
|
+
main();
|
|
192
|
+
}
|