@ikunin/sprintpilot 2.0.9 → 2.1.0

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.
Files changed (52) hide show
  1. package/README.md +245 -10
  2. package/_Sprintpilot/Sprintpilot.md +1 -1
  3. package/_Sprintpilot/bin/autopilot.js +581 -0
  4. package/_Sprintpilot/lib/orchestrator/action-ledger.js +148 -0
  5. package/_Sprintpilot/lib/orchestrator/adapt.js +502 -0
  6. package/_Sprintpilot/lib/orchestrator/decision-log.js +224 -0
  7. package/_Sprintpilot/lib/orchestrator/divergence.js +201 -0
  8. package/_Sprintpilot/lib/orchestrator/git-plan.js +259 -0
  9. package/_Sprintpilot/lib/orchestrator/impact-classifier.js +108 -0
  10. package/_Sprintpilot/lib/orchestrator/land.js +155 -0
  11. package/_Sprintpilot/lib/orchestrator/parallel-batch.js +99 -0
  12. package/_Sprintpilot/lib/orchestrator/profile-rules.js +167 -0
  13. package/_Sprintpilot/lib/orchestrator/report.js +95 -0
  14. package/_Sprintpilot/lib/orchestrator/state-machine.js +402 -0
  15. package/_Sprintpilot/lib/orchestrator/state-store.js +260 -0
  16. package/_Sprintpilot/lib/orchestrator/user-command-applier.js +157 -0
  17. package/_Sprintpilot/lib/orchestrator/user-commands.js +115 -0
  18. package/_Sprintpilot/lib/orchestrator/verify.js +397 -0
  19. package/_Sprintpilot/manifest.yaml +1 -1
  20. package/_Sprintpilot/modules/git/config.yaml +26 -0
  21. package/_Sprintpilot/scripts/agent-adapter.js +4 -5
  22. package/_Sprintpilot/scripts/auto-merge-bmad-docs.js +112 -0
  23. package/_Sprintpilot/scripts/dispatch-layer.js +12 -8
  24. package/_Sprintpilot/scripts/infer-dependencies.js +78 -21
  25. package/_Sprintpilot/scripts/inject-tasks-section.js +4 -3
  26. package/_Sprintpilot/scripts/land-this-pr.js +110 -0
  27. package/_Sprintpilot/scripts/lint-test-pitfalls.js +133 -0
  28. package/_Sprintpilot/scripts/list-remaining-stories.js +1 -1
  29. package/_Sprintpilot/scripts/log-timing.js +12 -3
  30. package/_Sprintpilot/scripts/merge-shards.js +32 -12
  31. package/_Sprintpilot/scripts/post-green-gates.js +187 -0
  32. package/_Sprintpilot/scripts/preflight-merge.js +2 -1
  33. package/_Sprintpilot/scripts/resolve-dag.js +3 -1
  34. package/_Sprintpilot/scripts/scan.js +109 -13
  35. package/_Sprintpilot/scripts/stack-snapshot.js +128 -0
  36. package/_Sprintpilot/scripts/state-shard.js +8 -1
  37. package/_Sprintpilot/scripts/summarize-timings.js +30 -12
  38. package/_Sprintpilot/scripts/with-retry.js +17 -5
  39. package/_Sprintpilot/skills/sprint-autopilot-on/SKILL.md +23 -1
  40. package/_Sprintpilot/skills/sprint-autopilot-on/workflow.orchestrator.md +148 -0
  41. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/architecture-mapper.md +9 -0
  42. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/concerns-hunter.md +9 -0
  43. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/integration-mapper.md +9 -0
  44. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/quality-assessor.md +9 -0
  45. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/stack-analyzer.md +10 -0
  46. package/_Sprintpilot/skills/sprintpilot-codebase-map/workflow.md +2 -0
  47. package/_Sprintpilot/skills/sprintpilot-reverse-architect/agents/component-mapper.md +7 -0
  48. package/_Sprintpilot/skills/sprintpilot-reverse-architect/agents/data-flow-tracer.md +7 -0
  49. package/_Sprintpilot/skills/sprintpilot-reverse-architect/agents/pattern-extractor.md +7 -0
  50. package/lib/core/update-check.js +11 -1
  51. package/package.json +1 -1
  52. package/_Sprintpilot/skills/sprint-autopilot-on/workflow.md +0 -1388
