Skip to content

Commit 21922f8

Browse files
mnriemCopilot
andcommitted
fix(bob): detect Spec Kit layout from managed artifacts, not any skills dir (review #3415)
Two related mis-detections from review 4723246468: 1. `BobIntegration.is_skills_mode` treated the mere presence of a `.bob/skills/` directory as proof the project is skills-based. A legacy Spec Kit install (managed `.bob/commands/speckit.*.md`) that also carried unrelated Bob 2 skills would be misclassified as skills, so `integration use bob` persisted `ai_skills` and rewrote shared refs. Now the layout is inferred from managed Spec Kit artifacts: legacy/command mode only when managed `speckit.*.md` command files exist and no managed `speckit-*` skill dirs do. 2. The `register_commands` separator for an inactive agent used a disk-based `effective_invoke_separator(None, project_root)` fallback that could pick the skills separator even though the registrar writes the static command layout (`.bob/commands/*.md`). Inactive agents now resolve the separator from the registrar's actual output layout (`extension == "/SKILL.md"`), so command-layout files keep `/speckit.*` refs regardless of sibling dirs. Update the affected hook/E2E tests to use managed artifacts and add regression tests for the mixed-layout and inactive-registrar scenarios. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 63f93544-a77f-4f01-bf04-c88806a97dbf
1 parent eeaf85f commit 21922f8

3 files changed

Lines changed: 109 additions & 20 deletions

File tree

src/specify_cli/agents.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -662,7 +662,21 @@ def register_commands(
662662
is_ai_skills_enabled(_opts)
663663
)
664664
else:
665-
_sep = _integ.effective_invoke_separator(None, project_root)
665+
# Inactive agent: the reference separator must match the
666+
# layout THIS registrar writes into — determined by the
667+
# agent's static output config (its command dir + file
668+
# extension), not by unrelated sibling directories on disk.
669+
# A skill-scaffold output ("/SKILL.md") uses the skills
670+
# separator; a command-layout output uses the command
671+
# separator. This avoids mislabeling a Bob command-layout
672+
# write as skills just because an unrelated .bob/skills
673+
# directory happens to exist.
674+
registrar_writes_skills = (
675+
agent_config.get("extension") == "/SKILL.md"
676+
)
677+
_sep = _integ.invoke_separator_for_mode(
678+
registrar_writes_skills
679+
)
666680
except Exception:
667681
pass
668682

src/specify_cli/integrations/bob/__init__.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -155,18 +155,25 @@ def is_skills_mode(
155155
*parsed_options* is typically empty (existing Bob 1.x installs never
156156
stored ``legacy_commands``). Defaulting to skills there would rewrite
157157
such a project's ``ai_skills`` flag to ``True`` even though it still
158-
only contains ``.bob/commands/`` — silently switching its extension /
159-
command-reference handling to the skills layout. So when a
160-
*project_root* is supplied, an already-installed legacy layout
161-
(``.bob/commands/`` present, ``.bob/skills/`` absent) is preserved
162-
until an explicit upgrade actually creates ``.bob/skills/``. A fresh
163-
project (no ``.bob/`` layout yet) still defaults to skills.
158+
only contains a command layout — silently switching its extension /
159+
command-reference handling to the skills layout.
160+
161+
So when a *project_root* is supplied, the Spec Kit layout is inferred
162+
from **managed Spec Kit artifacts**, not from the mere presence of a
163+
``.bob/skills/`` directory: a user may keep unrelated Bob 2 skills in
164+
``.bob/skills/`` while their Spec Kit commands still live in
165+
``.bob/commands/speckit.*.md``. We therefore treat the project as
166+
legacy (command) mode only when managed Spec Kit command files exist
167+
and no managed Spec Kit skills (``speckit-*`` skill dirs) do. A fresh
168+
project (no managed artifacts yet) still defaults to skills.
164169
"""
165170
if (parsed_options or {}).get("legacy_commands", False):
166171
return False
167172
if project_root is not None:
168173
bob_dir = Path(project_root) / ".bob"
169-
if (bob_dir / "commands").is_dir() and not (bob_dir / "skills").is_dir():
174+
has_managed_skills = any((bob_dir / "skills").glob("speckit-*"))
175+
has_managed_commands = any((bob_dir / "commands").glob("speckit.*.md"))
176+
if has_managed_commands and not has_managed_skills:
170177
return False
171178
return True
172179

tests/integrations/test_integration_bob.py

Lines changed: 80 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -85,32 +85,63 @@ def test_legacy_commands_disables_skills(self):
8585
assert bob.is_skills_mode({"legacy_commands": True}) is False
8686

8787
def test_existing_commands_layout_preserved_on_use(self, tmp_path):
88-
"""Regression (review #3415): an existing Bob 1.x project (only
89-
``.bob/commands/`` on disk, no stored ``legacy_commands``) must NOT be
90-
treated as skills mode when re-resolved with a project_root, so
91-
``use``/``switch``/``upgrade`` never silently migrate it to skills.
88+
"""Regression (review #3415): an existing Bob 1.x project (managed
89+
``.bob/commands/speckit.*.md`` on disk, no stored ``legacy_commands``)
90+
must NOT be treated as skills mode when re-resolved with a
91+
project_root, so ``use``/``switch``/``upgrade`` never silently migrate
92+
it to skills.
9293
"""
9394
bob = get_integration("bob")
94-
(tmp_path / ".bob" / "commands").mkdir(parents=True)
95+
cmds = tmp_path / ".bob" / "commands"
96+
cmds.mkdir(parents=True)
97+
(cmds / "speckit.plan.md").write_text("# plan", encoding="utf-8")
9598
# No parsed options at all — the pre-existing-install scenario.
9699
assert bob.is_skills_mode(None, project_root=tmp_path) is False
97100
assert bob.is_skills_mode({}, project_root=tmp_path) is False
98101

99102
def test_existing_skills_layout_stays_skills_on_use(self, tmp_path):
100-
"""A ``.bob/skills/`` project resolves to skills mode."""
103+
"""A project with managed ``speckit-*`` skills resolves to skills mode."""
101104
bob = get_integration("bob")
102-
(tmp_path / ".bob" / "skills").mkdir(parents=True)
105+
(tmp_path / ".bob" / "skills" / "speckit-plan").mkdir(parents=True)
106+
assert bob.is_skills_mode(None, project_root=tmp_path) is True
107+
108+
def test_managed_commands_with_unrelated_skills_dir_stays_legacy(
109+
self, tmp_path
110+
):
111+
"""Regression (review #3415, 4723246468): a legacy Spec Kit install
112+
(managed ``.bob/commands/speckit.*.md``) that *also* carries unrelated
113+
Bob 2 skills (a ``.bob/skills/`` dir with no managed ``speckit-*``
114+
skills) must stay in command mode — the mere presence of a skills
115+
directory is not evidence that Spec Kit is skills-based.
116+
"""
117+
bob = get_integration("bob")
118+
cmds = tmp_path / ".bob" / "commands"
119+
cmds.mkdir(parents=True)
120+
(cmds / "speckit.plan.md").write_text("# plan", encoding="utf-8")
121+
# An unrelated (non-Spec-Kit) skill the user authored.
122+
(tmp_path / ".bob" / "skills" / "my-own-skill").mkdir(parents=True)
123+
assert bob.is_skills_mode(None, project_root=tmp_path) is False
124+
assert bob.effective_invoke_separator(None, project_root=tmp_path) == "."
125+
126+
def test_managed_skills_win_when_both_layouts_present(self, tmp_path):
127+
"""When managed Spec Kit skills exist, skills mode wins even if a stale
128+
managed command file is still on disk (upgrade leftover)."""
129+
bob = get_integration("bob")
130+
cmds = tmp_path / ".bob" / "commands"
131+
cmds.mkdir(parents=True)
132+
(cmds / "speckit.plan.md").write_text("# plan", encoding="utf-8")
133+
(tmp_path / ".bob" / "skills" / "speckit-plan").mkdir(parents=True)
103134
assert bob.is_skills_mode(None, project_root=tmp_path) is True
104135

105136
def test_fresh_project_defaults_to_skills_with_project_root(self, tmp_path):
106-
"""A project with no ``.bob/`` layout yet still defaults to skills."""
137+
"""A project with no managed ``.bob/`` artifacts yet defaults to skills."""
107138
bob = get_integration("bob")
108139
assert bob.is_skills_mode(None, project_root=tmp_path) is True
109140

110141
def test_explicit_legacy_flag_wins_over_disk_layout(self, tmp_path):
111142
"""An explicit ``--legacy-commands`` overrides on-disk detection."""
112143
bob = get_integration("bob")
113-
(tmp_path / ".bob" / "skills").mkdir(parents=True)
144+
(tmp_path / ".bob" / "skills" / "speckit-plan").mkdir(parents=True)
114145
assert (
115146
bob.is_skills_mode({"legacy_commands": True}, project_root=tmp_path)
116147
is False
@@ -519,7 +550,9 @@ def test_update_init_options_preserves_legacy_commands_project(self, tmp_path):
519550
from specify_cli import load_init_options
520551

521552
# Existing Bob 1.x project: legacy commands dir on disk, no ai_skills.
522-
(tmp_path / ".bob" / "commands").mkdir(parents=True)
553+
cmds = tmp_path / ".bob" / "commands"
554+
cmds.mkdir(parents=True)
555+
(cmds / "speckit.plan.md").write_text("# plan", encoding="utf-8")
523556
bob = get_integration("bob")
524557

525558
# Simulate the use/switch path: no parsed options were stored.
@@ -538,7 +571,7 @@ def test_update_init_options_keeps_skills_project_as_skills(self, tmp_path):
538571
)
539572
from specify_cli import load_init_options
540573

541-
(tmp_path / ".bob" / "skills").mkdir(parents=True)
574+
(tmp_path / ".bob" / "skills" / "speckit-plan").mkdir(parents=True)
542575
bob = get_integration("bob")
543576

544577
_update_init_options_for_integration(tmp_path, bob, parsed_options=None)
@@ -556,7 +589,9 @@ def test_with_integration_setting_stores_dot_separator_for_legacy(self, tmp_path
556589
"""
557590
from specify_cli.integration_runtime import with_integration_setting
558591

559-
(tmp_path / ".bob" / "commands").mkdir(parents=True)
592+
cmds = tmp_path / ".bob" / "commands"
593+
cmds.mkdir(parents=True)
594+
(cmds / "speckit.plan.md").write_text("# plan", encoding="utf-8")
560595
bob = get_integration("bob")
561596

562597
# Simulate the use/switch path: no parsed options stored.
@@ -694,3 +729,36 @@ def test_active_bob_skills_still_uses_hyphen(self, tmp_path):
694729
content = written[0].read_text(encoding="utf-8")
695730
assert "/speckit-plan" in content
696731
assert "/speckit.plan" not in content
732+
733+
def test_inactive_bob_command_output_uses_dot_even_with_skills_dir(
734+
self, tmp_path
735+
):
736+
"""Regression (review #3415, 4723246468): for an inactive Bob install
737+
the registrar's separator must match the layout it is actually writing
738+
(``.bob/commands/*.md`` — command layout), not on-disk sibling dirs.
739+
Even when a ``.bob/skills/`` directory (with managed ``speckit-*``
740+
skills) coexists, command-layout files must keep ``/speckit.<cmd>``.
741+
"""
742+
from specify_cli._init_options import save_init_options
743+
from specify_cli.agents import CommandRegistrar
744+
745+
# Both layouts on disk; the active agent is something else entirely.
746+
(tmp_path / ".bob" / "commands").mkdir(parents=True)
747+
(tmp_path / ".bob" / "skills" / "speckit-plan").mkdir(parents=True)
748+
save_init_options(tmp_path, {"ai": "claude", "ai_skills": True})
749+
750+
source_dir = tmp_path / "ext-src"
751+
commands = self._write_command_ref_ext(source_dir)
752+
753+
registrar = CommandRegistrar()
754+
registrar.register_commands("bob", commands, "ext", source_dir, tmp_path)
755+
756+
written = list((tmp_path / ".bob" / "commands").glob("*.md"))
757+
assert written, "expected a rendered Bob command file"
758+
content = written[0].read_text(encoding="utf-8")
759+
assert "__SPECKIT_COMMAND_PLAN__" not in content
760+
assert "/speckit.plan" in content, (
761+
"Bob command-layout output must use the dot separator regardless "
762+
"of a coexisting .bob/skills directory"
763+
)
764+
assert "/speckit-plan" not in content

0 commit comments

Comments
 (0)