pactkit-opencode 2.7.0__tar.gz → 2.9.0__tar.gz

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 (19) hide show
  1. {pactkit_opencode-2.7.0 → pactkit_opencode-2.9.0}/PKG-INFO +2 -2
  2. pactkit_opencode-2.9.0/README.md +99 -0
  3. {pactkit_opencode-2.7.0 → pactkit_opencode-2.9.0}/pyproject.toml +2 -2
  4. {pactkit_opencode-2.7.0 → pactkit_opencode-2.9.0}/src/pactkit_opencode/deployer.py +114 -17
  5. {pactkit_opencode-2.7.0 → pactkit_opencode-2.9.0}/src/pactkit_opencode.egg-info/PKG-INFO +2 -2
  6. {pactkit_opencode-2.7.0 → pactkit_opencode-2.9.0}/src/pactkit_opencode.egg-info/SOURCES.txt +1 -0
  7. pactkit_opencode-2.9.0/src/pactkit_opencode.egg-info/requires.txt +1 -0
  8. {pactkit_opencode-2.7.0 → pactkit_opencode-2.9.0}/tests/test_bug035_opencode_dual_layer.py +1 -1
  9. {pactkit_opencode-2.7.0 → pactkit_opencode-2.9.0}/tests/test_story069_opencode_format.py +14 -11
  10. {pactkit_opencode-2.7.0 → pactkit_opencode-2.9.0}/tests/test_story070_opencode_compliance.py +7 -3
  11. {pactkit_opencode-2.7.0 → pactkit_opencode-2.9.0}/tests/test_story073_command_model_routing.py +16 -5
  12. pactkit_opencode-2.7.0/src/pactkit_opencode.egg-info/requires.txt +0 -1
  13. {pactkit_opencode-2.7.0 → pactkit_opencode-2.9.0}/setup.cfg +0 -0
  14. {pactkit_opencode-2.7.0 → pactkit_opencode-2.9.0}/src/pactkit_opencode/__init__.py +0 -0
  15. {pactkit_opencode-2.7.0 → pactkit_opencode-2.9.0}/src/pactkit_opencode.egg-info/dependency_links.txt +0 -0
  16. {pactkit_opencode-2.7.0 → pactkit_opencode-2.9.0}/src/pactkit_opencode.egg-info/entry_points.txt +0 -0
  17. {pactkit_opencode-2.7.0 → pactkit_opencode-2.9.0}/src/pactkit_opencode.egg-info/top_level.txt +0 -0
  18. {pactkit_opencode-2.7.0 → pactkit_opencode-2.9.0}/tests/test_deploy_opencode_parity.py +0 -0
  19. {pactkit_opencode-2.7.0 → pactkit_opencode-2.9.0}/tests/test_story071_opencode_config_parity.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pactkit-opencode
