@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.
@@ -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 !== 'land_story') return action;
871
- try {
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 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;
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
- command: ['npm', 'install'],
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(
@@ -1,6 +1,6 @@
1
1
  addon:
2
2
  name: sprintpilot
3
- version: 2.2.18
3
+ version: 2.2.20
4
4
  description: Sprintpilot — autopilot and multi-agent addon for BMad Method (git workflow, parallel agents, autonomous story execution)
5
5
  bmad_compatibility: ">=6.2.0"
6
6
  modules:
@@ -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.18",
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": {