pactkit-opencode 3.0.4__tar.gz → 3.0.5__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 (27) hide show
  1. {pactkit_opencode-3.0.4 → pactkit_opencode-3.0.5}/PKG-INFO +1 -1
  2. {pactkit_opencode-3.0.4 → pactkit_opencode-3.0.5}/pyproject.toml +1 -1
  3. pactkit_opencode-3.0.5/src/pactkit_opencode/declarations.py +209 -0
  4. {pactkit_opencode-3.0.4 → pactkit_opencode-3.0.5}/src/pactkit_opencode/deployer.py +11 -2
  5. {pactkit_opencode-3.0.4 → pactkit_opencode-3.0.5}/src/pactkit_opencode.egg-info/PKG-INFO +1 -1
  6. {pactkit_opencode-3.0.4 → pactkit_opencode-3.0.5}/src/pactkit_opencode.egg-info/SOURCES.txt +2 -0
  7. pactkit_opencode-3.0.5/tests/test_step6_self_sufficiency.py +128 -0
  8. {pactkit_opencode-3.0.4 → pactkit_opencode-3.0.5}/README.md +0 -0
  9. {pactkit_opencode-3.0.4 → pactkit_opencode-3.0.5}/setup.cfg +0 -0
  10. {pactkit_opencode-3.0.4 → pactkit_opencode-3.0.5}/src/pactkit_opencode/__init__.py +0 -0
  11. {pactkit_opencode-3.0.4 → pactkit_opencode-3.0.5}/src/pactkit_opencode/enforcement.py +0 -0
  12. {pactkit_opencode-3.0.4 → pactkit_opencode-3.0.5}/src/pactkit_opencode.egg-info/dependency_links.txt +0 -0
  13. {pactkit_opencode-3.0.4 → pactkit_opencode-3.0.5}/src/pactkit_opencode.egg-info/entry_points.txt +0 -0
  14. {pactkit_opencode-3.0.4 → pactkit_opencode-3.0.5}/src/pactkit_opencode.egg-info/requires.txt +0 -0
  15. {pactkit_opencode-3.0.4 → pactkit_opencode-3.0.5}/src/pactkit_opencode.egg-info/top_level.txt +0 -0
  16. {pactkit_opencode-3.0.4 → pactkit_opencode-3.0.5}/tests/test_a1_config_recovery.py +0 -0
  17. {pactkit_opencode-3.0.4 → pactkit_opencode-3.0.5}/tests/test_a2_mcp_retired.py +0 -0
  18. {pactkit_opencode-3.0.4 → pactkit_opencode-3.0.5}/tests/test_bug035_opencode_dual_layer.py +0 -0
  19. {pactkit_opencode-3.0.4 → pactkit_opencode-3.0.5}/tests/test_deploy_opencode_parity.py +0 -0
  20. {pactkit_opencode-3.0.4 → pactkit_opencode-3.0.5}/tests/test_gate_plugin_deploy.py +0 -0
  21. {pactkit_opencode-3.0.4 → pactkit_opencode-3.0.5}/tests/test_host_body_snapshot.py +0 -0
  22. {pactkit_opencode-3.0.4 → pactkit_opencode-3.0.5}/tests/test_opencode_enforcement_channel.py +0 -0
  23. {pactkit_opencode-3.0.4 → pactkit_opencode-3.0.5}/tests/test_story069_opencode_format.py +0 -0
  24. {pactkit_opencode-3.0.4 → pactkit_opencode-3.0.5}/tests/test_story070_opencode_compliance.py +0 -0
  25. {pactkit_opencode-3.0.4 → pactkit_opencode-3.0.5}/tests/test_story071_opencode_config_parity.py +0 -0
  26. {pactkit_opencode-3.0.4 → pactkit_opencode-3.0.5}/tests/test_story073_command_model_routing.py +0 -0
  27. {pactkit_opencode-3.0.4 → pactkit_opencode-3.0.5}/tests/test_story_work_units.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pactkit-opencode