@@ -0,0 +1,224 @@
1
+ // decision-log.js — validate + append Decision[] entries to decision-log.yaml.
2
+ //
3
+ // Schema (from workflow.md:107–112 + the plan):
4
+ // - id: auto-generated DEC-<seq>
5
+ // - timestamp: ISO 8601, orchestrator-generated
6
+ // - story: required (current story key) — orchestrator-injected
7
+ // - phase: '<skill>:<sub_phase>' e.g. 'dev-story:RED'
8
+ // - category: enum (8 values)
9
+ // - decision: non-empty string
10
+ // - rationale: non-empty string
11
+ // - impact: enum 'low' | 'medium' | 'high'
12
+ //
13
+ // LLM emits Decision via the optional decisions[] field on any signal.
14
+ // Orchestrator validates each entry, auto-assigns id + timestamp + story,
15
+ // then appends to decision-log.yaml.
16
+
17
+ 'use strict';
18
+
19
+ const fs = require('node:fs');
20
+ const path = require('node:path');
21
+
22
+ const VALID_CATEGORIES = [
23
+ 'architecture',
24
+ 'test-strategy',
25
+ 'dependency',
26
+ 'review-triage',
27
+ 'review-accept',
28
+ 'halt-recovery',
29
+ 'scope',
30
+ 'workaround',
31
+ ];
32
+
33
+ const VALID_IMPACTS = ['low', 'medium', 'high'];
34
+
35
+ const PHASE_RE = /^[a-zA-Z][a-zA-Z0-9_-]*:[a-zA-Z][a-zA-Z0-9_-]*$/;
36
+
37
+ function isPlainObject(v) {
38
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
39
+ }
40
+
41
+ function nonEmptyString(v) {
42
+ return typeof v === 'string' && v.trim().length > 0;
43
+ }
44
+
45
+ // Validate a Decision object as emitted by the LLM. Returns
46
+ // { ok: true, decision } | { ok: false, errors: string[] }
47
+ // `story` and `id` and `timestamp` are NOT required from the LLM — the
48
+ // orchestrator stamps them at append time.
49
+ function validateOne(d) {
50
+ const errors = [];
51
+ if (!isPlainObject(d)) return { ok: false, errors: ['decision is not an object'] };
52
+
53
+ if (!nonEmptyString(d.category)) errors.push('category required');
54
+ else if (!VALID_CATEGORIES.includes(d.category))
55
+ errors.push(`category must be one of: ${VALID_CATEGORIES.join(',')}`);
56
+
57
+ if (!nonEmptyString(d.impact)) errors.push('impact required');
58
+ else if (!VALID_IMPACTS.includes(d.impact))
59
+ errors.push(`impact must be one of: ${VALID_IMPACTS.join(',')}`);
60
+
61
+ if (!nonEmptyString(d.phase)) errors.push('phase required');
62
+ else if (!PHASE_RE.test(d.phase)) errors.push('phase must match <skill>:<sub_phase>');
63
+
64
+ if (!nonEmptyString(d.decision)) errors.push('decision required');
65
+ if (!nonEmptyString(d.rationale)) errors.push('rationale required');
66
+
67
+ if (errors.length > 0) return { ok: false, errors };
68
+ return { ok: true, decision: d };
69
+ }
70
+
71
+ function validateMany(decisions) {
72
+ const list = Array.isArray(decisions) ? decisions : [];
73
+ const valid = [];
74
+ const errors = [];
75
+ for (let i = 0; i < list.length; i += 1) {
76
+ const r = validateOne(list[i]);
77
+ if (r.ok) valid.push(r.decision);
78
+ else errors.push({ index: i, errors: r.errors });
79
+ }
80
+ if (errors.length > 0) return { ok: false, errors, valid };
81
+ return { ok: true, decisions: valid };
82
+ }
83
+
84
+ // Render one decision as YAML lines (matching workflow.md schema).
85
+ function renderDecision(d) {
86
+ const yamlEscape = (s) => {
87
+ const str = String(s);
88
+ // Single-line, no embedded special chars → bare; else double-quote.
89
+ // YAML bare-scalar rules. Quote if any of:
90
+ // - matches a reserved literal (true/false/null/~)
91
+ // - looks like a number
92
+ // - contains `: ` (key-value ambiguity)
93
+ // - contains a YAML comment intro `#`
94
+ // - contains chars outside the bare-safe set (allow `:` w/o space, `.`, `/`, `-`)
95
+ const reserved = /^(true|false|null|~)$/i.test(str);
96
+ const numeric = /^-?\d/.test(str);
97
+ const ambiguousColon = /:\s/.test(str);
98
+ const hasHash = /#/.test(str);
99
+ const bareSafe = /^[A-Za-z0-9_.:/\- ]+$/.test(str);
100
+ if (!reserved && !numeric && !ambiguousColon && !hasHash && bareSafe) return str;
101
+ return JSON.stringify(str);
102
+ };
103
+ return [
104
+ ` - id: ${d.id}`,
105
+ ` timestamp: ${yamlEscape(d.timestamp)}`,
106
+ ` story: ${yamlEscape(d.story)}`,
107
+ ` phase: ${yamlEscape(d.phase)}`,
108
+ ` category: ${d.category}`,
109
+ ` impact: ${d.impact}`,
110
+ ` decision: ${yamlEscape(d.decision)}`,
111
+ ` rationale: ${yamlEscape(d.rationale)}`,
112
+ ].join('\n');
113
+ }
114
+
115
+ // Read existing decision-log.yaml and return the next sequence number.
116
+ // We deliberately parse with a regex rather than a full YAML parser so this
117
+ // works in install-time contexts without js-yaml available.
118
+ function nextSeq(existingText) {
119
+ if (!existingText) return 1;
120
+ let max = 0;
121
+ const re = /^\s*- id:\s*DEC-(\d+)\b/gm;
122
+ let m;
123
+ while ((m = re.exec(existingText))) {
124
+ const n = Number.parseInt(m[1], 10);
125
+ if (Number.isFinite(n) && n > max) max = n;
126
+ }
127
+ return max + 1;
128
+ }
129
+
130
+ // append(logPath, decisions, context, options?)
131
+ // logPath: absolute path to decision-log.yaml
132
+ // decisions: validated Decision[] from validateMany
133
+ // context: { story: string, now: () => Date } — orchestrator-injected
134
+ // options: { fs? } — for testing
135
+ // Returns { appended: number, ids: string[] }
136
+ function append(logPath, decisions, context, options) {
137
+ const fsImpl = (options && options.fs) || fs;
138
+ const story = context && context.story ? String(context.story) : 'sprint';
139
+ const nowFn = (context && context.now) || (() => new Date());
140
+
141
+ let existing = '';
142
+ try {
143
+ existing = fsImpl.readFileSync(logPath, 'utf8');
144
+ } catch (_e) {
145
+ existing = '';
146
+ }
147
+
148
+ const isFresh = !existing.trim();
149
+ let seq = nextSeq(existing);
150
+ const ids = [];
151
+ const renderedBlocks = [];
152
+
153
+ for (const d of decisions) {
154
+ const stamped = {
155
+ id: `DEC-${String(seq).padStart(3, '0')}`,
156
+ timestamp: nowFn().toISOString(),
157
+ story,
158
+ phase: d.phase,
159
+ category: d.category,
160
+ impact: d.impact,
161
+ decision: d.decision,
162
+ rationale: d.rationale,
163
+ };
164
+ ids.push(stamped.id);
165
+ renderedBlocks.push(renderDecision(stamped));
166
+ seq += 1;
167
+ }
168
+
169
+ const header = isFresh
170
+ ? [
171
+ `generated: ${nowFn().toISOString().slice(0, 10)}`,
172
+ `last_updated: ${nowFn().toISOString()}`,
173
+ 'decisions:',
174
+ ].join('\n')
175
+ : updateLastUpdated(existing, nowFn().toISOString());
176
+
177
+ const body = renderedBlocks.join('\n');
178
+ const finalText = isFresh ? `${header}\n${body}\n` : `${header}${body}\n`;
179
+
180
+ fsImpl.mkdirSync(path.dirname(logPath), { recursive: true });
181
+ fsImpl.writeFileSync(logPath, finalText, 'utf8');
182
+
183
+ return { appended: decisions.length, ids };
184
+ }
185
+
186
+ // Replace or insert the `last_updated:` line; leave everything else intact.
187
+ // Returns a string ending with the existing content (no trailing newline
188
+ // trim) so renderedBlocks can be appended directly.
189
+ function updateLastUpdated(existing, isoNow) {
190
+ const lines = existing.split(/\r?\n/);
191
+ let touched = false;
192
+ for (let i = 0; i < lines.length; i += 1) {
193
+ if (/^last_updated:/.test(lines[i])) {
194
+ lines[i] = `last_updated: ${isoNow}`;
195
+ touched = true;
196
+ break;
197
+ }
198
+ }
199
+ if (!touched) {
200
+ // Insert after `generated:` if present, else at top.
201
+ let insertAt = 0;
202
+ for (let i = 0; i < lines.length; i += 1) {
203
+ if (/^generated:/.test(lines[i])) {
204
+ insertAt = i + 1;
205
+ break;
206
+ }
207
+ }
208
+ lines.splice(insertAt, 0, `last_updated: ${isoNow}`);
209
+ }
210
+ // Strip trailing empty lines so we can append the new block(s) directly.
211
+ while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop();
212
+ return `${lines.join('\n')}\n`;
213
+ }
214
+
215
+ module.exports = {
216
+ VALID_CATEGORIES,
217
+ VALID_IMPACTS,
218
+ validateOne,
219
+ validateMany,
220
+ append,
221
+ renderDecision,
222
+ // exported for tests
223
+ nextSeq,
224
+ };
@@ -0,0 +1,201 @@
1
+ // divergence.js — resume fingerprint diff.
2
+ //
3
+ // When the autopilot resumes (next session, after a halt, after the user
4
+ // did stuff manually), we don't trust that the world still matches what
5
+ // the ledger says. We fingerprint the relevant on-disk state, compare to
6
+ // the fingerprint recorded at last halt, and report divergences.
7
+ //
8
+ // Returns a Divergence object the CLI edge can either:
9
+ // - accept (`{ identical: true }` → emit next planned action)
10
+ // - escalate to user_prompt (`{ identical: false, differences: ... }`)
11
+ //
12
+ // Fingerprint inputs (per the plan):
13
+ // - sprint-status.yaml: SHA-256 of canonical content (trailing-WS stripped)
14
+ // - per-story branch HEADs: SHA list from `git rev-parse refs/heads/...`
15
+ // (skipped if git not available / no git_enabled in profile)
16
+ // - _bmad-output tree: { relative-path → size }
17
+ // - active worktree paths: list of `.worktrees/<name>` dirs
18
+ //
19
+ // All inputs are computed via injected dependencies so tests can drive
20
+ // without a real repo.
21
+
22
+ 'use strict';
23
+
24
+ const crypto = require('node:crypto');
25
+ const nodeFs = require('node:fs');
26
+ const path = require('node:path');
27
+
28
+ function sha256(text) {
29
+ return crypto.createHash('sha256').update(text).digest('hex');
30
+ }
31
+
32
+ function exists(fs, p) {
33
+ try {
34
+ fs.accessSync(p, fs.constants ? fs.constants.F_OK : 0);
35
+ return true;
36
+ } catch (_e) {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ function readSafe(fs, p) {
42
+ try {
43
+ return fs.readFileSync(p, 'utf8');
44
+ } catch (_e) {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ // canonicalizeYaml — strip trailing whitespace per line; ensure final newline.
50
+ // We don't try to canonicalize key ordering because users may legitimately
51
+ // reorder fields; we just want to ignore whitespace-only churn.
52
+ function canonicalizeYaml(text) {
53
+ if (!text) return '';
54
+ const lines = text.split(/\r?\n/).map((l) => l.replace(/[ \t]+$/g, ''));
55
+ while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
56
+ return `${lines.join('\n')}\n`;
57
+ }
58
+
59
+ function walkTree(fs, root, out, relBase) {
60
+ let entries;
61
+ try {
62
+ entries = fs.readdirSync(root, { withFileTypes: true });
63
+ } catch (_e) {
64
+ return;
65
+ }
66
+ for (const ent of entries) {
67
+ const full = path.join(root, ent.name);
68
+ const rel = path.join(relBase, ent.name);
69
+ if (ent.isDirectory()) {
70
+ walkTree(fs, full, out, rel);
71
+ } else if (ent.isFile()) {
72
+ try {
73
+ const st = fs.statSync(full);
74
+ out[rel.split(path.sep).join('/')] = st.size;
75
+ } catch (_e) {
76
+ // ignore unreadable
77
+ }
78
+ }
79
+ }
80
+ }
81
+
82
+ // fingerprint(context) → { sprintStatusSha, bmadTree, branchHeads, worktreePaths }
83
+ //
84
+ // context = { projectRoot, fs?, gitHeadResolver?, worktreeScanner? }
85
+ // gitHeadResolver(branch): string | null — return SHA or null
86
+ // worktreeScanner(): string[] — return list of worktree paths
87
+ //
88
+ // Both git-side functions are injected so tests don't need a real repo.
89
+ function fingerprint(context) {
90
+ if (!context || !context.projectRoot) throw new Error('fingerprint: projectRoot required');
91
+ const fs = context.fs || nodeFs;
92
+ const root = context.projectRoot;
93
+
94
+ const sprintStatusPath = path.join(
95
+ root,
96
+ '_bmad-output',
97
+ 'implementation-artifacts',
98
+ 'sprint-status.yaml',
99
+ );
100
+ const sprintStatus = readSafe(fs, sprintStatusPath);
101
+ const sprintStatusSha = sprintStatus ? sha256(canonicalizeYaml(sprintStatus)) : null;
102
+
103
+ const bmadTree = {};
104
+ const bmadRoot = path.join(root, '_bmad-output');
105
+ if (exists(fs, bmadRoot)) {
106
+ walkTree(fs, bmadRoot, bmadTree, '');
107
+ }
108
+
109
+ const branchHeads = {};
110
+ if (typeof context.gitHeadResolver === 'function' && Array.isArray(context.branches)) {
111
+ for (const b of context.branches) {
112
+ branchHeads[b] = context.gitHeadResolver(b);
113
+ }
114
+ }
115
+
116
+ const worktreePaths =
117
+ typeof context.worktreeScanner === 'function' ? context.worktreeScanner() : [];
118
+
119
+ return { sprintStatusSha, bmadTree, branchHeads, worktreePaths };
120
+ }
121
+
122
+ // diff(expected, actual) → Divergence
123
+ //
124
+ // Divergence shape:
125
+ // {
126
+ // identical: boolean,
127
+ // differences: {
128
+ // sprint_status?: { expected: sha, actual: sha },
129
+ // branch_heads?: { branch, expected, actual }[],
130
+ // bmad_tree?: { added: string[], removed: string[], changed: string[] },
131
+ // worktrees?: { added: string[], removed: string[] },
132
+ // }
133
+ // }
134
+ function diff(expected, actual) {
135
+ if (!expected) return { identical: false, differences: { reason: 'no_baseline_fingerprint' } };
136
+ if (!actual) return { identical: false, differences: { reason: 'no_actual_fingerprint' } };
137
+
138
+ const differences = {};
139
+
140
+ if (expected.sprintStatusSha !== actual.sprintStatusSha) {
141
+ differences.sprint_status = {
142
+ expected: expected.sprintStatusSha,
143
+ actual: actual.sprintStatusSha,
144
+ };
145
+ }
146
+
147
+ const headDiffs = [];
148
+ const branches = new Set([
149
+ ...Object.keys(expected.branchHeads || {}),
150
+ ...Object.keys(actual.branchHeads || {}),
151
+ ]);
152
+ for (const b of branches) {
153
+ const e = expected.branchHeads ? expected.branchHeads[b] : undefined;
154
+ const a = actual.branchHeads ? actual.branchHeads[b] : undefined;
155
+ if (e !== a) headDiffs.push({ branch: b, expected: e ?? null, actual: a ?? null });
156
+ }
157
+ if (headDiffs.length > 0) differences.branch_heads = headDiffs;
158
+
159
+ const expTree = expected.bmadTree || {};
160
+ const actTree = actual.bmadTree || {};
161
+ const added = [];
162
+ const removed = [];
163
+ const changed = [];
164
+ for (const p of Object.keys(actTree)) {
165
+ if (!(p in expTree)) added.push(p);
166
+ else if (expTree[p] !== actTree[p]) changed.push(p);
167
+ }
168
+ for (const p of Object.keys(expTree)) {
169
+ if (!(p in actTree)) removed.push(p);
170
+ }
171
+ if (added.length || removed.length || changed.length) {
172
+ differences.bmad_tree = { added: added.sort(), removed: removed.sort(), changed: changed.sort() };
173
+ }
174
+
175
+ const expWt = new Set(expected.worktreePaths || []);
176
+ const actWt = new Set(actual.worktreePaths || []);
177
+ const wtAdded = Array.from(actWt).filter((p) => !expWt.has(p)).sort();
178
+ const wtRemoved = Array.from(expWt).filter((p) => !actWt.has(p)).sort();
179
+ if (wtAdded.length || wtRemoved.length) {
180
+ differences.worktrees = { added: wtAdded, removed: wtRemoved };
181
+ }
182
+
183
+ return { identical: Object.keys(differences).length === 0, differences };
184
+ }
185
+
186
+ // detect(context) — convenience: read the last baseline fingerprint from the
187
+ // ledger, compute current fingerprint, diff. The baseline is recorded as a
188
+ // `state_transition` entry with `fingerprint` field when the orchestrator
189
+ // halts. (The CLI edge wires this up; this module just composes.)
190
+ function detect(context, baselineFingerprint) {
191
+ const actual = fingerprint(context);
192
+ return diff(baselineFingerprint, actual);
193
+ }
194
+
195
+ module.exports = {
196
+ sha256,
197
+ canonicalizeYaml,
198
+ fingerprint,
199
+ diff,
200
+ detect,
201
+ };
@@ -0,0 +1,259 @@
1
+ // git-plan.js — produce an argv sequence for a git_op action.
2
+ //
3
+ // Given (state, profile, action), return:
4
+ // { steps: [{ args, description, retry? }] }
5
+ //
6
+ // Pure. Argv-only — no shell strings. The CLI edge executes each step in
7
+ // order, halting on first failure. Steps are deterministic: same inputs
8
+ // always produce the same argv.
9
+
10
+ 'use strict';
11
+
12
+ const { STATES } = require('./state-machine');
13
+
14
+ const STORY_BRANCH_RE = /^[a-z0-9][a-z0-9._-]*$/i;
15
+
16
+ function sanitizeStoryKey(key) {
17
+ if (typeof key !== 'string') return null;
18
+ // Per existing sanitize-branch.js: only allow [a-z0-9._-], lowercase.
19
+ const s = key.toLowerCase().replace(/[^a-z0-9._-]/g, '-');
20
+ if (!STORY_BRANCH_RE.test(s)) return null;
21
+ return s;
22
+ }
23
+
24
+ // branchName(profile, storyKey, epicKey, state?)
25
+ // When `state?.user_branch` is set (because git.reuse_user_branch=true and
26
+ // the user pre-created a working branch), every story commits to that
27
+ // single branch — per-story/per-epic branches are NOT created.
28
+ // Otherwise honor profile.granularity for story/epic per-unit branches.
29
+ //
30
+ // Format matches the legacy workflow (see git/config.yaml:12 + the legacy
31
+ // workflow.md:685+716): `<branch_prefix>epic-<epic_id>` for epic granularity
32
+ // and `<branch_prefix><story_key>` for story granularity. The default prefix
33
+ // is "story/", so under nano you get `story/epic-1` (not `epic/1`) — that's
34
+ // what the existing tooling and e2e tests expect.
35
+ function branchName(profile, storyKey, epicKey, state) {
36
+ if (state && state.user_branch) return state.user_branch;
37
+ const prefix = profile.branch_prefix || 'story/';
38
+ // git.granularity: 'story' (default) or 'epic'. Nano + large can be epic.
39
+ if (profile.granularity === 'epic' && epicKey) {
40
+ return `${prefix}epic-${sanitizeStoryKey(epicKey) || 'unknown'}`;
41
+ }
42
+ const safe = sanitizeStoryKey(storyKey) || 'unknown';
43
+ return `${prefix}${safe}`;
44
+ }
45
+
46
+ // commit_and_push_story — full sequence for STORY_DONE.
47
+ // Phase 1 — commit + push the story branch (code lives here):
48
+ // 1. git add (specific files only — never -A / .)
49
+ // 2. git commit -m "<message>"
50
+ // 3. git push -u origin <branch> (retried 4x with exponential backoff)
51
+ // Phase 2 — sync `_bmad-output/` to <base_branch> (BMad artifacts live
52
+ // on main so `git log main` is the canonical sprint audit trail):
53
+ // 4. git switch <base_branch>
54
+ // 5. git checkout <branch> -- _bmad-output
55
+ // 6. git add _bmad-output
56
+ // 7. git commit --allow-empty -m "docs(<story>): BMad artifacts"
57
+ // 8. git push origin <base_branch> (retried 4x)
58
+ // 9. git switch <branch> (return to story for next phase)
59
+ // --allow-empty on step 7 covers multi-story sprints where _bmad-output/
60
+ // on main already matches the story-branch version (e.g. epics.md was
61
+ // authored during story 1, unchanged for story 2). The empty commit is
62
+ // audit-trail noise but cheap.
63
+ function plan(state, profile, action) {
64
+ if (!action || !action.type || action.type !== 'git_op') {
65
+ throw new Error('git-plan.plan: action.type must be git_op');
66
+ }
67
+ const op = action.op;
68
+ const branch = branchName(profile, state.story_key, state.current_epic, state);
69
+
70
+ switch (op) {
71
+ case 'commit_and_push_story':
72
+ return planCommitAndPush(state, profile, action, branch);
73
+ case 'merge_epic':
74
+ return planMergeEpic(state, profile, action, branch);
75
+ case 'push':
76
+ return planPush(state, profile, action, branch);
77
+ case 'fetch':
78
+ return planFetch(state, profile);
79
+ case 'create_branch':
80
+ return planCreateBranch(state, profile, branch);
81
+ default:
82
+ throw new Error(`git-plan.plan: unknown op ${op}`);
83
+ }
84
+ }
85
+
86
+ function planCreateBranch(state, profile, branch) {
87
+ // Branch reuse: when the user pre-created the branch, do not create a
88
+ // new one. Just confirm HEAD is on the right branch (idempotent switch).
89
+ if (state && state.user_branch) {
90
+ return {
91
+ branch,
92
+ steps: [
93
+ {
94
+ args: ['git', 'switch', branch],
95
+ description: `switch to user branch ${branch} (reuse mode)`,
96
+ },
97
+ ],
98
+ };
99
+ }
100
+ const baseBranch = profile.base_branch || 'main';
101
+ return {
102
+ branch,
103
+ steps: [
104
+ // Create branch from base; -B is idempotent (creates or resets).
105
+ // But for new-story creation we want to fail if it already exists,
106
+ // so use -b instead. The CLI edge can downgrade to -B on retry.
107
+ {
108
+ args: ['git', 'switch', '-c', branch, baseBranch],
109
+ description: `create story branch ${branch} from ${baseBranch}`,
110
+ },
111
+ ],
112
+ };
113
+ }
114
+
115
+ function planCommitAndPush(state, profile, action, branch) {
116
+ const files = Array.isArray(action.files) && action.files.length > 0 ? action.files : null;
117
+ const message =
118
+ action.message ||
119
+ (state.story_key
120
+ ? `feat(${state.story_key}): ${state.ac_summary || 'story done'}`
121
+ : 'feat: story done');
122
+ const baseBranch = profile.base_branch || 'main';
123
+ const storyKey = state.story_key || 'sprint';
124
+ const hasOrigin = profile.has_origin !== false;
125
+
126
+ const steps = [];
127
+
128
+ // Phase 1 — commit + push the story branch (code lives here).
129
+ if (files) {
130
+ steps.push({
131
+ args: ['git', 'add', ...files],
132
+ description: `stage ${files.length} file(s) explicitly`,
133
+ });
134
+ } else {
135
+ // No explicit file list provided. Add all tracked changes only — the
136
+ // CLI edge should populate `files` from the LLM's success.output.
137
+ // Fall back to `-u` which stages tracked modifications without picking
138
+ // up untracked files (which might include secrets).
139
+ steps.push({
140
+ args: ['git', 'add', '-u'],
141
+ description: 'stage tracked modifications only (no -A / .)',
142
+ });
143
+ }
144
+ steps.push({
145
+ args: ['git', 'commit', '-m', message],
146
+ description: `commit on ${branch}`,
147
+ });
148
+ if (hasOrigin) {
149
+ steps.push({
150
+ args: ['git', 'push', '-u', 'origin', branch],
151
+ description: `push ${branch} (retry 4× exponential backoff on network)`,
152
+ retry: { attempts: 4, backoff_ms: [2000, 4000, 8000, 16000], on: 'network' },
153
+ });
154
+ }
155
+
156
+ // Phase 2 — sync `_bmad-output/` to `<base_branch>`. BMad planning +
157
+ // bookkeeping artifacts must land on main per story so `git log main`
158
+ // is the canonical sprint audit trail. Without this, planning
159
+ // artifacts, sprint-status, story files, and reviews exist only on
160
+ // the story branch.
161
+ steps.push({
162
+ args: ['git', 'switch', baseBranch],
163
+ description: `switch to ${baseBranch} to sync BMad artifacts`,
164
+ });
165
+ steps.push({
166
+ args: ['git', 'checkout', branch, '--', '_bmad-output'],
167
+ description: `bring _bmad-output/ from ${branch} into ${baseBranch}'s working tree`,
168
+ });
169
+ steps.push({
170
+ args: ['git', 'add', '_bmad-output'],
171
+ description: 'stage BMad artifacts',
172
+ });
173
+ steps.push({
174
+ args: ['git', 'commit', '--allow-empty', '-m', `docs(${storyKey}): BMad artifacts`],
175
+ description: `commit BMad artifacts to ${baseBranch} (--allow-empty for no-diff stories)`,
176
+ });
177
+ if (hasOrigin) {
178
+ steps.push({
179
+ args: ['git', 'push', 'origin', baseBranch],
180
+ description: `push ${baseBranch}`,
181
+ retry: { attempts: 4, backoff_ms: [2000, 4000, 8000, 16000], on: 'network' },
182
+ });
183
+ }
184
+ steps.push({
185
+ args: ['git', 'switch', branch],
186
+ description: `return to ${branch} for the next phase`,
187
+ });
188
+
189
+ return { branch, steps };
190
+ }
191
+
192
+ function planMergeEpic(state, profile, _action, branch) {
193
+ const baseBranch = profile.base_branch || 'main';
194
+ const squash = !!profile.squash_on_merge;
195
+ const steps = [];
196
+
197
+ if (profile.has_origin !== false) {
198
+ steps.push({ args: ['git', 'fetch', 'origin'], description: 'sync with remote' });
199
+ }
200
+ steps.push({ args: ['git', 'switch', baseBranch], description: `switch to ${baseBranch}` });
201
+ if (profile.has_origin !== false) {
202
+ steps.push({
203
+ args: ['git', 'merge', '--ff-only', `origin/${baseBranch}`],
204
+ description: 'fast-forward base to remote',
205
+ });
206
+ }
207
+ if (squash) {
208
+ steps.push({
209
+ args: ['git', 'merge', '--squash', branch],
210
+ description: `squash-merge ${branch}`,
211
+ });
212
+ steps.push({
213
+ args: ['git', 'commit', '-m', `feat(${state.current_epic || 'epic'}): squash merge`],
214
+ description: 'squash commit',
215
+ });
216
+ } else {
217
+ steps.push({
218
+ args: ['git', 'merge', '--no-ff', '-m', `Merge ${branch}`, branch],
219
+ description: `non-ff merge ${branch}`,
220
+ });
221
+ }
222
+ if (profile.has_origin !== false) {
223
+ steps.push({
224
+ args: ['git', 'push', 'origin', baseBranch],
225
+ description: `push ${baseBranch}`,
226
+ retry: { attempts: 4, backoff_ms: [2000, 4000, 8000, 16000], on: 'network' },
227
+ });
228
+ }
229
+ return { branch, steps };
230
+ }
231
+
232
+ function planPush(_state, profile, _action, branch) {
233
+ if (profile.has_origin === false) return { branch, steps: [] };
234
+ return {
235
+ branch,
236
+ steps: [
237
+ {
238
+ args: ['git', 'push', '-u', 'origin', branch],
239
+ description: `push ${branch}`,
240
+ retry: { attempts: 4, backoff_ms: [2000, 4000, 8000, 16000], on: 'network' },
241
+ },
242
+ ],
243
+ };
244
+ }
245
+
246
+ function planFetch(_state, profile) {
247
+ if (profile.has_origin === false) return { branch: null, steps: [] };
248
+ return {
249
+ branch: null,
250
+ steps: [{ args: ['git', 'fetch', 'origin'], description: 'fetch origin' }],
251
+ };
252
+ }
253
+
254
+ module.exports = {
255
+ plan,
256
+ branchName,
257
+ sanitizeStoryKey,
258
+ STATES,
259
+ };