@ikunin/sprintpilot 2.1.2 → 2.1.4
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 +353 -47
- package/_Sprintpilot/lib/orchestrator/adapt.js +21 -2
- package/_Sprintpilot/lib/orchestrator/git-plan.js +522 -36
- package/_Sprintpilot/lib/orchestrator/land.js +11 -1
- package/_Sprintpilot/lib/orchestrator/profile-rules.js +65 -1
- package/_Sprintpilot/lib/orchestrator/state-machine.js +183 -4
- package/_Sprintpilot/manifest.yaml +1 -1
- package/_Sprintpilot/modules/git/config.yaml +8 -0
- package/_Sprintpilot/scripts/create-pr.js +178 -7
- package/_Sprintpilot/scripts/run-step.js +221 -0
- package/_Sprintpilot/skills/sprint-autopilot-on/workflow.orchestrator.md +35 -1
- package/package.json +1 -1
|
@@ -39,6 +39,10 @@ const userCommands = require('../lib/orchestrator/user-commands');
|
|
|
39
39
|
const divergence = require('../lib/orchestrator/divergence');
|
|
40
40
|
const reportRenderer = require('../lib/orchestrator/report');
|
|
41
41
|
const gitPlan = require('../lib/orchestrator/git-plan');
|
|
42
|
+
const {
|
|
43
|
+
parseStatuses: parseSprintStatuses,
|
|
44
|
+
remainingFrom: remainingStoriesFrom,
|
|
45
|
+
} = require('../scripts/list-remaining-stories');
|
|
42
46
|
|
|
43
47
|
const { STATES } = stateMachine;
|
|
44
48
|
|
|
@@ -86,6 +90,62 @@ function loadState(projectRoot) {
|
|
|
86
90
|
return stateStore.read({ projectRoot });
|
|
87
91
|
}
|
|
88
92
|
|
|
93
|
+
// Existence probe that never throws. Used by composeRuntimeState's
|
|
94
|
+
// migration guard so a stale `persisted.story_file_path` from before
|
|
95
|
+
// the file was actually written doesn't suppress migration. Returns
|
|
96
|
+
// false on any error (path is null/undefined, fs permission, etc.).
|
|
97
|
+
function safeExistsSync(p) {
|
|
98
|
+
if (!p || typeof p !== 'string') return false;
|
|
99
|
+
try {
|
|
100
|
+
return fs.existsSync(p);
|
|
101
|
+
} catch (_e) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Resolve the next pending story key from BMad's sprint-status.yaml.
|
|
107
|
+
// Used by composeRuntimeState to populate state.story_key BEFORE
|
|
108
|
+
// emitting PREPARE_STORY_BRANCH — without this, branchName() falls
|
|
109
|
+
// back to "story/unknown" because state.story_key is null on a fresh
|
|
110
|
+
// sprint (CREATE_STORY hasn't run yet; for nano there's no CREATE_STORY
|
|
111
|
+
// at all and quick-dev reads sprint-status itself). Returns the first
|
|
112
|
+
// story whose status is NOT "done" (case-insensitive), or null when:
|
|
113
|
+
// - the status file doesn't exist (pre-planning)
|
|
114
|
+
// - all stories are done (sprint complete)
|
|
115
|
+
// - the file can't be parsed
|
|
116
|
+
function resolveNextStoryKey(projectRoot) {
|
|
117
|
+
if (!projectRoot) return null;
|
|
118
|
+
const sprintStatusPath = path.join(
|
|
119
|
+
projectRoot,
|
|
120
|
+
'_bmad-output',
|
|
121
|
+
'implementation-artifacts',
|
|
122
|
+
'sprint-status.yaml',
|
|
123
|
+
);
|
|
124
|
+
if (!safeExistsSync(sprintStatusPath)) return null;
|
|
125
|
+
try {
|
|
126
|
+
const raw = fs.readFileSync(sprintStatusPath, 'utf8');
|
|
127
|
+
const stories = parseSprintStatuses(raw);
|
|
128
|
+
const remaining = remainingStoriesFrom(stories);
|
|
129
|
+
return remaining.length > 0 ? remaining[0] : null;
|
|
130
|
+
} catch (_e) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Derive the epic identifier from a BMad story key. Convention:
|
|
136
|
+
// `epic-N-slug` → `epic-N`; `<epic>-<story>-<slug>` → `<epic>`.
|
|
137
|
+
// Returns null when the key doesn't parse cleanly. Kept in sync with
|
|
138
|
+
// adapt.js#deriveEpicKey; centralized here so composeRuntimeState
|
|
139
|
+
// doesn't have to import adapt's private helper.
|
|
140
|
+
function deriveEpicFromStoryKey(storyKey) {
|
|
141
|
+
if (typeof storyKey !== 'string' || !storyKey) return null;
|
|
142
|
+
const epicPrefixed = storyKey.match(/^(epic-[A-Za-z0-9_]+)-/);
|
|
143
|
+
if (epicPrefixed) return epicPrefixed[1];
|
|
144
|
+
const firstSeg = storyKey.match(/^([A-Za-z0-9_]+)-/);
|
|
145
|
+
if (firstSeg) return firstSeg[1];
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
89
149
|
function persistState(updates, profile, projectRoot, story) {
|
|
90
150
|
return stateStore.write(updates, profile, { projectRoot, story });
|
|
91
151
|
}
|
|
@@ -101,17 +161,111 @@ function persistState(updates, profile, projectRoot, story) {
|
|
|
101
161
|
// regardless of which CLI entrypoint composed the runtime (workflow.
|
|
102
162
|
// orchestrator.md tells the LLM to call `next` directly, bypassing
|
|
103
163
|
// cmdStart).
|
|
104
|
-
function composeRuntimeState(persisted, profile) {
|
|
105
|
-
|
|
164
|
+
function composeRuntimeState(persisted, profile, projectRoot) {
|
|
165
|
+
// Fresh-sprint initial phase. When git settings require a per-story or
|
|
166
|
+
// per-epic branch (granularity ∈ {story, epic} AND !reuse_user_branch
|
|
167
|
+
// AND git.enabled !== false), boot at PREPARE_STORY_BRANCH so the very
|
|
168
|
+
// first action is a `git_op: create_branch` — the story file is then
|
|
169
|
+
// authored on the story branch rather than on `main`.
|
|
170
|
+
//
|
|
171
|
+
// Skipped when:
|
|
172
|
+
// - reuse_user_branch: true → cmdStart detects + locks user branch
|
|
173
|
+
// - enabled: false → git is disabled entirely; no branch
|
|
174
|
+
// to prepare. State machine still emits
|
|
175
|
+
// git_ops at STORY_DONE but decorateGitOp
|
|
176
|
+
// empties their steps.
|
|
177
|
+
const needsBranchPrep =
|
|
178
|
+
profile &&
|
|
179
|
+
profile.enabled !== false &&
|
|
180
|
+
!profile.reuse_user_branch &&
|
|
181
|
+
(profile.granularity === 'story' || profile.granularity === 'epic');
|
|
182
|
+
const flowStart =
|
|
106
183
|
profile && profile.implementation_flow === 'quick'
|
|
107
184
|
? STATES.NANO_QUICK_DEV
|
|
108
185
|
: STATES.CREATE_STORY;
|
|
109
|
-
const
|
|
186
|
+
const defaultPhase = needsBranchPrep ? STATES.PREPARE_STORY_BRANCH : flowStart;
|
|
187
|
+
let phase = persisted.current_bmad_step || defaultPhase;
|
|
188
|
+
|
|
189
|
+
// Phase enum validation: if persisted state has a garbage phase (typo
|
|
190
|
+
// / manual edit / pre-rename leftover), don't pass it through to
|
|
191
|
+
// nextAction which would throw "unknown phase" with a stack trace.
|
|
192
|
+
// Emit a clear warning and reset to the profile-aware default; the
|
|
193
|
+
// user can re-run after fixing the file or accept the reset.
|
|
194
|
+
const KNOWN_PHASES = new Set(Object.values(STATES));
|
|
195
|
+
if (!KNOWN_PHASES.has(phase)) {
|
|
196
|
+
process.stderr.write(
|
|
197
|
+
`[autopilot] WARN persisted current_bmad_step "${phase}" is not a known phase — resetting to ${defaultPhase}. Edit autopilot-state.yaml or run \`autopilot start\` to override.\n`,
|
|
198
|
+
);
|
|
199
|
+
phase = defaultPhase;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Migration: a sprint that was started before PREPARE_STORY_BRANCH
|
|
203
|
+
// shipped will have persisted `current_bmad_step: create_story` (or
|
|
204
|
+
// `nano_quick_dev`) with no story-level state set yet. On upgrade,
|
|
205
|
+
// route those fresh-story-start phases through PREPARE_STORY_BRANCH
|
|
206
|
+
// so the bug we fixed actually applies to existing sprints.
|
|
207
|
+
//
|
|
208
|
+
// Bail out if there's any sign of an in-flight story:
|
|
209
|
+
// - persisted.current_story set → story_key is being tracked
|
|
210
|
+
// - persisted.story_file_path set AND the file actually exists →
|
|
211
|
+
// bmad-create-story already wrote it; we'd lose work by re-routing
|
|
212
|
+
// - prior_diagnosis / retry_count_this_phase → mid-retry of this phase
|
|
213
|
+
// The file-exists check guards against stale persisted paths (e.g.
|
|
214
|
+
// when `coalesce_state_writes` persisted the field optimistically
|
|
215
|
+
// before the skill ran). Any genuine in-flight marker means mid-cycle.
|
|
216
|
+
const storyFileExists =
|
|
217
|
+
!!persisted.story_file_path && safeExistsSync(persisted.story_file_path);
|
|
218
|
+
const midStorySignals =
|
|
219
|
+
!!persisted.current_story ||
|
|
220
|
+
storyFileExists ||
|
|
221
|
+
!!persisted.prior_diagnosis ||
|
|
222
|
+
(persisted.retry_count_this_phase || 0) > 0;
|
|
223
|
+
if (
|
|
224
|
+
needsBranchPrep &&
|
|
225
|
+
!midStorySignals &&
|
|
226
|
+
(phase === STATES.CREATE_STORY || phase === STATES.NANO_QUICK_DEV)
|
|
227
|
+
) {
|
|
228
|
+
phase = STATES.PREPARE_STORY_BRANCH;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Resolve story_key for PREPARE_STORY_BRANCH. The branch creation step
|
|
232
|
+
// needs a known story_key (and current_epic under granularity=epic) to
|
|
233
|
+
// compute the branch name — without resolution, branchName() falls
|
|
234
|
+
// back to "story/unknown" and we'd push a useless ref to origin.
|
|
235
|
+
//
|
|
236
|
+
// Why this is needed: PREPARE_STORY_BRANCH runs BEFORE CREATE_STORY in
|
|
237
|
+
// the full flow (and NANO_QUICK_DEV picks the story itself in quick
|
|
238
|
+
// flow), so the story_key from persisted state can be null on a fresh
|
|
239
|
+
// sprint. Read sprint-status.yaml — the same source of truth bmad-
|
|
240
|
+
// create-story / bmad-quick-dev use — to look ahead and find the
|
|
241
|
+
// next pending story.
|
|
242
|
+
//
|
|
243
|
+
// If nothing is pending (pre-planning OR sprint complete OR parse
|
|
244
|
+
// failure), fall back to flowStart so the LLM gets a meaningful
|
|
245
|
+
// skill invocation. PREPARE_STORY_BRANCH with no story_key would be
|
|
246
|
+
// a confusing emission to act on.
|
|
247
|
+
let resolvedStoryKey = persisted.current_story || null;
|
|
248
|
+
let resolvedEpic = persisted.current_epic || null;
|
|
249
|
+
let resolvedStoryFilePath = persisted.story_file_path || null;
|
|
250
|
+
if (phase === STATES.PREPARE_STORY_BRANCH && !resolvedStoryKey) {
|
|
251
|
+
const next = resolveNextStoryKey(projectRoot);
|
|
252
|
+
if (next) {
|
|
253
|
+
resolvedStoryKey = next;
|
|
254
|
+
if (!resolvedEpic) resolvedEpic = deriveEpicFromStoryKey(next);
|
|
255
|
+
} else {
|
|
256
|
+
process.stderr.write(
|
|
257
|
+
`[autopilot] WARN PREPARE_STORY_BRANCH needs a next-story key but sprint-status.yaml has none pending — falling back to ${flowStart}. ` +
|
|
258
|
+
'Run BMad sprint-planning first, or set git.reuse_user_branch=true to commit on the current branch.\n',
|
|
259
|
+
);
|
|
260
|
+
phase = flowStart;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
110
264
|
return {
|
|
111
265
|
phase,
|
|
112
|
-
story_key:
|
|
113
|
-
story_file_path:
|
|
114
|
-
current_epic:
|
|
266
|
+
story_key: resolvedStoryKey,
|
|
267
|
+
story_file_path: resolvedStoryFilePath,
|
|
268
|
+
current_epic: resolvedEpic,
|
|
115
269
|
ac_summary: persisted.ac_summary || null,
|
|
116
270
|
prior_diagnosis: persisted.prior_diagnosis || null,
|
|
117
271
|
relevant_decisions: persisted.relevant_decisions || [],
|
|
@@ -225,10 +379,55 @@ function logSkillTiming(projectRoot, event, story, skillName, profile) {
|
|
|
225
379
|
// Inline the planned argv steps from git-plan.js so the LLM doesn't have
|
|
226
380
|
// to interpret the op — it just executes `action.steps` in order.
|
|
227
381
|
// Without this, live-LLM sessions silently skip `git push` after STORY_DONE.
|
|
228
|
-
function decorateGitOp(action, state, profile) {
|
|
382
|
+
function decorateGitOp(action, state, profile, projectRoot) {
|
|
229
383
|
if (!action || action.type !== 'git_op') return action;
|
|
384
|
+
// git.enabled: false — emit the git_op with an empty step list so the
|
|
385
|
+
// LLM's "execute steps in order" loop trivially succeeds and signals
|
|
386
|
+
// back, advancing the state machine without touching git. A bare
|
|
387
|
+
// `type: noop` would loop here because cmdNext re-emits the same phase
|
|
388
|
+
// until a success signal is recorded.
|
|
389
|
+
if (profile && profile.enabled === false) {
|
|
390
|
+
return {
|
|
391
|
+
...action,
|
|
392
|
+
branch: null,
|
|
393
|
+
steps: [],
|
|
394
|
+
git_disabled: true,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
230
397
|
try {
|
|
231
|
-
|
|
398
|
+
// For create_branch: probe git locally to detect whether the planned
|
|
399
|
+
// branch already exists (resume after partial failure, second story
|
|
400
|
+
// on an epic branch under granularity=epic). The plan uses this to
|
|
401
|
+
// emit `git switch <branch>` (idempotent) instead of `git switch -c`
|
|
402
|
+
// (which would fail on collision). Probe is best-effort — failure
|
|
403
|
+
// leaves branch_exists false and the plan defaults to the create
|
|
404
|
+
// path (the safer default for fresh stories).
|
|
405
|
+
// Threading project_root onto the state so pure-ish helpers in
|
|
406
|
+
// git-plan.js can load files (pr_template_path) without taking it
|
|
407
|
+
// as a separate arg. The plan itself is still deterministic given
|
|
408
|
+
// the same inputs.
|
|
409
|
+
let enrichedState = { ...state, project_root: projectRoot || process.cwd() };
|
|
410
|
+
if (action.op === 'create_branch') {
|
|
411
|
+
const branch = gitPlan.branchName(profile, state.story_key, state.current_epic, state);
|
|
412
|
+
const branchExists = probeBranchExists(enrichedState.project_root, branch);
|
|
413
|
+
enrichedState = { ...enrichedState, branch_exists: branchExists };
|
|
414
|
+
}
|
|
415
|
+
const planned = gitPlan.plan(enrichedState, profile, action);
|
|
416
|
+
// Surface plan-level warnings (e.g. pr_template_path not found) via
|
|
417
|
+
// the orchestrator's stderr so the LLM context sees them, without
|
|
418
|
+
// git-plan.js itself writing to stderr from a pure-ish function.
|
|
419
|
+
if (Array.isArray(planned.warnings)) {
|
|
420
|
+
for (const w of planned.warnings) {
|
|
421
|
+
process.stderr.write(`[git-plan] WARN: ${w}\n`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
// Some plans (e.g. epic merge on unsupported platforms) return a
|
|
425
|
+
// `halt_action` field instead of executable steps. Convert it into
|
|
426
|
+
// a top-level user_prompt action so the orchestrator pauses instead
|
|
427
|
+
// of silently running zero steps and advancing.
|
|
428
|
+
if (planned.halt_action) {
|
|
429
|
+
return { ...planned.halt_action, phase: action.phase };
|
|
430
|
+
}
|
|
232
431
|
return { ...action, branch: planned.branch, steps: planned.steps };
|
|
233
432
|
} catch (e) {
|
|
234
433
|
log.warn(`git-plan failed for op=${action.op}: ${e.message}`);
|
|
@@ -236,6 +435,69 @@ function decorateGitOp(action, state, profile) {
|
|
|
236
435
|
}
|
|
237
436
|
}
|
|
238
437
|
|
|
438
|
+
// Detect whether a branch exists, locally OR on origin. Used by
|
|
439
|
+
// decorateGitOp so the create_branch plan can degrade to a plain switch
|
|
440
|
+
// when the branch is already known. Checking remote refs avoids the
|
|
441
|
+
// failure mode where a teammate / prior worktree pushed the branch but
|
|
442
|
+
// it's not in our local refs — `git switch -c` would either fail or
|
|
443
|
+
// later collide on push. Returns false on any error so the create path
|
|
444
|
+
// (the safer default for fresh stories) is selected.
|
|
445
|
+
//
|
|
446
|
+
// Refreshes the local mirror of the specific remote ref via `git fetch
|
|
447
|
+
// origin <branch>` before checking refs/remotes/origin/<branch> — without
|
|
448
|
+
// this, a stale local clone can miss a recently-pushed remote branch.
|
|
449
|
+
// The fetch is best-effort and capped at 5s.
|
|
450
|
+
function probeBranchExists(projectRoot, branch) {
|
|
451
|
+
if (!branch || typeof branch !== 'string') return false;
|
|
452
|
+
const { execFileSync } = require('node:child_process');
|
|
453
|
+
// Local ref?
|
|
454
|
+
try {
|
|
455
|
+
execFileSync(
|
|
456
|
+
'git',
|
|
457
|
+
['-C', projectRoot, 'show-ref', '--verify', '--quiet', `refs/heads/${branch}`],
|
|
458
|
+
{ stdio: 'ignore', timeout: 5_000 },
|
|
459
|
+
);
|
|
460
|
+
return true;
|
|
461
|
+
} catch (_e) {
|
|
462
|
+
/* fall through */
|
|
463
|
+
}
|
|
464
|
+
// Skip the remote-ref dance entirely when there's no origin
|
|
465
|
+
// configured — every emit on a local-only repo would otherwise pay
|
|
466
|
+
// ~5s of fetch timeout for no gain. `git remote get-url origin`
|
|
467
|
+
// exits non-zero (~50ms) when origin is absent.
|
|
468
|
+
try {
|
|
469
|
+
execFileSync('git', ['-C', projectRoot, 'remote', 'get-url', 'origin'], {
|
|
470
|
+
stdio: 'ignore',
|
|
471
|
+
timeout: 2_000,
|
|
472
|
+
});
|
|
473
|
+
} catch (_e) {
|
|
474
|
+
return false; // no origin → no remote ref to check
|
|
475
|
+
}
|
|
476
|
+
// Best-effort: refresh the remote ref before checking. Fetching a
|
|
477
|
+
// specific branch ref is much cheaper than `git fetch origin` (no
|
|
478
|
+
// tag/all-branch traffic) and is silent on a non-existent ref.
|
|
479
|
+
try {
|
|
480
|
+
execFileSync(
|
|
481
|
+
'git',
|
|
482
|
+
['-C', projectRoot, 'fetch', 'origin', branch, '--quiet', '--no-tags'],
|
|
483
|
+
{ stdio: 'ignore', timeout: 5_000 },
|
|
484
|
+
);
|
|
485
|
+
} catch (_e) {
|
|
486
|
+
/* network / branch absent — fall through to local check */
|
|
487
|
+
}
|
|
488
|
+
// Remote ref now (possibly) up to date.
|
|
489
|
+
try {
|
|
490
|
+
execFileSync(
|
|
491
|
+
'git',
|
|
492
|
+
['-C', projectRoot, 'show-ref', '--verify', '--quiet', `refs/remotes/origin/${branch}`],
|
|
493
|
+
{ stdio: 'ignore', timeout: 5_000 },
|
|
494
|
+
);
|
|
495
|
+
return true;
|
|
496
|
+
} catch (_e) {
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
239
501
|
// ------------------------------------------------------------ side effects
|
|
240
502
|
|
|
241
503
|
function applySideEffects(sideEffects, runtime, profile, projectRoot) {
|
|
@@ -312,6 +574,44 @@ function applySideEffects(sideEffects, runtime, profile, projectRoot) {
|
|
|
312
574
|
|
|
313
575
|
// ------------------------------------------------------------ subcommands
|
|
314
576
|
|
|
577
|
+
// Detect + lock the user's working branch under `reuse_user_branch:
|
|
578
|
+
// true`. Returns null if the runtime is already locked, or `{ halt }`
|
|
579
|
+
// with a halt/user_prompt action when the environment is invalid. Side-
|
|
580
|
+
// effect: mutates `runtime.user_branch` on success and appends a ledger
|
|
581
|
+
// entry. Used by both cmdStart and cmdNext so the LLM-direct path
|
|
582
|
+
// (workflow.orchestrator.md tells LLMs to call `next` without `start`)
|
|
583
|
+
// gets the same enforcement.
|
|
584
|
+
function lockUserBranchIfNeeded(runtime, profile, projectRoot) {
|
|
585
|
+
if (!profile.reuse_user_branch || runtime.user_branch) return null;
|
|
586
|
+
const current = detectCurrentBranch(projectRoot);
|
|
587
|
+
const base = profile.base_branch || 'main';
|
|
588
|
+
if (!current) {
|
|
589
|
+
return {
|
|
590
|
+
halt: {
|
|
591
|
+
type: 'halt',
|
|
592
|
+
reason: 'reuse_user_branch_no_git',
|
|
593
|
+
prompt:
|
|
594
|
+
'reuse_user_branch is on but git is not available / no current branch detected. Initialize a git repo and check out the branch you want autopilot to use.',
|
|
595
|
+
},
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
if (current === base) {
|
|
599
|
+
return {
|
|
600
|
+
halt: {
|
|
601
|
+
type: 'user_prompt',
|
|
602
|
+
reason: 'reuse_user_branch_on_base',
|
|
603
|
+
prompt: `reuse_user_branch is on but you're on the base branch (${base}). Create + checkout the branch you want autopilot to commit on, then re-run.`,
|
|
604
|
+
},
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
runtime.user_branch = current;
|
|
608
|
+
ledger.append(
|
|
609
|
+
{ kind: 'state_transition', detail: { user_branch_detected: current } },
|
|
610
|
+
{ projectRoot },
|
|
611
|
+
);
|
|
612
|
+
return null;
|
|
613
|
+
}
|
|
614
|
+
|
|
315
615
|
function cmdStart(opts) {
|
|
316
616
|
const projectRoot = resolveProjectRoot(opts);
|
|
317
617
|
const { typed: profile } = resolveProfile(projectRoot, opts.profile);
|
|
@@ -335,43 +635,21 @@ function cmdStart(opts) {
|
|
|
335
635
|
|
|
336
636
|
// Fresh start or clean resume. `composeRuntimeState` applies the
|
|
337
637
|
// profile-aware initial phase when persisted state is empty.
|
|
338
|
-
const runtime = composeRuntimeState(persisted, profile);
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
// commit every story onto this branch.
|
|
343
|
-
if (profile.reuse_user_branch && !runtime.user_branch) {
|
|
344
|
-
const current = detectCurrentBranch(projectRoot);
|
|
345
|
-
const base = profile.base_branch || 'main';
|
|
346
|
-
if (!current) {
|
|
347
|
-
const halt = {
|
|
348
|
-
type: 'halt',
|
|
349
|
-
reason: 'reuse_user_branch_no_git',
|
|
350
|
-
prompt:
|
|
351
|
-
'reuse_user_branch is on but git is not available / no current branch detected. Initialize a git repo and check out the branch you want autopilot to use.',
|
|
352
|
-
};
|
|
353
|
-
ledger.append({ kind: 'action_emitted', phase: runtime.phase, action: halt }, { projectRoot });
|
|
354
|
-
process.stdout.write(`${JSON.stringify({ action: halt, phase: runtime.phase }, null, 2)}\n`);
|
|
355
|
-
return 0;
|
|
356
|
-
}
|
|
357
|
-
if (current === base) {
|
|
358
|
-
const halt = {
|
|
359
|
-
type: 'user_prompt',
|
|
360
|
-
reason: 'reuse_user_branch_on_base',
|
|
361
|
-
prompt: `reuse_user_branch is on but you're on the base branch (${base}). Create + checkout the branch you want autopilot to commit on, then re-run.`,
|
|
362
|
-
};
|
|
363
|
-
ledger.append({ kind: 'action_emitted', phase: runtime.phase, action: halt }, { projectRoot });
|
|
364
|
-
process.stdout.write(`${JSON.stringify({ action: halt, phase: runtime.phase }, null, 2)}\n`);
|
|
365
|
-
return 0;
|
|
366
|
-
}
|
|
367
|
-
runtime.user_branch = current;
|
|
638
|
+
const runtime = composeRuntimeState(persisted, profile, projectRoot);
|
|
639
|
+
|
|
640
|
+
const lockResult = lockUserBranchIfNeeded(runtime, profile, projectRoot);
|
|
641
|
+
if (lockResult && lockResult.halt) {
|
|
368
642
|
ledger.append(
|
|
369
|
-
{ kind: '
|
|
643
|
+
{ kind: 'action_emitted', phase: runtime.phase, action: lockResult.halt },
|
|
370
644
|
{ projectRoot },
|
|
371
645
|
);
|
|
646
|
+
process.stdout.write(
|
|
647
|
+
`${JSON.stringify({ action: lockResult.halt, phase: runtime.phase }, null, 2)}\n`,
|
|
648
|
+
);
|
|
649
|
+
return 0;
|
|
372
650
|
}
|
|
373
651
|
|
|
374
|
-
const action = decorateGitOp(stateMachine.nextAction(runtime, profile), runtime, profile);
|
|
652
|
+
const action = decorateGitOp(stateMachine.nextAction(runtime, profile), runtime, profile, projectRoot);
|
|
375
653
|
ledger.append({ kind: 'action_emitted', phase: runtime.phase, action }, { projectRoot });
|
|
376
654
|
persistRuntimeState(runtime, profile, projectRoot);
|
|
377
655
|
if (profile.coalesce_state_writes) stateStore.flush(profile, { projectRoot, story: runtime.story_key });
|
|
@@ -383,9 +661,29 @@ function cmdNext(opts) {
|
|
|
383
661
|
const projectRoot = resolveProjectRoot(opts);
|
|
384
662
|
const { typed: profile } = resolveProfile(projectRoot, opts.profile);
|
|
385
663
|
const persisted = loadState(projectRoot);
|
|
386
|
-
const runtime = composeRuntimeState(persisted, profile);
|
|
387
|
-
|
|
664
|
+
const runtime = composeRuntimeState(persisted, profile, projectRoot);
|
|
665
|
+
|
|
666
|
+
// The LLM-driven workflow (workflow.orchestrator.md) tells the LLM to
|
|
667
|
+
// call `next` directly without `start` — apply the same branch-reuse
|
|
668
|
+
// enforcement here so a missed `start` doesn't bypass it.
|
|
669
|
+
const lockResult = lockUserBranchIfNeeded(runtime, profile, projectRoot);
|
|
670
|
+
if (lockResult && lockResult.halt) {
|
|
671
|
+
ledger.append(
|
|
672
|
+
{ kind: 'action_emitted', phase: runtime.phase, action: lockResult.halt },
|
|
673
|
+
{ projectRoot },
|
|
674
|
+
);
|
|
675
|
+
process.stdout.write(
|
|
676
|
+
`${JSON.stringify({ action: lockResult.halt, phase: runtime.phase }, null, 2)}\n`,
|
|
677
|
+
);
|
|
678
|
+
return 0;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const action = decorateGitOp(stateMachine.nextAction(runtime, profile), runtime, profile, projectRoot);
|
|
388
682
|
ledger.append({ kind: 'action_emitted', phase: runtime.phase, action }, { projectRoot });
|
|
683
|
+
// Persist any mutations done by lockUserBranchIfNeeded — without this
|
|
684
|
+
// every cmdNext under reuse_user_branch=true re-detects the branch and
|
|
685
|
+
// emits a redundant `state_transition` ledger entry forever.
|
|
686
|
+
persistRuntimeState(runtime, profile, projectRoot);
|
|
389
687
|
// Skill timing: emit a `skill.<name>` start event when we hand off an
|
|
390
688
|
// invoke_skill action. The matching end event is emitted on `record`
|
|
391
689
|
// when the signal advances the phase. This makes parallelism +
|
|
@@ -401,7 +699,7 @@ function cmdRecord(opts) {
|
|
|
401
699
|
const projectRoot = resolveProjectRoot(opts);
|
|
402
700
|
const { typed: profile } = resolveProfile(projectRoot, opts.profile);
|
|
403
701
|
const persisted = loadState(projectRoot);
|
|
404
|
-
const runtime = composeRuntimeState(persisted, profile);
|
|
702
|
+
const runtime = composeRuntimeState(persisted, profile, projectRoot);
|
|
405
703
|
|
|
406
704
|
let signalJson;
|
|
407
705
|
if (opts['signal-file']) {
|
|
@@ -423,9 +721,17 @@ function cmdRecord(opts) {
|
|
|
423
721
|
{ projectRoot },
|
|
424
722
|
);
|
|
425
723
|
|
|
426
|
-
// Verify only on `success` and `verify_override`.
|
|
724
|
+
// Verify only on `success` and `verify_override`. Under `git.enabled:
|
|
725
|
+
// false`, git-op phases skip verify entirely — there's no commit_sha/
|
|
726
|
+
// branch to assert and verify would reject every success in a loop.
|
|
727
|
+
// The state machine still routes through these phases so the BMad
|
|
728
|
+
// cycle stays intact; only the bookkeeping check is bypassed. The
|
|
729
|
+
// phase list is centralized in state-machine.js#isGitOpPhase so a
|
|
730
|
+
// future git-op phase automatically gets the bypass.
|
|
731
|
+
const isGitDisabledPhase =
|
|
732
|
+
profile.enabled === false && stateMachine.shouldSkipVerifyWhenGitDisabled(runtime.phase);
|
|
427
733
|
let verifyResult;
|
|
428
|
-
if (signal.status === 'success') {
|
|
734
|
+
if (signal.status === 'success' && !isGitDisabledPhase) {
|
|
429
735
|
verifyResult = verifyMod.verify(runtime, signal.output, { projectRoot });
|
|
430
736
|
ledger.append(
|
|
431
737
|
{ kind: 'verify_result', phase: runtime.phase, ok: verifyResult.ok, issues: verifyResult.issues || [] },
|
|
@@ -499,7 +805,7 @@ function cmdRecord(opts) {
|
|
|
499
805
|
}
|
|
500
806
|
|
|
501
807
|
const payload = {
|
|
502
|
-
action: decorateGitOp(result.nextAction, result.newState, result.newProfile),
|
|
808
|
+
action: decorateGitOp(result.nextAction, result.newState, result.newProfile, projectRoot),
|
|
503
809
|
verdict: result.verdict,
|
|
504
810
|
phase: result.newState.phase,
|
|
505
811
|
profile: result.newProfile.name,
|
|
@@ -588,4 +894,4 @@ if (require.main === module) {
|
|
|
588
894
|
process.exit(main(process.argv.slice(2)));
|
|
589
895
|
}
|
|
590
896
|
|
|
591
|
-
module.exports = { main, SUBCOMMANDS };
|
|
897
|
+
module.exports = { main, SUBCOMMANDS, decorateGitOp, composeRuntimeState };
|
|
@@ -397,12 +397,31 @@ function handleUserInput(state, signal, profile, sideEffects) {
|
|
|
397
397
|
|
|
398
398
|
// One-shot dispatch (e.g. accept_alternative resolved a pending alt)?
|
|
399
399
|
// Return the dispatched action in place of the state-machine's default.
|
|
400
|
+
//
|
|
401
|
+
// Sync story metadata onto newState. The dispatched action carries
|
|
402
|
+
// story_key / current_epic / story_file_path / ac_summary in its
|
|
403
|
+
// `template_slots` (and sometimes as top-level fields on git_ops).
|
|
404
|
+
// Without propagating these, accept_alternative dispatches work on
|
|
405
|
+
// a specific story but autopilot-state.yaml still shows
|
|
406
|
+
// `current_story: null` — subsequent emissions / persists / verify
|
|
407
|
+
// checks all reference the wrong story.
|
|
400
408
|
const dispatch = applied.sideEffects.find((e) => e && e.kind === 'dispatch_action');
|
|
401
409
|
if (dispatch && dispatch.action) {
|
|
410
|
+
const a = dispatch.action;
|
|
411
|
+
const slots = a.template_slots || {};
|
|
412
|
+
const enrichedState = {
|
|
413
|
+
...newState,
|
|
414
|
+
story_key: newState.story_key || slots.story_key || a.story_key || null,
|
|
415
|
+
current_epic:
|
|
416
|
+
newState.current_epic || slots.current_epic || a.epic_key || null,
|
|
417
|
+
story_file_path:
|
|
418
|
+
newState.story_file_path || slots.story_file_path || null,
|
|
419
|
+
ac_summary: newState.ac_summary || slots.ac_summary || null,
|
|
420
|
+
};
|
|
402
421
|
return {
|
|
403
|
-
newState,
|
|
422
|
+
newState: enrichedState,
|
|
404
423
|
newProfile,
|
|
405
|
-
nextAction: { ...
|
|
424
|
+
nextAction: { ...a, _dispatched_via: dispatch.reason || 'user_input' },
|
|
406
425
|
sideEffects,
|
|
407
426
|
verdict: 'advanced',
|
|
408
427
|
};
|