3
- Version: 3.0.4
3
+ Version: 3.0.5
4
4
  Summary: PactKit adapter for OpenCode IDE
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pactkit-opencode"
7
- version = "3.0.4"
7
+ version = "3.0.5"
8
8
  description = "PactKit adapter for OpenCode IDE"
9
9
  requires-python = ">=3.10"
10
10
  license = "MIT"
@@ -0,0 +1,209 @@
1
+ """Opencode's own declarations (SPEC-4.0-22 Step 6).
2
+
3
+ These lived in Core's transitional seed (`_CORE_CAPABILITIES`, `_CORE_SECURITY`,
4
+ `_CORE_PRETOOL_SURFACES`, `_CORE_DEPLOYMENT_CLAIMS`) until Step 6. While they
5
+ did, this adapter could not answer for itself: `import pactkit_opencode` read Core's
6
+ table
7
+ for its own identity, so removing the seed broke the import rather than the
8
+ deployment.
9
+
10
+ Core still owns the **vocabulary** (`capability.CAPABILITY_VOCABULARY`), the
11
+ four levels, and the hole-filling rule in `resolve_capabilities`. What a given
12
+ Host scores on them is the Host's own declaration — that is the split
13
+ SPEC-4.0-22 R1 draws, and this module is the Opencode side of it.
14
+
15
+ Every value here was materialised from the seed Core was carrying, so this is a
16
+ move, not a re-measurement. The `Capability.evidence` strings are preserved
17
+ verbatim for the same reason: they are what the seed said, and re-wording them
18
+ here would quietly change what the registry reports.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from pactkit.capability import Capability, CapabilityLevel
24
+ from pactkit.profiles import (
25
+ CLIPolicy,
26
+ DeploymentClaim,
27
+ FormatProfile,
28
+ InputTranslation,
29
+ SecurityDeclaration,
30
+ SecurityFacts,
31
+ )
32
+
33
+ __all__ = [
34
+ "CAPABILITIES",
35
+ "DEPLOYMENT_CLAIM",
36
+ "INPUT_TRANSLATION",
37
+ "PRETOOL_SURFACE",
38
+ "PROFILE",
39
+ "SECURITY",
40
+ ]
41
+
42
+
43
+ def _cap(
44
+ name: str, level: CapabilityLevel, implemented: bool, evidence: str
45
+ ) -> Capability:
46
+ return Capability(
47
+ name=name, level=level, implemented=implemented, evidence=evidence
48
+ )
49
+
50
+
51
+ #: The Host's profile. Core no longer owns it; this module is the source.
52
+ PROFILE = FormatProfile(
53
+ name='opencode',
54
+ display_name='OpenCode',
55
+ global_config_dir='~/.config/opencode',
56
+ project_config_dir='.opencode',
57
+ skills_dir='~/.config/opencode/skills',
58
+ agents_dir='~/.config/opencode/agents',
59
+ commands_dir='~/.config/opencode/commands',
60
+ rules_dir='~/.config/opencode/rules',
61
+ project_instructions_file='AGENTS.md',
62
+ global_instructions_file='AGENTS.md',
63
+ pactkit_yaml_path='.opencode/pactkit.yaml',
64
+ agent_format='md',
65
+ rules_import_style='inline',
66
+ excluded_agent_fields=frozenset({'memory', 'permissionMode', 'skills'}),
67
+ excluded_commands=frozenset(),
68
+ supports_model_routing=True,
69
+ skills_path_var='~/.config/opencode/skills',
70
+ command_deploy_form='flat',
71
+ agent_dialect='opencode',
72
+ command_dialect='opencode',
73
+ core_bundled=False,
74
+ cli_policy=CLIPolicy.REQUIRED,
75
+ )
76
+
77
+ #: What this Host scores on the capability vocabulary.
78
+ CAPABILITIES: dict[str, Capability] = {
79
+ 'completion_hook': _cap('completion_hook', CapabilityLevel.DEGRADED, False,
80
+ "unverified — not measured on this Host; absence of the capability is NOT "
81
+ "established (SPEC-4.0-04 R2: unverified is not unavailable)",
82
+ ),
83
+ 'git_hook': _cap('git_hook', CapabilityLevel.NATIVE, True,
84
+ 'shipped (commit_gate pre-commit chain)',
85
+ ),
86
+ 'global_instruction': _cap('global_instruction', CapabilityLevel.DEGRADED, False,
87
+ "unverified — not measured on this Host; absence of the capability is NOT "
88
+ "established (SPEC-4.0-04 R2: unverified is not unavailable)",
89
+ ),
90
+ 'native_command': _cap('native_command', CapabilityLevel.NATIVE, True,
91
+ 'shipped (generators.deployer command deploy)',
92
+ ),
93
+ 'native_skill': _cap('native_skill', CapabilityLevel.NATIVE, True,
94
+ 'shipped (generators.deployer._deploy_skills)',
95
+ ),
96
+ 'persistent_session': _cap('persistent_session', CapabilityLevel.DEGRADED, False,
97
+ "unverified — not measured on this Host; absence of the capability is NOT "
98
+ "established (SPEC-4.0-04 R2: unverified is not unavailable)",
99
+ ),
100
+ 'post_tool_hook': _cap('post_tool_hook', CapabilityLevel.DEGRADED, False,
101
+ "unverified — not measured on this Host; absence of the capability is NOT "
102
+ "established (SPEC-4.0-04 R2: unverified is not unavailable)",
103
+ ),
104
+ 'pre_tool_hook': _cap('pre_tool_hook', CapabilityLevel.EQUIVALENT, True,
105
+ "shipped as a plugin veto (tool.execute.before) — Equivalent, not Native: it "
106
+ "is PactKit's plugin, not a host facility. End-to-end in a real session "
107
+ "2026-09-18 (docs/guides/opencode-pretool-channel-evidence.md)",
108
+ ),
109
+ 'project_instruction': _cap('project_instruction', CapabilityLevel.EQUIVALENT, True,
110
+ 'shipped (deployed instruction file)',
111
+ ),
112
+ 'rule_import': _cap('rule_import', CapabilityLevel.DEGRADED, False,
113
+ "unverified — not measured on this Host; absence of the capability is NOT "
114
+ "established (SPEC-4.0-04 R2: unverified is not unavailable)",
115
+ ),
116
+ 'session_resume': _cap('session_resume', CapabilityLevel.DEGRADED, False,
117
+ "unverified — not measured on this Host; absence of the capability is NOT "
118
+ "established (SPEC-4.0-04 R2: unverified is not unavailable)",
119
+ ),
120
+ 'shell_execution': _cap('shell_execution', CapabilityLevel.NATIVE, True,
121
+ 'shipped (native bash tool)',
122
+ ),
123
+ 'structured_tool_result': _cap('structured_tool_result', CapabilityLevel.DEGRADED,
124
+ False,
125
+ "unverified — not measured on this Host; absence of the capability is NOT "
126
+ "established (SPEC-4.0-04 R2: unverified is not unavailable)",
127
+ ),
128
+ 'subagent': _cap('subagent', CapabilityLevel.DEGRADED, False,
129
+ "unverified — not measured on this Host; absence of the capability is NOT "
130
+ "established (SPEC-4.0-04 R2: unverified is not unavailable)",
131
+ ),
132
+ }
133
+
134
+ #: Files a gate must treat specially on this Host (SPEC-4.0-22 R8).
135
+ SECURITY = SecurityDeclaration(
136
+ env_capable=SecurityFacts.declared(['opencode.json']),
137
+ protected=SecurityFacts.empty(),
138
+ conditional=SecurityFacts.empty(),
139
+ )
140
+
141
+ #: Which mechanism this Host offers for pre-tool interception, when one
142
+ #: has been measured. `None` means unverified — not "cannot".
143
+ PRETOOL_SURFACE = 'tool.execute.before (plugin; veto by throwing)'
144
+
145
+ #: How this Host executes and where its verification comes from.
146
+ DEPLOYMENT_CLAIM = DeploymentClaim(
147
+ execution_mode='portable',
148
+ verification_source='adapter_native_command_facade',
149
+ )
150
+
151
+ #: Read-side wire-format data (SPEC-4.0-22 Step 4/7).
152
+ #:
153
+ #: **This Host's wire format, not Core's data about it.** Until Step 7 this
154
+ #: lived in Core (`host_payloads.OPENCODE_INPUT`) and was registered into the
155
+ #: registry at import time. That made Core the owner of a Host-specific
156
+ #: format, and it stayed invisible for exactly as long as Core seeded this Host
157
+ #: at import — the moment the seed went, Core's import-time registration raised
158
+ #: `UnknownAdapterError` in every process that had not run entry-point
159
+ #: discovery, and the gate it feeds failed **open** (measured 2026-09-24).
160
+ #:
161
+ #: The Step 6 commit on this branch declared the slot but left it `None`, which
162
+ #: would have been worse than the bug it replaced: with Core's copy deleted,
163
+ #: nothing would recognise an OpenCode payload at all and the whole channel
164
+ #: would fail open. The values below are Core's, moved **verbatim** — this is
165
+ #: a move, not a re-measurement, and re-deriving them would quietly change what
166
+ #: the gate reads.
167
+ #:
168
+ #: Two entries are explicitly *not* evidence, and say so: `patch` has an
169
+ #: unmeasured argument shape (it is listed so that "every write tool is in the
170
+ #: table" holds, and it is refused through `uncovered_write_tools`), and
171
+ #: unknown tools carrying a path-ish argument are treated as writes as a
172
+ #: fallback.
173
+ #:
174
+ #: `tool_map` is **not injective** — `patch` and `write` both canonicalise to
175
+ #: `Write`. The order below is therefore not semantic: Core's reverse lookup
176
+ #: breaks the tie on `uncovered_write_tools` (preferring the verified spelling)
177
+ #: rather than on iteration order, precisely so this declaration can be written
178
+ #: in whatever order reads best.
179
+ INPUT_TRANSLATION: InputTranslation | None = InputTranslation(
180
+ requires_keys=('tool', 'args'),
181
+ excludes_keys=('tool_name',),
182
+ tool_map={
183
+ 'bash': 'Bash',
184
+ 'edit': 'Edit',
185
+ 'patch': 'Write',
186
+ 'write': 'Write',
187
+ },
188
+ arg_map={
189
+ 'bash': {
190
+ 'command': 'command',
191
+ },
192
+ 'edit': {
193
+ 'filePath': 'file_path',
194
+ 'newString': 'new_string',
195
+ 'oldString': 'old_string',
196
+ 'replaceAll': 'replace_all',
197
+ },
198
+ 'patch': {
199
+ 'filePath': 'file_path',
200
+ 'patchText': 'content',
201
+ },
202
+ 'write': {
203
+ 'content': 'content',
204
+ 'filePath': 'file_path',
205
+ },
206
+ },
207
+ read_only_tools=frozenset({'glob', 'grep', 'list', 'read', 'task', 'todoread', 'todowrite', 'webfetch'}),
208
+ uncovered_write_tools=frozenset({'patch'}),
209
+ path_arg_keys=('filePath', 'file_path', 'path'))
@@ -28,7 +28,7 @@ from pactkit.generators.deployer import (
28
28
  _deploy_rules,
29
29
  _deploy_skills,
30
30
  )
31
- from pactkit.profiles import get_profile
31
+ from pactkit_opencode import declarations
32
32
  from pactkit.utils import atomic_write
33
33
 
34
34
  # All canonical commands deploy. Sprint has a sequential capability fallback.
@@ -121,7 +121,16 @@ class OpenCodeDeployer(DeployerBase):
121
121
  registers, nor recommends external services)
122
122
  """
