@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.
- package/README.md +245 -10
- package/_Sprintpilot/Sprintpilot.md +1 -1
- package/_Sprintpilot/bin/autopilot.js +581 -0
- package/_Sprintpilot/lib/orchestrator/action-ledger.js +148 -0
- package/_Sprintpilot/lib/orchestrator/adapt.js +502 -0
- package/_Sprintpilot/lib/orchestrator/decision-log.js +224 -0
- package/_Sprintpilot/lib/orchestrator/divergence.js +201 -0
- package/_Sprintpilot/lib/orchestrator/git-plan.js +259 -0
- package/_Sprintpilot/lib/orchestrator/impact-classifier.js +108 -0
- package/_Sprintpilot/lib/orchestrator/land.js +155 -0
- package/_Sprintpilot/lib/orchestrator/parallel-batch.js +99 -0
- package/_Sprintpilot/lib/orchestrator/profile-rules.js +167 -0
- package/_Sprintpilot/lib/orchestrator/report.js +95 -0
- package/_Sprintpilot/lib/orchestrator/state-machine.js +402 -0
- package/_Sprintpilot/lib/orchestrator/state-store.js +260 -0
- package/_Sprintpilot/lib/orchestrator/user-command-applier.js +157 -0
- package/_Sprintpilot/lib/orchestrator/user-commands.js +115 -0
- package/_Sprintpilot/lib/orchestrator/verify.js +397 -0
- package/_Sprintpilot/manifest.yaml +1 -1
- package/_Sprintpilot/modules/git/config.yaml +26 -0
- package/_Sprintpilot/scripts/agent-adapter.js +4 -5
- package/_Sprintpilot/scripts/auto-merge-bmad-docs.js +112 -0
- package/_Sprintpilot/scripts/dispatch-layer.js +12 -8
- package/_Sprintpilot/scripts/infer-dependencies.js +78 -21
- package/_Sprintpilot/scripts/inject-tasks-section.js +4 -3
- package/_Sprintpilot/scripts/land-this-pr.js +110 -0
- package/_Sprintpilot/scripts/lint-test-pitfalls.js +133 -0
- package/_Sprintpilot/scripts/list-remaining-stories.js +1 -1
- package/_Sprintpilot/scripts/log-timing.js +12 -3
- package/_Sprintpilot/scripts/merge-shards.js +32 -12
- package/_Sprintpilot/scripts/post-green-gates.js +187 -0
- package/_Sprintpilot/scripts/preflight-merge.js +2 -1
- package/_Sprintpilot/scripts/resolve-dag.js +3 -1
- package/_Sprintpilot/scripts/scan.js +109 -13
- package/_Sprintpilot/scripts/stack-snapshot.js +128 -0
- package/_Sprintpilot/scripts/state-shard.js +8 -1
- package/_Sprintpilot/scripts/summarize-timings.js +30 -12
- package/_Sprintpilot/scripts/with-retry.js +17 -5
- package/_Sprintpilot/skills/sprint-autopilot-on/SKILL.md +23 -1
- package/_Sprintpilot/skills/sprint-autopilot-on/workflow.orchestrator.md +148 -0
- package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/architecture-mapper.md +9 -0
- package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/concerns-hunter.md +9 -0
- package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/integration-mapper.md +9 -0
- package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/quality-assessor.md +9 -0
- package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/stack-analyzer.md +10 -0
- package/_Sprintpilot/skills/sprintpilot-codebase-map/workflow.md +2 -0
- package/_Sprintpilot/skills/sprintpilot-reverse-architect/agents/component-mapper.md +7 -0
- package/_Sprintpilot/skills/sprintpilot-reverse-architect/agents/data-flow-tracer.md +7 -0
- package/_Sprintpilot/skills/sprintpilot-reverse-architect/agents/pattern-extractor.md +7 -0
- package/lib/core/update-check.js +11 -1
- package/package.json +1 -1
- 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
|
+
};
|