@ikunin/sprintpilot 2.2.18 → 2.2.20
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/_Sprintpilot/bin/autopilot.js +115 -23
- package/_Sprintpilot/lib/orchestrator/adapt.js +7 -1
- package/_Sprintpilot/lib/orchestrator/git-plan.js +24 -0
- package/_Sprintpilot/lib/orchestrator/profile-rules.js +9 -0
- package/_Sprintpilot/manifest.yaml +1 -1
- package/_Sprintpilot/scripts/cleanup-worktrees.js +158 -0
- package/package.json +1 -1
|
@@ -867,31 +867,123 @@ function decorateGitOp(action, state, profile, projectRoot) {
|
|
|
867
867
|
// options), inline the resulting `steps[]` onto the action.
|
|
868
868
|
function decorateRunScript(action, state, profile, projectRoot) {
|
|
869
869
|
if (!action || action.type !== 'run_script') return action;
|
|
870
|
-
if (action.op
|
|
871
|
-
|
|
870
|
+
if (action.op === 'land_story') {
|
|
871
|
+
try {
|
|
872
|
+
const root = projectRoot || process.cwd();
|
|
873
|
+
const scriptsDir = path.join(root, '_Sprintpilot', 'scripts');
|
|
874
|
+
const snapshotPath = path.join(
|
|
875
|
+
root,
|
|
876
|
+
'_bmad-output',
|
|
877
|
+
'implementation-artifacts',
|
|
878
|
+
'.land-snapshots',
|
|
879
|
+
`${state.story_key || 'sprint'}.json`,
|
|
880
|
+
);
|
|
881
|
+
const branch = gitPlan.branchName(profile, state.story_key, state.current_epic, state);
|
|
882
|
+
const platform = profile.platform_provider || 'auto';
|
|
883
|
+
const planned = land.planLand(state, profile, {
|
|
884
|
+
scriptsDir,
|
|
885
|
+
snapshotPath,
|
|
886
|
+
branch,
|
|
887
|
+
platform,
|
|
888
|
+
projectRoot: root,
|
|
889
|
+
});
|
|
890
|
+
return { ...action, branch: planned.branch, steps: planned.steps };
|
|
891
|
+
} catch (e) {
|
|
892
|
+
log.warn(`land-plan failed for op=${action.op}: ${e.message}`);
|
|
893
|
+
return action;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
if (action.op === 'install_dependencies') {
|
|
872
897
|
const root = projectRoot || process.cwd();
|
|
873
|
-
const
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
'
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
898
|
+
const steps = planDependencyInstall(root);
|
|
899
|
+
if (steps.length === 0) {
|
|
900
|
+
// No manifest detected — fall back to a no-op success rather than
|
|
901
|
+
// halting the autopilot on an unrecognized project shape. The LLM
|
|
902
|
+
// already had a recoverable blocker; the orchestrator's retry will
|
|
903
|
+
// either succeed (the LLM resolves the dependency another way) or
|
|
904
|
+
// hit the retry budget and prompt.
|
|
905
|
+
return { ...action, steps: [], no_manifest_detected: true };
|
|
906
|
+
}
|
|
907
|
+
return { ...action, steps };
|
|
908
|
+
}
|
|
909
|
+
return action;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Detect manifest files in the project root and return install steps
|
|
913
|
+
// for each language. Returns [] when no manifest is found (caller can
|
|
914
|
+
// degrade to a no-op rather than hardcoding npm install).
|
|
915
|
+
//
|
|
916
|
+
// Order matters: the first match wins for the install. We pick the
|
|
917
|
+
// first detected, since most projects are single-language at the root.
|
|
918
|
+
// Monorepos with multiple manifests still install for the primary
|
|
919
|
+
// (and the LLM can run additional installs via subsequent signals).
|
|
920
|
+
function planDependencyInstall(projectRoot) {
|
|
921
|
+
const exists = (rel) => {
|
|
922
|
+
try {
|
|
923
|
+
return fs.existsSync(path.join(projectRoot, rel));
|
|
924
|
+
} catch {
|
|
925
|
+
return false;
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
// pnpm / yarn / npm: pick the lockfile that exists; fall back to npm.
|
|
929
|
+
if (exists('package.json')) {
|
|
930
|
+
if (exists('pnpm-lock.yaml')) {
|
|
931
|
+
return [{ args: ['pnpm', 'install', '--frozen-lockfile'], description: 'install pnpm deps' }];
|
|
932
|
+
}
|
|
933
|
+
if (exists('yarn.lock')) {
|
|
934
|
+
return [{ args: ['yarn', 'install', '--frozen-lockfile'], description: 'install yarn deps' }];
|
|
935
|
+
}
|
|
936
|
+
if (exists('bun.lockb')) {
|
|
937
|
+
return [{ args: ['bun', 'install', '--frozen-lockfile'], description: 'install bun deps' }];
|
|
938
|
+
}
|
|
939
|
+
return [{ args: ['npm', 'install'], description: 'install npm deps' }];
|
|
940
|
+
}
|
|
941
|
+
// Python: prefer uv > poetry > pipenv > pip
|
|
942
|
+
if (exists('pyproject.toml')) {
|
|
943
|
+
if (exists('uv.lock')) return [{ args: ['uv', 'sync'], description: 'install python deps via uv' }];
|
|
944
|
+
if (exists('poetry.lock')) {
|
|
945
|
+
return [{ args: ['poetry', 'install'], description: 'install python deps via poetry' }];
|
|
946
|
+
}
|
|
947
|
+
return [{ args: ['pip', 'install', '-e', '.'], description: 'install python project deps' }];
|
|
948
|
+
}
|
|
949
|
+
if (exists('requirements.txt')) {
|
|
950
|
+
return [{ args: ['pip', 'install', '-r', 'requirements.txt'], description: 'install pip requirements' }];
|
|
951
|
+
}
|
|
952
|
+
if (exists('Pipfile')) {
|
|
953
|
+
return [{ args: ['pipenv', 'install'], description: 'install python deps via pipenv' }];
|
|
954
|
+
}
|
|
955
|
+
// Rust
|
|
956
|
+
if (exists('Cargo.toml')) {
|
|
957
|
+
return [{ args: ['cargo', 'fetch'], description: 'fetch rust deps via cargo' }];
|
|
958
|
+
}
|
|
959
|
+
// Go
|
|
960
|
+
if (exists('go.mod')) {
|
|
961
|
+
return [{ args: ['go', 'mod', 'download'], description: 'download go modules' }];
|
|
962
|
+
}
|
|
963
|
+
// Ruby
|
|
964
|
+
if (exists('Gemfile')) {
|
|
965
|
+
return [{ args: ['bundle', 'install'], description: 'install ruby deps via bundler' }];
|
|
966
|
+
}
|
|
967
|
+
// Java / Kotlin
|
|
968
|
+
if (exists('pom.xml')) {
|
|
969
|
+
return [{ args: ['mvn', '-q', 'dependency:resolve'], description: 'resolve maven deps' }];
|
|
970
|
+
}
|
|
971
|
+
if (exists('build.gradle') || exists('build.gradle.kts')) {
|
|
972
|
+
return [{ args: ['./gradlew', '--quiet', 'dependencies'], description: 'resolve gradle deps' }];
|
|
973
|
+
}
|
|
974
|
+
// PHP
|
|
975
|
+
if (exists('composer.json')) {
|
|
976
|
+
return [{ args: ['composer', 'install'], description: 'install composer deps' }];
|
|
977
|
+
}
|
|
978
|
+
// .NET
|
|
979
|
+
if (exists('global.json') || exists('*.csproj')) {
|
|
980
|
+
return [{ args: ['dotnet', 'restore'], description: 'restore dotnet deps' }];
|
|
981
|
+
}
|
|
982
|
+
// Swift
|
|
983
|
+
if (exists('Package.swift')) {
|
|
984
|
+
return [{ args: ['swift', 'package', 'resolve'], description: 'resolve swift packages' }];
|
|
894
985
|
}
|
|
986
|
+
return [];
|
|
895
987
|
}
|
|
896
988
|
|
|
897
989
|
// Detect whether a branch exists, locally OR on origin. Used by
|
|
@@ -273,6 +273,11 @@ function handleBlocked(state, signal, profile, sideEffects) {
|
|
|
273
273
|
// Recoverable blockers: deterministic recovery per kind (initial set).
|
|
274
274
|
switch (kind) {
|
|
275
275
|
case 'missing_dependency':
|
|
276
|
+
// Emit an abstract install action. The CLI edge (autopilot.js
|
|
277
|
+
// decorateRunScript) detects the project's language(s) from
|
|
278
|
+
// manifest files and inlines the concrete `command`. Pre-2.2.19
|
|
279
|
+
// this hardcoded `npm install`, which failed on non-Node projects
|
|
280
|
+
// (Python, Rust, Go, Ruby, etc.).
|
|
276
281
|
return {
|
|
277
282
|
newState: state,
|
|
278
283
|
newProfile: profile,
|
|
@@ -280,7 +285,8 @@ function handleBlocked(state, signal, profile, sideEffects) {
|
|
|
280
285
|
type: 'run_script',
|
|
281
286
|
phase: state.phase,
|
|
282
287
|
reason: 'install_missing_dependency',
|
|
283
|
-
|
|
288
|
+
op: 'install_dependencies',
|
|
289
|
+
details: signal.details || null,
|
|
284
290
|
},
|
|
285
291
|
sideEffects,
|
|
286
292
|
verdict: 'retry',
|
|
@@ -523,6 +523,27 @@ function buildPrBody(state, profile, projectRoot, warnings) {
|
|
|
523
523
|
// Both paths honor profile.squash_on_merge. The PR-merge path also
|
|
524
524
|
// requires profile.platform_provider; non-github platforms fall through
|
|
525
525
|
// to the local-merge sequence with a description note.
|
|
526
|
+
// Build the worktree-cleanup steps appended to every merge_epic plan
|
|
527
|
+
// when profile.worktree_cleanup_on_merge is true. Documented in
|
|
528
|
+
// modules/git/config.yaml ("false = keep worktrees after epic
|
|
529
|
+
// completion for inspection"). Falls back silently when the helper
|
|
530
|
+
// script isn't available (partial install).
|
|
531
|
+
function buildCleanupSteps(profile) {
|
|
532
|
+
if (!profile.worktree_cleanup_on_merge) return [];
|
|
533
|
+
if (!profile.worktree_enabled) return [];
|
|
534
|
+
return [
|
|
535
|
+
{
|
|
536
|
+
args: ['node', '_Sprintpilot/scripts/cleanup-worktrees.js'],
|
|
537
|
+
description: 'remove orphan worktrees whose branches were merged + deleted',
|
|
538
|
+
// SKIPPED (no .worktrees/ dir) returns 0; real failures here should
|
|
539
|
+
// not halt the merge — the epic is already merged, cleanup is
|
|
540
|
+
// best-effort housekeeping.
|
|
541
|
+
tolerate_exit_codes: [0, 1, 2],
|
|
542
|
+
optional: true,
|
|
543
|
+
},
|
|
544
|
+
];
|
|
545
|
+
}
|
|
546
|
+
|
|
526
547
|
function planMergeEpic(state, profile, _action, branch) {
|
|
527
548
|
const baseBranch = profile.base_branch || 'main';
|
|
528
549
|
const squash = !!profile.squash_on_merge;
|
|
@@ -610,6 +631,7 @@ function planMergeEpic(state, profile, _action, branch) {
|
|
|
610
631
|
description: `merge epic PR for ${branch} via gh (${squash ? 'squash' : 'merge'}, delete branch)`,
|
|
611
632
|
env: Object.keys(env).length ? env : undefined,
|
|
612
633
|
},
|
|
634
|
+
...buildCleanupSteps(profile),
|
|
613
635
|
],
|
|
614
636
|
};
|
|
615
637
|
}
|
|
@@ -635,6 +657,7 @@ function planMergeEpic(state, profile, _action, branch) {
|
|
|
635
657
|
description: `merge epic MR for ${branch} via glab${squash ? ' (squash)' : ''}`,
|
|
636
658
|
env: Object.keys(env).length ? env : undefined,
|
|
637
659
|
},
|
|
660
|
+
...buildCleanupSteps(profile),
|
|
638
661
|
],
|
|
639
662
|
};
|
|
640
663
|
}
|
|
@@ -712,6 +735,7 @@ function planMergeEpic(state, profile, _action, branch) {
|
|
|
712
735
|
retry: { attempts: 4, backoff_ms: [2000, 4000, 8000, 16000], on: 'network' },
|
|
713
736
|
});
|
|
714
737
|
}
|
|
738
|
+
for (const s of buildCleanupSteps(profile)) steps.push(s);
|
|
715
739
|
return { branch, steps };
|
|
716
740
|
}
|
|
717
741
|
|
|
@@ -105,6 +105,15 @@ function flatToProfile(resolved, profileName) {
|
|
|
105
105
|
get(resolved, 'git.worktree.health_check_on_boot'),
|
|
106
106
|
true,
|
|
107
107
|
),
|
|
108
|
+
// git.worktree.cleanup_on_merge — when true, planMergeEpic appends
|
|
109
|
+
// `git worktree prune` + per-directory cleanup steps so .worktrees/
|
|
110
|
+
// doesn't accumulate orphans after an epic merges. Documented in
|
|
111
|
+
// modules/git/config.yaml ("false = keep worktrees after epic
|
|
112
|
+
// completion for inspection").
|
|
113
|
+
worktree_cleanup_on_merge: coerceBool(
|
|
114
|
+
get(resolved, 'git.worktree.cleanup_on_merge'),
|
|
115
|
+
true,
|
|
116
|
+
),
|
|
108
117
|
squash_on_merge: coerceBool(get(resolved, 'git.squash_on_merge'), false),
|
|
109
118
|
reuse_user_branch: coerceBool(get(resolved, 'git.reuse_user_branch'), false),
|
|
110
119
|
merge_strategy: coerceEnum(
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// cleanup-worktrees.js — remove orphan worktrees under .worktrees/.
|
|
4
|
+
//
|
|
5
|
+
// After `gh pr merge --delete-branch` (or any branch deletion), the
|
|
6
|
+
// branch is gone but the `.worktrees/<name>/` directory remains. Git's
|
|
7
|
+
// `git worktree prune` removes the `.git/worktrees/<name>/` metadata
|
|
8
|
+
// but NOT the actual directory. Without explicit cleanup, .worktrees/
|
|
9
|
+
// accumulates orphans every epic merge.
|
|
10
|
+
//
|
|
11
|
+
// This script:
|
|
12
|
+
// 1. Runs `git worktree prune --expire now` to clear metadata.
|
|
13
|
+
// 2. Walks `.worktrees/*` and removes directories whose branches no
|
|
14
|
+
// longer resolve (locally and on origin).
|
|
15
|
+
//
|
|
16
|
+
// Honors `git.worktree.cleanup_on_merge` via the orchestrator's plan —
|
|
17
|
+
// this script is only invoked when that flag is true. Standalone use:
|
|
18
|
+
//
|
|
19
|
+
// node _Sprintpilot/scripts/cleanup-worktrees.js \
|
|
20
|
+
// [--worktrees-dir .worktrees] [--project-root <path>] [--dry-run]
|
|
21
|
+
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const path = require('node:path');
|
|
26
|
+
const cp = require('node:child_process');
|
|
27
|
+
|
|
28
|
+
const { parseArgs } = require('../lib/runtime/args');
|
|
29
|
+
const log = require('../lib/runtime/log');
|
|
30
|
+
|
|
31
|
+
function git(cwd, args, opts) {
|
|
32
|
+
return cp.spawnSync('git', ['-C', cwd, ...args], Object.assign({
|
|
33
|
+
encoding: 'utf8',
|
|
34
|
+
timeout: 10000,
|
|
35
|
+
}, opts || {}));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function localBranchExists(projectRoot, branch) {
|
|
39
|
+
const r = git(projectRoot, ['show-ref', '--verify', '--quiet', 'refs/heads/' + branch], {
|
|
40
|
+
stdio: 'ignore',
|
|
41
|
+
});
|
|
42
|
+
return r.status === 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function remoteBranchExists(projectRoot, branch) {
|
|
46
|
+
const r = git(
|
|
47
|
+
projectRoot,
|
|
48
|
+
['show-ref', '--verify', '--quiet', 'refs/remotes/origin/' + branch],
|
|
49
|
+
{ stdio: 'ignore' },
|
|
50
|
+
);
|
|
51
|
+
return r.status === 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function detectBranchFromGitfile(worktreeDir) {
|
|
55
|
+
const gitfile = path.join(worktreeDir, '.git');
|
|
56
|
+
let raw;
|
|
57
|
+
try {
|
|
58
|
+
raw = fs.readFileSync(gitfile, 'utf8');
|
|
59
|
+
} catch (_e) {
|
|
60
|
+
return { kind: 'unknown', branch: null };
|
|
61
|
+
}
|
|
62
|
+
const m = /^gitdir:\s*(.+)$/m.exec(raw);
|
|
63
|
+
if (!m) return { kind: 'unknown', branch: null };
|
|
64
|
+
const gitdir = m[1].trim();
|
|
65
|
+
if (!fs.existsSync(gitdir)) {
|
|
66
|
+
return { kind: 'orphan', branch: null };
|
|
67
|
+
}
|
|
68
|
+
const headPath = path.join(gitdir, 'HEAD');
|
|
69
|
+
let head;
|
|
70
|
+
try {
|
|
71
|
+
head = fs.readFileSync(headPath, 'utf8').trim();
|
|
72
|
+
} catch (_e) {
|
|
73
|
+
return { kind: 'orphan', branch: null };
|
|
74
|
+
}
|
|
75
|
+
const refMatch = /^ref:\s*refs\/heads\/(.+)$/m.exec(head);
|
|
76
|
+
if (!refMatch) return { kind: 'detached', branch: null };
|
|
77
|
+
return { kind: 'attached', branch: refMatch[1] };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function main() {
|
|
81
|
+
const { opts } = parseArgs(process.argv.slice(2));
|
|
82
|
+
if (opts.help) {
|
|
83
|
+
log.out(
|
|
84
|
+
'Usage: cleanup-worktrees.js [--worktrees-dir .worktrees] [--project-root <path>] [--dry-run]',
|
|
85
|
+
);
|
|
86
|
+
process.exit(0);
|
|
87
|
+
}
|
|
88
|
+
const projectRoot = opts['project-root'] || process.cwd();
|
|
89
|
+
const worktreesDir = opts['worktrees-dir']
|
|
90
|
+
? path.resolve(opts['worktrees-dir'])
|
|
91
|
+
: path.join(projectRoot, '.worktrees');
|
|
92
|
+
const dryRun = !!opts['dry-run'];
|
|
93
|
+
|
|
94
|
+
const prune = git(projectRoot, ['worktree', 'prune', '--expire', 'now']);
|
|
95
|
+
if (prune.status !== 0 && prune.error) {
|
|
96
|
+
log.error('git worktree prune failed: ' + prune.error.message);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!fs.existsSync(worktreesDir)) {
|
|
101
|
+
log.out('SUMMARY:0:0:0');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
let entries;
|
|
105
|
+
try {
|
|
106
|
+
entries = fs.readdirSync(worktreesDir, { withFileTypes: true });
|
|
107
|
+
} catch (e) {
|
|
108
|
+
log.error('cannot read ' + worktreesDir + ': ' + e.message);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let inspected = 0;
|
|
113
|
+
let removed = 0;
|
|
114
|
+
let kept = 0;
|
|
115
|
+
for (const ent of entries) {
|
|
116
|
+
if (!ent.isDirectory()) continue;
|
|
117
|
+
inspected += 1;
|
|
118
|
+
const wt = path.join(worktreesDir, ent.name);
|
|
119
|
+
const info = detectBranchFromGitfile(wt);
|
|
120
|
+
|
|
121
|
+
let orphan = false;
|
|
122
|
+
if (info.kind === 'orphan') {
|
|
123
|
+
orphan = true;
|
|
124
|
+
} else if (info.kind === 'attached' && info.branch) {
|
|
125
|
+
const localOk = localBranchExists(projectRoot, info.branch);
|
|
126
|
+
const remoteOk = remoteBranchExists(projectRoot, info.branch);
|
|
127
|
+
if (!localOk && !remoteOk) orphan = true;
|
|
128
|
+
} else {
|
|
129
|
+
kept += 1;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!orphan) {
|
|
134
|
+
kept += 1;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
log.out('ORPHAN:' + ent.name);
|
|
139
|
+
if (dryRun) {
|
|
140
|
+
removed += 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const r = git(projectRoot, ['worktree', 'remove', '--force', wt], { stdio: 'ignore' });
|
|
144
|
+
if (r.status !== 0) {
|
|
145
|
+
try {
|
|
146
|
+
fs.rmSync(wt, { recursive: true, force: true });
|
|
147
|
+
} catch (e) {
|
|
148
|
+
log.err('WARN: cannot remove ' + wt + ': ' + e.message);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
removed += 1;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
log.out('SUMMARY:' + inspected + ':' + removed + ':' + kept);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
main();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ikunin/sprintpilot",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.20",
|
|
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": {
|