123
123
 
124
- profile = get_profile("opencode")
124
+ # SPEC-4.0-22 Step 6: this Host answers for itself. These were Core's
125
+ # transitional seed until now; `complete_registration` harvests them off the
126
+ # class, so the adapter no longer reads a table Core owns. `input_translation`
127
+ # rides its own lifecycle — it is installed after the registration exists.
128
+ profile = declarations.PROFILE
129
+ capabilities = declarations.CAPABILITIES
130
+ security = declarations.SECURITY
131
+ pretool_surface = declarations.PRETOOL_SURFACE
132
+ deployment_claim = declarations.DEPLOYMENT_CLAIM
133
+ input_translation = declarations.INPUT_TRANSLATION
125
134
 
126
135
  def deploy(self, config=None, target=None):
127
136
  opencode_root = Path(target) if target else Path.home() / ".config" / "opencode"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pactkit-opencode
3
- Version: 3.0.4
3
+ Version: 3.0.5
4
4
  Summary: PactKit adapter for OpenCode IDE
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -1,6 +1,7 @@
1
1
  README.md
2
2
  pyproject.toml
3
3
  src/pactkit_opencode/__init__.py
4
+ src/pactkit_opencode/declarations.py
4
5
  src/pactkit_opencode/deployer.py
5
6
  src/pactkit_opencode/enforcement.py
