pactkit-codex 3.0.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pactkit-codex
3
- Version: 3.0.3
3
+ Version: 3.0.5
4
4
  Summary: PactKit adapter for OpenAI Codex CLI
5
5
  Author: Slim
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pactkit-codex"
7
- version = "3.0.3"
7
+ version = "3.0.5"
8
8
  description = "PactKit adapter for OpenAI Codex CLI"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -0,0 +1,10 @@
1
+ """PactKit Codex — thin adapter for OpenAI Codex CLI."""
2
+
3
+ from pactkit import __version__ # noqa: F401
4
+
5
+ # The second import is for its side effect: it registers Codex's enforcement
6
+ # channel (SPEC-4.0-06 R3), and registration IS Core's availability signal —
7
+ # an adapter that is not installed cannot register, so `format == "all"` never
8
+ # installs a channel for a host that never deployed.
9
+ from pactkit_codex.deployer import CodexDeployer # noqa: F401
10
+ from pactkit_codex.enforcement import codex_channel # noqa: F401
@@ -0,0 +1,141 @@
1
+ """Codex'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_codex` read Core's table
6
+ for its own identity, so removing the seed broke the import rather than the
7
+ deployment.
8
+
9
+ Core still owns the **vocabulary** (`capability.CAPABILITY_VOCABULARY`), the
10
+ four levels, and the hole-filling rule in `resolve_capabilities`. What a given
11
+ Host scores on them is the Host's own declaration — that is the split
12
+ SPEC-4.0-22 R1 draws, and this module is the Codex side of it.
13
+
14
+ Every value here was materialised from the seed Core was carrying, so this is a
15
+ move, not a re-measurement. The `Capability.evidence` strings are preserved
16
+ verbatim for the same reason: they are what the seed said, and re-wording them
17
+ here would quietly change what the registry reports.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from pactkit.capability import Capability, CapabilityLevel
23
+ from pactkit.profiles import (
24
+ CLIPolicy,
25
+ DeploymentClaim,
26
+ FormatProfile,
27
+ InputTranslation,
28
+ SecurityDeclaration,
29
+ SecurityFacts,
30
+ )
31
+
32
+ __all__ = [
33
+ "CAPABILITIES",
34
+ "DEPLOYMENT_CLAIM",
35
+ "INPUT_TRANSLATION",
36
+ "PRETOOL_SURFACE",
37
+ "PROFILE",
38
+ "SECURITY",
39
+ ]
40
+
41
+
42
+ def _cap(name: str, level: CapabilityLevel, implemented: bool, evidence: str) -> Capability:
43
+ return Capability(name=name, level=level, implemented=implemented, evidence=evidence)
44
+
45
+
46
+ #: The Host's profile. Core no longer owns it; this module is the source.
47
+ PROFILE = FormatProfile(
48
+ name='codex',
49
+ display_name='Codex CLI',
50
+ global_config_dir='~/.codex',
51
+ project_config_dir='.codex',
52
+ skills_dir='~/.codex/skills',
53
+ agents_dir='~/.codex/agents',
54
+ commands_dir='~/.codex/prompts',
55
+ rules_dir='~/.codex/rules',
56
+ project_instructions_file='AGENTS.md',
57
+ global_instructions_file='AGENTS.md',
58
+ pactkit_yaml_path='.codex/pactkit.yaml',
59
+ agent_format='md',
60
+ rules_import_style='inline',
61
+ excluded_agent_fields=frozenset({'memory', 'permissionMode', 'skills', 'hooks'}),
62
+ excluded_commands=frozenset(),
63
+ supports_model_routing=False,
64
+ skills_path_var='~/.codex/skills',
65
+ command_deploy_form='skill',
66
+ agent_dialect=None,
67
+ command_dialect=None,
68
+ core_bundled=False,
69
+ cli_policy=CLIPolicy.PREFERRED,
70
+ )
71
+
72
+ #: What this Host scores on the capability vocabulary.
73
+ CAPABILITIES: dict[str, Capability] = {
74
+ 'completion_hook': _cap('completion_hook', CapabilityLevel.DEGRADED, False,
75
+ "unverified — not measured on this Host; absence of the capability is NOT established (SPEC-4.0-04 R2: "
76
+ "unverified is not unavailable)",
77
+ ),
78
+ 'git_hook': _cap('git_hook', CapabilityLevel.NATIVE, True, 'shipped (commit_gate pre-commit chain)',
79
+ ),
80
+ 'global_instruction': _cap('global_instruction', CapabilityLevel.EQUIVALENT, True, 'shipped (~/.codex/AGENTS.md)',
81
+ ),
82
+ 'native_command': _cap('native_command', CapabilityLevel.DEGRADED, False,
83
+ "unverified — not measured on this Host; absence of the capability is NOT established (SPEC-4.0-04 R2: "
84
+ "unverified is not unavailable)",
85
+ ),
86
+ 'native_skill': _cap('native_skill', CapabilityLevel.DEGRADED, False,
87
+ "unverified — not measured on this Host; absence of the capability is NOT established (SPEC-4.0-04 R2: "
88
+ "unverified is not unavailable)",
89
+ ),
90
+ 'persistent_session': _cap('persistent_session', CapabilityLevel.DEGRADED, False,
91
+ "unverified — not measured on this Host; absence of the capability is NOT established (SPEC-4.0-04 R2: "
92
+ "unverified is not unavailable)",
93
+ ),
94
+ 'post_tool_hook': _cap('post_tool_hook', CapabilityLevel.DEGRADED, False,
95
+ "unverified — not measured on this Host; absence of the capability is NOT established (SPEC-4.0-04 R2: "
96
+ "unverified is not unavailable)",
97
+ ),
98
+ 'pre_tool_hook': _cap('pre_tool_hook', CapabilityLevel.NATIVE, True,
99
+ 'shipped (doctor.check_codex_hook_capability; native hooks engine)',
100
+ ),
101
+ 'project_instruction': _cap('project_instruction', CapabilityLevel.EQUIVALENT, True,
102
+ 'shipped (AGENTS.md at project root)',
103
+ ),
104
+ 'rule_import': _cap('rule_import', CapabilityLevel.EQUIVALENT, True, 'shipped as AGENTS.md (generators.deployer)',
105
+ ),
106
+ 'session_resume': _cap('session_resume', CapabilityLevel.DEGRADED, False,
107
+ "unverified — not measured on this Host; absence of the capability is NOT established (SPEC-4.0-04 R2: "
108
+ "unverified is not unavailable)",
109
+ ),
110
+ 'shell_execution': _cap('shell_execution', CapabilityLevel.NATIVE, True, 'shipped (native shell tool)',
111
+ ),
112
+ 'structured_tool_result': _cap('structured_tool_result', CapabilityLevel.DEGRADED, False,
113
+ "unverified — not measured on this Host; absence of the capability is NOT established (SPEC-4.0-04 R2: "
114
+ "unverified is not unavailable)",
115
+ ),
116
+ 'subagent': _cap('subagent', CapabilityLevel.DEGRADED, False,
117
+ "unverified — not measured on this Host; absence of the capability is NOT established (SPEC-4.0-04 R2: "
118
+ "unverified is not unavailable)",
119
+ ),
120
+ }
121
+
122
+ #: Files a gate must treat specially on this Host (SPEC-4.0-22 R8).
123
+ SECURITY = SecurityDeclaration(
124
+ env_capable=SecurityFacts.declared(['.codex/config.toml']),
125
+ protected=SecurityFacts.declared(['.codex/hooks.json']),
126
+ conditional=SecurityFacts.empty(),
127
+ )
128
+
129
+ #: Which mechanism this Host offers for pre-tool interception, when one
130
+ #: has been measured. `None` means unverified — not "cannot".
131
+ PRETOOL_SURFACE = 'native hooks engine'
132
+
133
+ #: How this Host executes and where its verification comes from.
134
+ DEPLOYMENT_CLAIM = DeploymentClaim(
135
+ execution_mode='portable',
136
+ verification_source='native_codex_session',
137
+ )
138
+
139
+ #: Read-side wire-format data, when the Host has one. `None` means it
140
+ #: declares none — the ladder then never asks it to recognise a payload.
141
+ INPUT_TRANSLATION: InputTranslation | None = None
@@ -36,13 +36,16 @@ from pactkit.generators.command_ownership import (
36
36
  write_command_manifest,
37
37
  )
38
38
  from pactkit.generators.deploy_base import DeployerBase, register_deployer
39
+
40
+ # SPEC-4.0-06 R1: of this adapter's Core imports, only the two from
41
+ # `pactkit.generators.spi` below have a frozen public equivalent; everything
42
+ # else in this block is reached for directly.
39
43
  from pactkit.generators.deployer import (
40
44
  _cleanup_legacy,
41
45
  _cleanup_legacy_portable_methods,
42
46
  _enforce_deploy_integrity,
43
- _render_prompt,
44
47
  )
45
- from pactkit.profiles import get_profile
48
+ from pactkit.generators.spi import render_prompt, render_skill_md
46
49
  from pactkit.prompts.guides import GUIDES_FILES
47
50
  from pactkit.prompts.rules import (
48
51
  COMMAND_CONDITIONAL_RULES_MAP,
@@ -53,6 +56,8 @@ from pactkit.prompts.rules import (
53
56
  )
54
57
  from pactkit.utils import atomic_write
55
58
 
59
+ from pactkit_codex import declarations
60
+
56
61
  # All canonical commands deploy. Sprint degrades to sequential execution when
57
62
  # Codex does not expose team orchestration primitives.
58
63
  CODEX_EXCLUDED_COMMANDS: frozenset[str] = frozenset()
@@ -107,7 +112,13 @@ class CodexDeployer(DeployerBase):
107
112
  - config.toml (not JSON/YAML)
108
113
  """
