@ikunin/sprintpilot 2.2.13 → 2.2.15
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.
|
@@ -702,6 +702,10 @@ function composeRuntimeState(persisted, profile, projectRoot) {
|
|
|
702
702
|
// not lifetime). Persisted across in-session resumes so a `pause` mid-flow
|
|
703
703
|
// doesn't reset progress against the limit.
|
|
704
704
|
session_stories_completed: persisted.session_stories_completed || 0,
|
|
705
|
+
// .autopilot.lock holder ID, persisted so subsequent cmdStart calls
|
|
706
|
+
// recognize their own lock and refresh in place. Cleared by
|
|
707
|
+
// sprint-autopilot-off (which calls `lock.js release`).
|
|
708
|
+
lock_session_id: persisted.lock_session_id || null,
|
|
705
709
|
// halt_requested is intentionally NOT carried forward here: cmdStart
|
|
706
710
|
// clears it on each new session (a `pause` cleanly halts THIS session
|
|
707
711
|
// and the next /sprint-autopilot-on resumes normally).
|
|
@@ -731,6 +735,7 @@ function persistRuntimeState(runtime, profile, projectRoot) {
|
|
|
731
735
|
land_pending: runtime.land_pending,
|
|
732
736
|
pending_alternative: runtime.pending_alternative || null,
|
|
733
737
|
session_stories_completed: runtime.session_stories_completed || 0,
|
|
738
|
+
lock_session_id: runtime.lock_session_id || null,
|
|
734
739
|
};
|
|
735
740
|
return persistState(updates, profile, projectRoot, runtime.story_key || 'sprint');
|
|
736
741
|
}
|
|
@@ -1066,6 +1071,186 @@ function lockUserBranchIfNeeded(runtime, profile, projectRoot) {
|
|
|
1066
1071
|
return null;
|
|
1067
1072
|
}
|
|
1068
1073
|
|
|
1074
|
+
// .autopilot.lock: prevent concurrent autopilot sessions on the same
|
|
1075
|
+
// project. Lockfile contract documented in modules/git/config.yaml
|
|
1076
|
+
// ("Lock file (.autopilot.lock — prevents concurrent autopilot sessions)")
|
|
1077
|
+
// and implemented in scripts/lock.js. cmdStart wires it in here.
|
|
1078
|
+
//
|
|
1079
|
+
// Idempotency: a /sprint-autopilot-on mid-flow (e.g. after a halt) must
|
|
1080
|
+
// not refuse to resume just because the prior cmdStart left a lock. We
|
|
1081
|
+
// store the lock's session_id in autopilot-state.yaml on first acquire and
|
|
1082
|
+
// treat a matching id on subsequent cmdStart calls as "my lock; refresh".
|
|
1083
|
+
//
|
|
1084
|
+
// Return shape:
|
|
1085
|
+
// { acquired: true, id, refreshed?: true } — proceed
|
|
1086
|
+
// { acquired: false, holder, ageMin } — halt; caller emits user_prompt
|
|
1087
|
+
// { acquired: true, id, takeover: 'stale' } — stale takeover; proceed
|
|
1088
|
+
function acquireAutopilotLock(persisted, profile, projectRoot) {
|
|
1089
|
+
const { execFileSync: runFile } = require('node:child_process');
|
|
1090
|
+
const lockScript = path.join(projectRoot, '_Sprintpilot', 'scripts', 'lock.js');
|
|
1091
|
+
if (!fs.existsSync(lockScript)) {
|
|
1092
|
+
return { acquired: true, id: null, skipped: true };
|
|
1093
|
+
}
|
|
1094
|
+
const lockFile = path.join(projectRoot, '.autopilot.lock');
|
|
1095
|
+
const stale = typeof profile.lock_stale_timeout_minutes === 'number'
|
|
1096
|
+
? profile.lock_stale_timeout_minutes
|
|
1097
|
+
: 30;
|
|
1098
|
+
// stale_timeout_minutes <= 0 means "never auto-take-over". Pass a very
|
|
1099
|
+
// large value to lock.js so it never deems anything STALE.
|
|
1100
|
+
const staleArg = stale > 0 ? String(stale) : '999999';
|
|
1101
|
+
|
|
1102
|
+
const callLock = (action) => {
|
|
1103
|
+
try {
|
|
1104
|
+
const out = runFile(
|
|
1105
|
+
'node',
|
|
1106
|
+
[lockScript, action, '--file', lockFile, '--stale-minutes', staleArg],
|
|
1107
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
|
|
1108
|
+
).trim();
|
|
1109
|
+
return { ok: true, out };
|
|
1110
|
+
} catch (e) {
|
|
1111
|
+
return { ok: false, out: (e.stdout && e.stdout.toString().trim()) || '', code: e.status };
|
|
1112
|
+
}
|
|
1113
|
+
};
|
|
1114
|
+
|
|
1115
|
+
const checkResult = callLock('check');
|
|
1116
|
+
const checkOut = checkResult.out || 'FREE';
|
|
1117
|
+
|
|
1118
|
+
if (checkOut === 'FREE') {
|
|
1119
|
+
const acq = callLock('acquire');
|
|
1120
|
+
if (acq.ok && acq.out.startsWith('ACQUIRED:')) {
|
|
1121
|
+
return { acquired: true, id: acq.out.slice('ACQUIRED:'.length) };
|
|
1122
|
+
}
|
|
1123
|
+
// Race: another acquirer just created the lock. Fall through to retry.
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
const match = /^(LOCKED|STALE):([^:]+):(\d+)m$/.exec(checkOut);
|
|
1127
|
+
if (match) {
|
|
1128
|
+
const state = match[1];
|
|
1129
|
+
const holderId = match[2];
|
|
1130
|
+
const ageMin = parseInt(match[3], 10);
|
|
1131
|
+
|
|
1132
|
+
// My own lock? Refresh (rewrite ts + same id) and proceed.
|
|
1133
|
+
if (state === 'LOCKED' && persisted.lock_session_id && persisted.lock_session_id === holderId) {
|
|
1134
|
+
try {
|
|
1135
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
1136
|
+
fs.writeFileSync(lockFile, `${ts}\n${holderId}\n`, { encoding: 'utf8', mode: 0o644 });
|
|
1137
|
+
return { acquired: true, id: holderId, refreshed: true };
|
|
1138
|
+
} catch (e) {
|
|
1139
|
+
return { acquired: false, holder: holderId, ageMin, error: `lock refresh failed: ${e.message}` };
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
if (state === 'STALE') {
|
|
1144
|
+
const acq = callLock('acquire');
|
|
1145
|
+
if (acq.ok && acq.out.startsWith('ACQUIRED_STALE:')) {
|
|
1146
|
+
return { acquired: true, id: acq.out.slice('ACQUIRED_STALE:'.length), takeover: 'stale' };
|
|
1147
|
+
}
|
|
1148
|
+
if (acq.ok && acq.out.startsWith('ACQUIRED:')) {
|
|
1149
|
+
return { acquired: true, id: acq.out.slice('ACQUIRED:'.length) };
|
|
1150
|
+
}
|
|
1151
|
+
const reMatch = /^LOCKED:([^:]+):(\d+)m$/.exec(acq.out);
|
|
1152
|
+
if (reMatch) {
|
|
1153
|
+
return { acquired: false, holder: reMatch[1], ageMin: parseInt(reMatch[2], 10) };
|
|
1154
|
+
}
|
|
1155
|
+
return { acquired: false, holder: holderId, ageMin };
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
return { acquired: false, holder: holderId, ageMin };
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
return { acquired: true, id: null, warning: `unrecognized lock state: ${checkOut}` };
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// Worktree health check on boot. Documented in modules/git/config.yaml
|
|
1165
|
+
// as "check for orphaned worktrees from crashed sessions". The script
|
|
1166
|
+
// (scripts/health-check.js) categorizes worktrees as CLEAN_DONE /
|
|
1167
|
+
// COMMITTED / STALE / DIRTY / ORPHAN and writes a SUMMARY line.
|
|
1168
|
+
//
|
|
1169
|
+
// We treat ORPHAN as halt-worthy (a worktree directory exists but
|
|
1170
|
+
// `git rev-parse --git-dir` fails or no branch is checked out — almost
|
|
1171
|
+
// certainly leftover from a crashed session that needs cleanup). DIRTY
|
|
1172
|
+
// is logged but doesn't halt (user may be actively working in it).
|
|
1173
|
+
//
|
|
1174
|
+
// Returns one of:
|
|
1175
|
+
// { ok: true, summary } — no orphans; proceed
|
|
1176
|
+
// { ok: false, prompt, orphans, summary } — halt; caller emits user_prompt
|
|
1177
|
+
// { ok: true, skipped: true } — script missing / no worktrees dir / disabled
|
|
1178
|
+
function runWorktreeHealthCheck(profile, projectRoot) {
|
|
1179
|
+
if (!profile.worktree_health_check_on_boot) {
|
|
1180
|
+
return { ok: true, skipped: true, reason: 'disabled' };
|
|
1181
|
+
}
|
|
1182
|
+
if (!profile.worktree_enabled) {
|
|
1183
|
+
return { ok: true, skipped: true, reason: 'worktrees_disabled' };
|
|
1184
|
+
}
|
|
1185
|
+
const script = path.join(projectRoot, '_Sprintpilot', 'scripts', 'health-check.js');
|
|
1186
|
+
if (!fs.existsSync(script)) {
|
|
1187
|
+
return { ok: true, skipped: true, reason: 'script_missing' };
|
|
1188
|
+
}
|
|
1189
|
+
const worktreesDir = path.join(projectRoot, '.worktrees');
|
|
1190
|
+
if (!fs.existsSync(worktreesDir)) {
|
|
1191
|
+
return { ok: true, skipped: true, reason: 'no_worktrees_dir' };
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
const { execFileSync: runFile } = require('node:child_process');
|
|
1195
|
+
let stdout = '';
|
|
1196
|
+
try {
|
|
1197
|
+
stdout = runFile(
|
|
1198
|
+
'node',
|
|
1199
|
+
[
|
|
1200
|
+
script,
|
|
1201
|
+
'--worktrees-dir',
|
|
1202
|
+
worktreesDir,
|
|
1203
|
+
'--base-branch',
|
|
1204
|
+
profile.base_branch || 'main',
|
|
1205
|
+
],
|
|
1206
|
+
{
|
|
1207
|
+
encoding: 'utf8',
|
|
1208
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1209
|
+
cwd: projectRoot,
|
|
1210
|
+
timeout: 60_000,
|
|
1211
|
+
},
|
|
1212
|
+
);
|
|
1213
|
+
} catch (e) {
|
|
1214
|
+
// Health check failure isn't fatal — log and proceed. A broken
|
|
1215
|
+
// script shouldn't gate the autopilot.
|
|
1216
|
+
return {
|
|
1217
|
+
ok: true,
|
|
1218
|
+
skipped: true,
|
|
1219
|
+
reason: 'health_check_error',
|
|
1220
|
+
error: e.message || String(e),
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
const lines = stdout.split(/\r?\n/).filter(Boolean);
|
|
1225
|
+
const summaryLine = lines.find((l) => l.startsWith('SUMMARY:')) || '';
|
|
1226
|
+
// SUMMARY:total:cleanDone:committed:stale:dirty:orphan
|
|
1227
|
+
const parts = summaryLine.split(':');
|
|
1228
|
+
const summary = {
|
|
1229
|
+
total: parseInt(parts[1] || '0', 10),
|
|
1230
|
+
clean_done: parseInt(parts[2] || '0', 10),
|
|
1231
|
+
committed: parseInt(parts[3] || '0', 10),
|
|
1232
|
+
stale: parseInt(parts[4] || '0', 10),
|
|
1233
|
+
dirty: parseInt(parts[5] || '0', 10),
|
|
1234
|
+
orphan: parseInt(parts[6] || '0', 10),
|
|
1235
|
+
};
|
|
1236
|
+
const orphans = lines
|
|
1237
|
+
.filter((l) => l.startsWith('ORPHAN:'))
|
|
1238
|
+
.map((l) => l.slice('ORPHAN:'.length));
|
|
1239
|
+
|
|
1240
|
+
if (summary.orphan > 0) {
|
|
1241
|
+
return {
|
|
1242
|
+
ok: false,
|
|
1243
|
+
summary,
|
|
1244
|
+
orphans,
|
|
1245
|
+
prompt:
|
|
1246
|
+
`Found ${summary.orphan} orphaned worktree(s) under .worktrees/ from a previous (possibly crashed) session: ${orphans.join(', ')}. ` +
|
|
1247
|
+
`Run \`git worktree prune\` and remove the leftover directories before resuming, or run \`node _Sprintpilot/scripts/health-check.js --worktrees-dir .worktrees\` to see all categories.`,
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
return { ok: true, summary };
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1069
1254
|
function cmdStart(opts) {
|
|
1070
1255
|
const projectRoot = resolveProjectRoot(opts);
|
|
1071
1256
|
const { typed: profile } = resolveProfile(projectRoot, opts.profile);
|
|
@@ -1118,6 +1303,83 @@ function cmdStart(opts) {
|
|
|
1118
1303
|
}
|
|
1119
1304
|
}
|
|
1120
1305
|
|
|
1306
|
+
// .autopilot.lock — acquire before any state mutation. If another
|
|
1307
|
+
// session holds the lock (and it isn't ours and isn't stale), bail out
|
|
1308
|
+
// with a user_prompt action so the LLM/user knows to either wait or
|
|
1309
|
+
// run `sprint-autopilot-off` in the other session.
|
|
1310
|
+
const lockOutcome = acquireAutopilotLock(persisted, profile, projectRoot);
|
|
1311
|
+
if (!lockOutcome.acquired) {
|
|
1312
|
+
const haltAction = {
|
|
1313
|
+
type: 'user_prompt',
|
|
1314
|
+
reason: 'autopilot_lock_held',
|
|
1315
|
+
prompt:
|
|
1316
|
+
`Another autopilot session holds .autopilot.lock (session ${lockOutcome.holder}, age ${lockOutcome.ageMin}m). ` +
|
|
1317
|
+
`Wait for it to finish, run \`/sprint-autopilot-off\` in the other session, or delete .autopilot.lock if you're sure the holder crashed.`,
|
|
1318
|
+
holder: lockOutcome.holder,
|
|
1319
|
+
age_minutes: lockOutcome.ageMin,
|
|
1320
|
+
};
|
|
1321
|
+
ledger.append(
|
|
1322
|
+
{ kind: 'action_emitted', phase: persisted.current_bmad_step || null, action: haltAction },
|
|
1323
|
+
{ projectRoot },
|
|
1324
|
+
);
|
|
1325
|
+
process.stdout.write(`${JSON.stringify({ action: haltAction, phase: persisted.current_bmad_step || null }, null, 2)}\n`);
|
|
1326
|
+
return 0;
|
|
1327
|
+
}
|
|
1328
|
+
if (lockOutcome.id) {
|
|
1329
|
+
persisted.lock_session_id = lockOutcome.id;
|
|
1330
|
+
// Eagerly persist lock_session_id so a crash between here and the
|
|
1331
|
+
// final persistRuntimeState below doesn't leave the lockfile owned
|
|
1332
|
+
// by an ID that nothing knows about. Without this, a mid-cmdStart
|
|
1333
|
+
// crash would brick the project until the lock goes stale.
|
|
1334
|
+
persistState({ lock_session_id: lockOutcome.id }, profile, projectRoot, 'sprint');
|
|
1335
|
+
if (profile.coalesce_state_writes) stateStore.flush(profile, { projectRoot, story: 'sprint' });
|
|
1336
|
+
ledger.append(
|
|
1337
|
+
{
|
|
1338
|
+
kind: 'lock_acquired',
|
|
1339
|
+
detail: {
|
|
1340
|
+
session_id: lockOutcome.id,
|
|
1341
|
+
takeover: lockOutcome.takeover || null,
|
|
1342
|
+
refreshed: !!lockOutcome.refreshed,
|
|
1343
|
+
},
|
|
1344
|
+
},
|
|
1345
|
+
{ projectRoot },
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// Worktree health check — once per session, after lock acquire so we
|
|
1350
|
+
// don't compete with another active session for the same .worktrees
|
|
1351
|
+
// directory.
|
|
1352
|
+
const healthOutcome = runWorktreeHealthCheck(profile, projectRoot);
|
|
1353
|
+
ledger.append(
|
|
1354
|
+
{
|
|
1355
|
+
kind: 'worktree_health_check',
|
|
1356
|
+
detail: {
|
|
1357
|
+
ok: healthOutcome.ok,
|
|
1358
|
+
skipped: !!healthOutcome.skipped,
|
|
1359
|
+
reason: healthOutcome.reason || null,
|
|
1360
|
+
summary: healthOutcome.summary || null,
|
|
1361
|
+
},
|
|
1362
|
+
},
|
|
1363
|
+
{ projectRoot },
|
|
1364
|
+
);
|
|
1365
|
+
if (!healthOutcome.ok) {
|
|
1366
|
+
const haltAction = {
|
|
1367
|
+
type: 'user_prompt',
|
|
1368
|
+
reason: 'worktree_orphans_detected',
|
|
1369
|
+
prompt: healthOutcome.prompt,
|
|
1370
|
+
orphans: healthOutcome.orphans,
|
|
1371
|
+
summary: healthOutcome.summary,
|
|
1372
|
+
};
|
|
1373
|
+
ledger.append(
|
|
1374
|
+
{ kind: 'action_emitted', phase: persisted.current_bmad_step || null, action: haltAction },
|
|
1375
|
+
{ projectRoot },
|
|
1376
|
+
);
|
|
1377
|
+
process.stdout.write(
|
|
1378
|
+
`${JSON.stringify({ action: haltAction, phase: persisted.current_bmad_step || null }, null, 2)}\n`,
|
|
1379
|
+
);
|
|
1380
|
+
return 0;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1121
1383
|
// Persist the new queue BEFORE composing runtime state so the queue
|
|
1122
1384
|
// head is visible to composeRuntimeState's resolver.
|
|
1123
1385
|
if (explicitQueue.length > 0) {
|
|
@@ -1419,4 +1681,12 @@ if (require.main === module) {
|
|
|
1419
1681
|
process.exit(main(process.argv.slice(2)));
|
|
1420
1682
|
}
|
|
1421
1683
|
|
|
1422
|
-
module.exports = {
|
|
1684
|
+
module.exports = {
|
|
1685
|
+
main,
|
|
1686
|
+
SUBCOMMANDS,
|
|
1687
|
+
decorateGitOp,
|
|
1688
|
+
decorateRunScript,
|
|
1689
|
+
composeRuntimeState,
|
|
1690
|
+
acquireAutopilotLock,
|
|
1691
|
+
runWorktreeHealthCheck,
|
|
1692
|
+
};
|
|
@@ -39,6 +39,11 @@ const VALID_KINDS = [
|
|
|
39
39
|
// `--epic`. Logged once per start invocation so resume/audit can see
|
|
40
40
|
// why a queue head differs from sprint-status's natural order.
|
|
41
41
|
'story_queue_set',
|
|
42
|
+
// Worktree health check result, logged once per cmdStart when
|
|
43
|
+
// git.worktree.health_check_on_boot is true (the default). Detail
|
|
44
|
+
// includes `summary` (counts) or `reason` ('disabled' / 'no_worktrees_dir'
|
|
45
|
+
// / 'script_missing' / 'health_check_error' / 'worktrees_disabled').
|
|
46
|
+
'worktree_health_check',
|
|
42
47
|
];
|
|
43
48
|
|
|
44
49
|
function isPlainObject(v) {
|
|
@@ -96,6 +96,15 @@ function flatToProfile(resolved, profileName) {
|
|
|
96
96
|
conditional_boot_work: coerceBool(get(resolved, 'autopilot.conditional_boot_work'), false),
|
|
97
97
|
granularity: coerceEnum(get(resolved, 'git.granularity'), VALID_GRANULARITIES, 'story'),
|
|
98
98
|
worktree_enabled: coerceBool(get(resolved, 'git.worktree.enabled'), true),
|
|
99
|
+
// git.worktree.health_check_on_boot — when true, cmdStart runs
|
|
100
|
+
// scripts/health-check.js once per session and halts if it finds
|
|
101
|
+
// ORPHAN worktrees (left over from crashed sessions). Documented in
|
|
102
|
+
// modules/git/config.yaml ("check for orphaned worktrees from
|
|
103
|
+
// crashed sessions").
|
|
104
|
+
worktree_health_check_on_boot: coerceBool(
|
|
105
|
+
get(resolved, 'git.worktree.health_check_on_boot'),
|
|
106
|
+
true,
|
|
107
|
+
),
|
|
99
108
|
squash_on_merge: coerceBool(get(resolved, 'git.squash_on_merge'), false),
|
|
100
109
|
reuse_user_branch: coerceBool(get(resolved, 'git.reuse_user_branch'), false),
|
|
101
110
|
merge_strategy: coerceEnum(
|
|
@@ -148,6 +157,12 @@ function flatToProfile(resolved, profileName) {
|
|
|
148
157
|
// (story keys + prefix) to this length with a 6-char hash suffix to
|
|
149
158
|
// keep the name unique. Honors the contract advertised in config.yaml.
|
|
150
159
|
max_branch_length: coerceInt(get(resolved, 'git.max_branch_length'), 60),
|
|
160
|
+
// git.lock.stale_timeout_minutes — .autopilot.lock is auto-taken-over
|
|
161
|
+
// by cmdStart when older than this. Documented in modules/git/config.yaml
|
|
162
|
+
// ("auto-remove locks older than this"). Forwarded to lock.js via
|
|
163
|
+
// --stale-minutes. 0 disables the auto-takeover entirely (locks are
|
|
164
|
+
// never considered stale; manual `autopilot off` required).
|
|
165
|
+
lock_stale_timeout_minutes: coerceInt(get(resolved, 'git.lock.stale_timeout_minutes'), 30),
|
|
151
166
|
// git.platform.provider + base_url — forwarded to create-pr.js when
|
|
152
167
|
// the orchestrator opens or polls PRs. 'auto' delegates platform
|
|
153
168
|
// detection to create-pr.js (currently defaults to github).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ikunin/sprintpilot",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.15",
|
|
4
4
|
"description": "Sprintpilot — autopilot and multi-agent addon for BMad Method v6: git workflow, parallel agents, autonomous story execution",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|