@ikunin/sprintpilot 2.1.1 → 2.1.3
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 +278 -43
- package/_Sprintpilot/lib/orchestrator/adapt.js +62 -9
- 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 +159 -4
- package/_Sprintpilot/lib/orchestrator/user-command-applier.js +50 -3
- package/_Sprintpilot/lib/orchestrator/user-commands.js +15 -7
- 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 +37 -3
- package/lib/commands/install.js +197 -0
- package/lib/core/config-merger.js +238 -0
- package/lib/core/v2-upgrade-recovery.js +47 -0
- package/package.json +1 -1
|
@@ -86,6 +86,19 @@ function loadState(projectRoot) {
|
|
|
86
86
|
return stateStore.read({ projectRoot });
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
// Existence probe that never throws. Used by composeRuntimeState's
|
|
90
|
+
// migration guard so a stale `persisted.story_file_path` from before
|
|
91
|
+
// the file was actually written doesn't suppress migration. Returns
|
|
92
|
+
// false on any error (path is null/undefined, fs permission, etc.).
|
|
93
|
+
function safeExistsSync(p) {
|
|
94
|
+
if (!p || typeof p !== 'string') return false;
|
|
95
|
+
try {
|
|
96
|
+
return fs.existsSync(p);
|
|
97
|
+
} catch (_e) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
89
102
|
function persistState(updates, profile, projectRoot, story) {
|
|
90
103
|
return stateStore.write(updates, profile, { projectRoot, story });
|
|
91
104
|
}
|
|
@@ -102,11 +115,71 @@ function persistState(updates, profile, projectRoot, story) {
|
|
|
102
115
|
// orchestrator.md tells the LLM to call `next` directly, bypassing
|
|
103
116
|
// cmdStart).
|
|
104
117
|
function composeRuntimeState(persisted, profile) {
|
|
105
|
-
|
|
118
|
+
// Fresh-sprint initial phase. When git settings require a per-story or
|
|
119
|
+
// per-epic branch (granularity ∈ {story, epic} AND !reuse_user_branch
|
|
120
|
+
// AND git.enabled !== false), boot at PREPARE_STORY_BRANCH so the very
|
|
121
|
+
// first action is a `git_op: create_branch` — the story file is then
|
|
122
|
+
// authored on the story branch rather than on `main`.
|
|
123
|
+
//
|
|
124
|
+
// Skipped when:
|
|
125
|
+
// - reuse_user_branch: true → cmdStart detects + locks user branch
|
|
126
|
+
// - enabled: false → git is disabled entirely; no branch
|
|
127
|
+
// to prepare. State machine still emits
|
|
128
|
+
// git_ops at STORY_DONE but decorateGitOp
|
|
129
|
+
// empties their steps.
|
|
130
|
+
const needsBranchPrep =
|
|
131
|
+
profile &&
|
|
132
|
+
profile.enabled !== false &&
|
|
133
|
+
!profile.reuse_user_branch &&
|
|
134
|
+
(profile.granularity === 'story' || profile.granularity === 'epic');
|
|
135
|
+
const flowStart =
|
|
106
136
|
profile && profile.implementation_flow === 'quick'
|
|
107
137
|
? STATES.NANO_QUICK_DEV
|
|
108
138
|
: STATES.CREATE_STORY;
|
|
109
|
-
const
|
|
139
|
+
const defaultPhase = needsBranchPrep ? STATES.PREPARE_STORY_BRANCH : flowStart;
|
|
140
|
+
let phase = persisted.current_bmad_step || defaultPhase;
|
|
141
|
+
|
|
142
|
+
// Phase enum validation: if persisted state has a garbage phase (typo
|
|
143
|
+
// / manual edit / pre-rename leftover), don't pass it through to
|
|
144
|
+
// nextAction which would throw "unknown phase" with a stack trace.
|
|
145
|
+
// Emit a clear warning and reset to the profile-aware default; the
|
|
146
|
+
// user can re-run after fixing the file or accept the reset.
|
|
147
|
+
const KNOWN_PHASES = new Set(Object.values(STATES));
|
|
148
|
+
if (!KNOWN_PHASES.has(phase)) {
|
|
149
|
+
process.stderr.write(
|
|
150
|
+
`[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`,
|
|
151
|
+
);
|
|
152
|
+
phase = defaultPhase;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Migration: a sprint that was started before PREPARE_STORY_BRANCH
|
|
156
|
+
// shipped will have persisted `current_bmad_step: create_story` (or
|
|
157
|
+
// `nano_quick_dev`) with no story-level state set yet. On upgrade,
|
|
158
|
+
// route those fresh-story-start phases through PREPARE_STORY_BRANCH
|
|
159
|
+
// so the bug we fixed actually applies to existing sprints.
|
|
160
|
+
//
|
|
161
|
+
// Bail out if there's any sign of an in-flight story:
|
|
162
|
+
// - persisted.current_story set → story_key is being tracked
|
|
163
|
+
// - persisted.story_file_path set AND the file actually exists →
|
|
164
|
+
// bmad-create-story already wrote it; we'd lose work by re-routing
|
|
165
|
+
// - prior_diagnosis / retry_count_this_phase → mid-retry of this phase
|
|
166
|
+
// The file-exists check guards against stale persisted paths (e.g.
|
|
167
|
+
// when `coalesce_state_writes` persisted the field optimistically
|
|
168
|
+
// before the skill ran). Any genuine in-flight marker means mid-cycle.
|
|
169
|
+
const storyFileExists =
|
|
170
|
+
!!persisted.story_file_path && safeExistsSync(persisted.story_file_path);
|
|
171
|
+
const midStorySignals =
|
|
172
|
+
!!persisted.current_story ||
|
|
173
|
+
storyFileExists ||
|
|
174
|
+
!!persisted.prior_diagnosis ||
|
|
175
|
+
(persisted.retry_count_this_phase || 0) > 0;
|
|
176
|
+
if (
|
|
177
|
+
needsBranchPrep &&
|
|
178
|
+
!midStorySignals &&
|
|
179
|
+
(phase === STATES.CREATE_STORY || phase === STATES.NANO_QUICK_DEV)
|
|
180
|
+
) {
|
|
181
|
+
phase = STATES.PREPARE_STORY_BRANCH;
|
|
182
|
+
}
|
|
110
183
|
return {
|
|
111
184
|
phase,
|
|
112
185
|
story_key: persisted.current_story || null,
|
|
@@ -128,6 +201,13 @@ function composeRuntimeState(persisted, profile) {
|
|
|
128
201
|
user_branch: persisted.user_branch || null,
|
|
129
202
|
// Land-as-you-go: pending land state survives rebase-conflict halts.
|
|
130
203
|
land_pending: persisted.land_pending || null,
|
|
204
|
+
// Pending alternative (propose_alternative → user_prompt) survives
|
|
205
|
+
// across halts so the next session re-emits the prompt rather than
|
|
206
|
+
// silently dropping the LLM's proposal.
|
|
207
|
+
pending_alternative: persisted.pending_alternative || null,
|
|
208
|
+
// halt_requested is intentionally NOT carried forward here: cmdStart
|
|
209
|
+
// clears it on each new session (a `pause` cleanly halts THIS session
|
|
210
|
+
// and the next /sprint-autopilot-on resumes normally).
|
|
131
211
|
};
|
|
132
212
|
}
|
|
133
213
|
|
|
@@ -151,6 +231,7 @@ function persistRuntimeState(runtime, profile, projectRoot) {
|
|
|
151
231
|
consecutive_test_failures: runtime.consecutive_test_failures,
|
|
152
232
|
user_branch: runtime.user_branch,
|
|
153
233
|
land_pending: runtime.land_pending,
|
|
234
|
+
pending_alternative: runtime.pending_alternative || null,
|
|
154
235
|
};
|
|
155
236
|
return persistState(updates, profile, projectRoot, runtime.story_key || 'sprint');
|
|
156
237
|
}
|
|
@@ -217,10 +298,55 @@ function logSkillTiming(projectRoot, event, story, skillName, profile) {
|
|
|
217
298
|
// Inline the planned argv steps from git-plan.js so the LLM doesn't have
|
|
218
299
|
// to interpret the op — it just executes `action.steps` in order.
|
|
219
300
|
// Without this, live-LLM sessions silently skip `git push` after STORY_DONE.
|
|
220
|
-
function decorateGitOp(action, state, profile) {
|
|
301
|
+
function decorateGitOp(action, state, profile, projectRoot) {
|
|
221
302
|
if (!action || action.type !== 'git_op') return action;
|
|
303
|
+
// git.enabled: false — emit the git_op with an empty step list so the
|
|
304
|
+
// LLM's "execute steps in order" loop trivially succeeds and signals
|
|
305
|
+
// back, advancing the state machine without touching git. A bare
|
|
306
|
+
// `type: noop` would loop here because cmdNext re-emits the same phase
|
|
307
|
+
// until a success signal is recorded.
|
|
308
|
+
if (profile && profile.enabled === false) {
|
|
309
|
+
return {
|
|
310
|
+
...action,
|
|
311
|
+
branch: null,
|
|
312
|
+
steps: [],
|
|
313
|
+
git_disabled: true,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
222
316
|
try {
|
|
223
|
-
|
|
317
|
+
// For create_branch: probe git locally to detect whether the planned
|
|
318
|
+
// branch already exists (resume after partial failure, second story
|
|
319
|
+
// on an epic branch under granularity=epic). The plan uses this to
|
|
320
|
+
// emit `git switch <branch>` (idempotent) instead of `git switch -c`
|
|
321
|
+
// (which would fail on collision). Probe is best-effort — failure
|
|
322
|
+
// leaves branch_exists false and the plan defaults to the create
|
|
323
|
+
// path (the safer default for fresh stories).
|
|
324
|
+
// Threading project_root onto the state so pure-ish helpers in
|
|
325
|
+
// git-plan.js can load files (pr_template_path) without taking it
|
|
326
|
+
// as a separate arg. The plan itself is still deterministic given
|
|
327
|
+
// the same inputs.
|
|
328
|
+
let enrichedState = { ...state, project_root: projectRoot || process.cwd() };
|
|
329
|
+
if (action.op === 'create_branch') {
|
|
330
|
+
const branch = gitPlan.branchName(profile, state.story_key, state.current_epic, state);
|
|
331
|
+
const branchExists = probeBranchExists(enrichedState.project_root, branch);
|
|
332
|
+
enrichedState = { ...enrichedState, branch_exists: branchExists };
|
|
333
|
+
}
|
|
334
|
+
const planned = gitPlan.plan(enrichedState, profile, action);
|
|
335
|
+
// Surface plan-level warnings (e.g. pr_template_path not found) via
|
|
336
|
+
// the orchestrator's stderr so the LLM context sees them, without
|
|
337
|
+
// git-plan.js itself writing to stderr from a pure-ish function.
|
|
338
|
+
if (Array.isArray(planned.warnings)) {
|
|
339
|
+
for (const w of planned.warnings) {
|
|
340
|
+
process.stderr.write(`[git-plan] WARN: ${w}\n`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// Some plans (e.g. epic merge on unsupported platforms) return a
|
|
344
|
+
// `halt_action` field instead of executable steps. Convert it into
|
|
345
|
+
// a top-level user_prompt action so the orchestrator pauses instead
|
|
346
|
+
// of silently running zero steps and advancing.
|
|
347
|
+
if (planned.halt_action) {
|
|
348
|
+
return { ...planned.halt_action, phase: action.phase };
|
|
349
|
+
}
|
|
224
350
|
return { ...action, branch: planned.branch, steps: planned.steps };
|
|
225
351
|
} catch (e) {
|
|
226
352
|
log.warn(`git-plan failed for op=${action.op}: ${e.message}`);
|
|
@@ -228,6 +354,69 @@ function decorateGitOp(action, state, profile) {
|
|
|
228
354
|
}
|
|
229
355
|
}
|
|
230
356
|
|
|
357
|
+
// Detect whether a branch exists, locally OR on origin. Used by
|
|
358
|
+
// decorateGitOp so the create_branch plan can degrade to a plain switch
|
|
359
|
+
// when the branch is already known. Checking remote refs avoids the
|
|
360
|
+
// failure mode where a teammate / prior worktree pushed the branch but
|
|
361
|
+
// it's not in our local refs — `git switch -c` would either fail or
|
|
362
|
+
// later collide on push. Returns false on any error so the create path
|
|
363
|
+
// (the safer default for fresh stories) is selected.
|
|
364
|
+
//
|
|
365
|
+
// Refreshes the local mirror of the specific remote ref via `git fetch
|
|
366
|
+
// origin <branch>` before checking refs/remotes/origin/<branch> — without
|
|
367
|
+
// this, a stale local clone can miss a recently-pushed remote branch.
|
|
368
|
+
// The fetch is best-effort and capped at 5s.
|
|
369
|
+
function probeBranchExists(projectRoot, branch) {
|
|
370
|
+
if (!branch || typeof branch !== 'string') return false;
|
|
371
|
+
const { execFileSync } = require('node:child_process');
|
|
372
|
+
// Local ref?
|
|
373
|
+
try {
|
|
374
|
+
execFileSync(
|
|
375
|
+
'git',
|
|
376
|
+
['-C', projectRoot, 'show-ref', '--verify', '--quiet', `refs/heads/${branch}`],
|
|
377
|
+
{ stdio: 'ignore', timeout: 5_000 },
|
|
378
|
+
);
|
|
379
|
+
return true;
|
|
380
|
+
} catch (_e) {
|
|
381
|
+
/* fall through */
|
|
382
|
+
}
|
|
383
|
+
// Skip the remote-ref dance entirely when there's no origin
|
|
384
|
+
// configured — every emit on a local-only repo would otherwise pay
|
|
385
|
+
// ~5s of fetch timeout for no gain. `git remote get-url origin`
|
|
386
|
+
// exits non-zero (~50ms) when origin is absent.
|
|
387
|
+
try {
|
|
388
|
+
execFileSync('git', ['-C', projectRoot, 'remote', 'get-url', 'origin'], {
|
|
389
|
+
stdio: 'ignore',
|
|
390
|
+
timeout: 2_000,
|
|
391
|
+
});
|
|
392
|
+
} catch (_e) {
|
|
393
|
+
return false; // no origin → no remote ref to check
|
|
394
|
+
}
|
|
395
|
+
// Best-effort: refresh the remote ref before checking. Fetching a
|
|
396
|
+
// specific branch ref is much cheaper than `git fetch origin` (no
|
|
397
|
+
// tag/all-branch traffic) and is silent on a non-existent ref.
|
|
398
|
+
try {
|
|
399
|
+
execFileSync(
|
|
400
|
+
'git',
|
|
401
|
+
['-C', projectRoot, 'fetch', 'origin', branch, '--quiet', '--no-tags'],
|
|
402
|
+
{ stdio: 'ignore', timeout: 5_000 },
|
|
403
|
+
);
|
|
404
|
+
} catch (_e) {
|
|
405
|
+
/* network / branch absent — fall through to local check */
|
|
406
|
+
}
|
|
407
|
+
// Remote ref now (possibly) up to date.
|
|
408
|
+
try {
|
|
409
|
+
execFileSync(
|
|
410
|
+
'git',
|
|
411
|
+
['-C', projectRoot, 'show-ref', '--verify', '--quiet', `refs/remotes/origin/${branch}`],
|
|
412
|
+
{ stdio: 'ignore', timeout: 5_000 },
|
|
413
|
+
);
|
|
414
|
+
return true;
|
|
415
|
+
} catch (_e) {
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
231
420
|
// ------------------------------------------------------------ side effects
|
|
232
421
|
|
|
233
422
|
function applySideEffects(sideEffects, runtime, profile, projectRoot) {
|
|
@@ -272,10 +461,12 @@ function applySideEffects(sideEffects, runtime, profile, projectRoot) {
|
|
|
272
461
|
},
|
|
273
462
|
{ projectRoot },
|
|
274
463
|
);
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
464
|
+
// adapt.handleUserInput now applies these commands itself (so
|
|
465
|
+
// pause halts on the same turn, accept_alternative dispatches the
|
|
466
|
+
// stored alternative, etc.). This branch is kept purely for the
|
|
467
|
+
// ledger entry — re-applying here would double-mutate state.
|
|
468
|
+
// BMad-owned mutations (e.g. skip_story → sprint-status) still
|
|
469
|
+
// live elsewhere; this CLI never touches sprint-status directly.
|
|
279
470
|
break;
|
|
280
471
|
}
|
|
281
472
|
case 'profile_escalated':
|
|
@@ -302,6 +493,44 @@ function applySideEffects(sideEffects, runtime, profile, projectRoot) {
|
|
|
302
493
|
|
|
303
494
|
// ------------------------------------------------------------ subcommands
|
|
304
495
|
|
|
496
|
+
// Detect + lock the user's working branch under `reuse_user_branch:
|
|
497
|
+
// true`. Returns null if the runtime is already locked, or `{ halt }`
|
|
498
|
+
// with a halt/user_prompt action when the environment is invalid. Side-
|
|
499
|
+
// effect: mutates `runtime.user_branch` on success and appends a ledger
|
|
500
|
+
// entry. Used by both cmdStart and cmdNext so the LLM-direct path
|
|
501
|
+
// (workflow.orchestrator.md tells LLMs to call `next` without `start`)
|
|
502
|
+
// gets the same enforcement.
|
|
503
|
+
function lockUserBranchIfNeeded(runtime, profile, projectRoot) {
|
|
504
|
+
if (!profile.reuse_user_branch || runtime.user_branch) return null;
|
|
505
|
+
const current = detectCurrentBranch(projectRoot);
|
|
506
|
+
const base = profile.base_branch || 'main';
|
|
507
|
+
if (!current) {
|
|
508
|
+
return {
|
|
509
|
+
halt: {
|
|
510
|
+
type: 'halt',
|
|
511
|
+
reason: 'reuse_user_branch_no_git',
|
|
512
|
+
prompt:
|
|
513
|
+
'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.',
|
|
514
|
+
},
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
if (current === base) {
|
|
518
|
+
return {
|
|
519
|
+
halt: {
|
|
520
|
+
type: 'user_prompt',
|
|
521
|
+
reason: 'reuse_user_branch_on_base',
|
|
522
|
+
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.`,
|
|
523
|
+
},
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
runtime.user_branch = current;
|
|
527
|
+
ledger.append(
|
|
528
|
+
{ kind: 'state_transition', detail: { user_branch_detected: current } },
|
|
529
|
+
{ projectRoot },
|
|
530
|
+
);
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
|
|
305
534
|
function cmdStart(opts) {
|
|
306
535
|
const projectRoot = resolveProjectRoot(opts);
|
|
307
536
|
const { typed: profile } = resolveProfile(projectRoot, opts.profile);
|
|
@@ -327,41 +556,19 @@ function cmdStart(opts) {
|
|
|
327
556
|
// profile-aware initial phase when persisted state is empty.
|
|
328
557
|
const runtime = composeRuntimeState(persisted, profile);
|
|
329
558
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
// commit every story onto this branch.
|
|
333
|
-
if (profile.reuse_user_branch && !runtime.user_branch) {
|
|
334
|
-
const current = detectCurrentBranch(projectRoot);
|
|
335
|
-
const base = profile.base_branch || 'main';
|
|
336
|
-
if (!current) {
|
|
337
|
-
const halt = {
|
|
338
|
-
type: 'halt',
|
|
339
|
-
reason: 'reuse_user_branch_no_git',
|
|
340
|
-
prompt:
|
|
341
|
-
'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.',
|
|
342
|
-
};
|
|
343
|
-
ledger.append({ kind: 'action_emitted', phase: runtime.phase, action: halt }, { projectRoot });
|
|
344
|
-
process.stdout.write(`${JSON.stringify({ action: halt, phase: runtime.phase }, null, 2)}\n`);
|
|
345
|
-
return 0;
|
|
346
|
-
}
|
|
347
|
-
if (current === base) {
|
|
348
|
-
const halt = {
|
|
349
|
-
type: 'user_prompt',
|
|
350
|
-
reason: 'reuse_user_branch_on_base',
|
|
351
|
-
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.`,
|
|
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
|
-
runtime.user_branch = current;
|
|
559
|
+
const lockResult = lockUserBranchIfNeeded(runtime, profile, projectRoot);
|
|
560
|
+
if (lockResult && lockResult.halt) {
|
|
358
561
|
ledger.append(
|
|
359
|
-
{ kind: '
|
|
562
|
+
{ kind: 'action_emitted', phase: runtime.phase, action: lockResult.halt },
|
|
360
563
|
{ projectRoot },
|
|
361
564
|
);
|
|
565
|
+
process.stdout.write(
|
|
566
|
+
`${JSON.stringify({ action: lockResult.halt, phase: runtime.phase }, null, 2)}\n`,
|
|
567
|
+
);
|
|
568
|
+
return 0;
|
|
362
569
|
}
|
|
363
570
|
|
|
364
|
-
const action = decorateGitOp(stateMachine.nextAction(runtime, profile), runtime, profile);
|
|
571
|
+
const action = decorateGitOp(stateMachine.nextAction(runtime, profile), runtime, profile, projectRoot);
|
|
365
572
|
ledger.append({ kind: 'action_emitted', phase: runtime.phase, action }, { projectRoot });
|
|
366
573
|
persistRuntimeState(runtime, profile, projectRoot);
|
|
367
574
|
if (profile.coalesce_state_writes) stateStore.flush(profile, { projectRoot, story: runtime.story_key });
|
|
@@ -374,8 +581,28 @@ function cmdNext(opts) {
|
|
|
374
581
|
const { typed: profile } = resolveProfile(projectRoot, opts.profile);
|
|
375
582
|
const persisted = loadState(projectRoot);
|
|
376
583
|
const runtime = composeRuntimeState(persisted, profile);
|
|
377
|
-
|
|
584
|
+
|
|
585
|
+
// The LLM-driven workflow (workflow.orchestrator.md) tells the LLM to
|
|
586
|
+
// call `next` directly without `start` — apply the same branch-reuse
|
|
587
|
+
// enforcement here so a missed `start` doesn't bypass it.
|
|
588
|
+
const lockResult = lockUserBranchIfNeeded(runtime, profile, projectRoot);
|
|
589
|
+
if (lockResult && lockResult.halt) {
|
|
590
|
+
ledger.append(
|
|
591
|
+
{ kind: 'action_emitted', phase: runtime.phase, action: lockResult.halt },
|
|
592
|
+
{ projectRoot },
|
|
593
|
+
);
|
|
594
|
+
process.stdout.write(
|
|
595
|
+
`${JSON.stringify({ action: lockResult.halt, phase: runtime.phase }, null, 2)}\n`,
|
|
596
|
+
);
|
|
597
|
+
return 0;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const action = decorateGitOp(stateMachine.nextAction(runtime, profile), runtime, profile, projectRoot);
|
|
378
601
|
ledger.append({ kind: 'action_emitted', phase: runtime.phase, action }, { projectRoot });
|
|
602
|
+
// Persist any mutations done by lockUserBranchIfNeeded — without this
|
|
603
|
+
// every cmdNext under reuse_user_branch=true re-detects the branch and
|
|
604
|
+
// emits a redundant `state_transition` ledger entry forever.
|
|
605
|
+
persistRuntimeState(runtime, profile, projectRoot);
|
|
379
606
|
// Skill timing: emit a `skill.<name>` start event when we hand off an
|
|
380
607
|
// invoke_skill action. The matching end event is emitted on `record`
|
|
381
608
|
// when the signal advances the phase. This makes parallelism +
|
|
@@ -413,9 +640,17 @@ function cmdRecord(opts) {
|
|
|
413
640
|
{ projectRoot },
|
|
414
641
|
);
|
|
415
642
|
|
|
416
|
-
// Verify only on `success` and `verify_override`.
|
|
643
|
+
// Verify only on `success` and `verify_override`. Under `git.enabled:
|
|
644
|
+
// false`, git-op phases skip verify entirely — there's no commit_sha/
|
|
645
|
+
// branch to assert and verify would reject every success in a loop.
|
|
646
|
+
// The state machine still routes through these phases so the BMad
|
|
647
|
+
// cycle stays intact; only the bookkeeping check is bypassed. The
|
|
648
|
+
// phase list is centralized in state-machine.js#isGitOpPhase so a
|
|
649
|
+
// future git-op phase automatically gets the bypass.
|
|
650
|
+
const isGitDisabledPhase =
|
|
651
|
+
profile.enabled === false && stateMachine.shouldSkipVerifyWhenGitDisabled(runtime.phase);
|
|
417
652
|
let verifyResult;
|
|
418
|
-
if (signal.status === 'success') {
|
|
653
|
+
if (signal.status === 'success' && !isGitDisabledPhase) {
|
|
419
654
|
verifyResult = verifyMod.verify(runtime, signal.output, { projectRoot });
|
|
420
655
|
ledger.append(
|
|
421
656
|
{ kind: 'verify_result', phase: runtime.phase, ok: verifyResult.ok, issues: verifyResult.issues || [] },
|
|
@@ -489,7 +724,7 @@ function cmdRecord(opts) {
|
|
|
489
724
|
}
|
|
490
725
|
|
|
491
726
|
const payload = {
|
|
492
|
-
action: decorateGitOp(result.nextAction, result.newState, result.newProfile),
|
|
727
|
+
action: decorateGitOp(result.nextAction, result.newState, result.newProfile, projectRoot),
|
|
493
728
|
verdict: result.verdict,
|
|
494
729
|
phase: result.newState.phase,
|
|
495
730
|
profile: result.newProfile.name,
|
|
@@ -578,4 +813,4 @@ if (require.main === module) {
|
|
|
578
813
|
process.exit(main(process.argv.slice(2)));
|
|
579
814
|
}
|
|
580
815
|
|
|
581
|
-
module.exports = { main, SUBCOMMANDS };
|
|
816
|
+
module.exports = { main, SUBCOMMANDS, decorateGitOp, composeRuntimeState };
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
const { STATES, nextAction, nextStateAfterSuccess, nextStoryStart } = require('./state-machine');
|
|
14
14
|
const { classifyImpact } = require('./impact-classifier');
|
|
15
15
|
const { escalateOnFailure } = require('./profile-rules');
|
|
16
|
+
const userCommandApplier = require('./user-command-applier');
|
|
16
17
|
|
|
17
18
|
// Threshold for `consecutive_test_failures` — workflow.md:81 says 3.
|
|
18
19
|
const CONSECUTIVE_TEST_FAILURE_THRESHOLD = 3;
|
|
@@ -328,8 +329,20 @@ function handleProposeAlternative(state, signal, profile, sideEffects) {
|
|
|
328
329
|
};
|
|
329
330
|
}
|
|
330
331
|
|
|
332
|
+
// Store the proposed alternative on state so a later `accept_alternative`
|
|
333
|
+
// user command can dispatch it. Without this, the alternative would
|
|
334
|
+
// evaporate the moment the prompt is emitted.
|
|
335
|
+
const newState = {
|
|
336
|
+
...state,
|
|
337
|
+
pending_alternative: {
|
|
338
|
+
action: alternative,
|
|
339
|
+
impact,
|
|
340
|
+
reason: signal.reason || null,
|
|
341
|
+
prompted_at: new Date().toISOString(),
|
|
342
|
+
},
|
|
343
|
+
};
|
|
331
344
|
return {
|
|
332
|
-
newState
|
|
345
|
+
newState,
|
|
333
346
|
newProfile: profile,
|
|
334
347
|
nextAction: {
|
|
335
348
|
type: 'user_prompt',
|
|
@@ -346,19 +359,59 @@ function handleProposeAlternative(state, signal, profile, sideEffects) {
|
|
|
346
359
|
}
|
|
347
360
|
|
|
348
361
|
function handleUserInput(state, signal, profile, sideEffects) {
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
362
|
+
// Apply the user's commands directly so the resulting state changes
|
|
363
|
+
// (halt_requested, cleared pending_alternative, dispatch_action effect)
|
|
364
|
+
// take effect on this same turn. Prior versions only emitted an
|
|
365
|
+
// apply_user_commands side-effect and the CLI never re-dispatched —
|
|
366
|
+
// pause never halted, accept_alternative had nowhere to land.
|
|
367
|
+
const commands = signal.commands || [];
|
|
368
|
+
const applied = userCommandApplier.apply(state, profile, commands);
|
|
369
|
+
|
|
370
|
+
// Mirror the legacy apply_user_commands side-effect so the ledger trail
|
|
371
|
+
// stays human-readable (kind: user_commands_applied).
|
|
353
372
|
sideEffects.push({
|
|
354
373
|
kind: 'apply_user_commands',
|
|
355
|
-
commands
|
|
374
|
+
commands,
|
|
356
375
|
phase: state.phase,
|
|
357
376
|
});
|
|
377
|
+
for (const e of applied.sideEffects) sideEffects.push(e);
|
|
378
|
+
|
|
379
|
+
const newState = applied.newState;
|
|
380
|
+
const newProfile = applied.newProfile;
|
|
381
|
+
|
|
382
|
+
// Halt requested? Emit a halt action and let cmdRecord write the
|
|
383
|
+
// resume fingerprint.
|
|
384
|
+
if (newState.halt_requested) {
|
|
385
|
+
return {
|
|
386
|
+
newState,
|
|
387
|
+
newProfile,
|
|
388
|
+
nextAction: {
|
|
389
|
+
type: 'halt',
|
|
390
|
+
phase: newState.phase,
|
|
391
|
+
reason: newState.halt_requested.reason || 'user_pause',
|
|
392
|
+
},
|
|
393
|
+
sideEffects,
|
|
394
|
+
verdict: 'halt',
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// One-shot dispatch (e.g. accept_alternative resolved a pending alt)?
|
|
399
|
+
// Return the dispatched action in place of the state-machine's default.
|
|
400
|
+
const dispatch = applied.sideEffects.find((e) => e && e.kind === 'dispatch_action');
|
|
401
|
+
if (dispatch && dispatch.action) {
|
|
402
|
+
return {
|
|
403
|
+
newState,
|
|
404
|
+
newProfile,
|
|
405
|
+
nextAction: { ...dispatch.action, _dispatched_via: dispatch.reason || 'user_input' },
|
|
406
|
+
sideEffects,
|
|
407
|
+
verdict: 'advanced',
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
358
411
|
return {
|
|
359
|
-
newState
|
|
360
|
-
newProfile
|
|
361
|
-
nextAction: nextAction(
|
|
412
|
+
newState,
|
|
413
|
+
newProfile,
|
|
414
|
+
nextAction: nextAction(newState, newProfile),
|
|
362
415
|
sideEffects,
|
|
363
416
|
verdict: 'advanced',
|
|
364
417
|
};
|