109
114
 
110
- profile = get_profile("codex")
115
+ # SPEC-4.0-22 Step 6 — the Host's own declarations (see declarations.py).
116
+ profile = declarations.PROFILE
117
+ capabilities = declarations.CAPABILITIES
118
+ security = declarations.SECURITY
119
+ pretool_surface = declarations.PRETOOL_SURFACE
120
+ deployment_claim = declarations.DEPLOYMENT_CLAIM
121
+ input_translation = declarations.INPUT_TRANSLATION
111
122
 
112
123
  @staticmethod
113
124
  def continuation_capabilities(codex_root=None):
@@ -115,16 +126,17 @@ class CodexDeployer(DeployerBase):
115
126
 
116
127
  Codex PDCA commands run in the user's active session. PactKit no
117
128
  longer ships runners, workflow hooks, or background execution.
129
+
130
+ The `(execution_mode, verification_source)` pair is deliberately
131
+ **absent**: it is this Host's declaration in `_write_deployment_manifest`,
132
+ and Core derives the manifest from it (SPEC-4.0-06 R8). A copy here would
133
+ not follow the declaration — R8's own test for "copy" not "derived" — and
134
+ the one this adapter carried had drifted from Core's `protocol_version`.
118
135
  """
119
136
  del codex_root
120
137
  return {
121
138
  "finish_guard_supported": False,
122
139
  "protocol_version": 0,
123
- # ``portable`` is the Core protocol guarantee. The separate
124
- # session_execution field records the host UX without inventing
125
- # a new doctor/workflow-engine enum value.
126
- "execution_mode": "portable",
127
- "verification_source": "native_codex_session",
128
140
  "completion_hook": False,
129
141
  "session_reentry": False,
130
142
  "auto_resume_available": False,
@@ -283,6 +295,38 @@ class CodexDeployer(DeployerBase):
283
295
  ))
284
296
  return tuple(paths)
285
297
 
298
+ @staticmethod
299
+ def _rule_paths(enabled_commands, enabled_rules) -> dict[str, list[str]]:
300
+ """Canonical rule id -> every relative path that rule is deployed to.
301
+
302
+ One definition, three readers: `deploy_codex_rules` retires stale
303
+ globals from it, the stale-reference sweep decides what to keep, and
304
+ the deployment manifest declares it to Core (SPEC-4.0-06 R2/AC2).
305
+ Codex keeps global rules in `rules/` and copies conditional ones into
306
+ each applicable command skill; Core derived that from the host name and
307
+ can no longer, and the host-agnostic fallback records none of the
308
+ command-local paths — under-recording the ledger that licenses deletion.
309
+ """
310
+ rules = sorted(DEFAULT_RULE_IDS) if enabled_rules is None else enabled_rules
311
+ enabled = {
312
+ r for rule_id in rules if (r := normalize_rule_id(rule_id)) is not None
313
+ }
314
+ commands = set(VALID_COMMANDS if enabled_commands is None else enabled_commands)
315
+ declared = {
316
+ rule_id: [f"rules/{RULE_DEFINITIONS[rule_id].filename}"]
317
+ for rule_id in sorted(enabled & RULE_DEFINITIONS.keys())
318
+ if RULE_DEFINITIONS[rule_id].load_policy == "global"
319
+ }
320
+ for command in sorted(COMMAND_CONDITIONAL_RULES_MAP):
321
+ if command not in commands:
322
+ continue
323
+ for rule_id in COMMAND_CONDITIONAL_RULES_MAP[command]:
324
+ if rule_id in enabled:
325
+ declared.setdefault(rule_id, []).append(
326
+ f"skills/{command}/references/rules/{rule_id}.md"
327
+ )
328
+ return declared
329
+
286
330
  @staticmethod
287
331
  def _cleanup_stale_command_references(
288
332
  codex_root: Path, enabled_commands, enabled_rules,
@@ -312,19 +356,9 @@ class CodexDeployer(DeployerBase):
312
356
  return
313
357
 
314
358
  commands = set(VALID_COMMANDS if enabled_commands is None else enabled_commands)
315
- configured = sorted(DEFAULT_RULE_IDS) if enabled_rules is None else enabled_rules
316
- rule_ids = {
317
- normalized
318
- for rule_id in configured
319
- if (normalized := normalize_rule_id(rule_id)) is not None
320
- }
321
- desired = {
322
- f"skills/{command}/references/rules/{rule_id}.md"
323
- for command, candidates in COMMAND_CONDITIONAL_RULES_MAP.items()
324
- if command in commands
325
- for rule_id in candidates
326
- if rule_id in rule_ids
327
- }
359
+ desired = set().union(
360
+ *CodexDeployer._rule_paths(enabled_commands, enabled_rules).values()
361
+ )
328
362
  if "project-act" in commands:
329
363
  desired.update(
330
364
  f"skills/project-act/references/guides/{filename}"
@@ -365,34 +399,45 @@ class CodexDeployer(DeployerBase):
365
399
  parent = parent.parent
366
400
 
367
401
  def _write_deployment_manifest(self, codex_root, cfg):
368
- """Write the Core manifest and Codex-native capability projection."""
369
- from pactkit.deploy_manifest import write_deploy_manifest
370
-
371
- manifest_path = write_deploy_manifest(codex_root, "codex", cfg)
402
+ """Write the Core manifest, declaring what only this Host knows."""
403
+ from pactkit.deploy_manifest import HostDeclaration, write_deploy_manifest
404
+
405
+ # Codex's rule layout is not derivable from the host name any more
406
+ # (SPEC-4.0-06 R2). Core used to branch on `format_name == "codex"` to
407
+ # find command-local rule references; the host-agnostic fallback records
408
+ # only `rules/pactkit-runtime.md`, under-recording the ledger that is
409
+ # what licenses `_cleanup_stale_command_references` to delete.
410
+ # R8: the `(execution_mode, verification_source)` pair is the Host's
411
+ # **declaration** — Core records it and derives both sections from it,
412
+ # including `tool_execution`, `manual_resume` and `guarantee_level`,
413
+ # which are functions of `execution_mode` and not separate facts. This
414
+ # adapter used to write both sections itself, restating the same two
415
+ # values; they happened to agree, and nothing kept them agreeing.
416
+ manifest_path = write_deploy_manifest(
417
+ codex_root, "codex", cfg,
418
+ declaration=HostDeclaration(
419
+ rule_paths=CodexDeployer._rule_paths(
420
+ cfg.get("commands"), cfg.get("rules", sorted(DEFAULT_RULE_IDS)),
421
+ ),
422
+ execution_mode="portable",
423
+ verification_source="native_codex_session",
424
+ ),
425
+ )
372
426
  manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
373
427
  capability = self.continuation_capabilities(codex_root)
374
428
  if not capability["hook_installed"]:
375
429
  capability["hook_observed"] = False
376
430
  capability["hook_trusted"] = False
377
- manifest["workflow_continuation"] = capability
378
- manifest["host_capabilities"] = {
379
- "protocol_version": 0,
380
- "verification_source": "native_codex_session",
381
- "instructions_discovery": True,
382
- "skills_discovery": True,
383
- "structured_results": False,
384
- "tool_execution": False,
385
- "approval": False,
386
- "lifecycle_events": False,
387
- "thread_resume": False,
388
- "turn_steer": False,
389
- "background_execution": False,
390
- "cancellation": False,
391
- "e2e_validated": False,
392
- "execution_mode": "portable",
393
- "manual_resume": False,
394
- "session_execution": "native_current_session",
395
- }
431
+ # Add only what Core cannot know; restating the pair here would
432
+ # re-create the second definition point R8 removes. The sections take
433
+ # different extras — hook state, and the session model's name.
434
+ derived = ("execution_mode", "verification_source", "guarantee_level")
435
+ manifest["workflow_continuation"].update({
436
+ key: value
437
+ for key, value in capability.items()
438
+ if key not in derived and key not in manifest["workflow_continuation"]
439
+ })
440
+ manifest["host_capabilities"]["session_execution"] = capability["session_execution"]
396
441
  atomic_write(
397
442
  manifest_path,
398
443
  json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
@@ -550,10 +595,13 @@ class CodexDeployer(DeployerBase):
550
595
 
551
596
  # Selective deploys describe the desired projection. Remove a disabled
552
597
  # rule only when the prior manifest proves ownership of unchanged bytes.
598
+ # Only `rules/` is this method's business; both readers take the layout
599
+ # from `_rule_paths`.
553
600
  enabled_paths = {
554
- (Path("rules") / RULE_DEFINITIONS[rule_id].filename).as_posix()
555
- for rule_id in enabled_ids
556
- if RULE_DEFINITIONS[rule_id].load_policy == "global"
601
+ path
602
+ for paths in CodexDeployer._rule_paths(None, enabled_rules).values()
603
+ for path in paths
604
+ if path.startswith("rules/")
557
605
  }
558
606
  for definition in RULE_DEFINITIONS.values():
559
607
  destination = rules_dir / definition.filename
@@ -597,7 +645,7 @@ class CodexDeployer(DeployerBase):
597
645
  if not content:
598
646
  continue
599
647
  content = content.strip()
600
- content = _render_prompt(content, profile)
648
+ content = render_prompt(content, profile)
601
649
  content = CodexDeployer.strip_model_selection_table(content)
602
650
  for pattern in CLAUDE_PATH_PATTERNS:
603
651
  content = content.replace(pattern, "~/.codex/")
@@ -647,7 +695,7 @@ class CodexDeployer(DeployerBase):
647
695
  lines = [f"# PactKit Runtime Contract (v{__version__})", ""]
648
696
  if "runtime" in enabled_rule_ids:
649
697
  lines.extend((
650
- _render_prompt(RULE_DEFINITIONS["runtime"].content.strip(), profile),
698
+ render_prompt(RULE_DEFINITIONS["runtime"].content.strip(), profile),
651
699
  "",
652
700
  ))
653
701
  lines.extend((
@@ -656,7 +704,7 @@ class CodexDeployer(DeployerBase):
656
704
  "",
657
705
  ))
658
706
 
659
- raw_content = _render_prompt("\n".join(lines), profile)
707
+ raw_content = render_prompt("\n".join(lines), profile)
660
708
 
661
709
  for pattern in CLAUDE_PATH_PATTERNS:
662
710
  raw_content = raw_content.replace(pattern, "~/.codex/")
@@ -730,7 +778,7 @@ class CodexDeployer(DeployerBase):
730
778
  description = line.split(":", 1)[1].strip().strip('"')
731
779
  content = parts[2].lstrip("\n")
732
780
 
733
- content = _render_prompt(content, profile)
781
+ content = render_prompt(content, profile)
734
782
  if cmd_name == "project-sprint":
735
783
  description = "Sequential PDCA in the current Codex session"
736
784
  content = _codex_single_session_sprint()
@@ -774,7 +822,7 @@ class CodexDeployer(DeployerBase):
774
822
  continue
775
823
  elif key in RULE_DEFINITIONS:
776
824
  inline_rules.append(
777
- _render_prompt(RULE_DEFINITIONS[key].content.strip(), profile)
825
+ render_prompt(RULE_DEFINITIONS[key].content.strip(), profile)
778
826
  )
779
827
 
780
828
  conditional = {
@@ -807,7 +855,7 @@ class CodexDeployer(DeployerBase):
807
855
  rendered[cmd_name] = skill_content
808
856
  command_references = {}
809
857
  for rule_id, definition in conditional.items():
810
- reference_content = _render_prompt(
858
+ reference_content = render_prompt(
811
859
  definition.content.strip(), profile,
812
860
  ).replace(
813
861
  "~/.codex/skills/_rules/guides/", "../guides/",
@@ -999,7 +1047,6 @@ class CodexDeployer(DeployerBase):
999
1047
  # STORY-slim-139 R4: consume the core SKILL_MANIFEST — no local
1000
1048
  # hardcoded skill list (the old 10-item snapshot silently dropped
1001
1049
  # garden/audit/report when core added them).
1002
- from pactkit.generators.deployer import _render_skill_md
1003
1050
  from pactkit.prompts.skills import get_skill_manifest
1004
1051
 
1005
1052
  enabled_set = set(enabled_skills)
@@ -1011,7 +1058,7 @@ class CodexDeployer(DeployerBase):
1011
1058
  skill_dir = skills_dir / sd["name"]
1012
1059
  skill_dir.mkdir(parents=True, exist_ok=True)
1013
1060
 
1014
- skill_md = _render_skill_md(sd, profile, _prefix)
1061
+ skill_md = render_skill_md(sd, profile, _prefix)
1015
1062
  skill_md = _replace_cli_with_scripts(skill_md)
1016
1063
  skill_md = CodexDeployer.strip_model_references(skill_md)
1017
1064
  _enforce_deploy_integrity(skill_md, profile, f"skill:{sd['name']}")
@@ -1200,7 +1247,8 @@ normal verification evidence.
1200
1247
  # CLI subcommands that map to skill scripts (STORY-slim-145 R3: lossy CLI
1201
1248
  # prefix replacements for regression/lint/context/clean/visualize/guard/
1202
1249
  # doctor/update REMOVED — Codex is CLIPolicy.PREFERRED, so canonical `pactkit`
1203
- # CLI commands are preserved by Core _render_prompt rather than rewritten here.
1250
+ # CLI commands are preserved by Core's render_prompt (via the adapter SPI)
1251
+ # rather than rewritten here.
1204
1252
  # Only genuine path normalization (board/scaffold) remains in this table.
1205
1253
  _CLI_TO_SCRIPT = [
1206
1254
  # board (path normalization)
@@ -0,0 +1,77 @@
1
+ """Codex's enforcement channel, stated by its owner (SPEC-4.0-06 R3).
2
+
3
+ Core used to install this channel from a ``format_name == "codex"`` branch:
4
+ the config file, the matchers, the trust notice and the transport key were
5
+ Core constants. R3 moved all four onto the host, and Core now traverses a
6
+ registry instead of branching. The transport key has since been deleted
7
+ outright rather than moved — see ``codex_channel``. This module is what makes "the codex adapter
8
+ is installed" and "Codex's channel can be installed" the same fact — the
9
+ registry's own docstring names import-time registration as the availability
10
+ signal, so nothing here asks Core whether this host is present.
11
+
12
+ Registering nothing is a valid state and means the channel is not installed:
13
+ that is the honest answer for a host whose adapter is absent, and it is why
14
+ ``gate_matrix.channels_for`` must not be read as an install guarantee on its
15
+ own.
16
+
17
+ The merge, the config lock, the atomic replace and the "unrecognized
18
+ structure → leave byte-identical" protections are Core's ``install_channel``.
19
+ Nothing here re-implements them; this module supplies host facts and nothing
20
+ else.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from pathlib import Path
26
+
27
+ from pactkit.commit_gate import HookChannel, register_enforcement_channel
28
+
29
+ # MUST equal the string `gate_matrix.channels_for("codex")` advertises, or the
30
+ # coverage notice names a channel that is not the one installed — the
31
+ # divergence SPEC-4.0-06 R4 records as this dataclass's standing tension.
32
+ # `test_codex_enforcement_channel.py` asserts the agreement; nothing can
33
+ # enforce it structurally, which is exactly why it is asserted.
34
+ IDENTITY = "codex PreToolUse hook"
35
+
36
+ # The wire contract is Codex's, and it matches Claude Code's (tool_name
37
+ # "Bash", exit 2 + stderr block), so the handler is `pactkit commit-gate
38
+ # --hook` unchanged. Bash feeds the commit/push/tamper pipeline; Edit|Write
39
+ # feeds the tamper guard. Stated here rather than imported from Core: which
40
+ # tools a host gates is host knowledge, and importing Core's tuple would put
41
+ # it back.
42
+ MATCHERS = ("Bash", "Edit|Write")
43
+
44
+ # Deployment never activates the hook silently — Codex requires a one-time
45
+ # content-hash trust confirmation, and the report has to say so.
46
+ TRUST_NOTICE = (
47
+ " — needs one-time trust confirmation inside Codex "
48
+ "(content-hash prompt) before it takes effect"
49
+ )
50
+
51
+
52
+ def codex_channel(root: Path) -> HookChannel:
53
+ """Codex's channel as of this deploy root.
54
+
55
+ No ``transport`` is declared: the retired MCP transport is gone, so this
56
+ host's transport is the shell hook, which is ``HookChannel``'s default.
57
+
58
+ The config key that used to select it (``enforcement.codex_hook_transport``)
59
+ was the last Policy-layer remnant of the MCP retirement and was deleted on
60
+ 2026-09-22 — Core never owned it (§4 P1.1 Policy #2: which key selects a
61
+ host's load policy is host knowledge) and this adapter only read it to
62
+ RECOGNIZE a pending migration. That recognition was not load-bearing: the
63
+ installer already decides from the hooks file itself, and the key's only
64
+ remaining effect was to report a migration on projects that had nothing to
65
+ migrate.
66
+ """
67
+ del root
68
+ return HookChannel(
69
+ relative_path=".codex/hooks.json",
70
+ identity=IDENTITY,
71
+ matchers=MATCHERS,
72
+ label="codex hook",
73
+ notice=TRUST_NOTICE,
74
+ )
75
+
76
+
77
+ register_enforcement_channel("codex", codex_channel)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pactkit-codex
3
- Version: 3.0.3
3
+ Version: 3.0.5
4
4
  Summary: PactKit adapter for OpenAI Codex CLI