6
7
  src/pactkit_opencode.egg-info/PKG-INFO
@@ -16,6 +17,7 @@ tests/test_deploy_opencode_parity.py
16
17
  tests/test_gate_plugin_deploy.py
17
18
  tests/test_host_body_snapshot.py
18
19
  tests/test_opencode_enforcement_channel.py
20
+ tests/test_step6_self_sufficiency.py
19
21
  tests/test_story069_opencode_format.py
20
22
  tests/test_story070_opencode_compliance.py
21
23
  tests/test_story071_opencode_config_parity.py
@@ -0,0 +1,128 @@
1
+ """SPEC-4.0-22 Step 6: this adapter answers for itself.
2
+
3
+ The claim under test is not "registration is complete" — it already was, because
4
+ Core's transitional seed filled the same members. The claim is that **every one
5
+ of them comes from the adapter**.
6
+
7
+ The probe removes Core's declaration for this Host and re-seeds, then imports
8
+ the adapter in that state. It does so **without naming Core's tables**: the
9
+ claim does not depend on whether they are called `_CORE_*` (everything
10
+ published up to 3.0.4) or `_BUILTIN_*` (Step 7, where they hold the builtin
11
+ alone). A probe that hardcodes either name is coupled to a Core version — and
12
+ adapter CI installs Core from PyPI, so it would be testing the version it
13
+ happens to pull rather than the adapter.
14
+
15
+ The assertions name **properties**, not literals from the seed — the seed is
16
+ gone, so quoting it would assert the thing being ruled out.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import subprocess
22
+ import sys
23
+
24
+ HOST = "opencode"
25
+ MODULE = "pactkit_opencode"
26
+
27
+ #: Asserts Core declares nothing for this Host, then imports the adapter. Runs
28
+ #: in a fresh interpreter so the registry is exactly what `profiles` leaves
29
+ #: behind — no earlier import of this adapter can have registered it.
30
+ _UNSEED_TEMPLATE = '''
31
+ import sys
32
+ from pactkit import profiles as P
33
+
34
+ # Remove every trace of Core's declaration for this Host — by SHAPE, not by a
35
+ # hardcoded table name. Naming `_CORE_*` breaks on Step 7 core; naming
36
+ # `_BUILTIN_*` breaks on everything published before it. The claim under test
37
+ # does not depend on what the table is called, so the probe must not either.
38
+ removed = [n for n, v in list(vars(P).items())
39
+ if isinstance(v, dict) and HOST in v]
40
+ for n in removed:
41
+ vars(P)[n].pop(HOST, None)
42
+
43
+ # Re-seed through whatever this Core calls its seeder, so the registry is a
44
+ # consistent post-seed state rather than a half-emptied one.
45
+ for seeder in ("_seed_builtin", "_seed_from_core"):
46
+ fn = getattr(P, seeder, None)
47
+ if fn is not None:
48
+ fn()
49
+ break
50
+
51
+ # The precondition the import has to survive — and the guard against a probe
52
+ # that proves nothing. On Core 3.0.4 the removal above is what establishes it;
53
+ # on Step 7 Core it was already true, because there is no seed to remove. Both
54
+ # readings are the same claim, so this assertion is meaningful on both rather
55
+ # than demanding that something was removed (which Step 7 Core would fail for
56
+ # having done its job).
57
+ assert HOST not in P.FORMAT_PROFILES, (
58
+ "Core still carries this Host's profile when the adapter loads"
59
+ )
60
+ assert HOST not in P._ADAPTER_REGISTRY, (
61
+ "this Host is registered before its adapter has loaded"
62
+ )
63
+
64
+ import MODULE # noqa: F401 — the import IS the assertion
65
+
66
+ from pactkit.profiles import get_registration
67
+ reg = get_registration(HOST)
68
+ assert reg.complete, "registration incomplete after an unseeded import: " + reg.state
69
+ assert reg.source == P.SOURCE_ENTRY_POINT, "source is " + reg.source
70
+ print("OK")
71
+ '''
72
+
73
+
74
+ def _code(import_adapter: bool = True) -> str:
75
+ """The probe, optionally without the adapter import (the control).
76
+
77
+ The control substitution happens **before** the MODULE/HOST placeholders are
78
+ filled in. Doing it after silently matched nothing — the literal it looked
79
+ for no longer contained `MODULE` — and the control then ran the full probe
80
+ and passed for the wrong reason. Measured 2026-09-24.
81
+ """
82
+ code = _UNSEED_TEMPLATE
83
+ if not import_adapter:
84
+ before = code
85
+ code = code.replace(
86
+ "import MODULE # noqa: F401 — the import IS the assertion",
87
+ "pass # control: do NOT import the adapter",
88
+ )
89
+ assert code != before, "the control substitution matched nothing"
90
+ return code.replace("HOST", repr(HOST)).replace("MODULE", MODULE)
91
+
92
+
93
+ def _run(code: str) -> subprocess.CompletedProcess:
94
+ return subprocess.run(
95
+ [sys.executable, "-c", code], capture_output=True, text=True, timeout=180
96
+ )
97
+
98
+
99
+ def test_the_adapter_stands_up_without_its_core_seed():
100
+ """The unseeded import is the whole claim — it raised before Step 6."""
101
+ result = _run(_code())
102
+ assert result.returncode == 0, (
103
+ "the adapter could not stand up without Core's transitional seed — "
104
+ "which is exactly what Step 7 deletes:\n" + result.stderr[-2000:]
105
+ )
106
+
107
+
108
+ def test_the_probe_moves_when_the_adapter_is_not_imported():
109
+ """Control: the probe's assertions must distinguish the two states.
110
+
111
+ A control that merely re-runs the probe with one of its own assertions
112
+ disabled proves nothing — it passes because the check was removed, not
113
+ because the probe is sound. (Measured: the first version of this control did
114
+ exactly that and passed for that reason.)
115
+
116
+ The discriminating question is whether the probe's assertions *move*. So
117
+ this runs the same removal **without importing the adapter**: now nothing
118
+ has registered opencode, and the probe must report that — a registration
119
+ that is absent or incomplete, and a host still counted as transitional.
120
+ """
121
+ probe = _code(import_adapter=False)
122
+ result = _run(probe)
123
+ assert result.returncode != 0, (
124
+ "with no adapter imported, the probe still reported a complete "
125
+ "entry-point registration — so its assertions do not depend on the "
126
+ "adapter at all, and the main test is not measuring self-sufficiency"
127
+ )
128
+ assert "Traceback" in result.stderr, result.stderr[-800:]