Allow configuring the Build spec editor handoff (#2652)

* fix: respect preferred app for Build review specs

* fix: restore Build VS Code handoff default

* fix: allow disabling Build spec opener

* docs: document Build editor opener options

* fix: use established Build path placeholders

* fix: address Build opener review findings

* docs: tighten Build opener explanation

* docs: simplify Build review handoff wording
This commit is contained in:
Alex Verkhovsky
2026-07-29 23:04:22 -07:00
committed by GitHub
parent 022bcbc66d
commit 9b672e1e6b
7 changed files with 145 additions and 22 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ sidebar:
## The Typical Flow
You run `bmad-build`. It clarifies your intent, builds a spec, implements the change, and when it's done it appends a review trail to the spec file and opens it in your editor. You look at the spec and see the change touched 20 files across several modules.
You run `bmad-build`. It clarifies your intent, builds a spec, implements the change, and when it's done it appends a review trail to the spec file and shows it to you. You look at the spec and see the change touched 20 files across several modules.
You could eyeball the diff. But 20 files is where eyeballing starts to fail — you lose the thread, miss a connection between two distant changes, or approve something you didn't fully understand. So instead, you say "checkpoint" and the LLM walks you through it.
+1 -1
View File
@@ -62,7 +62,7 @@ Build may ask clarifying questions or present a short spec for your approval bef
### 4. Review and Push
Build implements the change, reviews its own work, patches issues, and commits locally. When it's done, it opens the affected files in your editor.
Build implements the change, reviews its own work, patches issues, and commits locally. When it's done, it shows you the review spec.
- Skim the diff to confirm the change matches your intent
- If something looks off, tell the agent what to fix — it can iterate in the same session
@@ -33,6 +33,44 @@ persistent_facts = [
on_complete = ""
# How Build opens a completed spec for human review. The default deliberately
# opens the repository root first and the spec second in a reused VS Code
# window so relative review links resolve in the correct workspace. Override
# this with the application and equivalent behavior you prefer. Set it to an
# empty string to disable automatic opening and all related completion output.
# Example requests for bmad-customize: "Open completed specs in Cursor and
# reuse the project window" or "Do not open completed specs automatically."
#
# Example `open_spec` override instructions for bmad-build.user.toml:
# {project-root} and {spec_file} are substituted at run time.
#
# VS Code and compatible GUI editors — shipped default uses `code`; Cursor,
# Windsurf, Antigravity, Zed, and similar editors can substitute their launcher.
# Preserve reuse-window behavior when the launcher supports it, and always pass
# the repository root first and the spec second:
# Run `code -r "{project-root}" "{spec_file}"`, root first.
# Run `cursor -r "{project-root}" "{spec_file}"`, root first.
# Run `windsurf -r "{project-root}" "{spec_file}"`, root first.
# Run `zed "{project-root}" "{spec_file}"`, root first.
#
# IntelliJ IDEA — its CLI accepts one path per invocation:
# Run `idea "{project-root}"` to open the project, then
# `idea "{spec_file}"` to open the spec in that project.
#
# Vim — keep repository context as the working directory:
# Run `cd "{project-root}" && vim "{spec_file}"`.
#
# Emacs — reuse an Emacs server without waiting for the buffer to close:
# Run `cd "{project-root}" && emacsclient -n "{spec_file}"`.
#
# For every non-empty override, also tell Build to note in the completion summary
# that the spec was sent to the chosen editor and contains a Suggested Review
# Order, then include the default navigation tip shown below.
open_spec = """
Run `code -r "{project-root}" "{spec_file}"` — the repository root first so VS Code opens in the right context, then the spec file. Always double-quote both paths to handle spaces and special characters. If `code` is unavailable or the command fails, skip gracefully and tell the user the spec file path instead. In the completion summary, note that the spec was sent to VS Code and that it contains a Suggested Review Order, then add this navigation tip: "Ctrl+click (Cmd+click on macOS) the links in the Suggested Review Order to jump to each stop."
"""
# Handoff for the implementation subagent in step 03. The whole execution
# recipe — a subagent by default, but an override may run it any other way
# (a different model, an external coding tool via bash). {spec_file} is
@@ -391,6 +391,13 @@ def main():
skill_dir = script_dir.replace(os.sep, "/")
workflow = resolve_workflow(root, skill_dir, skill_name)
open_spec = workflow.get("open_spec")
if not isinstance(open_spec, str):
print(
"HALT and report to the user: customization `workflow.open_spec` "
"must be a string"
)
sys.exit(1)
workflow = expand_review_layer_skill_roots(workflow, skill_dir)
out_dir = posixpath.join(root, "_bmad", "render", skill_name)
@@ -54,20 +54,17 @@ Change `{spec_file}` status to `done` in the frontmatter.
Follow `./sync-sprint-status.md` with `target_status` = `review`.
### Commit and Open
### Commit and Complete
1. If version control is available and the tree is dirty, create a local commit with a conventional message derived from the spec title.
2. Open the spec in the user's editor so they can click through the Suggested Review Order:
- Resolve two absolute paths: (1) the repository root (`git rev-parse --show-toplevel` — returns the worktree root when in a worktree, project root otherwise; if this fails, fall back to the current working directory), (2) `{spec_file}`. Run `code -r "{absolute-root}" "{absolute-spec-file}"` — the root first so VS Code opens in the right context, then the spec file. Always double-quote paths to handle spaces and special characters.
- If `code` is not available (command fails), skip gracefully and tell the user the spec file path instead.
If version control is available and the tree is dirty, create a local commit with a conventional message derived from the spec title.
{workflow.open_spec}
### Display Summary
Display summary of your work to the user, including the commit hash if one was created. Any file paths shown in conversation/terminal output must use CWD-relative format (no leading `/`) with `:line` notation (e.g., `src/path/file.ts:42`) for terminal clickability — the goal is to make paths clickable in terminal emulators. Include:
Display summary of your work to the user, including the commit hash if one was created. Any file paths shown in conversation/terminal output must use CWD-relative format (no leading `/`) with `:line` notation (e.g., `src/path/file.ts:42`) for terminal clickability — the goal is to make paths clickable in terminal emulators.
- A note that the spec is open in their editor (or the file path if it couldn't be opened). Mention that `{spec_file}` now contains a Suggested Review Order.
- **Navigation tip:** "Ctrl+click (Cmd+click on macOS) the links in the Suggested Review Order to jump to each stop."
- Offer to push and/or create a pull request.
Offer to push and/or create a pull request.
Workflow complete.
@@ -56,16 +56,15 @@ If version control is available and the tree is dirty, create a local commit wit
### Present
1. Open the spec in the user's editor so they can click through the Suggested Review Order:
- Resolve two absolute paths: (1) the repository root (`git rev-parse --show-toplevel` — returns the worktree root when in a worktree, project root otherwise; if this fails, fall back to the current working directory), (2) `{spec_file}`. Run `code -r "{absolute-root}" "{absolute-spec-file}"` — the root first so VS Code opens in the right context, then the spec file. Always double-quote paths to handle spaces and special characters.
- If `code` is not available (command fails), skip gracefully and tell the user the spec file path instead.
2. Display a summary in conversation output, including:
- The commit hash (if one was created).
- List of files changed with one-line descriptions. Any file paths shown in conversation/terminal output must use CWD-relative format (no leading `/`) with `:line` notation (e.g., `src/path/file.ts:42`) for terminal clickability — this differs from spec-file links which use spec-file-relative paths.
- Review findings breakdown: patches applied, items deferred, items rejected. If all findings were rejected, say so.
- A note that the spec is open in their editor (or the file path if it couldn't be opened). Mention that `{spec_file}` now contains a Suggested Review Order.
- **Navigation tip:** "Ctrl+click (Cmd+click on macOS) the links in the Suggested Review Order to jump to each stop."
3. Offer to push and/or create a pull request.
{workflow.open_spec}
Display a summary in conversation output, including:
- The commit hash (if one was created).
- List of files changed with one-line descriptions. Any file paths shown in conversation/terminal output must use CWD-relative format (no leading `/`) with `:line` notation (e.g., `src/path/file.ts:42`) for terminal clickability — this differs from spec-file links which use spec-file-relative paths.
- Review findings breakdown: patches applied, items deferred, items rejected. If all findings were rejected, say so.
Offer to push and/or create a pull request.
HALT and wait for human input.
+83 -1
View File
@@ -10,7 +10,7 @@
* 2. sprint_status is an absolute path rooted at the temp project dir.
* 3. [workflow] customization is self-resolved and inlined: prepend bullet,
* persistent_facts append (base kept), empty list -> _None._, on_complete
* scalar baked into step-05/step-oneshot.
* and open_spec scalars baked into step-05/step-oneshot.
* 4. Review layers materialize as direct invocation blocks: default layers
* become #### sections in step-04, an override replacing a layer by id
* wins, an empty-instruction override drops its layer, a `when` renders
@@ -140,6 +140,10 @@ try {
'activation_steps_prepend = ["TEST_PREPEND_STEP"]',
'persistent_facts = ["TEST_EXTRA_FACT"]',
'on_complete = "TEST_ON_COMPLETE_INSTRUCTION"',
'open_spec = """',
'TEST_OPEN_SPEC_LINE_ONE',
'TEST_OPEN_SPEC_LINE_TWO',
'"""',
'',
'[[workflow.review_layers]]',
'id = "edge-case-hunter"',
@@ -234,6 +238,29 @@ try {
}
});
test('open_spec scalar inlined into step-05 and step-oneshot', () => {
for (const file of ['step-05-present.md', 'step-oneshot.md']) {
const content = readRendered(file);
assert(
content.includes('\nTEST_OPEN_SPEC_LINE_ONE\nTEST_OPEN_SPEC_LINE_TWO\n'),
`multiline open_spec not inlined at top-level in ${file}`,
);
}
});
test('step-05 keeps the push offer separate from summary content', () => {
const content = readRendered('step-05-present.md');
assert(!content.includes('Include:\n\n- Offer to push'), 'push offer remains under a dangling Include list');
assert(content.includes('\n\nOffer to push and/or create a pull request.\n'), 'standalone push offer missing');
});
test('open_spec default and examples use only established runtime path placeholders', () => {
const content = fs.readFileSync(path.join(skillDst, 'customize.toml'), 'utf-8');
assert(content.includes('code -r "{project-root}" "{spec_file}"'), 'default supported-placeholder command missing');
assert(!content.includes('{absolute-root}'), 'invented {absolute-root} placeholder remains in customize.toml');
assert(!content.includes('{absolute-spec-file}'), 'invented {absolute-spec-file} placeholder remains in customize.toml');
});
test('review layers materialize as invocation blocks in step-04', () => {
const content = readRendered('step-04-review.md');
const expectedPromptPath = `${skillDst.replaceAll('\\', '/')}/review-prompts/adversarial.md`;
@@ -318,6 +345,12 @@ try {
for (const file of ['step-04-review.md', 'step-oneshot.md']) {
assert(readRendered(file).includes(halt), `HALT instruction missing from ${file}`);
}
for (const file of ['step-05-present.md', 'step-oneshot.md']) {
const content = readRendered(file);
assert(content.includes('code -r "{project-root}" "{spec_file}"'), `default root-first VS Code command missing from ${file}`);
assert(!content.includes('{absolute-root}'), `invented {absolute-root} placeholder survived in ${file}`);
assert(!content.includes('{absolute-spec-file}'), `invented {absolute-spec-file} placeholder survived in ${file}`);
}
});
test('no {workflow.*} placeholder survives in any rendered file', () => {
@@ -395,6 +428,55 @@ try {
assert(!res.stderr.includes('Traceback'), `renderer crashed with a traceback instead of HALTing:\n${res.stderr}`);
});
test('empty open_spec customization renders as an explicit disable', () => {
const { dir, skillDst: dst } = makeProject(
[
'[core]',
'communication_language = "French"',
'document_output_language = "Klingon"',
'planning_artifacts = "{project-root}/plan"',
'implementation_artifacts = "{project-root}/impl"',
].join('\n'),
);
fs.mkdirSync(path.join(dir, '_bmad', 'custom'), { recursive: true });
fs.writeFileSync(path.join(dir, '_bmad', 'custom', 'bmad-build.user.toml'), '[workflow]\nopen_spec = ""\n', 'utf-8');
const res = spawnSync('python3', [path.join(dst, 'render.py')], { cwd: dst, encoding: 'utf-8' });
assert(res.status === 0, `expected exit 0, got ${res.status}\nstdout: ${res.stdout}\nstderr: ${res.stderr}`);
const renderDir = path.join(dir, '_bmad', 'render', 'bmad-build');
for (const file of ['step-05-present.md', 'step-oneshot.md']) {
const content = fs.readFileSync(path.join(renderDir, file), 'utf-8');
assert(!content.includes('code -r'), `default open_spec survived empty override in ${file}`);
assert(!content.includes('Suggested Review Order to jump'), `navigation output survived empty override in ${file}`);
assert(!content.includes('spec was sent'), `opening summary survived empty override in ${file}`);
assert(content.includes('Suggested Review Order'), `spec review trail generation disappeared from ${file}`);
}
});
test('non-string open_spec customization HALTs cleanly', () => {
const { dir, skillDst: dst } = makeProject(
[
'[core]',
'communication_language = "French"',
'document_output_language = "Klingon"',
'planning_artifacts = "{project-root}/plan"',
'implementation_artifacts = "{project-root}/impl"',
].join('\n'),
);
fs.mkdirSync(path.join(dir, '_bmad', 'custom'), { recursive: true });
fs.writeFileSync(
path.join(dir, '_bmad', 'custom', 'bmad-build.user.toml'),
'[workflow]\nopen_spec = ["not", "an", "instruction"]\n',
'utf-8',
);
const res = spawnSync('python3', [path.join(dst, 'render.py')], { cwd: dst, encoding: 'utf-8' });
assert(res.status === 1, `expected exit 1, got ${res.status}\nstdout: ${res.stdout}\nstderr: ${res.stderr}`);
assert(
res.stdout.includes('customization `workflow.open_spec` must be a string'),
`stdout missing open_spec type error.\nstdout: ${res.stdout}`,
);
assert(!res.stderr.includes('Traceback'), `renderer crashed with a traceback instead of HALTing:\n${res.stderr}`);
});
test('non-table [modules] does not crash the renderer', () => {
const { dir, skillDst: dst } = makeProject(
[