3
- Version: 2.7.0
3
+ Version: 2.9.0
4
4
  Summary: PactKit adapter for OpenCode IDE
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
7
- Requires-Dist: pactkit>=2.7.0
7
+ Requires-Dist: pactkit>=2.9.0
@@ -0,0 +1,99 @@
1
+ # pactkit-opencode
2
+
3
+ > PactKit PDCA workflow framework adapted for [OpenCode](https://opencode.ai).
4
+
5
+ [![PyPI version](https://badge.fury.io/py/pactkit-opencode.svg)](https://pypi.org/project/pactkit-opencode/)
6
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
+
9
+ ## What is this?
10
+
11
+ **pactkit-opencode** brings the [PactKit](https://pactkit.dev) spec-driven development workflow to OpenCode IDE. It deploys:
12
+
13
+ - **Commands** (`commands/*.md`) — 11 PDCA workflow commands, auto-discovered by OpenCode (invoked via `/project-plan`, `/project-act`, etc.)
14
+ - **Skills** (`skills/*/SKILL.md`) — 10 embedded skills loaded by AI agent on demand
15
+ - **Agents** (`agents/*.md`) — 9 specialized agent definitions
16
+ - **Rules** (`rules/*.md`) — Governance rules loaded per-command
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ pip install pactkit
22
+ ```
23
+
24
+ > `pactkit-opencode` is automatically installed as a dependency of `pactkit`.
25
+
26
+ ## Quick Start
27
+
28
+ ```bash
29
+ # Deploy PactKit to OpenCode (~/.config/opencode/)
30
+ pactkit init --format opencode
31
+
32
+ # Update when pactkit is upgraded
33
+ pactkit upgrade --format opencode
34
+ ```
35
+
36
+ Then in OpenCode, use commands like:
37
+
38
+ ```
39
+ /project-plan "Add user authentication"
40
+ /project-act STORY-001
41
+ /project-done
42
+ ```
43
+
44
+ ## What Gets Deployed
45
+
46
+ ```
47
+ ~/.config/opencode/
48
+ ├── AGENTS.md # Global constitution
49
+ ├── rules/ # Governance rule modules
50
+ ├── commands/ # 11 PDCA command playbooks (auto-discovered by OpenCode)
51
+ ├── agents/ # 9 agent definitions (mode: subagent)
52
+ ├── skills/ # 10 skill packages (AI agent loads on demand)
53
+ └── opencode.json # Global config (model routing, instructions)
54
+
55
+ ./ # Project root
56
+ ├── .opencode/
57
+ │ ├── pactkit.yaml # Project config
58
+ │ └── AGENTS.local.md # Your custom instructions (never overwritten)
59
+ ```
60
+
61
+ ## OpenCode Architecture
62
+
63
+ OpenCode has two separate mechanisms for extending the AI assistant:
64
+
65
+ | Mechanism | Location | Invocation | Purpose |
66
+ |-----------|----------|------------|---------|
67
+ | **Commands** | `commands/*.md` | User types `/command-name` | PDCA workflow entry points |
68
+ | **Skills** | `skills/*/SKILL.md` | AI agent loads via `skill()` | Embedded tools (visualize, board, scaffold) |
69
+
70
+ Commands are auto-discovered from `commands/*.md` — no `opencode.json` configuration needed.
71
+
72
+ ## Key Differences from Claude Code
73
+
74
+ | Feature | Claude Code | OpenCode |
75
+ |---------|-------------|----------|
76
+ | Commands | Skills-only (`skills/project-*/SKILL.md`) | Dual: `commands/` + `skills/` |
77
+ | Config dir | `~/.claude/` | `~/.config/opencode/` |
78
+ | Agent format | Native multi-agent | `mode: subagent` |
79
+ | Model routing | Via skill frontmatter | Via `opencode.json` command entries |
80
+ | Rule loading | `@import` directives | Inline embedding per command |
81
+
82
+ ## Development
83
+
84
+ ```bash
85
+ git clone https://github.com/pactkit/pactkit-opencode.git
86
+ cd pactkit-opencode
87
+ pip install -e .
88
+ pytest tests/ -v
89
+ ```
90
+
91
+ ## License
92
+
93
+ MIT License - see [LICENSE](LICENSE) for details.
94
+
95
+ ## Related Projects
96
+
97
+ - [PactKit](https://pactkit.dev) — Core framework
98
+ - [pactkit-codex](https://github.com/pactkit/pactkit-codex) — Adapter for OpenAI Codex CLI
99
+ - [OpenCode](https://opencode.ai) — AI-powered coding IDE
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pactkit-opencode"
7
- version = "2.7.0"
7
+ version = "2.9.0"
8
8
  description = "PactKit adapter for OpenCode IDE"
9
9
  requires-python = ">=3.10"
10
10
  license = "MIT"
11
11
  dependencies = [
12
- "pactkit>=2.7.0",
12
+ "pactkit>=2.9.0",
13
13
  ]
14
14
 
15
15
  [project.entry-points."pactkit.deployers"]
@@ -4,6 +4,7 @@ Extracted from pactkit core (STORY-slim-058).
4
4
  """
5
5
 
6
6
  import json
7
+ import re
7
8
  from pathlib import Path
8
9
 
9
10
  from pactkit import __version__, prompts
@@ -25,8 +26,12 @@ from pactkit.generators.deployer import (
25
26
  _deploy_skills,
26
27
  )
27
28
  from pactkit.profiles import get_profile
29
+ from pactkit.prompts.rules import CREDENTIAL_SAFETY_FILE
28
30
  from pactkit.utils import atomic_write
29
31
 
32
+ # Commands excluded from OpenCode deployment (require multi-agent capabilities)
33
+ OPENCODE_EXCLUDED_COMMANDS = {"project-sprint"}
34
+
30
35
 
31
36
  class OpenCodeDeployer(DeployerBase):
32
37
  """OpenCode deployment — generate OpenCode-native configuration (STORY-069).
@@ -36,7 +41,9 @@ class OpenCodeDeployer(DeployerBase):
36
41
  - AGENTS.md (slim header — rules loaded via instructions)
37
42
  - rules/ directory with modular rule files (STORY-071 R6)
38
43
  - opencode.json (global config with instructions: ["rules/*.md"])
39
- - agents/, commands/, skills/ directories
44
+ - agents/ directory
45
+ - commands/ directory (PDCA commands, invoked via /command-name)
46
+ - skills/ directory (embedded skills like pactkit-board, pactkit-visualize)
40
47
 
41
48
  STORY-slim-008: Aligned with _deploy_classic() feature set:
42
49
  - Reads pactkit.yaml for selective deployment (R1)
@@ -55,11 +62,11 @@ class OpenCodeDeployer(DeployerBase):
55
62
 
56
63
  # Prepare directories
57
64
  agents_dir = opencode_root / "agents"
58
- commands_dir = opencode_root / "commands"
59
65
  skills_dir = opencode_root / "skills"
66
+ commands_dir = opencode_root / "commands"
60
67
  rules_dir = opencode_root / "rules"
61
68
 
62
- for d in [opencode_root, agents_dir, commands_dir, skills_dir, rules_dir]:
69
+ for d in [opencode_root, agents_dir, skills_dir, commands_dir, rules_dir]:
63
70
  d.mkdir(parents=True, exist_ok=True)
64
71
 
65
72
  # STORY-slim-008 R1+R2: Load config from project-level pactkit.yaml
@@ -76,13 +83,35 @@ class OpenCodeDeployer(DeployerBase):
76
83
  cfg = {}
77
84
 
78
85
  enabled_agents = cfg.get("agents", sorted(VALID_AGENTS))
79
- enabled_commands = cfg.get("commands", sorted(VALID_COMMANDS))
80
86
  enabled_skills = cfg.get("skills", sorted(VALID_SKILLS))
87
+ enabled_commands = cfg.get("commands", sorted(VALID_COMMANDS))
81
88
  enabled_rules = cfg.get("rules", sorted(VALID_RULES))
82
89
 
83
- # Deploy components with OpenCode profile
90
+ # Filter out excluded commands (project-sprint requires multi-agent)
91
+ enabled_commands = [c for c in enabled_commands if c not in OPENCODE_EXCLUDED_COMMANDS]
92
+
93
+ # Deploy embedded skills (pactkit-board, pactkit-visualize, etc.)
84
94
  n_skills = _deploy_skills(skills_dir, enabled_skills, profile=self.profile)
85
95
  _cleanup_legacy(skills_dir)
96
+ # Clean up legacy command skills from skills/ directory (migrated to commands/)
97
+ self._cleanup_command_skills(skills_dir)
98
+
99
+ # Apply CLI-to-script replacement to deployed embedded skill SKILL.md files
100
+ for skill_md in skills_dir.glob("*/SKILL.md"):
101
+ content = skill_md.read_text(encoding="utf-8")
102
+ replaced = _replace_cli_with_scripts(content)
103
+ if replaced != content:
104
+ atomic_write(skill_md, replaced)
105
+
106
+ # Deploy commands to commands/ directory (OpenCode uses /command-name to invoke)
107
+ n_commands = _deploy_commands(commands_dir, enabled_commands, profile=self.profile, config=cfg)
108
+
109
+ # Apply CLI-to-script replacement to deployed command files
110
+ for cmd_md in commands_dir.glob("*.md"):
111
+ content = cmd_md.read_text(encoding="utf-8")
112
+ replaced = _replace_cli_with_scripts(content)
113
+ if replaced != content:
114
+ atomic_write(cmd_md, replaced)
86
115
 
87
116
  n_rules = _deploy_rules(opencode_root, enabled_rules)
88
117
  self._deploy_agents_md_inline(opencode_root)
@@ -94,13 +123,13 @@ class OpenCodeDeployer(DeployerBase):
94
123
  )
95
124
  command_models = cfg.get("command_models", {})
96
125
  self._update_global_opencode_json(
97
- opencode_root, command_models=command_models, providers=providers
126
+ opencode_root,
127
+ command_models=command_models,
128
+ providers=providers,
129
+ enabled_commands=enabled_commands,
98
130
  )
99
131
 
100
132
  n_agents = _deploy_agents(agents_dir, enabled_agents, profile=self.profile)
101
- n_commands = _deploy_commands(
102
- commands_dir, enabled_commands, profile=self.profile, config=cfg
103
- )
104
133
 
105
134
  ci_config = cfg.get("ci", {})
106
135
  ci_provider = ci_config.get("provider", "none") if isinstance(ci_config, dict) else "none"
@@ -108,8 +137,8 @@ class OpenCodeDeployer(DeployerBase):
108
137
  _deploy_ci(ci_provider, project_root, cfg)
109
138
 
110
139
  print(
111
- f"\n✅ OpenCode: {n_agents} Agents, {n_commands} Commands, "
112
- f"{n_skills} Skills, {n_rules} Rules → {opencode_root}"
140
+ f"\n✅ OpenCode: {n_agents} Agents, {n_skills} Skills, "
141
+ f"{n_commands} Commands → {opencode_root}"
113
142
  )
114
143
 
115
144
  if target is None:
@@ -242,8 +271,10 @@ class OpenCodeDeployer(DeployerBase):
242
271
  return {}
243
272
 
244
273
  @staticmethod
245
- def _update_global_opencode_json(opencode_root, command_models=None, providers=None):
246
- """Update global opencode.json with instructions and command model routing."""
274
+ def _update_global_opencode_json(
275
+ opencode_root, command_models=None, providers=None, enabled_commands=None
276
+ ):
277
+ """Update global opencode.json with instructions, command templates, and model routing."""
247
278
  json_path = opencode_root / "opencode.json"
248
279
  config = {}
249
280
 
@@ -255,7 +286,7 @@ class OpenCodeDeployer(DeployerBase):
255
286
 
256
287
  config.setdefault("$schema", "https://opencode.ai/config.json")
257
288
 
258
- credential_path = f"rules/{prompts.CREDENTIAL_SAFETY_FILE}"
289
+ credential_path = f"rules/{CREDENTIAL_SAFETY_FILE}"
259
290
  existing = [
260
291
  i
261
292
  for i in config.get("instructions", [])
@@ -265,16 +296,34 @@ class OpenCodeDeployer(DeployerBase):
265
296
  existing.append(credential_path)
266
297
  config["instructions"] = existing
267
298
 
299
+ # OpenCode auto-discovers commands from commands/*.md directory.
300
+ # opencode.json "command" entries are only needed for model routing overrides.
301
+ # NOTE: "template" in JSON is inline prompt text, NOT a file path — do not write it.
302
+ cmd_config = config.get("command", {})
303
+ if enabled_commands:
304
+ # Remove stale command entries not in enabled set
305
+ stale_keys = [k for k in cmd_config if k.startswith("project-") and k not in enabled_commands]
306
+ for k in stale_keys:
307
+ del cmd_config[k]
308
+ # Remove legacy "template" fields (was incorrectly treated as file path)
309
+ for cmd_name in list(cmd_config):
310
+ if cmd_name.startswith("project-") and "template" in cmd_config[cmd_name]:
311
+ del cmd_config[cmd_name]["template"]
312
+ # Remove entry entirely if only template was present
313
+ if not cmd_config[cmd_name]:
314
+ del cmd_config[cmd_name]
315
+
316
+ # Apply model routing
268
317
  if command_models and providers:
269
- cmd_config = config.get("command", {})
270
318
  for cmd_name, model_short in command_models.items():
271
319
  model_id = OpenCodeDeployer._resolve_opencode_model_id(model_short, providers)
272
320
  if model_id:
273
321
  if cmd_name not in cmd_config:
274
322
  cmd_config[cmd_name] = {}
275
323
  cmd_config[cmd_name]["model"] = model_id
276
- if cmd_config:
277
- config["command"] = cmd_config
324
+
325
+ if cmd_config:
326
+ config["command"] = cmd_config
278
327
 
279
328
  content = json.dumps(config, indent=2, ensure_ascii=False) + "\n"
280
329
  atomic_write(json_path, content)
@@ -301,6 +350,54 @@ class OpenCodeDeployer(DeployerBase):
301
350
  '["npx", "-y", "@anthropic/mcp-puppeteer"] }'
302
351
  )
303
352
 
353
+ @staticmethod
354
+ def _cleanup_command_skills(skills_dir):
355
+ """Remove legacy command skills from skills/ directory.
356
+
357
+ Previously, PDCA commands were deployed as skills/{name}/SKILL.md.
358
+ Now they are in commands/{name}.md. Clean up the old entries.
359
+ """
360
+ for d in skills_dir.iterdir():
361
+ if d.is_dir() and d.name.startswith("project-"):
362
+ import shutil
363
+ shutil.rmtree(d)
364
+
365
+
366
+ # --- Module-level helper functions ---
367
+
368
+ _VIZ_SCRIPT = "python3 ~/.config/opencode/skills/pactkit-visualize/scripts/visualize.py"
369
+ _BOARD_SCRIPT = "python3 ~/.config/opencode/skills/pactkit-board/scripts/board.py"
370
+ _SCAFFOLD_SCRIPT = "python3 ~/.config/opencode/skills/pactkit-scaffold/scripts/scaffold.py"
371
+
372
+ # CLI subcommands that map to skill scripts (order matters: longer patterns first)
373
+ _CLI_TO_SCRIPT = [
374
+ # visualize variants
375
+ ("Run `pactkit visualize --lazy`", f"Run `{_VIZ_SCRIPT}` (file, `--mode class`, `--mode call` if source changed)"),
376
+ ("Run `pactkit visualize", f"Run `{_VIZ_SCRIPT}"),
377
+ ("Run `visualize --focus", f"Run `{_VIZ_SCRIPT} --focus"),
378
+ ("Run `visualize --mode", f"Run `{_VIZ_SCRIPT} --mode"),
379
+ ("Run `visualize`", f"Run `{_VIZ_SCRIPT}`"),
380
+ # board
381
+ ("Run `python3 ~/.config/opencode/skills/pactkit-board/scripts/board.py", f"Run `{_BOARD_SCRIPT}"),
382
+ # scaffold
383
+ ("Run `python3 ~/.config/opencode/skills/pactkit-scaffold/scripts/scaffold.py", f"Run `{_SCAFFOLD_SCRIPT}"),
384
+ # pactkit CLI → manual fallback hint
385
+ ("Run `pactkit clean`", "Run language-specific cleanup (e.g., `find . -name '__pycache__' -exec rm -rf {} +` for Python)"),
386
+ ]
387
+
388
+
389
+ def _replace_cli_with_scripts(content):
390
+ """Replace pactkit CLI and bare visualize commands with direct OpenCode script paths."""
391
+ for old, new in _CLI_TO_SCRIPT:
392
+ content = content.replace(old, new)
393
+ # Handle backtick-wrapped bare `visualize` with flags
394
+ content = re.sub(
395
+ r'`visualize (--(?:focus|mode|entry))',
396
+ rf'`{_VIZ_SCRIPT} \1',
397
+ content,
398
+ )
399
+ return content
400
+
304
401
 
305
402
  # Auto-register when this module is imported
306
403
  register_deployer("opencode", OpenCodeDeployer, force=True)
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pactkit-opencode
3
- Version: 2.7.0
3
+ Version: 2.9.0
4
4
  Summary: PactKit adapter for OpenCode IDE
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
7
- Requires-Dist: pactkit>=2.7.0
7
+ Requires-Dist: pactkit>=2.9.0
@@ -1,3 +1,4 @@
1
+ README.md
1
2
  pyproject.toml
2
3
  src/pactkit_opencode/__init__.py
3
4
  src/pactkit_opencode/deployer.py
@@ -39,7 +39,7 @@ class TestR1NoGlobalOpencodeJson:
39
39
  assert (out / "agents").is_dir()
40
40
 
41
41
  def test_global_deploy_has_commands_dir(self, tmp_path):
42
- """commands/ IS created in global deployment."""
42
+ """commands/ IS created OpenCode uses /command-name to invoke."""
43
43
  out = tmp_path / "opencode-global"
44
44
  deploy(format="opencode", target=str(out))
45
45
  assert (out / "commands").is_dir()
@@ -10,8 +10,11 @@ from pactkit.config import VALID_AGENTS, VALID_COMMANDS, VALID_FORMATS, VALID_SK
10
10
  from pactkit.generators.deployer import deploy
11
11
  from pactkit_opencode.deployer import OpenCodeDeployer
12
12
 
13
- # In pactkit 2.7.0, commands deploy as skills to skills/{name}/SKILL.md in classic format.
14
- # For OpenCode format, only embedded (non-command) skills go to skills/.
13
+ # OpenCode deploys commands to commands/{name}.md (invoked via /command-name)
14
+ # and embedded skills to skills/{name}/SKILL.md (auto-discovered by AI agent).
15
+ # project-sprint is excluded from OpenCode (requires multi-agent).
16
+ OPENCODE_EXCLUDED_COMMANDS = frozenset({"project-sprint"})
17
+ _DEPLOYABLE_COMMANDS = VALID_COMMANDS - OPENCODE_EXCLUDED_COMMANDS
15
18
  _EMBEDDED_SKILLS = VALID_SKILLS - VALID_COMMANDS
16
19
 
17
20
  # ===========================================================================
@@ -53,8 +56,8 @@ class TestR2GlobalDeployment:
53
56
  deploy(format="opencode", target=str(out))
54
57
  assert (out / "agents").is_dir()
55
58
 
56
- def test_commands_dir_exists(self, tmp_path):
57
- """commands/ directory is created."""
59
+ def test_commands_dir_created(self, tmp_path):
60
+ """commands/ directory IS created — OpenCode uses /command-name to invoke."""
58
61
  out = tmp_path / "opencode"
59
62
  deploy(format="opencode", target=str(out))
60
63
  assert (out / "commands").is_dir()
@@ -73,22 +76,22 @@ class TestR2GlobalDeployment:
73
76
  deployed = {f.stem for f in agents_dir.glob("*.md")}
74
77
  assert deployed == VALID_AGENTS
75
78
 
76
- def test_all_commands_deployed(self, tmp_path):
77
- """All commands are deployed."""
79
+ def test_all_commands_deployed_to_commands_dir(self, tmp_path):
80
+ """All commands (except project-sprint) are deployed as commands/{name}.md."""
78
81
  out = tmp_path / "opencode"
79
82
  deploy(format="opencode", target=str(out))
80
83
  commands_dir = out / "commands"
81
84
  deployed = {f.stem for f in commands_dir.glob("*.md")}
82
- assert deployed == VALID_COMMANDS
85
+ for cmd in _DEPLOYABLE_COMMANDS:
86
+ assert cmd in deployed, f"{cmd}.md not found in commands/"
83
87
 
84
- def test_all_skills_deployed(self, tmp_path):
85
- """All embedded skills are deployed to skills/. (pactkit 2.7.0: commands deploy
86
- to commands/{name}.md in opencode format, not skills/.)"""
88
+ def test_all_embedded_skills_deployed(self, tmp_path):
89
+ """All embedded skills are in skills/."""
87
90
  out = tmp_path / "opencode"
88
91
  deploy(format="opencode", target=str(out))
89
92
  skills_dir = out / "skills"
90
93
  deployed = {d.name for d in skills_dir.iterdir() if d.is_dir()}
91
- assert deployed == _EMBEDDED_SKILLS
94
+ assert _EMBEDDED_SKILLS.issubset(deployed)
92
95
 
93
96
 
94
97
  # ===========================================================================
@@ -49,14 +49,18 @@ class TestAC1CommandFrontmatter:
49
49
 
50
50
  def test_all_commands_converted(self, tmp_path):
51
51
  """All command files use OpenCode frontmatter."""
52
+ from pactkit.config import VALID_COMMANDS
53
+ EXCLUDED = frozenset({"project-sprint"})
52
54
  out = tmp_path / "oc"
53
55
  deploy(format="opencode", target=str(out))
54
56
  commands_dir = out / "commands"
55
- for cmd_file in commands_dir.glob("*.md"):
57
+ for cmd_name in VALID_COMMANDS - EXCLUDED:
58
+ cmd_file = commands_dir / f"{cmd_name}.md"
59
+ assert cmd_file.exists(), f"{cmd_name}.md missing in commands/"
56
60
  content = cmd_file.read_text()
57
61
  frontmatter = self._extract_frontmatter(content)
58
- assert "allowed-tools" not in frontmatter, f"{cmd_file.name} still has allowed-tools"
59
- assert "agent: build" in frontmatter, f"{cmd_file.name} missing agent: build"
62
+ assert "allowed-tools" not in frontmatter, f"{cmd_name} still has allowed-tools"
63
+ assert "agent: build" in frontmatter, f"{cmd_name} missing agent: build"
60
64
 
61
65
  def test_command_body_preserved(self, tmp_path):
62
66
  """Command body content is preserved after frontmatter conversion."""
@@ -65,8 +65,15 @@ class TestAC1CommandModel:
65
65
  """Command frontmatter should NOT contain model: (routing is in opencode.json)."""
66
66
  out = self._deploy_with_providers(tmp_path)
67
67
  content = (out / "commands" / "project-act.md").read_text()
68
- parts = content.split("---", 2)
69
- frontmatter = parts[1]
68
+ import re
69
+ matches = list(re.finditer(r'---\n(.*?)\n---', content, re.DOTALL))
70
+ assert matches, "No frontmatter found"
71
+ for m in matches:
72
+ if "description:" in m.group(1):
73
+ frontmatter = m.group(1)
74
+ break
75
+ else:
76
+ frontmatter = matches[-1].group(1)
70
77
  for line in frontmatter.strip().split("\n"):
71
78
  assert not line.strip().startswith("model:"), f"Unexpected model in frontmatter: {line}"
72
79
 
@@ -77,10 +84,14 @@ class TestAC1CommandModel:
77
84
 
78
85
 
79
86
  class TestAC2PlanNoModel:
80
- """AC2: Commands that inherit main model are NOT in opencode.json command section."""
87
+ """AC2: Commands that inherit main model have no model override in opencode.json."""
81
88
 
82
89
  def test_plan_not_in_command_routing(self, tmp_path):
83
- """project-plan is NOT in opencode.json command section."""
90
+ """project-plan has no entry in opencode.json command section (no model override needed).
91
+
92
+ OpenCode auto-discovers commands from commands/*.md directory.
93
+ JSON command entries are only for model routing overrides.
94
+ """
84
95
  out = tmp_path / "oc"
85
96
  out.mkdir(parents=True, exist_ok=True)
86
97
  providers = {"$schema": "x", "provider": {"tp": {"models": {"claude-sonnet-4.6": {}}}}}
@@ -90,7 +101,7 @@ class TestAC2PlanNoModel:
90
101
  assert "project-plan" not in data.get("command", {})
91
102
 
92
103
  def test_clarify_not_in_command_routing(self, tmp_path):
93
- """project-clarify is NOT in opencode.json command section."""
104
+ """project-clarify has no entry in opencode.json command section (no model override needed)."""
94
105
  out = tmp_path / "oc"
95
106
  out.mkdir(parents=True, exist_ok=True)
96
107
  providers = {"$schema": "x", "provider": {"tp": {"models": {"claude-sonnet-4.6": {}}}}}
@@ -1 +0,0 @@
1
- pactkit>=2.7.0