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.
- {pactkit_codex-3.0.1/src/pactkit_codex.egg-info → pactkit_codex-3.0.4}/PKG-INFO +1 -1
- {pactkit_codex-3.0.1 → pactkit_codex-3.0.4}/pyproject.toml +1 -1
- pactkit_codex-3.0.4/src/pactkit_codex/__init__.py +10 -0
- {pactkit_codex-3.0.1 → pactkit_codex-3.0.4}/src/pactkit_codex/deployer.py +95 -54
- pactkit_codex-3.0.4/src/pactkit_codex/enforcement.py +77 -0
- {pactkit_codex-3.0.1 → pactkit_codex-3.0.4/src/pactkit_codex.egg-info}/PKG-INFO +1 -1
- {pactkit_codex-3.0.1 → pactkit_codex-3.0.4}/src/pactkit_codex.egg-info/SOURCES.txt +1 -0
- pactkit_codex-3.0.4/tests/test_host_body_snapshot.py +239 -0
- pactkit_codex-3.0.1/src/pactkit_codex/__init__.py +0 -5
- pactkit_codex-3.0.1/tests/test_host_body_snapshot.py +0 -126
- {pactkit_codex-3.0.1 → pactkit_codex-3.0.4}/LICENSE +0 -0
- {pactkit_codex-3.0.1 → pactkit_codex-3.0.4}/README.md +0 -0
- {pactkit_codex-3.0.1 → pactkit_codex-3.0.4}/setup.cfg +0 -0
- {pactkit_codex-3.0.1 → pactkit_codex-3.0.4}/src/pactkit_codex.egg-info/dependency_links.txt +0 -0
- {pactkit_codex-3.0.1 → pactkit_codex-3.0.4}/src/pactkit_codex.egg-info/entry_points.txt +0 -0
- {pactkit_codex-3.0.1 → pactkit_codex-3.0.4}/src/pactkit_codex.egg-info/requires.txt +0 -0
- {pactkit_codex-3.0.1 → pactkit_codex-3.0.4}/src/pactkit_codex.egg-info/top_level.txt +0 -0
|
@@ -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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
|
369
|
-
from pactkit.deploy_manifest import write_deploy_manifest
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
"
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
555
|
-
for
|
|
556
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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)
|
|
@@ -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,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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|