Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"name": "bauto",
"source": "./src/automator/data/skills",
"description": "Automation-mode skills driven by the bmad-auto orchestrator: unattended dev (bmad-auto-dev), adversarial review (bmad-auto-review), and deferred-work sweep triage (bmad-auto-sweep)",
"version": "0.6.2",
"version": "0.6.3",
"author": {
"name": "pinkyd"
},
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ All notable changes to `bmad-auto` are documented here. The format is based on
[Semantic Versioning](https://semver.org/spec/v2.0.0.html). While the project is pre-1.0,
breaking changes may land in a minor release.

## [0.6.3] — 2026-06-21

### Fixed

- **GitHub Copilot adapter (CLI 1.0.63).** Turn-end is `agentStop`, not PascalCase `Stop`
(which never fires) — every session previously read as a timeout. Remapped events, dropped
the non-existent `PreCompact`, and the shared hook relay now reads camelCase payload keys
(`sessionId`/`transcriptPath`). Probe mode sends its prompt verbatim so a skill-templating
`prompt_template` no longer renders a missing-skill path that stalls the turn.

### Added

- **Copilot token accounting.** New `copilot-events` `usage_parser` reads
`~/.copilot/session-state/*/events.jsonl` (`data.modelMetrics.<model>.usage.*`); the `copilot`
profile is wired to it (was `usage_parser = "none"`).

## [0.6.2] — 2026-06-21

### Added
Expand Down Expand Up @@ -451,6 +467,7 @@ enforced in CI.
implementation phase, driven by a Python control loop with hook-based session transport and
resumable on-disk run state.

[0.6.3]: https://github.com/bmad-code-org/bmad-auto/releases/tag/v0.6.3
[0.6.2]: https://github.com/bmad-code-org/bmad-auto/releases/tag/v0.6.2
[0.6.1]: https://github.com/bmad-code-org/bmad-auto/releases/tag/v0.6.1
[0.6.0]: https://github.com/bmad-code-org/bmad-auto/releases/tag/v0.6.0
Expand Down
24 changes: 17 additions & 7 deletions docs/adapter-authoring-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,15 +150,25 @@ unknown CLI without `--binary` fails and lists the available profiles.

## Worked example: copilot

The bundled `copilot` profile ships with `usage_parser = "none"` — Copilot's
token-usage schema hadn't been captured when the profile landed. That's exactly
the gap `probe-adapter` closes:
The `copilot` profile was finalized from a real probe run — a good illustration of
why `probe-adapter` exists, because the as-drafted profile was wrong in ways no doc
would reveal:

```bash
bmad-auto probe-adapter copilot --probe --project /tmp/scratch
```

captures the `Stop` payload (confirming `session_id` / `transcript_path` casing),
locates `~/.copilot/session-state/*/events.jsonl`, and infers its token schema —
the data needed to write a `copilot-*` parser in `tokens.py` and flip the profile's
`usage_parser` off `"none"`. Confirm the `mkdtemp` dir is gone afterward.
On Copilot CLI 1.0.63 this surfaced three corrections:

- **Turn-end event.** The draft registered PascalCase `Stop`, which never fires —
the turn-end hook is `agentStop` (camelCase). Without this, every session reads
as a timeout. The profile now maps `agentStop = "Stop"` (and `sessionStart` /
`sessionEnd`; there is no `PreCompact` equivalent).
- **Payload casing.** Keys are camelCase (`sessionId`, `transcriptPath`), not
snake_case — so the shared relay (`bmad_auto_hook.py`) reads both casings.
- **Token schema.** The probe located `~/.copilot/session-state/*/events.jsonl` and
inferred its token fields (`data.modelMetrics.<model>.usage.*`), which became the
`copilot-events` parser in `tokens.py`; the profile's `usage_parser` is now wired
to it instead of `"none"`.

Confirm the `mkdtemp` dir is gone afterward.
2 changes: 1 addition & 1 deletion module.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
code: bauto
name: BMAD Auto Skills
description: "Automation-mode skills driven by the bmad-auto orchestrator: unattended dev (bmad-auto-dev), adversarial review (bmad-auto-review), and deferred-work sweep triage (bmad-auto-sweep)"
module_version: 0.6.2
module_version: 0.6.3
default_selected: false
module_greeting: >
BMAD Auto installed — both the four automation skills and the
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "bmad-auto"
version = "0.6.2"
version = "0.6.3"
description = "Deterministic ralph-loop orchestrator for the BMAD implementation phase"
readme = "README.md"
license = "MIT"
Expand Down
18 changes: 18 additions & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
REPO = sync_version.ROOT
CHANGELOG = REPO / "CHANGELOG.md"
SYNC_VERSION = REPO / "scripts" / "sync_version.py"
SEED_SKILLS = REPO / "scripts" / "seed_skills.py"
GEN_SCREENSHOTS = REPO / "scripts" / "gen_screenshots.py"
GEN_DEMO = REPO / "scripts" / "gen_demo.py"
TUI_PATH = "src/automator/tui"
Expand Down Expand Up @@ -257,6 +258,20 @@ def _regen_assets(dry_run: bool) -> None:
_run(cmd)


def _reseed_skills(dry_run: bool) -> None:
"""Re-copy the canonical bmad-auto skills into the gitignored dev-workspace
forks (.claude/skills, .agents/skills) so they pick up the just-stamped
module.yaml version. Without this the version bump drifts the forks and the
local `tests/test_module_skills_sync.py` fails until reseeded by hand. The
forks are gitignored, so nothing here is staged or committed."""
cmd = ["uv", "run", "python", str(SEED_SKILLS)]
if dry_run:
print(f" would run: {' '.join(cmd)}")
return
print("reseeding dev-workspace skill forks ...")
_run(cmd)


def _run_trunk_fmt(dry_run: bool) -> None:
if not shutil.which("trunk"):
print(" trunk not on PATH — skipping fmt (run `trunk check` before pushing)")
Expand Down Expand Up @@ -330,6 +345,7 @@ def cmd_prepare(args: argparse.Namespace) -> int:
print("\n[dry-run] planned actions:")
print(f" ensure CHANGELOG link ref: [{version}]: {url}/releases/tag/{tag}")
print(f" run: python {SYNC_VERSION.relative_to(REPO)} {version} (+ uv lock)")
_reseed_skills(dry_run=True)
if regen:
_regen_assets(dry_run=True)
_run_trunk_fmt(dry_run=True)
Expand All @@ -343,6 +359,8 @@ def cmd_prepare(args: argparse.Namespace) -> int:
print(f"stamping version via {SYNC_VERSION.name} ...")
_run(["uv", "run", "python", str(SYNC_VERSION), version])

_reseed_skills(dry_run=False)

if regen:
print("regenerating screenshots + demo ...")
_regen_assets(dry_run=False)
Expand Down
121 changes: 121 additions & 0 deletions scripts/seed_skills.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Reseed the dev-workspace skill forks from the canonical wheel source.

``src/automator/data/skills/<skill>`` is the single source of truth for the
``bmad-auto-*`` automation skills (bundled into the wheel; ``bmad-auto init``
installs them). Two dev-workspace trees hold byte-identical *forks* of those
skills so the local agents can run them out of this repo:

* ``.claude/skills/<skill>`` — read by Claude Code
* ``.agents/skills/<skill>`` — read by codex / gemini

``tests/test_module_skills_sync.py`` turns any drift between canonical and a
fork into a failure. The version bump in ``scripts/sync_version.py`` stamps the
canonical ``bmad-auto-setup/assets/module.yaml``, which immediately drifts both
forks — so every release had to be followed by a hand reseed before the local
suite went green again. This script is that reseed, and ``release.py prepare``
runs it automatically right after stamping.

Both fork trees are gitignored dev-only workspaces, so nothing here is committed
— a tree that is absent (as in CI) is simply skipped, never created.

Usage::

uv run python scripts/seed_skills.py # reseed every present fork
uv run python scripts/seed_skills.py --check # report drift, mutate nothing
"""

from __future__ import annotations

import filecmp
import shutil
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
# Import MODULE_SKILLS straight from the package so this list can never drift
# from the one the installer and the sync test use.
sys.path.insert(0, str(ROOT / "src"))
from automator.install import MODULE_SKILLS # noqa: E402

SKILLS_SRC = ROOT / "src" / "automator" / "data" / "skills"
FORK_TREES = (".claude/skills", ".agents/skills")


def drift(canonical: Path, fork: Path) -> list[str]:
"""Recursively compare a canonical skill dir against its fork, returning a
list of human-readable drift problems (empty when byte-identical). Mirrors
the comparison in tests/test_module_skills_sync.py."""
if not fork.exists():
return [f"fork missing: {fork.relative_to(ROOT)}"]
problems: list[str] = []
stack = [filecmp.dircmp(canonical, fork)]

@augmentcode augmentcode Bot Jun 21, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scripts/seed_skills.py:52filecmp.dircmp(canonical, fork) will raise if fork exists but isn’t a directory (e.g., a stray file at that path), which prevents the reseed path from recovering. Consider explicitly treating non-directory forks as drift (and ensuring reseed() can remove them safely) so release.py prepare doesn’t crash on that edge case.

Severity: low

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

while stack:
node = stack.pop()
rel = Path(node.left).relative_to(canonical)
problems += [f"only in canonical: {rel / n}" for n in node.left_only]
problems += [f"extra in fork: {rel / n}" for n in node.right_only]
_, mismatch, errors = filecmp.cmpfiles(
node.left, node.right, node.common_files, shallow=False
)
problems += [f"content differs: {rel / n}" for n in mismatch + errors]
stack.extend(node.subdirs.values())
return problems


def reseed(canonical: Path, fork: Path) -> None:
"""Replace ``fork`` with an exact copy of ``canonical``."""
if fork.exists():
shutil.rmtree(fork)
fork.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(canonical, fork)


def run(check: bool) -> int:
present = [tree for tree in FORK_TREES if (ROOT / tree).is_dir()]
if not present:
print("no dev-workspace skill forks present (.claude/.agents) — nothing to reseed")
return 0

drifted: list[tuple[str, list[str]]] = []
reseeded: list[str] = []
for tree in present:
for skill in MODULE_SKILLS:
canonical = SKILLS_SRC / skill
if not canonical.is_dir():
sys.exit(f"error: canonical skill missing: {canonical.relative_to(ROOT)}")
fork = ROOT / tree / skill
problems = drift(canonical, fork)
if not problems:
continue
if check:
drifted.append((f"{tree}/{skill}", problems))
else:
reseed(canonical, fork)
reseeded.append(f"{tree}/{skill}")

if check:
if drifted:
print("skill fork drift detected (run scripts/seed_skills.py to fix):", file=sys.stderr)
for label, problems in drifted:
for p in problems:
print(f" - {label}: {p}", file=sys.stderr)
return 1
print("ok: every skill fork matches canonical")
return 0

if reseeded:
print("reseeded skill forks from canonical:\n " + "\n ".join(reseeded))
else:
print("skill forks already match canonical — nothing to reseed")
return 0


def main(argv: list[str]) -> int:
if len(argv) > 1 or (argv and argv[0] != "--check"):
sys.exit("usage: seed_skills.py [--check]")
return run(check=bool(argv))


if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
2 changes: 1 addition & 1 deletion src/automator/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
spec files, and the per-run directory under .automator/runs/.
"""

__version__ = "0.6.2"
__version__ = "0.6.3"
2 changes: 1 addition & 1 deletion src/automator/adapters/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from importlib import resources
from pathlib import Path

USAGE_PARSERS = {"claude-jsonl", "codex-rollout", "gemini-chat", "none"}
USAGE_PARSERS = {"claude-jsonl", "codex-rollout", "gemini-chat", "copilot-events", "none"}
HOOK_DIALECTS = {
"claude-settings-json",
"codex-hooks-json",
Expand Down
17 changes: 12 additions & 5 deletions src/automator/data/bmad_auto_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@
"""Coding-CLI hook relay for bmad-auto. Stdlib only.

Each CLI's hook config registers this script under its native event names
(Claude/Codex: SessionStart/Stop/..., Gemini: AfterAgent for Stop) but always
passes the CANONICAL event name as argv[1] — the orchestrator only ever sees
canonical events. Reads the hook payload from stdin and writes one event file
(Claude/Codex: SessionStart/Stop/..., Gemini: AfterAgent for Stop, Copilot:
agentStop for Stop) but always passes the CANONICAL event name as argv[1] — the
orchestrator only ever sees canonical events. Payload keys vary too: snake_case
(claude/codex), conversation_id (cursor), or camelCase (copilot's sessionId/
transcriptPath); the field extraction below tries each. Reads the hook payload
from stdin and writes one event file
into the orchestrator's run directory. No-ops (exit 0) unless the session was
spawned by bmad-auto (detected via env vars set on the tmux window), so
normal interactive sessions are unaffected.
Expand Down Expand Up @@ -34,8 +37,12 @@ def main() -> int:
"ts": ts,
"event": event_name,
"task_id": task_id,
"session_id": payload.get("session_id") or payload.get("conversation_id"),
"transcript_path": payload.get("transcript_path"),
# Payload keys vary by CLI: snake_case (claude/codex), conversation_id
# (cursor), or camelCase (copilot's sessionId/transcriptPath). Try each.
"session_id": (
payload.get("session_id") or payload.get("conversation_id") or payload.get("sessionId")
),
"transcript_path": payload.get("transcript_path") or payload.get("transcriptPath"),
"cwd": payload.get("cwd"),
}
events_dir = os.path.join(run_dir, "events")
Expand Down
25 changes: 14 additions & 11 deletions src/automator/data/profiles/copilot.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,32 @@
# needed" keeps parallel skill phases (e.g. review layers) actually spawning
# subagents, same as codex.
#
# Hook events are registered under Copilot's VS Code-compatible PascalCase names
# (Stop/SessionStart/SessionEnd/PreCompact, same set as claude). That casing makes
# Copilot emit SNAKE_CASE payloads (session_id, transcript_path, cwd) — exactly
# what the shared relay reads — and the Stop payload carries transcript_path, so no
# relay change is needed and a future usage_parser gets the transcript for free.
# (The camelCase names agentStop/sessionStart emit camelCase payloads the relay
# would miss.) NOTE: an enterprise policy permissions.disableBypassPermissionsMode
# = 'disable' suppresses the --allow-all-* flags and will block unattended runs.
# Hook events (verified against Copilot CLI 1.0.63 via `bmad-auto probe-adapter
# copilot --probe`): the turn-end event is `agentStop` — PascalCase `Stop` does
# NOT fire on this build, so it must NOT be registered (it yields no completion
# signal and every session reads as a timeout). `sessionStart`/`sessionEnd` cover
# session lifecycle; Copilot has no PreCompact equivalent. Payload keys are
# camelCase (sessionId, transcriptPath, cwd) regardless of event-name casing, and
# the `agentStop` payload carries `transcriptPath` + `stopReason: end_turn` — the
# shared relay reads both camelCase and snake_case, so the usage_parser gets the
# transcript. CAVEAT: single build/OS (1.0.63, macOS); a future build may alias
# `Stop`. NOTE: an enterprise policy permissions.disableBypassPermissionsMode =
# 'disable' suppresses the --allow-all-* flags and will block unattended runs.
name = "copilot"
binary = "copilot"
prompt_template = "LOAD the FULL .agents/skills/{skill}/SKILL.md, read its entire contents and follow its directions exactly, using subagents as needed: {args}"
launch_args = ["-i"]
bypass_args = ["--allow-all-tools", "--allow-all-paths"]
model_flag = "--model"
usage_parser = "none"
usage_parser = "copilot-events"
first_run_note = "run `copilot` once and authenticate (gh / Copilot subscription); requires Copilot CLI GA (>= 2026-02)"
skill_tree = ".agents/skills"
# .github/copilot/settings.json is the inline hook config (and can also hold MCP
# servers) — gitignored in many projects, so a worktree checkout omits it and
# isolated sessions lose it; seeded first, then the Stop hook is merged in.
# isolated sessions lose it; seeded first, then the agentStop hook is merged in.
seed_files = [".github/copilot/settings.json"]

[hooks]
dialect = "copilot-settings-json"
config_path = ".github/copilot/settings.json"
events = { Stop = "Stop", SessionStart = "SessionStart", SessionEnd = "SessionEnd", PreCompact = "PreCompact" }
events = { agentStop = "Stop", sessionStart = "SessionStart", sessionEnd = "SessionEnd" }
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
code: bauto
name: BMAD Auto Skills
description: "Automation-mode skills driven by the bmad-auto orchestrator: unattended dev (bmad-auto-dev), adversarial review (bmad-auto-review), and deferred-work sweep triage (bmad-auto-sweep)"
module_version: 0.6.2
module_version: 0.6.3
default_selected: false
module_greeting: >
BMAD Auto installed — both the four automation skills and the
Expand Down
7 changes: 6 additions & 1 deletion src/automator/probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"claude-jsonl": "~/.claude/projects/*/*.jsonl",
"codex-rollout": "~/.codex/sessions/*/*/*/rollout-*.jsonl",
"gemini-chat": "~/.gemini/tmp/*/chats/session-*.jsonl",
"copilot-events": "~/.copilot/session-state/*/events.jsonl",
}
# Fallback family glob keyed by the `cli` name, so a CLI whose usage_parser is
# still "none" (e.g. copilot, freshly added) still gets transcript discovery.
Expand Down Expand Up @@ -450,7 +451,11 @@ def _probe_argv(profile: CLIProfile, binary: str, hints: Hints) -> list[str]:
argv = [
binary,
*profile.launch_args,
profile.render_prompt(PROBE_PROMPT),
# Send the probe prompt verbatim, NOT through profile.render_prompt: a
# content-free turn has no skill name, so a skill-templating prompt_template
# (copilot, codex) would render a nonexistent .../skills//SKILL.md path the
# agent hunts for, and the turn never ends within the probe timeout.
PROBE_PROMPT,
*profile.bypass_args,
]
if hints.model:
Expand Down
Loading
Loading