@skyf0xx/hedgehog 6.4.0 → 6.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. package/bin/cli.mjs +31 -13
  2. package/package.json +1 -1
  3. package/src/hosts/gemini/gemini-extension.json +1 -1
  4. package/vendor-skills/BMAD/ATTRIBUTION.md +46 -23
  5. package/vendor-skills/BMAD/bmm-skills/plan/bmad-prd/SKILL.md +2 -2
  6. package/vendor-skills/BMAD/bmm-skills/plan/bmad-prd/assets/prd-template.md +1 -1
  7. package/vendor-skills/BMAD/bmm-skills/plan/bmad-prd/references/validate.md +3 -3
  8. package/vendor-skills/BMAD/bmm-skills/plan/bmad-prfaq/SKILL.md +2 -2
  9. package/vendor-skills/BMAD/bmm-skills/plan/bmad-prfaq/bmad-manifest.json +2 -2
  10. package/vendor-skills/BMAD/bmm-skills/plan/bmad-prfaq/references/verdict.md +1 -1
  11. package/vendor-skills/BMAD/bmm-skills/plan/bmad-product-brief/SKILL.md +2 -2
  12. package/vendor-skills/BMAD/bmm-skills/plan/bmad-ux/SKILL.md +2 -2
  13. package/vendor-skills/BMAD/bmm-skills/plan/bmad-ux/assets/color-themes.md +1 -1
  14. package/vendor-skills/BMAD/bmm-skills/plan/bmad-ux/references/creative-tools.md +1 -1
  15. package/vendor-skills/BMAD/bmm-skills/plan/bmad-ux/references/validate.md +1 -1
  16. package/vendor-skills/BMAD/core-skills/bmad-advanced-elicitation/SKILL.md +23 -24
  17. package/vendor-skills/BMAD/core-skills/bmad-brainstorming/SKILL.md +2 -2
  18. package/vendor-skills/BMAD/core-skills/bmad-brainstorming/scripts/brain.py +19 -0
  19. package/vendor-skills/BMAD/core-skills/bmad-brainstorming/scripts/tests/test_brain.py +50 -0
  20. package/vendor-skills/BMAD/core-skills/bmad-deep-recon/SKILL.md +2 -2
  21. package/vendor-skills/BMAD/core-skills/bmad-forge-idea/SKILL.md +3 -3
  22. package/vendor-skills/BMAD/core-skills/bmad-forge-idea/scripts/resolve_personas.py +3 -1
  23. package/vendor-skills/BMAD/core-skills/bmad-forge-idea/scripts/tests/test_resolve_personas.py +22 -0
  24. package/vendor-skills/BMAD/scripts/resolve_customization.py +102 -178
package/bin/cli.mjs CHANGED
@@ -105,7 +105,12 @@ import { NOOP_DIR } from '../src/db/noop.mjs';
105
105
  import { runFastpath, loadFastpaths, orphanedFastpathTasks, FASTPATH_DIR } from '../src/db/fastpath.mjs';
106
106
  import { HOSTS, HOST_FLAGS, DEFAULT_HOST, availableHosts } from '../src/hosts/index.mjs';
107
107
  import { recordHosts, installedHosts } from '../src/hosts/installed.mjs';