5
5
  Author: Slim
6
6
  License: MIT
@@ -2,7 +2,9 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  src/pactkit_codex/__init__.py
5
+ src/pactkit_codex/declarations.py
5
6
  src/pactkit_codex/deployer.py
7
+ src/pactkit_codex/enforcement.py
6
8
  src/pactkit_codex.egg-info/PKG-INFO
7
9
  src/pactkit_codex.egg-info/SOURCES.txt
8
10
  src/pactkit_codex.egg-info/dependency_links.txt
@@ -0,0 +1,239 @@
1
+ """This host's deployed prompt bodies, pinned against a golden (R7/AC10).
2
+
3
+ 2026-09-12 review (83/100, gap 2): the zero-credential acceptance split
4
+ left adapter-host body comparison out of CI — the core workflow compares
5
+ classic only. This suite pins the CODEX deployment's prompt bodies:
6
+
7
+ - digest drift (a rule edited or deleted inside a prompt body) fails,
8
+ naming the file;
9
+ - a core version mismatch fails with an explicit action — regenerate the
10
+ golden against the new core, or publish the matching core release
11
+ (PENDING-COMPATIBLE-CORE, see the spec R8 release-window semantics);
12
+ - a drift at a MATCHING version is diagnosed as such rather than as a
13
+ version skew (`_drift_diagnosis`) — the version guard compares strings and
14
+ so cannot see a core prompt edit made inside one version;
15
+ - a probe test deploys, edits one body in place, and proves the SAME
16
+ comparison logic flags it (删改必被检出, not vacuous).
17
+
18
+ Regenerate with: tests/scripts/refresh_host_body_golden.py — and record the
19
+ cause when you do; an unexplained regeneration turns the golden into a copy
20
+ of today's output and its guard value goes to zero.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import json
27
+ import subprocess
28
+ import sys
29
+ from pathlib import Path
30
+
31
+ import pytest
32
+
33
+ HOST = "codex"
34
+ GOLDEN = Path(__file__).parent / "fixtures" / "host_body_golden.json"
35
+ DEPLOY_SUFFIXES = (".md", ".json", ".toml")
36
+
37
+
38
+ def _core_version() -> str:
39
+ import importlib.metadata as m
40
+
41
+ return m.version("pactkit")
42
+
43
+
44
+ def _normalize(content: str, deploy_root: str) -> str:
45
+ """Strip only the deterministic environment difference: the deploy root
46
+ path baked into rendered prompts. Rule text stays — normalizing it would
47
+ swallow the drift this suite exists to catch."""
48
+ return content.replace(deploy_root, "<DEPLOY_ROOT>")
49
+
50
+
51
+ def _deployed_digests(tmp_path: Path) -> tuple[dict[str, str] | None, subprocess.CompletedProcess]:
52
+ home = tmp_path / "home"
53
+ target = tmp_path / "deploy"
54
+ home.mkdir()
55
+ proc = subprocess.run(
56
+ [sys.executable, "-m", "pactkit", "init", "--format", HOST, "--target", str(target)],
57
+ capture_output=True, text=True, timeout=180,
58
+ env={"PATH": "/usr/bin:/bin", "HOME": str(home), "COLUMNS": "200"},
59
+ )
60
+ if proc.returncode != 0:
61
+ return None, proc
62
+ digests: dict[str, str] = {}
63
+ for path in sorted(target.rglob("*")):
64
+ if not path.is_file() or path.suffix not in DEPLOY_SUFFIXES:
65
+ continue
66
+ body = _normalize(path.read_text(encoding="utf-8"), str(target))
67
+ digests[path.relative_to(target).as_posix()] = hashlib.sha256(
68
+ body.encode("utf-8")
69
+ ).hexdigest()
70
+ return digests, proc
71
+
72
+
73
+ def _drift_diagnosis(golden: dict, core_version: str) -> str:
74
+ """Which of the two red lights is this? R7's answer to the version guard's
75
+ structural blindness.
76
+
77
+ The guard compares version STRINGS, so "core edited a prompt without
78
+ bumping the version" and "the adapter broke" produce the same diff — and
79
+ the old message resolved that ambiguity by hedging, offering "publish the
80
+ matching core release" as an alternative to regenerating. In a path only
81
+ reachable once the versions already match, that advice cannot apply: it
82
+ was describing the other red light. So the diagnosis is derived from the
83
+ version facts instead of guessed, and names the one thing the evidence
84
+ establishes — that a version skew is ruled out.
85
+
86
+ What it does NOT claim: that the adapter is innocent. Matching versions
87
+ plus differing bodies means the difference is content, and content can
88
+ have moved on either side. Both diffs are named, because guessing between
89
+ them is what made this message useless.
90
+
91
+ Measured instance (2026-09-21, Batch 4) — three bodies drifted with the
92
+ versions matching. Attributed by deploying the SAME adapter against core
93
+ v3.0.3 and against the working tree:
94
+
95
+ - `skills/project-act/SKILL.md` — core `49ef848` added `--outcome green`
96
+ and the unavailable/red branches to the Act playbook's "Verification
97
+ Record" line (`prompts/commands.py:282`), after this golden was
98
+ regenerated and without a version bump. That commit's own message
99
+ predicted this drift and deferred it to Batch 5.
100
+ - `skills/.pactkit-command-manifest.json` — the sha256 of the body above,
101
+ so a consequence of the same edit rather than an independent drift.
102
+ - `.pactkit-deployed.json` — core `04b1689` deleted the `rule_loading.
103
+ layout` host-policy string, and the rule records changed under
104
+ SPEC-4.0-06 R2. No prompt body is involved.
105
+
106
+ Not a cause, though it looks like one: `04b1689` also edited
107
+ `prompts/rules.py`, but that line sits in the legacy `architecture`
108
+ module, which this host does not deploy — every deployed body is
109
+ byte-identical across the two cores, so it drifted nothing. A plausible
110
+ cause that the evidence rules out is worth as much as one it confirms.
111
+
112
+ Measured instance (2026-09-22, SPEC-4.0-06 R8) — one body drifted with the
113
+ versions matching, and this time **the adapter moved, not Core**:
114
+
115
+ - `.pactkit-deployed.json` — `_write_deployment_manifest` now declares the
116
+ `(execution_mode, verification_source)` pair to Core instead of writing
117
+ both manifest sections itself. Three consequences, all inside that one
118
+ artifact: `protocol_version` 0 -> 1 in both sections (Core's
119
+ `CORE_PROTOCOL_VERSION` — the adapter's hardcoded 0 had drifted from it
120
+ with nothing keeping the two equal, which is the defect R8 removes),
121
+ `workflow_continuation.verification_source` is no longer restated, and
122
+ `workflow_continuation.e2e_validated` is now Core's.
123
+
124
+ The other 67 bodies are byte-identical, and that is the evidence the move
125
+ is adapter-side: a Core prompt edit would have moved prompt bodies, and
126
+ none moved.
127
+ """
128
+ if golden["core_version"] == core_version:
129
+ return (
130
+ f"core_version matches but content differs — the golden and this "
131
+ f"environment are both core {core_version}, so a version skew is "
132
+ f"ruled out and the difference is content: either Core's prompts "
133
+ f"changed inside this version (no bump, so the check above cannot "
134
+ f"see it — measured instance: core 49ef848 edited the Act playbook "
135
+ f"after this golden was regenerated) or this adapter's rendering "
136
+ f"changed. Diff the core prompts and this adapter since the golden "
137
+ f"was generated; regenerate only once the cause is known — see "
138
+ f"this function's docstring for the 2026-09-21 attribution"
139
+ )
140
+ return (
141
+ f"core_version differs — the golden was generated against core "
142
+ f"{golden['core_version']} and this environment has {core_version}. "
143
+ f"Regenerate the golden after the core change, or publish the matching "
144
+ f"core release first (PENDING-COMPATIBLE-CORE, spec R8 release-window "
145
+ f"semantics)"
146
+ )
147
+
148
+
149
+ def _assert_no_drift(golden: dict, live: dict[str, str]) -> None:
150
+ """The one comparison both the golden test and the tamper probe use —
151
+ including the diagnosis, so the probe exercises the message a real drift
152
+ would produce rather than a similar one."""
153
+ golden_digests = golden["body_digests"]
154
+ changed = sorted(
155
+ k for k in set(golden_digests) & set(live) if golden_digests[k] != live[k]
156
+ )
157
+ removed = sorted(set(golden_digests) - set(live))
158
+ added = sorted(set(live) - set(golden_digests))
159
+ assert not (changed or removed or added), (
160
+ f"prompt body drift: changed={changed} removed={removed} added={added} "
161
+ f"— {_drift_diagnosis(golden, _core_version())}"
162
+ )
163
+
164
+
165
+ class TestHostBodySnapshot:
166
+ def test_bodies_match_the_golden(self, tmp_path):
167
+ golden = json.loads(GOLDEN.read_text(encoding="utf-8"))
168
+ core_v = _core_version()
169
+ if golden["core_version"] != core_v:
170
+ # Fail here rather than at the comparison: under a version skew
171
+ # every body reads as "changed", which is noise, not signal. The
172
+ # wording comes from the same diagnosis function the comparison
173
+ # uses, so the two red lights cannot drift apart.
174
+ pytest.fail(_drift_diagnosis(golden, core_v))
175
+ live, proc = _deployed_digests(tmp_path)
176
+ assert live is not None, (
177
+ f"{HOST} deploy failed: {proc.stdout[-300:]}{proc.stderr[-300:]}"
178
+ )
179
+ _assert_no_drift(golden, live)
180
+
181
+ def test_a_body_edit_is_caught_by_the_same_comparison(self, tmp_path):
182
+ """Non-vacuity, end to end: deploy, edit one prompt body in place,
183
+ and the SAME comparison that guards the golden must flag it."""
184
+ golden = json.loads(GOLDEN.read_text(encoding="utf-8"))
185
+ live, proc = _deployed_digests(tmp_path)
186
+ assert live is not None, f"{HOST} deploy failed: {proc.stderr[-300:]}"
187
+ target = tmp_path / "deploy"
188
+ # Pick a real deployed prompt body and delete one rule line from it.
189
+ victims = sorted(
190
+ p for p in target.rglob("*.md") if p.relative_to(target).as_posix() in golden["body_digests"]
191
+ )
192
+ assert victims, "no deployed .md body found to tamper with"
193
+ victim = victims[0]
194
+ rel = victim.relative_to(target).as_posix()
195
+ body = victim.read_text(encoding="utf-8")
196
+ victim.write_text(body + "\n- deleted in a review\n", encoding="utf-8")
197
+
198
+ tampered = {
199
+ p.relative_to(target).as_posix(): hashlib.sha256(
200
+ _normalize(p.read_text(encoding="utf-8"), str(target)).encode("utf-8")
201
+ ).hexdigest()
202
+ for p in sorted(target.rglob("*"))
203
+ if p.is_file() and p.suffix in DEPLOY_SUFFIXES
204
+ }
205
+ with pytest.raises(AssertionError, match=rel):
206
+ _assert_no_drift(golden, tampered)
207
+
208
+ def test_a_drift_at_a_matching_version_is_diagnosed_as_content(self, tmp_path):
209
+ """R7(b): on drift the guard must say the versions matched. The old
210
+ message offered "publish the matching core release" as an alternative
211
+ to regenerating — advice that cannot apply here, because this path is
212
+ only reachable once the versions already match."""
213
+ golden = json.loads(GOLDEN.read_text(encoding="utf-8"))
214
+ assert golden["core_version"] == _core_version(), (
215
+ "this test is about a drift at a MATCHING version"
216
+ )
217
+ live, proc = _deployed_digests(tmp_path)
218
+ assert live is not None, f"{HOST} deploy failed: {proc.stderr[-300:]}"
219
+ victim = sorted(golden["body_digests"])[0]
220
+ drifted = dict(live)
221
+ drifted[victim] = hashlib.sha256(b"not the deployed body").hexdigest()
222
+
223
+ with pytest.raises(AssertionError) as caught:
224
+ _assert_no_drift(golden, drifted)
225
+
226
+ message = str(caught.value)
227
+ assert victim in message, "the drifting body is not named"
228
+ assert "core_version matches but content differs" in message, (
229
+ f"the guard did not diagnose the matching version: {message}"
230
+ )
231
+
232
+ def test_a_version_skew_is_diagnosed_as_a_skew(self):
233
+ """The other red light, from the same function: a golden pinned against
234
+ a different core must be told to regenerate or publish — not told its
235
+ content changed inside a version it does not share."""
236
+ golden = {"core_version": "0.0.0-not-this-core", "body_digests": {}}
237
+ message = _drift_diagnosis(golden, _core_version())
238
+ assert "PENDING-COMPATIBLE-CORE" in message
239
+ assert "core_version matches but content differs" not in message
@@ -1,5 +0,0 @@
1
- """PactKit Codex — thin adapter for OpenAI Codex CLI."""
2
-
3
- from pactkit import __version__ # noqa: F401
4
-
5
- from pactkit_codex.deployer import CodexDeployer # noqa: F401
@@ -1,126 +0,0 @@
1
- """This host's deployed prompt bodies, pinned against a golden (R7/AC10).
2
-
3
- 2026-09-12 review (83/100, gap 2): the zero-credential acceptance split
4
- left adapter-host body comparison out of CI — the core workflow compares
5
- classic only. This suite pins the CODEX deployment's prompt bodies:
6
-
7
- - digest drift (a rule edited or deleted inside a prompt body) fails,
8
- naming the file;
9
- - a core version mismatch fails with an explicit action — regenerate the
10
- golden against the new core, or publish the matching core release
11
- (PENDING-COMPATIBLE-CORE, see the spec R8 release-window semantics);
12
- - a probe test deploys, edits one body in place, and proves the SAME
13
- comparison logic flags it (删改必被检出, not vacuous).
14
-
15
- Regenerate with: tests/scripts/refresh_host_body_golden.py
16
- """
17
-
18
- from __future__ import annotations
19
-
20
- import hashlib
21
- import json
22
- import subprocess
23
- import sys
24
- from pathlib import Path
25
-
26
- import pytest
27
-
28
- HOST = "codex"
29
- GOLDEN = Path(__file__).parent / "fixtures" / "host_body_golden.json"
30
- DEPLOY_SUFFIXES = (".md", ".json", ".toml")
31
-
32
-
33
- def _core_version() -> str:
34
- import importlib.metadata as m
35
-
36
- return m.version("pactkit")
37
-
38
-
39
- def _normalize(content: str, deploy_root: str) -> str:
40
- """Strip only the deterministic environment difference: the deploy root
41
- path baked into rendered prompts. Rule text stays — normalizing it would
42
- swallow the drift this suite exists to catch."""
43
- return content.replace(deploy_root, "<DEPLOY_ROOT>")
44
-
45
-
46
- def _deployed_digests(tmp_path: Path) -> tuple[dict[str, str] | None, subprocess.CompletedProcess]:
47
- home = tmp_path / "home"
48
- target = tmp_path / "deploy"
49
- home.mkdir()
50
- proc = subprocess.run(
51
- [sys.executable, "-m", "pactkit", "init", "--format", HOST, "--target", str(target)],
52
- capture_output=True, text=True, timeout=180,
53
- env={"PATH": "/usr/bin:/bin", "HOME": str(home), "COLUMNS": "200"},
54
- )
55
- if proc.returncode != 0:
56
- return None, proc
57
- digests: dict[str, str] = {}
58
- for path in sorted(target.rglob("*")):
59
- if not path.is_file() or path.suffix not in DEPLOY_SUFFIXES:
60
- continue
61
- body = _normalize(path.read_text(encoding="utf-8"), str(target))
62
- digests[path.relative_to(target).as_posix()] = hashlib.sha256(
63
- body.encode("utf-8")
64
- ).hexdigest()
65
- return digests, proc
66
-
67
-
68
- def _assert_no_drift(golden_digests: dict[str, str], live: dict[str, str]) -> None:
69
- """The one comparison both the golden test and the tamper probe use."""
70
- changed = sorted(
71
- k for k in set(golden_digests) & set(live) if golden_digests[k] != live[k]
72
- )
73
- removed = sorted(set(golden_digests) - set(live))
74
- added = sorted(set(live) - set(golden_digests))
75
- assert not (changed or removed or added), (
76
- f"prompt body drift: changed={changed} removed={removed} added={added} "
77
- f"— if the core prompts changed, regenerate the golden "
78
- f"(tests/scripts/refresh_host_body_golden.py) or publish the matching "
79
- f"core release first (PENDING-COMPATIBLE-CORE)"
80
- )
81
-
82
-
83
- class TestHostBodySnapshot:
84
- def test_bodies_match_the_golden(self, tmp_path):
85
- golden = json.loads(GOLDEN.read_text(encoding="utf-8"))
86
- core_v = _core_version()
87
- if golden["core_version"] != core_v:
88
- pytest.fail(
89
- f"PENDING-COMPATIBLE-CORE: the {HOST} golden was generated "
90
- f"against core {golden['core_version']}, this environment has "
91
- f"{core_v}. Either regenerate it after the core change "
92
- f"(tests/scripts/refresh_host_body_golden.py) or publish the "
93
- f"matching core release first (spec R8 release-window semantics)."
94
- )
95
- live, proc = _deployed_digests(tmp_path)
96
- assert live is not None, (
97
- f"{HOST} deploy failed: {proc.stdout[-300:]}{proc.stderr[-300:]}"
98
- )
99
- _assert_no_drift(golden["body_digests"], live)
100
-
101
- def test_a_body_edit_is_caught_by_the_same_comparison(self, tmp_path):
102
- """Non-vacuity, end to end: deploy, edit one prompt body in place,
103
- and the SAME comparison that guards the golden must flag it."""
104
- golden = json.loads(GOLDEN.read_text(encoding="utf-8"))
105
- live, proc = _deployed_digests(tmp_path)
106
- assert live is not None, f"{HOST} deploy failed: {proc.stderr[-300:]}"
107
- target = tmp_path / "deploy"
108
- # Pick a real deployed prompt body and delete one rule line from it.
109
- victims = sorted(
110
- p for p in target.rglob("*.md") if p.relative_to(target).as_posix() in golden["body_digests"]
111
- )
112
- assert victims, "no deployed .md body found to tamper with"
113
- victim = victims[0]
114
- rel = victim.relative_to(target).as_posix()
115
- body = victim.read_text(encoding="utf-8")
116
- victim.write_text(body + "\n- deleted in a review\n", encoding="utf-8")
117
-
118
- tampered = {
119
- p.relative_to(target).as_posix(): hashlib.sha256(
120
- _normalize(p.read_text(encoding="utf-8"), str(target)).encode("utf-8")
121
- ).hexdigest()
122
- for p in sorted(target.rglob("*"))
123
- if p.is_file() and p.suffix in DEPLOY_SUFFIXES
124
- }
125
- with pytest.raises(AssertionError, match=rel):
126
- _assert_no_drift(golden["body_digests"], tampered)
File without changes
File without changes
File without changes