pactkit-codex 3.0.1__tar.gz → 3.0.4__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.1
3
+ Version: 3.0.4
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.1"
7
+ version = "3.0.4"
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
@@ -36,12 +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
  )
48
+ from pactkit.generators.spi import render_prompt, render_skill_md
45
49
  from pactkit.profiles import get_profile
46
50
  from pactkit.prompts.guides import GUIDES_FILES
47
51
  from pactkit.prompts.rules import (
@@ -115,16 +119,17 @@ class CodexDeployer(DeployerBase):
115
119
 
116
120
  Codex PDCA commands run in the user's active session. PactKit no
117
121
  longer ships runners, workflow hooks, or background execution.
122
+
123
+ The `(execution_mode, verification_source)` pair is deliberately
124
+ **absent**: it is this Host's declaration in `_write_deployment_manifest`,
125
+ and Core derives the manifest from it (SPEC-4.0-06 R8). A copy here would
126
+ not follow the declaration — R8's own test for "copy" not "derived" — and
127
+ the one this adapter carried had drifted from Core's `protocol_version`.
118
128
  """
119
129
  del codex_root
120
130
  return {
121
131
  "finish_guard_supported": False,
122
132
  "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
133
  "completion_hook": False,
129
134
  "session_reentry": False,
130
135
  "auto_resume_available": False,
@@ -283,6 +288,38 @@ class CodexDeployer(DeployerBase):
283
288
  ))
284
289
  return tuple(paths)
285
290
 
291
+ @staticmethod
292
+ def _rule_paths(enabled_commands, enabled_rules) -> dict[str, list[str]]:
293
+ """Canonical rule id -> every relative path that rule is deployed to.
294
+
295
+ One definition, three readers: `deploy_codex_rules` retires stale
296
+ globals from it, the stale-reference sweep decides what to keep, and
297
+ the deployment manifest declares it to Core (SPEC-4.0-06 R2/AC2).
298
+ Codex keeps global rules in `rules/` and copies conditional ones into
299
+ each applicable command skill; Core derived that from the host name and
300
+ can no longer, and the host-agnostic fallback records none of the
301
+ command-local paths — under-recording the ledger that licenses deletion.
302
+ """
303
+ rules = sorted(DEFAULT_RULE_IDS) if enabled_rules is None else enabled_rules
304
+ enabled = {
305
+ r for rule_id in rules if (r := normalize_rule_id(rule_id)) is not None
306
+ }
307
+ commands = set(VALID_COMMANDS if enabled_commands is None else enabled_commands)
308
+ declared = {
309
+ rule_id: [f"rules/{RULE_DEFINITIONS[rule_id].filename}"]
310
+ for rule_id in sorted(enabled & RULE_DEFINITIONS.keys())
311
+ if RULE_DEFINITIONS[rule_id].load_policy == "global"
312
+ }
313
+ for command in sorted(COMMAND_CONDITIONAL_RULES_MAP):
314
+ if command not in commands:
315
+ continue
316
+ for rule_id in COMMAND_CONDITIONAL_RULES_MAP[command]:
317
+ if rule_id in enabled:
318
+ declared.setdefault(rule_id, []).append(
319
+ f"skills/{command}/references/rules/{rule_id}.md"
320
+ )
321
+ return declared
322
+
286
323
  @staticmethod
287
324
  def _cleanup_stale_command_references(
288
325
  codex_root: Path, enabled_commands, enabled_rules,
@@ -312,19 +349,9 @@ class CodexDeployer(DeployerBase):
312
349
  return
313
350
 
314
351
  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
- }
352
+ desired = set().union(
353
+ *CodexDeployer._rule_paths(enabled_commands, enabled_rules).values()
354
+ )
328
355
  if "project-act" in commands:
329
356
  desired.update(
330
357
  f"skills/project-act/references/guides/{filename}"
@@ -365,34 +392,45 @@ class CodexDeployer(DeployerBase):
365
392
  parent = parent.parent
366
393
 
367
394
  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)
395
+ """Write the Core manifest, declaring what only this Host knows."""
396
+ from pactkit.deploy_manifest import HostDeclaration, write_deploy_manifest
397
+
398
+ # Codex's rule layout is not derivable from the host name any more
399
+ # (SPEC-4.0-06 R2). Core used to branch on `format_name == "codex"` to
400
+ # find command-local rule references; the host-agnostic fallback records
401
+ # only `rules/pactkit-runtime.md`, under-recording the ledger that is
402
+ # what licenses `_cleanup_stale_command_references` to delete.
403
+ # R8: the `(execution_mode, verification_source)` pair is the Host's
404
+ # **declaration** — Core records it and derives both sections from it,
405
+ # including `tool_execution`, `manual_resume` and `guarantee_level`,
406
+ # which are functions of `execution_mode` and not separate facts. This
407
+ # adapter used to write both sections itself, restating the same two
408
+ # values; they happened to agree, and nothing kept them agreeing.
409
+ manifest_path = write_deploy_manifest(
410
+ codex_root, "codex", cfg,
411
+ declaration=HostDeclaration(
412
+ rule_paths=CodexDeployer._rule_paths(
413
+ cfg.get("commands"), cfg.get("rules", sorted(DEFAULT_RULE_IDS)),
414
+ ),
415
+ execution_mode="portable",
416
+ verification_source="native_codex_session",
417
+ ),
418
+ )
372
419
  manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
373
420
  capability = self.continuation_capabilities(codex_root)
374
421
  if not capability["hook_installed"]:
375
422
  capability["hook_observed"] = False
376
423
  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
- }
424
+ # Add only what Core cannot know; restating the pair here would
425
+ # re-create the second definition point R8 removes. The sections take
426
+ # different extras — hook state, and the session model's name.
427
+ derived = ("execution_mode", "verification_source", "guarantee_level")
428
+ manifest["workflow_continuation"].update({
429
+ key: value
430
+ for key, value in capability.items()
431
+ if key not in derived and key not in manifest["workflow_continuation"]
432
+ })
433
+ manifest["host_capabilities"]["session_execution"] = capability["session_execution"]
396
434
  atomic_write(
397
435
  manifest_path,
398
436
  json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
@@ -550,10 +588,13 @@ class CodexDeployer(DeployerBase):
550
588
 
551
589
  # Selective deploys describe the desired projection. Remove a disabled
552
590
  # rule only when the prior manifest proves ownership of unchanged bytes.
591
+ # Only `rules/` is this method's business; both readers take the layout
592
+ # from `_rule_paths`.
553
593
  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"
594
+ path
595
+ for paths in CodexDeployer._rule_paths(None, enabled_rules).values()
596
+ for path in paths
597
+ if path.startswith("rules/")
557
598
  }
558
599
  for definition in RULE_DEFINITIONS.values():
559
600
  destination = rules_dir / definition.filename
@@ -597,7 +638,7 @@ class CodexDeployer(DeployerBase):
597
638
  if not content:
598
639
  continue
599
640
  content = content.strip()
600
- content = _render_prompt(content, profile)
641
+ content = render_prompt(content, profile)
601
642
  content = CodexDeployer.strip_model_selection_table(content)
602
643
  for pattern in CLAUDE_PATH_PATTERNS:
603
644
  content = content.replace(pattern, "~/.codex/")
@@ -647,7 +688,7 @@ class CodexDeployer(DeployerBase):
647
688
  lines = [f"# PactKit Runtime Contract (v{__version__})", ""]
648
689
  if "runtime" in enabled_rule_ids:
649
690
  lines.extend((
650
- _render_prompt(RULE_DEFINITIONS["runtime"].content.strip(), profile),
691
+ render_prompt(RULE_DEFINITIONS["runtime"].content.strip(), profile),
651
692
  "",
652
693
  ))
653
694
  lines.extend((
@@ -656,7 +697,7 @@ class CodexDeployer(DeployerBase):
656
697
  "",
657
698
  ))
658
699
 
659
- raw_content = _render_prompt("\n".join(lines), profile)
700
+ raw_content = render_prompt("\n".join(lines), profile)
660
701
 
661
702
  for pattern in CLAUDE_PATH_PATTERNS:
662
703
  raw_content = raw_content.replace(pattern, "~/.codex/")
@@ -730,7 +771,7 @@ class CodexDeployer(DeployerBase):
730
771
  description = line.split(":", 1)[1].strip().strip('"')
731
772
  content = parts[2].lstrip("\n")
732
773
 
733
- content = _render_prompt(content, profile)
774
+ content = render_prompt(content, profile)
734
775
  if cmd_name == "project-sprint":
735
776
  description = "Sequential PDCA in the current Codex session"
736
777
  content = _codex_single_session_sprint()
@@ -774,7 +815,7 @@ class CodexDeployer(DeployerBase):
774
815
  continue
775
816
  elif key in RULE_DEFINITIONS:
776
817
  inline_rules.append(
777
- _render_prompt(RULE_DEFINITIONS[key].content.strip(), profile)
818
+ render_prompt(RULE_DEFINITIONS[key].content.strip(), profile)
778
819
  )
779
820
 
780
821
  conditional = {
@@ -807,7 +848,7 @@ class CodexDeployer(DeployerBase):
807
848
  rendered[cmd_name] = skill_content
808
849
  command_references = {}
809
850
  for rule_id, definition in conditional.items():
810
- reference_content = _render_prompt(
851
+ reference_content = render_prompt(
811
852
  definition.content.strip(), profile,
812
853
  ).replace(
813
854
  "~/.codex/skills/_rules/guides/", "../guides/",
@@ -999,7 +1040,6 @@ class CodexDeployer(DeployerBase):
999
1040
  # STORY-slim-139 R4: consume the core SKILL_MANIFEST — no local
1000
1041
  # hardcoded skill list (the old 10-item snapshot silently dropped
1001
1042
  # garden/audit/report when core added them).
1002
- from pactkit.generators.deployer import _render_skill_md
1003
1043
  from pactkit.prompts.skills import get_skill_manifest
1004
1044
 
1005
1045
  enabled_set = set(enabled_skills)
@@ -1011,7 +1051,7 @@ class CodexDeployer(DeployerBase):
1011
1051
  skill_dir = skills_dir / sd["name"]
1012
1052
  skill_dir.mkdir(parents=True, exist_ok=True)
1013
1053
 
1014
- skill_md = _render_skill_md(sd, profile, _prefix)
1054
+ skill_md = render_skill_md(sd, profile, _prefix)
1015
1055
  skill_md = _replace_cli_with_scripts(skill_md)
1016
1056
  skill_md = CodexDeployer.strip_model_references(skill_md)
1017
1057
  _enforce_deploy_integrity(skill_md, profile, f"skill:{sd['name']}")
@@ -1200,7 +1240,8 @@ normal verification evidence.
1200
1240
  # CLI subcommands that map to skill scripts (STORY-slim-145 R3: lossy CLI
1201
1241
  # prefix replacements for regression/lint/context/clean/visualize/guard/
1202
1242
  # doctor/update REMOVED — Codex is CLIPolicy.PREFERRED, so canonical `pactkit`
1203
- # CLI commands are preserved by Core _render_prompt rather than rewritten here.
1243
+ # CLI commands are preserved by Core's render_prompt (via the adapter SPI)
1244
+ # rather than rewritten here.
1204
1245
  # Only genuine path normalization (board/scaffold) remains in this table.
1205
1246
  _CLI_TO_SCRIPT = [
1206
1247
  # 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.1
3
+ Version: 3.0.4
4
4
  Summary: PactKit adapter for OpenAI Codex CLI
5
5
  Author: Slim
6
6
  License: MIT
@@ -3,6 +3,7 @@ README.md
3
3
  pyproject.toml
4
4
  src/pactkit_codex/__init__.py
5
5
  src/pactkit_codex/deployer.py
6
+ src/pactkit_codex/enforcement.py
6
7
  src/pactkit_codex.egg-info/PKG-INFO
7
8
  src/pactkit_codex.egg-info/SOURCES.txt
8
9
  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