108
- import { wrapSection, stripPhaseBlocks } from '../src/hosts/claude-md-merge.mjs';
108
+ import {
109
+ wrapSection,
110
+ stripPhaseBlocks,
111
+ hasCoreSection,
112
+ appendCoreSection,
113
+ } from '../src/hosts/claude-md-merge.mjs';
109
114
  import {
110
115
  recordVersion,
111
116
  checkForUpdate,
@@ -491,9 +496,9 @@ const PHASE_NAME = 'bootstrap-only';
491
496
  // astro.config.mjs, which the engine has no business knowing about. Both
492
497
  // must hold: a build graph exists, and at least one task has been
493
498
  // compiled into it. The bootstrap file's own {{PROJECT_SUMMARY}}
494
- // placeholder is the third condition (see shedCommand and
495
- // writePlannedFile's self-heal) but is checked per-file, since a
496
- // multi-host project can have several bootstrap files to test.
499
+ // placeholder is the third condition (see summaryUnfilled) but is
500
+ // checked per-file, since a multi-host project can have several
501
+ // bootstrap files to test.
497
502
  async function graphPastBootstrap() {
498
503
  if (!(await exists(DB_PATH))) return false;
499
504
  const db = openDb();
@@ -504,9 +509,18 @@ async function graphPastBootstrap() {
504
509
  }
505
510
  }
506
511
 
512
+ // The shell's placeholder carries its own instructions inside the braces
513
+ // (`{{PROJECT_SUMMARY — 2–4 sentences…}}`), so the match is on the prefix.
514
+ // Bootstrap-only blocks are excluded because the shell's first-message
515
+ // block names the placeholder in prose, and that block is exactly what
516
+ // shedding removes.
517
+ function summaryUnfilled(bootstrapContent) {
518
+ return stripPhaseBlocks(bootstrapContent, PHASE_NAME).includes('{{PROJECT_SUMMARY');
519
+ }
520
+
507
521
  async function canShed(bootstrapContent) {
508
522
  if (!(await graphPastBootstrap())) return false;
509
- return !bootstrapContent.includes('{{PROJECT_SUMMARY}}');
523
+ return !summaryUnfilled(bootstrapContent);
510
524
  }
511
525
 
512
526
  // `hedgehog shed` — strips bootstrap-only content from every host
@@ -550,7 +564,7 @@ async function shedCommand() {
550
564
  const unfilled = [];
551
565
  for (const { rel, abs } of present) {
552
566
  const content = await readFile(abs, 'utf8');
553
- if (content.includes('{{PROJECT_SUMMARY}}')) unfilled.push(rel);
567
+ if (summaryUnfilled(content)) unfilled.push(rel);
554
568
  }
555
569
  if (unfilled.length > 0) {
556
570
  console.error(
@@ -587,13 +601,15 @@ async function writePlannedFile(f) {
587
601
  // `planner` already filled in: {{PROJECT_NAME}}/{{PROJECT_SUMMARY}}
588
602
  // are filled once, by hand or by `planner`, and never touched again.
589
603
  // Starting from the existing file instead of the pristine shell
590
- // whenever it's still missing only {{CORE_SECTION}} preserves that —
591
- // the shell is the base only for a destination that doesn't exist
592
- // yet, or one still holding the raw, unfilled template.
604
+ // whenever those are filled preserves that — whether the core section
605
+ // is still the {{CORE_SECTION}} placeholder or an already-filled,
606
+ // marked section that the new core's content replaces in place. The
607
+ // shell is the base only for a destination that doesn't exist yet, or
608
+ // one still holding the raw, unfilled template.
593
609
  let out = null;
594
610
  if (await exists(f.dest)) {
595
611
  const existing = await readFile(f.dest, 'utf8');
596
- if (!existing.includes('{{PROJECT_NAME}}') && existing.includes('{{CORE_SECTION}}')) {
612
+ if (!existing.includes('{{PROJECT_NAME}}') && hasCoreSection(existing)) {
597
613
  out = existing;
598
614
  } else if (!existing.includes('{{PROJECT_NAME}}') && !f.merge.include) {
599
615
  // Brownfield: hand-written content that predates Hedgehog, with
@@ -623,13 +639,15 @@ async function writePlannedFile(f) {
623
639
  // both paths' output mutually recognizable is what lets
624
640
  // hasCoreSection/appendCoreSection treat a template-filled file
625
641
  // and an appended one the same way on a later re-run.
626
- out = out.replaceAll('{{CORE_SECTION}}', wrapSection(section));
642
+ out = out.includes('{{CORE_SECTION}}')
643
+ ? out.replaceAll('{{CORE_SECTION}}', wrapSection(section))
644
+ : appendCoreSection(out, section);
627
645
  }
628
646
  const dispatch = await readFile(join(PKG_ROOT, f.merge.dispatch), 'utf8');
629
647
  out = out.replaceAll('{{HOST_DISPATCH}}', dispatch.trimEnd());
630
648
  // Self-heal: a project past bootstrap that re-runs `init --force`
631
- // must not silently regain bootstrap-only content just because this
632
- // write started from the pristine shell again.
649
+ // leaves this write with no bootstrap-only content, whether or not
650
+ // `hedgehog shed` ever ran.
633
651
  if (await canShed(out)) out = stripPhaseBlocks(out, PHASE_NAME);
634
652
  await writeFile(f.dest, out);
635
653
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyf0xx/hedgehog",
3
- "version": "6.4.0",
3
+ "version": "6.4.2",
4
4
  "description": "Install the Hedgehog build discipline (agents + skills) into a repo, for Claude Code, Cursor, or Gemini CLI.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hedgehog",
3
- "version": "6.4.0",
3
+ "version": "6.4.2",
4
4
  "description": "Hedgehog build discipline: ordered, tested, verified build steps.",
5
5
  "contextFileName": "GEMINI.md"
6
6
  }
@@ -5,8 +5,8 @@ This directory vendors a subset of [BMAD-METHOD](https://github.com/bmad-code-or
5
5
  for the full license text.
6
6
 
7
7
  - **Source repo:** https://github.com/bmad-code-org/BMAD-METHOD
8
- - **Vendored from:** tag `v6.11.0`, commit `890fcda760bade4d6080f5fa09aa8f658bc4a4a5`
9
- - **Vendored on:** 2026-08-11
8
+ - **Vendored from:** tag `v6.12.1`, commit `790dae9c8e2a1d73575cb2d40b14dd4963391f29`
9
+ - **Vendored on:** 2026-10-10
10
10
  - **Module:** `bmm` (BMAD Method Module)
11
11
 
12
12
  ## What's vendored
@@ -26,37 +26,48 @@ they depend on:
26
26
  every vendored skill calls, originally at `src/scripts/` in the source
27
27
  repo)
28
28
 
29
- Upstream collapsed the four `bmm-skills` above out of numbered
30
- `1-analysis/` and `2-plan-workflows/` directories into a single flat
31
- `plan/` directory as of this vendor pass; their content is otherwise
32
- unchanged. `bmad-deep-recon` is now included in a tagged release
33
- (previously only on `main`), so this pass pins to the `v6.11.0` tag
34
- instead of a `main` commit SHA.
35
-
36
- `bmad-forge-idea` is new upstream as of this pass: a persona-driven
37
- interrogation skill that pressure-tests an idea before any artifact gets
38
- written, added ahead of `bmad-brainstorming` as the shelf's first step.
39
- It carries its own script, `scripts/resolve_personas.py`, vendored
40
- alongside it (not shared — only this skill calls it). Its upstream
41
- counterpart `bmad-party-mode` (the multi-agent roster it can optionally
42
- draw on) is not vendored, since a real roster needs BMAD's own
43
- `bmm-skills/agents/bmad-agent-*` persona skills too — a parallel system
44
- to Hedgehog's own `src/agents/`, out of scope here. `bmad-forge-idea`
45
- degrades gracefully without it: `resolve_personas.py` returns an empty
46
- roster and the skill falls back to generating personas on the fly, which
47
- is its documented normal path, not a degraded one.
29
+ Upstream keeps the four `bmm-skills` above under a single flat
30
+ `bmm-skills/plan/` directory, collapsed out of the numbered
31
+ `1-analysis/` and `2-plan-workflows/` directories an earlier pass found
32
+ them in. `bmad-deep-recon` is in the pinned tag, so this pass pins to a
33
+ release tag rather than a `main` commit SHA.
34
+
35
+ `bmad-forge-idea` is a persona-driven interrogation skill that
36
+ pressure-tests an idea before any artifact gets written, run ahead of
37
+ `bmad-brainstorming` as the shelf's first step. It carries its own
38
+ script, `scripts/resolve_personas.py`, vendored alongside it (not shared
39
+ — only this skill calls it). Its upstream counterpart `bmad-party-mode`
40
+ (the multi-agent roster it can optionally draw on) is not vendored, since
41
+ a real roster needs BMAD's own `bmm-skills/agents/bmad-agent-*` persona
42
+ skills too — a parallel system to Hedgehog's own `src/agents/`, out of
43
+ scope here. `bmad-forge-idea` degrades gracefully without it:
44
+ `resolve_personas.py` returns an empty roster and the skill falls back to
45
+ generating personas on the fly, which is its documented normal path, not
46
+ a degraded one.
48
47
 
49
48
  Each skill directory carries its own templates, reference files, and
50
49
  scripts as vendored, unmodified except where noted below.
51
50
 
51
+ `scripts/resolve_customization.py` resolves the project root by checking,
52
+ in order, the working directory, the script's own install path, and the
53
+ invoking skill's directory — preferring any ancestor holding `_bmad/`
54
+ over one merely holding `.git` at every depth — and warns on stderr when
55
+ a candidate root it didn't pick has an override the chosen root lacks.
56
+ This keeps a skill installed outside the target project (e.g. one that
57
+ would otherwise walk up to `~`) from landing on the wrong root and
58
+ silently missing the project's own override.
59
+
52
60
  ## What's stripped
53
61
 
54
62
  BMAD-METHOD's own orchestration layer is not vendored and is removed from
55
63
  every skill file that referenced it:
56
64
 
57
65
  - Central config resolution (`_bmad/scripts/resolve_config.py`,
58
- `_bmad/config.toml`, `_bmad/bmm/config.yaml`) — replaced with trivial
59
- defaults inline in each skill.
66
+ `_bmad/scripts/config_utils.py`, `_bmad/config.toml`,
67
+ `_bmad/bmm/config.yaml`) — replaced with trivial defaults inline in
68
+ each skill. Upstream's `plan/` skills call `resolve_config.py` for
69
+ this; re-apply the same strip to whatever call shape upstream uses on
70
+ each re-vendor pass.
60
71
  - `bmad-party-mode` (multi-agent roster) mentions and invocations.
61
72
  - Chain-forward "common next skill" suggestions and `bmad-help` routing.
62
73
  - Misroute-detection logic pointing at non-vendored BMAD skills.
@@ -77,6 +88,18 @@ this vendored tree.
77
88
  deliverables are markdown/prose only (skyf0xx/hedgehog#386). A
78
89
  re-vendor pass must re-apply this key to the freshly-fetched files
79
90
  rather than letting it silently disappear.
91
+ - `persistent_facts` in every vendored `customize.toml` that carries it
92
+ (`bmad-forge-idea`, `bmad-brainstorming`, `bmad-product-brief`,
93
+ `bmad-prfaq`, `bmad-prd`, `bmad-ux`) defaults to
94
+ `["file:{project-root}/**/project-context.md"]`, not upstream's empty
95
+ default. Upstream moved repo-wide context to a hand-maintained
96
+ `AGENTS.md` loaded via `bmad-project-context`, a skill not vendored
97
+ here; Hedgehog's planning intake instead relies on project context
98
+ flowing into these skills automatically, so requiring a consuming
99
+ project to discover and set an opt-in override just to keep that
100
+ behavior would be a regression. A re-vendor pass must re-apply this
101
+ non-empty default to the freshly-fetched `customize.toml` files rather
102
+ than letting it silently reset to empty.
80
103
 
81
104
  ## Re-vendoring
82
105
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: bmad-prd
3
- description: Create, update, or validate a PRD. Use when the user wants help producing, editing, or validating a PRD.
3
+ description: Create, update, or validate a PRD. Use when the user wants help producing, editing, or validating a PRD
4
4
  ---
5
5
  # BMad PRD
6
6
 
@@ -17,7 +17,7 @@ You are a master facilitator and coach helping the user create, edit, or validat
17
17
 
18
18
  **Forwarded activation:** if a caller invoked you with a stated intent and pre-resolved customization fields (e.g. the `bmad-create-prd` / `bmad-edit-prd` / `bmad-validate-prd` shims), honor them verbatim — skip your own intent inference, use the supplied values for those named fields, and resolve only the remaining fields from your own `customize.toml`.
19
19
 
20
- 1. Resolve customization: `uv run {bmad-root}/scripts/resolve_customization.py --skill {skill-root} --key workflow`. On failure, read `{skill-root}/customize.toml` directly and use defaults.
20
+ 1. Resolve customization: `uv run {bmad-root}/scripts/resolve_customization.py --skill {skill-root} --project-root {project-root} --key workflow`. On failure, read `{skill-root}/customize.toml` directly and use defaults.
21
21
  2. Run `{workflow.activation_steps_prepend}`. Treat `{workflow.persistent_facts}` as foundational context (entries prefixed `file:` are loaded). `{workflow.external_sources}` is an org-configured registry of internal tools (knowledge bases, MCP tools); consult them alongside generic web research on the same triggers, org tools preferred when their directive matches. Research itself fires during Discovery — see **Research subagents**.
22
22
  3. Resolve `{user_name}` (ask the user or omit), `{communication_language}` (English), `{document_output_language}` (English), `{planning_artifacts}`, `{project_name}` (infer from the Hedgehog project), `{date}` (today's date) using sensible defaults. Missing values → neutral defaults; never block.
23
23
  4. If headless, follow `references/headless.md` for the whole run. Otherwise greet the user **by name** using `{user_name}` and **in their language** using `{communication_language}` — and stay in `{communication_language}` for every turn for the entire run, not just the greeting. In the greeting, let the user know that at any point they can invoke `bmad-advanced-elicitation` for deeper exploration on a specific section.
@@ -161,5 +161,5 @@ updated: {YYYY-MM-DD}
161
161
  - **Environmental and Reliability Requirements**.
162
162
 
163
163
  ### Small-scope all-inclusive *(use when scope is 1-2 stories' worth and the user wants a single captured artifact — chosen during the Right-skill check in Discovery)*
164
- - **Stories** — story-level specs listed inline at the end of the doc. Each story: *"As a [persona], I can [action] [under conditions]. Acceptance: [testable criteria]."* Numbered Story-1, Story-2, ... for reference. Pair with very lean §1 Vision, §2 Target User (often just JTBD + one UJ), §3 Glossary (handful of terms), §4 Features (often a single feature), §6 MVP Scope (in/out very tight). The whole doc fits on a page or two and captures intent + implementable stories in one place. If the user doesn't want the captured artifact at all, `bmad-quick-dev` is the better path — this cluster is only for "I want a doc *and* the stories."
164
+ - **Stories** — story-level specs listed inline at the end of the doc. Each story: *"As a [persona], I can [action] [under conditions]. Acceptance: [testable criteria]."* Numbered Story-1, Story-2, ... for reference. Pair with very lean §1 Vision, §2 Target User (often just JTBD + one UJ), §3 Glossary (handful of terms), §4 Features (often a single feature), §6 MVP Scope (in/out very tight). The whole doc fits on a page or two and captures intent + implementable stories in one place. If the user doesn't want the captured artifact at all, `bmad-build` is the better path — this cluster is only for "I want a doc *and* the stories."
165
165
 
@@ -41,11 +41,11 @@ Once every selected reviewer has returned, the parent synthesizes one consolidat
41
41
  - **Footer.** Rubric path, ISO timestamp.
42
42
  3. Write the filled HTML to `{doc_workspace}/validation-report.html`.
43
43
  4. Write the markdown twin to `{doc_workspace}/validation-report.md` (same content, grouped by severity rather than by dimension — see format below; this is the canonical form for downstream re-reading).
44
- 5. Open the HTML in the default browser:
44
+ 5. Open the HTML in the default browser with the platform opener — `open` on macOS, `xdg-open` on Linux, `start ""` on Windows — double-quoting the path:
45
45
  ```bash
46
- python3 -c "import webbrowser, pathlib; webbrowser.open(pathlib.Path('{doc_workspace}/validation-report.html').resolve().as_uri())"
46
+ open "{doc_workspace}/validation-report.html"
47
47
  ```
48
- Skip the open step in headless mode (see `references/headless.md`).
48
+ If the command fails, don't retry with another opener: tell the user the file path and move on. Skip the open step in headless mode (see `references/headless.md`).
49
49
 
50
50
  ### Markdown twin format
51
51
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: bmad-prfaq
3
- description: Working Backwards PRFAQ challenge that stress-tests a product concept customer-first. Use when the user requests to 'create a PRFAQ', 'work backwards', or 'run the PRFAQ challenge'.
3
+ description: "Test a product concept with Amazon's Working Backwards method: write the press release for the finished product first, then answer hard customer and stakeholder questions, ending in a complete PRFAQ document. Use when the user requests to 'create a PRFAQ', 'work backwards', or 'run the PRFAQ challenge'"
4
4
  ---
5
5
 
6
6
  # Working Backwards: The PRFAQ Challenge
@@ -31,7 +31,7 @@ The PRFAQ forces customer-first clarity: write the press release announcing the
31
31
 
32
32
  ### Step 1: Resolve the Workflow Block
33
33
 
34
- Run: `uv run {bmad-root}/scripts/resolve_customization.py --skill {skill-root} --key workflow`
34
+ Run: `uv run {bmad-root}/scripts/resolve_customization.py --skill {skill-root} --project-root {project-root} --key workflow`
35
35
 
36
36
  **If the script fails**, read `{skill-root}/customize.toml` directly and use its defaults.
37
37
 
@@ -4,9 +4,9 @@
4
4
  {
5
5
  "name": "working-backwards",
6
6
  "menu-code": "WB",
7
- "description": "Produces battle-tested PRFAQ document and optional LLM distillate for PRD input.",
7
+ "description": "Produces a PRFAQ document and an optional condensed version for use as PRD input.",
8
8
  "supports-headless": true,
9
- "phase-name": "1-analysis",
9
+ "phase-name": "plan",
10
10
  "preceded-by": ["brainstorming", "perform-research"],
11
11
  "followed-by": ["create-prd"],
12
12
  "is-required": false,
@@ -78,6 +78,6 @@ purpose: "Token-efficient context for downstream PRD creation"
78
78
 
79
79
  This is the terminal stage. If the user wants to revise, loop back to the relevant stage. Otherwise, the workflow is done.
80
80
 
81
- Run: `uv run {bmad-root}/scripts/resolve_customization.py --skill {skill-root} --key workflow.on_complete`
81
+ Run: `uv run {bmad-root}/scripts/resolve_customization.py --skill {skill-root} --project-root {project-root} --key workflow.on_complete`
82
82
 
83
83
  If the resolved `workflow.on_complete` is non-empty, follow it as the final terminal instruction before exiting.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: bmad-product-brief
3
- description: Create, update, or validate a product brief. Use when the user wants help producing, editing, or validating a brief.
3
+ description: Create, update, or validate a product brief. Use when the user wants help producing, editing, or validating a brief
4
4
  ---
5
5
 
6
6
  # Overview
@@ -19,7 +19,7 @@ At the opening greeting, let the user know they can invoke `bmad-advanced-elicit
19
19
 
20
20
  ## On Activation
21
21
 
22
- 1. Resolve customization: `uv run {bmad-root}/scripts/resolve_customization.py --skill {skill-root} --key workflow`. On failure, read `{skill-root}/customize.toml` directly and use defaults.
22
+ 1. Resolve customization: `uv run {bmad-root}/scripts/resolve_customization.py --skill {skill-root} --project-root {project-root} --key workflow`. On failure, read `{skill-root}/customize.toml` directly and use defaults.
23
23
  2. Execute each entry in `{workflow.activation_steps_prepend}` in order.
24
24
  3. Treat every entry in `{workflow.persistent_facts}` as foundational context for the rest of the run. Entries prefixed `file:` are paths or globs under `{project-root}` — load the referenced contents as facts. All other entries are facts verbatim.
25
25
  4. `{workflow.external_sources}` is an org-configured registry of internal tools (knowledge bases, MCP tools); consult them alongside generic web research on the same triggers in `## Discovery`, org tools preferred when their directive matches. If a named tool is unavailable at runtime, fall back to standard behavior and note the gap when relevant.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: bmad-ux
3
- description: Plan UX patterns and design specifications. Use when the user says "lets create UX design" or "create UX specifications" or "help me plan the UX"
3
+ description: 'Capture the user''s UX vision in two documents: DESIGN.md for how the product looks and EXPERIENCE.md for how it behaves. Use when the user says "lets create UX design" or "create UX specifications" or "help me plan the UX"'
4
4
  ---
5
5
  # BMad UX
6
6
 
@@ -34,7 +34,7 @@ UX may lead, follow, or stand alone. Inherit `sources:` by reference; the spines
34
34
 
35
35
  ## On Activation
36
36
 
37
- 1. Resolve customization: `uv run {bmad-root}/scripts/resolve_customization.py --skill {skill-root} --key workflow`. On failure, read `{skill-root}/customize.toml` directly and use defaults.
37
+ 1. Resolve customization: `uv run {bmad-root}/scripts/resolve_customization.py --skill {skill-root} --project-root {project-root} --key workflow`. On failure, read `{skill-root}/customize.toml` directly and use defaults.
38
38
  2. Run `{workflow.activation_steps_prepend}`. Treat `{workflow.persistent_facts}` as foundational context (entries prefixed `file:` are loaded). `{workflow.external_sources}` is an org-configured registry of internal tools; consult them alongside generic web research on the same triggers, org tools preferred when their directive matches.
39
39
  3. Resolve `{user_name}` (ask the user or omit), `{communication_language}` (English), `{document_output_language}` (English), `{planning_artifacts}`, `{project_name}` (infer from the Hedgehog project), `{date}` (today's date) using sensible defaults. Missing values → neutral defaults; never block.
40
40
  4. If headless, follow `references/headless.md` for the whole run. Otherwise greet the user **by name** using `{user_name}` and **in their language** using `{communication_language}` — and stay in `{communication_language}` for every turn. In the greeting, let the user know `bmad-advanced-elicitation` is always available.
@@ -6,4 +6,4 @@ Each variation: header (name + one-line emotional register), token chips for eve
6
6
 
7
7
  Inline CSS only, system font stack, no JS, no network. Document concrete hex values in `<style>` comments per variation so the user can lift them if they pick that theme. The spine itself stays semantic.
8
8
 
9
- Return to the parent: file path, one-line per variation, mode coverage. Do not dump HTML into the parent context. If interactive, open the file with `python3 -c "import webbrowser, pathlib; webbrowser.open(pathlib.Path('PATH').resolve().as_uri())"`.
9
+ Return to the parent: file path, one-line per variation, mode coverage. Do not dump HTML into the parent context. If interactive, open the file with the platform opener — `open "PATH"` on macOS, `xdg-open` on Linux, `start ""` on Windows, path always double-quoted. On failure, give the user the path instead.
@@ -16,4 +16,4 @@ Every renderer writes to `{doc_workspace}/.working/` with a descriptive filename
16
16
 
17
17
  The parent passes the subagent: current `.memlog.md`, relevant prior `.working/` captures, the user's stated intent for this pass, the output path. The subagent writes its artifact under `.working/` and returns ONLY a compact summary (file path, one line per variant, mode coverage). Parent never holds the full payload.
18
18
 
19
- For HTML, open in browser when interactive: `python3 -c "import webbrowser, pathlib; webbrowser.open(pathlib.Path('PATH').resolve().as_uri())"`. Skip in headless.
19
+ For HTML, open in the browser when interactive with the platform opener — `open "PATH"` on macOS, `xdg-open` on Linux, `start ""` on Windows, path always double-quoted. On failure, give the user the path instead. Skip in headless.
@@ -69,7 +69,7 @@ Under Validate intent, after every reviewer returns, render one consolidated rep
69
69
  2. Fill `{workflow.validation_report_template}`. No overall grade — the per-category verdicts and severity counts already say what's true. Synthesis paragraph lifts the rubric's overall verdict; add a second if extra reviewers shift the picture. One section per rubric category (open if thin / broken), one per extra reviewer (closed, adversarial voice preserved).
70
70
  3. Write `{doc_workspace}/validation-report.html`.
71
71
  4. Write the Markdown twin `{doc_workspace}/validation-report.md` — same content grouped by severity.
72
- 5. Open HTML: `python3 -c "import webbrowser, pathlib; webbrowser.open(pathlib.Path('{doc_workspace}/validation-report.html').resolve().as_uri())"`. Skip headless.
72
+ 5. Open HTML with the platform opener — `open "{doc_workspace}/validation-report.html"` on macOS, `xdg-open` on Linux, `start ""` on Windows, path always double-quoted. On failure, give the user the path instead. Skip headless.
73
73
 
74
74
  Re-running overwrites the consolidated report; individual `review-*.md` files persist.
75
75
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: bmad-advanced-elicitation
3
- description: 'Push the LLM to reconsider, refine, and improve its recent output. Use when user asks for deeper critique or mentions a known deeper critique method, e.g. socratic, first principles, pre-mortem, red team.'
3
+ description: 'Push the LLM to reconsider, refine, and improve its recent output. Use when user asks for deeper critique or mentions a known deeper critique method, e.g. socratic, first principles, pre-mortem, red team'
4
4
  ---
5
5
 
6
6
  # Advanced Elicitation
@@ -15,12 +15,12 @@ You are BMad's shared refinement checkpoint: other skills invoke you at natural
15
15
 
16
16
  ## On Activation
17
17
 
18
- 1. Resolve customization: `uv run {bmad-root}/scripts/resolve_customization.py --skill {skill-root} --key workflow`. On failure, read `{skill-root}/customize.toml` directly and use defaults.
18
+ 1. Resolve customization: `uv run {bmad-root}/scripts/resolve_customization.py --skill {skill-root} --project-root {project-root} --key workflow`. On failure, read `{skill-root}/customize.toml` directly and use defaults.
19
19
  2. Hold every `{workflow.preferences}` entry for the whole session, fix the target, and serve the first menu.
20
20
 
21
21
  ## Serving the Catalog
22
22
 
23
- `scripts/pick_methods.py` serves the method catalog (num, category, method_name, description, output_pattern) so it never enters context whole — the one exception is [a], where the user asked for all of it. Invoke as:
23
+ `scripts/pick_methods.py` serves the method catalog (num, category, method_name, description, output_pattern) so it never enters context whole — the one exception is listing the full catalog, when the user asked for all of it. Invoke as:
24
24
 
25
25
  ```bash
26
26
  uv run {skill-root}/scripts/pick_methods.py --file {workflow.methods_file} <command>
@@ -29,7 +29,7 @@ uv run {skill-root}/scripts/pick_methods.py --file {workflow.methods_file} <comm
29
29
  If `{workflow.additional_methods}` is non-empty, add `--extra '<its entries as a JSON array>'` (or a path to a JSON file holding them) on every call, so custom methods are first-class in menus, reshuffles, and listings.
30
30
 
31
31
  - `categories` — category names + counts, the cheap map.
32
- - `list --category <cat> [--category <cat>]` — the index for chosen categories; `--all` dumps the whole catalog, only for [a].
32
+ - `list --category <cat> [--category <cat>]` — the index for chosen categories; `--all` dumps the whole catalog, only when listing all.
33
33
  - `show <name-or-num> [...]` — full rows by name or num.
34
34
  - `random -n 5 --spread [--exclude <name>]...` — a category-diverse random draw.
35
35
 
@@ -37,30 +37,29 @@ If `{workflow.additional_methods}` is non-empty, add `--extra '<its entries as a
37
37
 
38
38
  ## The Menu
39
39
 
40
- ```
41
- **Advanced Elicitation Options**
42
- Choose a number (1-5), [r] to Reshuffle, [a] List All, or [x] to Proceed:
43
-
44
- 1. [Method Name]
45
- 2. [Method Name]
46
- 3. [Method Name]
47
- 4. [Method Name]
48
- 5. [Method Name]
49
- r. Reshuffle the list with 5 new options
50
- a. List all methods with descriptions
51
- x. Proceed / No Further Actions
52
- ```
40
+ HALT and give the user a choice:
41
+
42
+ - The five offered methods, listed by name. The user may pick one or several.
43
+ - **Reshuffle** — replace the list with five new options.
44
+ - **List all** — show the full catalog with descriptions.
45
+ - **Proceed** — no further elicitation.
53
46
 
54
- This menu is the interface other skills and their users rely on — keep its options and behavior stable. Handle the response:
47
+ This menu is the interface other skills and their users rely on — keep its options and behavior stable.
55
48
 
56
- - **1–5** — run that method (several numbers: in sequence), then re-present the menu.
57
- - **r** — reshuffle as above and re-present.
58
- - **a** — show the full catalog (`list --all`) as a compact table; a pick by name or number runs like a numbered choice.
59
- - **x** — done. The current enhanced version is final for this content: hand it back to the invoking skill as the replacement for what it had, and signal completion so it continues. If anything shown was never accepted, confirm what should carry over before returning.
60
- - **Anything else** — treat as direction: apply it to the target and re-present the menu.
49
+ - If the user picks methods: run them (several: in sequence), then offer the menu again.
50
+ - If the user chooses **Reshuffle**: reshuffle as above and offer the menu again.
51
+ - If the user chooses **List all**: show the full catalog (`list --all`) as a compact table; a pick by name or number runs like a method choice.
52
+ - If the user chooses **Proceed**: done. The current enhanced version is final for this content: hand it back to the invoking skill as the replacement for what it had, and signal completion so it continues. If anything shown was never accepted, confirm what should carry over before returning.
53
+ - Any other reply is direction: apply it to the target and offer the menu again.
61
54
 
62
55
  ## Running a Method
63
56
 
64
- Use the method's description as its intent and its output_pattern as a flexible flow guide; scale depth to the target — a paragraph gets a light pass, an architecture decision gets the full treatment. Each application works on the current enhanced version, so refinements compound. Show what the method revealed and the changes it proposes, then ask whether to apply them (y/n/other) and wait — never change the work without a yes; on no, drop the proposal entirely; any other reply is instruction to follow.
57
+ Use the method's description as its intent and its output_pattern as a flexible flow guide; scale depth to the target — a paragraph gets a light pass, an architecture decision gets the full treatment. Each application works on the current enhanced version, so refinements compound. Show what the method revealed and the changes it proposes, then HALT and give the user a choice:
58
+
59
+ - **Apply** — accept the proposed changes.
60
+ - **Reject** — drop the proposal entirely.
61
+ - Or give different direction.
62
+
63
+ Never change the work unless the user accepts the proposal. If they reject it, drop the proposal entirely. Any other reply is instruction to follow.
65
64
 
66
65
  When a method casts personas (round tables, panels, debates), invent named viewpoints suited to the content.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: bmad-brainstorming
3
- description: Facilitate a brainstorming session using diverse creative techniques. Use when the user says 'help me brainstorm' or 'help me ideate'.
3
+ description: Facilitate a brainstorming session using diverse creative techniques. Use when the user says 'help me brainstorm' or 'help me ideate'
4
4
  ---
5
5
 
6
6
  # BMad Brainstorming
@@ -19,7 +19,7 @@ The session runs in one of three stances, chosen by the user — set explicitly
19
19
 
20
20
  ## On Activation
21
21
 
22
- 1. Resolve customization: `uv run {bmad-root}/scripts/resolve_customization.py --skill {skill-root} --key workflow`. On failure, use a subagent to read `{skill-root}/customize.toml` directly with defaults.
22
+ 1. Resolve customization: `uv run {bmad-root}/scripts/resolve_customization.py --skill {skill-root} --project-root {project-root} --key workflow`. On failure, use a subagent to read `{skill-root}/customize.toml` directly with defaults.
23
23
  2. Run each `{workflow.activation_steps_prepend}` entry. Treat each `{workflow.persistent_facts}` entry as foundational context (`file:`-prefixed entries are paths/globs under `{project-root}` — load their contents; others are facts verbatim).
24
24
  3. Resolve `{user_name}`, `{communication_language}`, `{document_output_language}`, `{output_folder}`, `{project_name}` using sensible defaults (ask the user or omit `{user_name}`; English for language fields; infer `{project_name}` from the Hedgehog project); `{date}` is today. Missing values → neutral defaults; never block.
25
25
  4. **If launched headless** (a machine signal, not a human asking for output — `references/headless.md` lists them): load `references/headless.md` and follow it for the whole run; never load it otherwise. Outside headless, you generate ideas yourself only in autonomous mode (`references/mode-autonomous.md`) — never in facilitator or partner mode.
@@ -692,7 +692,26 @@ def html_doc(rows: list[dict]) -> str:
692
692
  )
693
693
 
694
694
 
695
+ def pin_utf8(stream):
696
+ """Pin a console stream to UTF-8, keeping its own error handler.
697
+
698
+ `--extra` technique text and the technique names echoed to stderr are
699
+ arbitrary user input, so either stream can carry a character the platform
700
+ default cannot encode (cp1252 on Windows) and print() then raises.
701
+
702
+ errors= is passed through deliberately: reconfigure(encoding=...) alone
703
+ resets the handler to "strict", which would silently downgrade stderr's
704
+ POSIX default of "backslashreplace" and turn a diagnostic about an
705
+ undecodable path into a traceback.
706
+ """
707
+ reconfigure = getattr(stream, "reconfigure", None)
708
+ if reconfigure is not None:
709
+ reconfigure(encoding="utf-8", errors=getattr(stream, "errors", None) or "strict")
710
+
711
+
695
712
  def main(argv: list[str] | None = None) -> int:
713
+ pin_utf8(sys.stdout)
714
+ pin_utf8(sys.stderr)
696
715
  p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
697
716
  p.add_argument("--file", type=Path, default=DEFAULT_FILE, help="technique CSV (default: sibling assets/brain-methods.csv)")
698
717
  p.add_argument("--extra", type=Path, help="JSON overlay of additional techniques (customize.toml additional_techniques), merged into every command")
@@ -3,6 +3,7 @@
3
3
  # dependencies = ["pytest>=8.0"]
4
4
  # ///
5
5
  """Tests for brain.py. Run: uv run -m pytest scripts/tests/test_brain.py"""
6
+ import io
6
7
  import json
7
8
  import sys
8
9
  from pathlib import Path
@@ -227,6 +228,55 @@ def test_unknown_category_style_uses_fallback_glyph():
227
228
  assert glyph == brain._FALLBACK_GLYPH
228
229
 
229
230
 
231
+ # --- console encoding (Windows cp1252) ----------------------------------
232
+
233
+ def _cp1252_stream():
234
+ """A text stream that behaves like a Windows console: cp1252, strict."""
235
+ return io.TextIOWrapper(io.BytesIO(), encoding="cp1252", errors="strict", write_through=True)
236
+
237
+
238
+ def test_extra_technique_prints_when_stdout_encoding_is_cp1252(lib, tmp_path, monkeypatch):
239
+ # --extra text is arbitrary user input; the shipped catalog happens to be
240
+ # cp1252-safe, an overlay is not. Unpinned stdout raises UnicodeEncodeError
241
+ # and the command exits having printed nothing.
242
+ overlay = tmp_path / "extra.json"
243
+ overlay.write_text(
244
+ json.dumps([{"category": "wild", "technique_name": "Fikir Fırtınası 🌪", "description": "Beyin fırtınası — 日本語"}]),
245
+ encoding="utf-8",
246
+ )
247
+ fake = _cp1252_stream()
248
+ monkeypatch.setattr(sys, "stdout", fake)
249
+ assert brain.main(["--file", str(lib), "--extra", str(overlay), "list", "--all"]) == 0
250
+ out = fake.buffer.getvalue().decode("utf-8")
251
+ assert "Fikir Fırtınası 🌪" in out
252
+ assert "日本語" in out
253
+
254
+
255
+ def test_missing_technique_name_reports_when_stderr_encoding_is_cp1252(lib, monkeypatch):
256
+ # `show` echoes the name it could not find; that name came from argv.
257
+ fake = _cp1252_stream()
258
+ monkeypatch.setattr(sys, "stderr", fake)
259
+ assert brain.main(["--file", str(lib), "show", "日本語"]) == 1
260
+ assert "日本語" in fake.buffer.getvalue().decode("utf-8")
261
+
262
+
263
+ def test_pin_utf8_preserves_the_streams_error_handler():
264
+ # reconfigure(encoding=...) on its own resets errors to "strict"; stderr on
265
+ # POSIX defaults to "backslashreplace" and must keep it, or a diagnostic
266
+ # carrying a surrogate-escaped path becomes a traceback.
267
+ stream = io.TextIOWrapper(io.BytesIO(), encoding="ascii", errors="backslashreplace")
268
+ brain.pin_utf8(stream)
269
+ assert stream.encoding == "utf-8"
270
+ assert stream.errors == "backslashreplace"
271
+
272
+
273
+ def test_pin_utf8_ignores_a_stream_without_reconfigure():
274
+ class Captured: # e.g. pytest's capture object, or a StringIO stand-in
275
+ errors = None
276
+
277
+ brain.pin_utf8(Captured()) # must not raise
278
+
279
+
230
280
  def test_shipped_selector_is_in_sync_with_catalog():
231
281
  # foolproofing: if someone edits brain-methods.csv they must regenerate the page.
232
282
  # Regenerate with: uv run brain.py html --out assets/brain-selector.html
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: bmad-deep-recon
3
- description: 'Decision-grade research, three ways: draft a deep-research prompt for the user to run in their own tool (ChatGPT, Gemini, Grok, Perplexity, …), process a finished research report — file it, distill a succinct cited summary with metadata that downstream skills consume without reprocessing — or run the research here through web fan-out. Shipped type packs: market, domain, technical, competitive, user-voice, academic-lit — plus a select shape for choose-between decisions and custom types via overrides. Use when the user says "deep recon", "research this", "draft a research prompt", "process this research report", "market research", "domain research", "technical research", "competitor research", "literature review", or "help me choose between".'
3
+ description: 'Research a topic to support a decision, three ways: draft a research prompt for the user to run in their own tool (ChatGPT, Gemini, Grok, Perplexity, …), turn a finished research report into a short summary with cited sources that other skills can use directly, or run the research here with parallel web searches. Built-in research types: market, domain, technical, competitive, user-voice, academic-lit; also supports choosing between candidates, and custom types via overrides. Use when the user says "deep recon", "research this", "draft a research prompt", "process this research report", "market research", "domain research", "technical research", "competitor research", "literature review", or "help me choose between"'
4
4
  ---
5
5
 
6
6
  # BMad Deep Recon
@@ -37,7 +37,7 @@ Three services, freely combined — each detailed in its reference: **Draft** a
37
37
 
38
38
  **Forwarded activation:** if a caller invoked you with a stated intent, research type, or pre-resolved customization fields, honor them verbatim — skip your own inference for those values and resolve only the rest.
39
39
 
40
- 1. Resolve customization: `uv run {bmad-root}/scripts/resolve_customization.py --skill {skill-root} --key workflow` (on failure read `{skill-root}/customize.toml`, use defaults). Run `{workflow.activation_steps_prepend}`, then `{workflow.activation_steps_append}`.
40
+ 1. Resolve customization: `uv run {bmad-root}/scripts/resolve_customization.py --skill {skill-root} --project-root {project-root} --key workflow` (on failure read `{skill-root}/customize.toml`, use defaults). Run `{workflow.activation_steps_prepend}`, then `{workflow.activation_steps_append}`.
41
41
  2. Resolve `{user_name}` (ask the user or omit), `{communication_language}` (English), `{document_output_language}` (English), `{project_name}` (infer from the Hedgehog project), `{output_folder}`, `{planning_artifacts}` (defaults to `{output_folder}` if unset), and `{date}` (today's date) using sensible defaults; missing values take neutral defaults, never block.
42
42
  3. Headless (no interactive user) → see `## Headless Mode`. Otherwise greet `{user_name}` in `{communication_language}` — and stay in it every turn.
43
43
  4. Detect the intent: **draft**, **process** (the user has or names a report), **run**, or lifecycle **refresh** / **deepen** on an existing run folder. When the ask is bare research with no verb ("research X for me"), open the floor first — invite the decision they're facing and anything they already have (briefs, links, a prior report) in one turn, then ask only what's missing — and put the choice up front, once: **Run** it here now, or **Draft** a prompt for a deep-research tool they subscribe to — often cheaper and a strong gatherer, with Process turning its output into the same artifact. State the trade honestly (tokens and minutes here vs. one manual round-trip there); their call, remembered for the session.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: bmad-forge-idea
3
- description: Pressure-test an idea through persona-driven interrogation until it hardens, proves out, or dies cheaply. Use when the user says 'forge an idea', 'pressure-test this idea', 'stress-test my thinking', or 'harden this idea'.
3
+ description: Test a half-formed idea in a questioning conversation, with different personas probing its weak points, until the user can act on it or drop it with confidence. Optionally writes a short brief for planning skills to build on. Use when the user says 'forge an idea', 'pressure-test this idea', 'stress-test my thinking', or 'harden this idea'
4
4
  ---
5
5
 
6
6
  # BMad Forge Idea
@@ -23,7 +23,7 @@ Lead by questioning, not lecturing. Ask one question at a time, press on weak po
23
23
 
24
24
  ## On Activation
25
25
 
26
- 1. Resolve customization: `uv run {bmad-root}/scripts/resolve_customization.py --skill {skill-root} --key workflow`. On failure, read `{skill-root}/customize.toml` directly with defaults. Apply the resolved `{workflow.*}` values throughout.
26
+ 1. Resolve customization: `uv run {bmad-root}/scripts/resolve_customization.py --skill {skill-root} --project-root {project-root} --key workflow`. On failure, read `{skill-root}/customize.toml` directly with defaults. Apply the resolved `{workflow.*}` values throughout.
27
27
  2. Run each `{workflow.activation_steps_prepend}` entry; treat each `{workflow.persistent_facts}` entry as foundational context (`file:` entries load their contents, `skill:` names a skill to consult, others are facts verbatim).
28
28
  3. Resolve `{user_name}`, `{communication_language}`, `{output_folder}` using sensible defaults (ask the user or omit `{user_name}`; English for language fields); `{date}` is today. Missing values → neutral defaults; never block. Greet `{user_name}` in `{communication_language}` and stay in it.
29
29
  4. Resume: glob `{workflow.forge_output_path}/**/.memlog.md` (recursive, so it still finds sessions when `run_folder_pattern` is overridden to nest paths) and read only each match's frontmatter to find any whose `status` is not `complete`. Offer to resume one — then read its full memlog once to rebuild state and continue append-only — or to start fresh.
@@ -48,7 +48,7 @@ Otherwise ask for what's missing, in order:
48
48
 
49
49
  ### Steering the conversation
50
50
 
51
- Tell the user they can say **"attack this"**, **"defend this"**, or **"switch roles"** at any time to change how the current idea is argued. In attack mode, do not agree with the idea; look for contradictions, weak assumptions, and failure cases. In defend mode, argue for the strongest version of the idea. Tell the user they can also name a persona at any time to change who participates in the session.
51
+ Tell the user they can say **"attack this"**, **"defend this"**, or **"switch roles"** at any time to change how the current idea is argued. In attack mode, do not agree with the idea; look for contradictions, weak assumptions, and failure cases. In defend mode, argue for the strongest version of the idea. Tell the user they can also name a persona or party at any time to change who participates in the session.
52
52
 
53
53
  ### Set up the session
54
54
 
@@ -110,7 +110,9 @@ def find_party_skill(project_root: Path, skill_root: Path):
110
110
  def load_party_workflow(project_root: Path, party_skill: Path):
111
111
  """Merged [workflow] table for bmad-party-mode (base + user overrides)."""
112
112
  resolver = project_root / "_bmad" / "scripts" / "resolve_customization.py"
113
- data = _run_json([sys.executable, str(resolver), "--skill", str(party_skill), "--key", "workflow"])
113
+ data = _run_json(
114
+ [sys.executable, str(resolver), "--skill", str(party_skill), "--project-root", str(project_root), "--key", "workflow"]
115
+ )
114
116
  if data is not None and isinstance(data.get("workflow"), dict):
115
117
  return data["workflow"]
116
118
  # Fallback: base customize.toml directly, no override merge.
@@ -5,6 +5,7 @@
5
5
  """Unit tests for resolve_personas.py — pool merge, alias, party resolution."""
6
6
 
7
7
  import sys
8
+ import tempfile
8
9
  import unittest
9
10
  from pathlib import Path
10
11
 
@@ -134,5 +135,26 @@ class TestOverrideMergeFallback(unittest.TestCase):
134
135
  self.assertEqual([m["code"] for m in wf["party_members"]], ["x", "y"]) # appended
135
136
 
136
137
 
138
+ class TestResolverInvocation(unittest.TestCase):
139
+ """The wrapper knows the project root, so it must not let the resolver
140
+ infer one from the working directory (#2796)."""
141
+
142
+ def _captured_command(self, tmp):
143
+ captured = []
144
+ original = rp._run_json
145
+ rp._run_json = lambda cmd: captured.append(cmd) or {"workflow": {}}
146
+ try:
147
+ rp.load_party_workflow(Path(tmp) / "project", Path(tmp) / "skill")
148
+ finally:
149
+ rp._run_json = original
150
+ return captured[0]
151
+
152
+ def test_passes_project_root_to_the_customization_resolver(self):
153
+ with tempfile.TemporaryDirectory() as tmp:
154
+ cmd = self._captured_command(tmp)
155
+ self.assertIn("--project-root", cmd)
156
+ self.assertEqual(cmd[cmd.index("--project-root") + 1], str(Path(tmp) / "project"))
157
+
158
+
137
159
  if __name__ == "__main__":
138
160
  unittest.main()
@@ -1,177 +1,104 @@
1
1
  #!/usr/bin/env python3
2
- """
3
- Resolve customization for a BMad skill using three-layer TOML merge.
4
-
5
- Reads customization from three layers (highest priority first):
6
- 1. {project-root}/_bmad/custom/{name}.user.toml (personal, gitignored)
7
- 2. {project-root}/_bmad/custom/{name}.toml (team/org, committed)
8
- 3. {skill-root}/customize.toml (skill defaults)
9
-
10
- Skill name is derived from the basename of the skill directory.
11
-
12
- Outputs merged JSON to stdout. Errors go to stderr.
13
-
14
- Uses only the Python stdlib (`tomllib`) — no third-party dependencies.
15
- BMad is standardizing on `uv run` to invoke scripts (uv provisions a suitable
16
- interpreter for you); a plain `python3` on PATH still works during the
17
- transition. Either runner needs Python 3.11+ for `tomllib`.
18
-
19
- uv run resolve_customization.py --skill /abs/path/to/skill-dir
20
- uv run resolve_customization.py --skill ... --key agent
21
- uv run resolve_customization.py --skill ... --key agent.menu
22
-
23
- Merge rules (purely structural — no field-name special-casing):
24
- - Scalars (string, int, bool, float): override wins
25
- - Tables: deep merge (recursively apply these rules)
26
- - Arrays of tables where every item shares the *same* identifier
27
- field (every item has `code`, or every item has `id`):
28
- merge by that key (matching keys replace, new keys append)
29
- - All other arrays — including arrays where only some items have
30
- `code` or `id`, or where items mix the two keys:
31
- append (base items followed by override items)
32
-
33
- No removal mechanism — overrides cannot delete base items. To suppress
34
- a default, fork the skill or override the item by code with a no-op
35
- description/prompt.
36
- """
2
+ # /// script
3
+ # requires-python = ">=3.11"
4
+ # ///
5
+ """Resolve a skill's default, team, and user TOML customization layers."""
37
6
 
38
7
  import argparse
39
8
  import json
40
9
  import sys
41
10
  from pathlib import Path
42
11
 
12
+ # Installed scripts are consumer files, not a location for interpreter caches.
13
+ sys.dont_write_bytecode = True
14
+
43
15
  try:
44
- import tomllib
45
- except ImportError:
46
- sys.stderr.write(
47
- "error: Python 3.11+ is required (stdlib `tomllib` not found).\n"
48
- "Install a newer Python or run the resolution manually per the\n"
49
- "fallback instructions in the skill's SKILL.md.\n"
50
- )
51
- sys.exit(3)
16
+ from config_utils import ConfigError, load_customization
17
+ except ModuleNotFoundError as error:
18
+ if error.name != "tomllib":
19
+ raise
20
+ sys.stderr.write("error: Python 3.11+ is required (stdlib `tomllib` not found).\n")
21
+ raise SystemExit(3) from None
52
22
 
53
23
 
54
24
  _MISSING = object()
55
- _KEYED_MERGE_FIELDS = ("code", "id")
56
25
 
57
26
 
58
- def find_project_root(start: Path):
27
+ def find_project_root(start: Path) -> Path | None:
28
+ """Nearest ancestor holding `_bmad/`, falling back to the nearest holding `.git`.
29
+
30
+ `_bmad/` outranks `.git` at every depth: a submodule or nested repo carries
31
+ `.git` without being the BMad project, so treating the two as equal stops the
32
+ walk short of the root that owns `_bmad/custom/`.
33
+ """
34
+ git_root: Path | None = None
59
35
  current = start.resolve()
60
36
  while True:
61
- if (current / "_bmad").exists() or (current / ".git").exists():
37
+ if (current / "_bmad").is_dir():
62
38
  return current
63
- parent = current.parent
64
- if parent == current:
65
- return None
66
- current = parent
67
-
68
-
69
- def load_toml(file_path: Path, required: bool = False) -> dict:
70
- if not file_path.exists():
71
- if required:
72
- sys.stderr.write(f"error: required customization file not found: {file_path}\n")
73
- sys.exit(1)
74
- return {}
75
- try:
76
- with file_path.open("rb") as f:
77
- parsed = tomllib.load(f)
78
- if not isinstance(parsed, dict):
79
- if required:
80
- sys.stderr.write(f"error: {file_path} did not parse to a table\n")
81
- sys.exit(1)
82
- return {}
83
- return parsed
84
- except tomllib.TOMLDecodeError as error:
85
- level = "error" if required else "warning"
86
- sys.stderr.write(f"{level}: failed to parse {file_path}: {error}\n")
87
- if required:
88
- sys.exit(1)
89
- return {}
90
- except OSError as error:
91
- level = "error" if required else "warning"
92
- sys.stderr.write(f"{level}: failed to read {file_path}: {error}\n")
93
- if required:
94
- sys.exit(1)
95
- return {}
96
-
97
-
98
- def _detect_keyed_merge_field(items):
99
- """Return 'code' or 'id' if every table item carries that *same* field.
100
-
101
- All items must share the same identifier (all `code`, or all `id`).
102
- Mixed arrays — where some items use `code` and others use `id` —
103
- return None and fall through to append semantics. This is intentional:
104
- mixing identifier keys within one array is a schema smell, and
105
- append-fallback is safer than guessing which key should merge.
106
- """
107
- if not items or not all(isinstance(item, dict) for item in items):
108
- return None
109
- for candidate in _KEYED_MERGE_FIELDS:
110
- if all(item.get(candidate) is not None for item in items):
111
- return candidate
112
- return None
113
-
39
+ if git_root is None and (current / ".git").exists():
40
+ git_root = current
41
+ if current.parent == current:
42
+ return git_root
43
+ current = current.parent
114
44
 
115
- def _merge_by_key(base, override, key_name):
116
- result = []
117
- index_by_key = {}
118
-
119
- for item in base:
120
- if not isinstance(item, dict):
121
- continue
122
- if item.get(key_name) is not None:
123
- index_by_key[item[key_name]] = len(result)
124
- result.append(dict(item))
125
-
126
- for item in override:
127
- if not isinstance(item, dict):
128
- result.append(item)
129
- continue
130
- key = item.get(key_name)
131
- if key is not None and key in index_by_key:
132
- result[index_by_key[key]] = dict(item)
133
- else:
134
- if key is not None:
135
- index_by_key[key] = len(result)
136
- result.append(dict(item))
137
45
 
138
- return result
46
+ def script_project_root() -> Path | None:
47
+ """Project root implied by this script's own install path.
139
48
 
49
+ Skills invoke `{project-root}/_bmad/scripts/resolve_customization.py`, so when
50
+ this file sits at that path its grandparent is a project root the caller already
51
+ resolved.
52
+ """
53
+ parents = Path(__file__).resolve().parents
54
+ if len(parents) >= 3 and parents[0].name == "scripts" and parents[1].name == "_bmad":
55
+ return parents[2]
56
+ return None
140
57
 
141
- def _merge_arrays(base, override):
142
- """Shape-aware array merge. Base + override combined tables may opt into
143
- keyed merge if every item has `code` or `id`. Otherwise: append."""
144
- base_arr = base if isinstance(base, list) else []
145
- override_arr = override if isinstance(override, list) else []
146
- keyed_field = _detect_keyed_merge_field(base_arr + override_arr)
147
- if keyed_field:
148
- return _merge_by_key(base_arr, override_arr, keyed_field)
149
- return base_arr + override_arr
150
58
 
59
+ def candidate_project_roots(skill_dir: Path) -> list[Path]:
60
+ """Plausible project roots, most trustworthy first.
151
61
 
152
- def deep_merge(base, override):
153
- """Recursively merge override into base using structural rules.
154
- - Table + table: deep merge
155
- - Array + array: shape-aware (keyed merge if all items have code/id, else append)
156
- - Anything else: override wins
62
+ The working directory leads because the project is where the user is working,
63
+ not where the skill happens to be installed — a home-installed skill walks up to
64
+ `~`, and any `~/_bmad` there would otherwise mask the real project's overrides.
157
65
  """
158
- if isinstance(base, dict) and isinstance(override, dict):
159
- result = dict(base)
160
- for key, over_val in override.items():
161
- if key in result:
162
- result[key] = deep_merge(result[key], over_val)
163
- else:
164
- result[key] = over_val
165
- return result
166
- if isinstance(base, list) and isinstance(override, list):
167
- return _merge_arrays(base, override)
168
- return override
66
+ ordered: list[Path] = []
67
+ for root in (
68
+ find_project_root(Path.cwd()),
69
+ script_project_root(),
70
+ find_project_root(skill_dir),
71
+ ):
72
+ if root is not None and root not in ordered:
73
+ ordered.append(root)
74
+ return ordered
75
+
76
+
77
+ def has_override(root: Path, skill_name: str) -> bool:
78
+ custom_dir = root / "_bmad" / "custom"
79
+ return any(
80
+ (custom_dir / name).is_file()
81
+ for name in (f"{skill_name}.toml", f"{skill_name}.user.toml")
82
+ )
83
+
84
+
85
+ def warn_on_masked_override(chosen: Path, rejected: list[Path], skill_name: str) -> None:
86
+ """Break the silence when a real override exists under a root we did not pick."""
87
+ if has_override(chosen, skill_name):
88
+ return
89
+ for root in rejected:
90
+ if has_override(root, skill_name):
91
+ sys.stderr.write(
92
+ f"note: resolved project root {chosen} has no customization for "
93
+ f"`{skill_name}`, but {root} does. Using {chosen}; pass "
94
+ f"--project-root to select the other explicitly.\n"
95
+ )
96
+ return
169
97
 
170
98
 
171
99
  def extract_key(data, dotted_key: str):
172
- parts = dotted_key.split(".")
173
100
  current = data
174
- for part in parts:
101
+ for part in dotted_key.split("."):
175
102
  if isinstance(current, dict) and part in current:
176
103
  current = current[part]
177
104
  else:
@@ -179,62 +106,59 @@ def extract_key(data, dotted_key: str):
179
106
  return current
180
107
 
181
108
 
182
- def write_json_stdout(output):
183
- """Write JSON as UTF-8 so Windows cp1252 stdout can carry emoji icons."""
109
+ def write_json_stdout(output) -> None:
184
110
  reconfigure = getattr(sys.stdout, "reconfigure", None)
185
111
  if reconfigure is not None:
186
112
  reconfigure(encoding="utf-8")
187
113
  sys.stdout.write(json.dumps(output, indent=2, ensure_ascii=False) + "\n")
188
114
 
189
115
 
190
- def main():
116
+ def main() -> int:
191
117
  parser = argparse.ArgumentParser(
192
- description="Resolve customization for a BMad skill using three-layer TOML merge.",
193
- add_help=True,
118
+ description="Resolve skill customization using three-layer TOML merge."
119
+ )
120
+ parser.add_argument(
121
+ "--skill", "-s", required=True, help="Absolute path to the skill directory"
194
122
  )
195
123
  parser.add_argument(
196
- "--skill", "-s", required=True,
197
- help="Absolute path to the skill directory (must contain customize.toml)",
124
+ "--project-root",
125
+ "-p",
126
+ help="Explicit project root containing _bmad/ (recommended)",
198
127
  )
199
128
  parser.add_argument(
200
- "--key", "-k", action="append", default=[],
129
+ "--key",
130
+ "-k",
131
+ action="append",
132
+ default=[],
201
133
  help="Dotted field path to resolve (repeatable). Omit for full dump.",
202
134
  )
203
135
  args = parser.parse_args()
204
136
 
205
137
  skill_dir = Path(args.skill).resolve()
206
- skill_name = skill_dir.name
207
- defaults_path = skill_dir / "customize.toml"
208
-
209
- defaults = load_toml(defaults_path, required=True)
210
-
211
- # Prefer the project that contains this skill. Only fall back to cwd if
212
- # the skill isn't inside a recognizable project tree (unusual but possible
213
- # for standalone skills invoked directly). Using cwd first is unsafe when
214
- # an ancestor of cwd happens to have a stray _bmad/ from another project.
215
- project_root = find_project_root(skill_dir) or find_project_root(Path.cwd())
216
-
217
- team = {}
218
- user = {}
219
- if project_root:
220
- custom_dir = project_root / "_bmad" / "custom"
221
- team = load_toml(custom_dir / f"{skill_name}.toml")
222
- user = load_toml(custom_dir / f"{skill_name}.user.toml")
138
+ if args.project_root:
139
+ project_root = Path(args.project_root).resolve()
140
+ else:
141
+ candidates = candidate_project_roots(skill_dir)
142
+ project_root = candidates[0] if candidates else None
143
+ if project_root is not None:
144
+ warn_on_masked_override(project_root, candidates[1:], skill_dir.name)
223
145
 
224
- merged = deep_merge(defaults, team)
225
- merged = deep_merge(merged, user)
146
+ try:
147
+ merged = load_customization(project_root, skill_dir)
148
+ except ConfigError as error:
149
+ sys.stderr.write(f"error: {error}\n")
150
+ return 1
226
151
 
152
+ output = merged
227
153
  if args.key:
228
154
  output = {}
229
155
  for key in args.key:
230
156
  value = extract_key(merged, key)
231
157
  if value is not _MISSING:
232
158
  output[key] = value
233
- else:
234
- output = merged
235
-
236
159
  write_json_stdout(output)
160
+ return 0
237
161
 
238
162
 
239
163
  if __name__ == "__main__":
240
- main()
164
+ raise SystemExit(main())