From a04859422515571dc86457b1f5e3bddfe9bc5c3c Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 17 Jul 2026 20:37:36 -0700 Subject: [PATCH 1/7] feat: add Codex intake discipline digest and wire into all delivery surfaces Introduce CODEX_INTAKE_DISCIPLINE_DIGEST with numeric read rules and sub-agent spawn discipline. Wire through the injector chain and all four Codex delivery surfaces (skill, food-truck, interactive, agent TOMLs) plus build_resume_cmd prepend. Claude Code surfaces explicitly excluded. Co-Authored-By: Claude Fable 5 --- src/autoskillit/core/__init__.pyi | 2 ++ src/autoskillit/core/types/_type_constants.py | 33 +++++++++++++++++++ .../execution/backends/_claude_prompt.py | 11 +++++++ src/autoskillit/execution/backends/claude.py | 2 ++ src/autoskillit/execution/backends/codex.py | 13 +++++--- 5 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/autoskillit/core/__init__.pyi b/src/autoskillit/core/__init__.pyi index b841e5ca1..2687eab6a 100644 --- a/src/autoskillit/core/__init__.pyi +++ b/src/autoskillit/core/__init__.pyi @@ -146,6 +146,8 @@ from .types import CLAUDE_MODEL_ALIASES as CLAUDE_MODEL_ALIASES from .types import CLOSURE_REPORT_SCHEMA_VERSION as CLOSURE_REPORT_SCHEMA_VERSION from .types import CODEX_CONTEXT_EXHAUSTION_MARKER as CODEX_CONTEXT_EXHAUSTION_MARKER from .types import CODEX_EFFORT_MAPPING as CODEX_EFFORT_MAPPING +from .types import CODEX_INTAKE_DISCIPLINE_DIGEST as CODEX_INTAKE_DISCIPLINE_DIGEST +from .types import CODEX_INTAKE_DISCIPLINE_VERSION as CODEX_INTAKE_DISCIPLINE_VERSION from .types import CODEX_INTERACTIVE_REQUIRED_ENV as CODEX_INTERACTIVE_REQUIRED_ENV from .types import CODEX_MCP_ENV_FORWARD_VARS as CODEX_MCP_ENV_FORWARD_VARS from .types import CODEX_MODEL_ALIASES as CODEX_MODEL_ALIASES diff --git a/src/autoskillit/core/types/_type_constants.py b/src/autoskillit/core/types/_type_constants.py index a36b8fedb..5fd34f0aa 100644 --- a/src/autoskillit/core/types/_type_constants.py +++ b/src/autoskillit/core/types/_type_constants.py @@ -17,6 +17,8 @@ "OUTPUT_DISCIPLINE_COMBINED_SHA256", "OUTPUT_DISCIPLINE_DIGEST", "OUTPUT_DISCIPLINE_REQUIRED_SKILLS", + "CODEX_INTAKE_DISCIPLINE_VERSION", + "CODEX_INTAKE_DISCIPLINE_DIGEST", "RETIRED_SKILL_NAMES", "RETIRED_AGENT_NAMES", "SKILL_COMMAND_PREFIX", @@ -156,6 +158,37 @@ {"investigate", "rectify", "audit-bugs", "audit-friction"} ) +CODEX_INTAKE_DISCIPLINE_VERSION: int = 1 + +CODEX_INTAKE_DISCIPLINE_DIGEST = "\n".join( + ( + "Context Intake Discipline v1:", + ( + "- Read at most 2 files per exec command; never chain whole-file dumps " + "(`cat`/`sed`/`nl`) across a list of files." + ), + ( + "- Never read a file end-to-end. Use `rg -n` with context flags, or " + "`sed -n` ranges of at most 250 lines." + ), + "- Never pass max_output_tokens above 10000.", + ( + "- After listing a directory, open at most 2 of the listed files before " + "deciding what to read next." + ), + ( + "- Package tables in AGENTS.md files are an index, not required reading; " + "consult a per-package AGENTS.md only for packages you are modifying." + ), + ( + '- Spawn sub-agents with fresh context: pass fork_turns "none" explicitly ' + '(omitting fork_turns silently defaults to "all", forking the full parent ' + "conversation). Give each sub-agent an explicit narrow brief; sub-agents " + "return a summary, not raw file contents." + ), + ) +) + RETIRED_SKILL_NAMES: frozenset[str] = frozenset( { # Skill directory names that have been renamed or removed. diff --git a/src/autoskillit/execution/backends/_claude_prompt.py b/src/autoskillit/execution/backends/_claude_prompt.py index 28c6b2bed..09ca4e538 100644 --- a/src/autoskillit/execution/backends/_claude_prompt.py +++ b/src/autoskillit/execution/backends/_claude_prompt.py @@ -7,6 +7,7 @@ from typing import Any, NamedTuple from autoskillit.core import ( + CODEX_INTAKE_DISCIPLINE_DIGEST, OUTPUT_DISCIPLINE_DIGEST, PROVIDER_PROFILE_ENV_VAR, ClaudeFlags, @@ -190,6 +191,13 @@ def _inject_output_discipline(prompt: str, *, include: bool = False) -> str: return f"{prompt}\n\n{OUTPUT_DISCIPLINE_DIGEST}" +def _inject_intake_discipline(prompt: str, *, include: bool = False) -> str: + """Append the context-intake discipline digest on Codex delivery surfaces.""" + if not include: + return prompt + return f"{prompt}\n\n{CODEX_INTAKE_DISCIPLINE_DIGEST}" + + def _inject_completion_reminder(prompt: str, marker: str) -> str: """Append a short completion marker reminder as the final prompt line. @@ -226,6 +234,7 @@ class PromptBuildContext: has_skill_prefix: bool = False profile_name: str = "" include_output_discipline: bool = False + include_intake_discipline: bool = False class InjectorDef(NamedTuple): @@ -239,6 +248,7 @@ class InjectorDef(NamedTuple): InjectorDef("cwd-anchor"), InjectorDef("narration-suppression"), InjectorDef("output-discipline"), + InjectorDef("intake-discipline"), InjectorDef("output-format-reinforcement"), InjectorDef("completion-reminder"), ) @@ -254,6 +264,7 @@ def apply_prompt_injector_chain(prompt: str, ctx: PromptBuildContext) -> str: prompt = _inject_cwd_anchor(prompt, ctx.cwd, temp_dir_relpath=ctx.temp_dir_relpath) prompt = _inject_narration_suppression(prompt, has_skill_prefix=ctx.has_skill_prefix) prompt = _inject_output_discipline(prompt, include=ctx.include_output_discipline) + prompt = _inject_intake_discipline(prompt, include=ctx.include_intake_discipline) prompt = _inject_output_format_reinforcement(prompt, profile_name=ctx.profile_name) prompt = _inject_completion_reminder(prompt, ctx.completion_marker) return prompt diff --git a/src/autoskillit/execution/backends/claude.py b/src/autoskillit/execution/backends/claude.py index 4236608dc..f7cc3381d 100644 --- a/src/autoskillit/execution/backends/claude.py +++ b/src/autoskillit/execution/backends/claude.py @@ -577,6 +577,7 @@ def build_skill_session_cmd( has_skill_prefix=_has_prefix, profile_name=profile_name, include_output_discipline=False, + include_intake_discipline=False, ), ) extras = self._assemble_shared_env_extras( @@ -667,6 +668,7 @@ def build_food_truck_cmd( has_skill_prefix=False, profile_name="", include_output_discipline=False, + include_intake_discipline=False, ), ) diff --git a/src/autoskillit/execution/backends/codex.py b/src/autoskillit/execution/backends/codex.py index c18d22981..c695e191d 100644 --- a/src/autoskillit/execution/backends/codex.py +++ b/src/autoskillit/execution/backends/codex.py @@ -23,6 +23,7 @@ AUTOSKILLIT_PRIVATE_ENV_VARS, AUTOSKILLIT_WRITE_GUARD_TOOL_NAMES, CODEX_EFFORT_MAPPING, + CODEX_INTAKE_DISCIPLINE_DIGEST, CODEX_INTERACTIVE_REQUIRED_ENV, CODEX_MCP_ENV_FORWARD_VARS, CODEX_MODEL_ALIASES, @@ -96,6 +97,8 @@ logger = get_logger(__name__) +_CODEX_DISCIPLINE_SUFFIX = f"{OUTPUT_DISCIPLINE_DIGEST}\n\n{CODEX_INTAKE_DISCIPLINE_DIGEST}" + @unique class CodexFlags(StrEnum): @@ -468,7 +471,7 @@ def _generate_agent_tomls(session_dir: Path) -> int: effort = CODEX_EFFORT_MAPPING.get(model_key) if effort: lines.append(f"model_reasoning_effort = {_format_toml_value(effort)}") - body = f"{body}\n\n{OUTPUT_DISCIPLINE_DIGEST}" + body = f"{body}\n\n{_CODEX_DISCIPLINE_SUFFIX}" lines.append(f"developer_instructions = '''\n{body}\n'''") toml_path = out_dir / f"{name}.toml" atomic_write(toml_path, "\n".join(lines) + "\n") @@ -794,6 +797,7 @@ def build_skill_session_cmd( has_skill_prefix=_has_prefix, profile_name=profile_name, include_output_discipline=True, + include_intake_discipline=True, ), ) @@ -907,6 +911,7 @@ def build_food_truck_cmd( has_skill_prefix=False, profile_name="", include_output_discipline=True, + include_intake_discipline=True, ), ) @@ -1004,9 +1009,9 @@ def build_interactive_cmd( builder.kv_flag(CodexFlags.CONFIG_OVERRIDE, _IMAGE_GENERATION_DISABLED) if isinstance(resume_spec, NoResume): developer_instructions = ( - f"{system_prompt}\n\n{OUTPUT_DISCIPLINE_DIGEST}" + f"{system_prompt}\n\n{_CODEX_DISCIPLINE_SUFFIX}" if system_prompt is not None - else OUTPUT_DISCIPLINE_DIGEST + else _CODEX_DISCIPLINE_SUFFIX ) builder.kv_flag( CodexFlags.CONFIG_OVERRIDE, @@ -1060,7 +1065,7 @@ def build_resume_cmd( cmd = _codex_exec_base(sandbox="read-only", json=(output_format == OutputFormat.JSON)) cmd.append(CodexFlags.RESUME_SUBCOMMAND) cmd.append(resume_session_id) - cmd.append(prompt) + cmd.append(f"{_CODEX_DISCIPLINE_SUFFIX}\n\n{prompt}") filtered_base = {k: v for k, v in os.environ.items() if k not in _HEADLESS_EXCLUSIVE_VARS} resume_extras = _codex_exec_extras( session_type="", include_session_baseline=True, include_agent_backend_flat=True From 263ee8b7dd9967b8c48508db515603761dae3af4 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 17 Jul 2026 20:41:37 -0700 Subject: [PATCH 2/7] feat: add AGENTS.md index preamble, per-repo ceiling docs, doctor Check 39 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite AGENTS.md §5 preamble to mark the package table as an index. Add per-repo Codex output ceiling guidance to configuration.md and ADR-0005. Add CODEX_LIMITS_LAST_VERIFIED_VERSION pin and doctor Check 39 (codex_limits_verified) with extracted version-parse helper. Update stale doctor check counts in docs. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 2 +- docs/cli.md | 2 +- docs/configuration.md | 20 +++++ docs/decisions/0005-output-budget-protocol.md | 36 +++++++++ docs/installation.md | 2 +- src/autoskillit/cli/doctor/AGENTS.md | 2 +- src/autoskillit/cli/doctor/__init__.py | 4 + src/autoskillit/cli/doctor/_doctor_runtime.py | 76 ++++++++++++------- src/autoskillit/execution/__init__.py | 2 + .../execution/backends/__init__.py | 2 + .../execution/backends/_codex_config.py | 6 ++ 11 files changed, 121 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bff6daad3..795bacd73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,7 +94,7 @@ generic_automation_mcp/ └── pyproject.toml ``` -`src/autoskillit/` packages — each has its own `AGENTS.md` with file-level detail, plus a thin `CLAUDE.md` shim that imports the corresponding `AGENTS.md` (except `recipes/`, `skills/`, and `skills_extended/` — `AGENTS.md` files for these are pending): +`src/autoskillit/` packages — the table below is an index, not required reading: consult a package's own `AGENTS.md` (file-level detail) only for packages you are modifying. Each package has its own `AGENTS.md` plus a thin `CLAUDE.md` shim that imports it (except `recipes/`, `skills/`, and `skills_extended/` — `AGENTS.md` files for these are pending): | Package | IL | Purpose | |---|---|---| diff --git a/docs/cli.md b/docs/cli.md index 101e81ce2..9ee3a3ca6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -82,7 +82,7 @@ Run health checks on your setup. **Flags:** - `--output-json` — Output results as JSON -Runs 38 checks (up to 44 with fleet enabled) enumerated by `run_doctor` in +Runs 41 checks (up to 47 with fleet enabled) enumerated by `run_doctor` in `cli/doctor/__init__.py` — numbered plus lettered sub-checks `2b`, `2c`, `2d`, `2e`, `4b`, `7b`, `7c`, and `31b`. The checks cover stale MCP servers, plugin registration, PATH, project config, secrets placement, version consistency, hook health, hook diff --git a/docs/configuration.md b/docs/configuration.md index bb0373ca7..8941d4cb3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -200,6 +200,26 @@ run_skill: stale_threshold: 1200 # 20 minutes of no output before declaring stale ``` +### Per-repo Codex output ceilings + +The global `~/.codex/config.toml` sets `tool_output_token_limit` to 54,500 (written +by `autoskillit init` via `ensure_codex_mcp_registered`). This value is sized for +autoskillit kitchen sessions — see [ADR-0005](decisions/0005-output-budget-protocol.md) +for the derivation. + +For non-autoskillit repos, cap casual reads at ~40 KB (10,000 tokens × 4 bytes/token) +using either approach: + +1. **Per-invocation**: `codex -c tool_output_token_limit=10000` +2. **Per-project**: set `CODEX_HOME=/.codex` with a dedicated `config.toml` + containing `tool_output_token_limit = 10000` + +**Scope caveat**: the ceiling clamps regular-path exec/tool output +(`min(model request, tool_output_token_limit)`), but code-mode models (gpt-5.6-sol) +honor model-declared `max_output_tokens` unclamped — for those sessions the ceiling +is not a hard cap and the intake digest's `max_output_tokens` <= 10000 rule is the +operative bound. + ## MCP Response Tracking ```yaml diff --git a/docs/decisions/0005-output-budget-protocol.md b/docs/decisions/0005-output-budget-protocol.md index b4c9e8fe9..f4a43b7c0 100644 --- a/docs/decisions/0005-output-budget-protocol.md +++ b/docs/decisions/0005-output-budget-protocol.md @@ -88,6 +88,24 @@ their own registered character and UTF-8 byte ceilings, which in turn remain bel measurements are independent release gates; the heuristic is not permission to omit those tests or reuse one surface's observed maximum as the other's authority. +### Per-Repo Ceiling Guidance + +The global `~/.codex/config.toml` `tool_output_token_limit` (54,500, written by +`autoskillit init` via `ensure_codex_mcp_registered`) is intentionally sized for +autoskillit kitchen sessions (the `open_kitchen` exemption ceiling above). For +non-autoskillit repos, two lower-ceiling launch paths cap casual reads at ~40 KB +(10,000 tokens × 4 bytes/token): + +1. **Per-invocation**: `codex -c tool_output_token_limit=10000` +2. **Per-project `CODEX_HOME`**: `export CODEX_HOME=/.codex` with its own + `config.toml` containing `tool_output_token_limit = 10000` + +The ceiling clamps regular-path exec/tool output via +`min(model request, tool_output_token_limit)`, but code-mode models (gpt-5.6-sol) +honor model-declared `max_output_tokens` unclamped — for those sessions the ceiling +is not a hard cap and the intake discipline digest's numeric rule +(`max_output_tokens` <= 10000) is the operative bound. + ## Corrections of Record Commit `6b421e38e` introduced the `_codex_config.py` comment framing @@ -145,6 +163,24 @@ reserve-trigger metrics, so instrumentation must not claim those mechanisms exis - Run and pass the live large-output probe before making any of those changes effective. - Preserve ADR-0004's end-to-end `load_recipe` re-delivery obligation if the 999,999,999 auto-compaction sentinel is ever relaxed. +- After each codex-cli upgrade, re-verify the truncation heuristic + (`CODEX_TOOL_OUTPUT_TOKEN_LIMIT`) and auto-compact sentinel + (`CODEX_AUTO_COMPACT_LIMIT`) against the upstream registry AND observed session + windows, then bump `CODEX_LIMITS_LAST_VERIFIED_VERSION`. Doctor Check 39 + (`codex_limits_verified`) mechanizes the reminder. Issue #4280's investigation + found upstream models.json (post-0.144.1) listing gpt-5.6-sol at 372,000 tokens + vs 258,400 in cli 0.144.1, but the effective window is server/catalog-controlled + and oscillated during July 2026 (openai/codex#31860, #32806) — re-verify, do not + assume an upgrade restores headroom. +- openai/codex#25458 / #27830: `fork_turns "none"` task-envelope delivery bug gates + the intake digest's sub-agent spawn rule (see Verification 6 in the + implementation plan). +- openai/codex#33881: agent-TOML `model`/`model_reasoning_effort` reportedly ignored + on 0.144.5 — affects the pins `_generate_agent_tomls` writes. +- Upstream auto-compact semantics are scope-dependent + (`model_auto_compact_token_limit` is consulted only under `BodyAfterPrefix` scope; + a separate full-context-window trigger ignores it) — re-verify + `CODEX_AUTO_COMPACT_LIMIT`'s disabling effect per upgrade. ## Consequences diff --git a/docs/installation.md b/docs/installation.md index 082faea6e..0e4a703a4 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -67,7 +67,7 @@ projects need. autoskillit doctor -Doctor runs 34 checks (23 numbered + 5 lettered sub-checks: `2b`, `2c`, `2d`, `4b`, `7b` + 6 backend runtime probes, checks 30–35); up to 40 with the fleet feature enabled. +Doctor runs 41 checks (23 numbered + 7 lettered sub-checks: `2b`, `2c`, `2d`, `2e`, `4b`, `7b`, `7c` + 11 backend runtime probes, checks 30–39 including `31b`); up to 47 with the fleet feature enabled. Enumerated by `run_doctor` in `src/autoskillit/cli/doctor/__init__.py`: | # | Check | What it verifies | diff --git a/src/autoskillit/cli/doctor/AGENTS.md b/src/autoskillit/cli/doctor/AGENTS.md index bca81d06d..5d8f19273 100644 --- a/src/autoskillit/cli/doctor/AGENTS.md +++ b/src/autoskillit/cli/doctor/AGENTS.md @@ -1,6 +1,6 @@ # doctor/ -Diagnostic health checks for the autoskillit installation (36 checks). +Diagnostic health checks for the autoskillit installation (47 checks). ## Files diff --git a/src/autoskillit/cli/doctor/__init__.py b/src/autoskillit/cli/doctor/__init__.py index 353fc54ec..cd26fbba7 100644 --- a/src/autoskillit/cli/doctor/__init__.py +++ b/src/autoskillit/cli/doctor/__init__.py @@ -66,6 +66,7 @@ _check_claude_process_state_breakdown, _check_cli_conformance_probes, _check_codex_graduation, + _check_codex_limits_verified, _check_codex_model_alias_staleness, _check_codex_ndjson_drift, _check_codex_version, @@ -229,6 +230,9 @@ def run_doctor(*, output_json: bool = False) -> None: # Check 38: Local recipe validity results.extend(_check_local_recipe_validity()) + # Check 39: Codex limits version pin freshness + results.append(_check_codex_limits_verified(backend=_backend)) + # Output if output_json: print( diff --git a/src/autoskillit/cli/doctor/_doctor_runtime.py b/src/autoskillit/cli/doctor/_doctor_runtime.py index a1de80389..d9425d4be 100644 --- a/src/autoskillit/cli/doctor/_doctor_runtime.py +++ b/src/autoskillit/cli/doctor/_doctor_runtime.py @@ -23,6 +23,7 @@ is_valid_codex_model_id, ) from autoskillit.execution import QUOTA_CACHE_SCHEMA_VERSION +from autoskillit.execution.backends._codex_config import CODEX_LIMITS_LAST_VERIFIED_VERSION from ._doctor_types import DoctorResult @@ -33,10 +34,13 @@ _CODEX_ALIAS_STALENESS_DAYS: int = 90 -def _check_codex_version(*, backend: CodingAgentBackend | None = None) -> DoctorResult: - check_name = "codex_version" +def _parse_codex_version( + *, + backend: CodingAgentBackend | None = None, +) -> tuple[int, int, int] | str: + """Parse the installed Codex CLI version, returning (major,minor,patch) or a skip reason.""" if backend is None or not backend.capabilities.version_check_command: - return DoctorResult(Severity.OK, check_name, "Skipped (no version check command)") + return "no version check command" try: result = subprocess.run( backend.capabilities.version_check_command.split(), @@ -45,39 +49,53 @@ def _check_codex_version(*, backend: CodingAgentBackend | None = None) -> Doctor timeout=5, ) except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc: - return DoctorResult( - Severity.OK, - check_name, - f"codex unavailable ({type(exc).__name__}); skipping version check", - ) + return f"codex unavailable ({type(exc).__name__})" if result.returncode != 0: - return DoctorResult( - Severity.OK, - check_name, - f"codex exited {result.returncode}; skipping version check", - ) + return f"codex exited {result.returncode}" for line in (result.stdout + result.stderr).splitlines(): m = re.search(r"(\d+)\.(\d+)\.(\d+)", line) if m: - parsed = (int(m.group(1)), int(m.group(2)), int(m.group(3))) - if parsed < CODEX_MIN_VERSION: - min_str = ".".join(str(v) for v in CODEX_MIN_VERSION) - cur_str = ".".join(str(v) for v in parsed) - return DoctorResult( - Severity.WARNING, - check_name, - f"Codex CLI {cur_str} is below minimum {min_str}", - ) - cur_str = ".".join(str(v) for v in parsed) - return DoctorResult(Severity.OK, check_name, f"Codex CLI {cur_str}") + return (int(m.group(1)), int(m.group(2)), int(m.group(3))) - return DoctorResult( - Severity.OK, - check_name, - "codex --version output unparseable; skipping version check", - ) + return "codex --version output unparseable" + + +def _check_codex_version(*, backend: CodingAgentBackend | None = None) -> DoctorResult: + check_name = "codex_version" + parsed = _parse_codex_version(backend=backend) + if isinstance(parsed, str): + return DoctorResult(Severity.OK, check_name, f"Skipped ({parsed})") + if parsed < CODEX_MIN_VERSION: + min_str = ".".join(str(v) for v in CODEX_MIN_VERSION) + cur_str = ".".join(str(v) for v in parsed) + return DoctorResult( + Severity.WARNING, + check_name, + f"Codex CLI {cur_str} is below minimum {min_str}", + ) + cur_str = ".".join(str(v) for v in parsed) + return DoctorResult(Severity.OK, check_name, f"Codex CLI {cur_str}") + + +def _check_codex_limits_verified(*, backend: CodingAgentBackend | None = None) -> DoctorResult: + check_name = "codex_limits_verified" + parsed = _parse_codex_version(backend=backend) + if isinstance(parsed, str): + return DoctorResult(Severity.OK, check_name, f"Skipped ({parsed})") + if parsed > CODEX_LIMITS_LAST_VERIFIED_VERSION: + pin_str = ".".join(str(v) for v in CODEX_LIMITS_LAST_VERIFIED_VERSION) + cur_str = ".".join(str(v) for v in parsed) + return DoctorResult( + Severity.WARNING, + check_name, + f"Codex CLI {cur_str} is newer than verified pin {pin_str}; " + f"re-verify CODEX_TOOL_OUTPUT_TOKEN_LIMIT and CODEX_AUTO_COMPACT_LIMIT " + f"against upstream registry, then bump CODEX_LIMITS_LAST_VERIFIED_VERSION", + ) + cur_str = ".".join(str(v) for v in parsed) + return DoctorResult(Severity.OK, check_name, f"Codex CLI {cur_str} at or below verified pin") def _check_quota_cache_schema(cache_path: Path | None = None) -> DoctorResult: diff --git a/src/autoskillit/execution/__init__.py b/src/autoskillit/execution/__init__.py index 569091036..fab7328d1 100644 --- a/src/autoskillit/execution/__init__.py +++ b/src/autoskillit/execution/__init__.py @@ -20,6 +20,7 @@ from autoskillit.execution.backends import ( BACKEND_REGISTRY, CODEX_AUTO_COMPACT_LIMIT, + CODEX_LIMITS_LAST_VERIFIED_VERSION, CODEX_MCP_REQUIRED_KEYS, CODEX_MCP_STARTUP_TIMEOUT_SEC, CODEX_MCP_TOOL_TIMEOUT_FLOOR, @@ -226,6 +227,7 @@ "CODEX_MCP_STARTUP_TIMEOUT_SEC", "CODEX_MCP_TOOL_TIMEOUT_FLOOR", "CODEX_TOOL_OUTPUT_TOKEN_LIMIT", + "CODEX_LIMITS_LAST_VERIFIED_VERSION", "CODEX_AUTO_COMPACT_LIMIT", "ClaudeCodeBackend", "CodexBackend", diff --git a/src/autoskillit/execution/backends/__init__.py b/src/autoskillit/execution/backends/__init__.py index 78f1c7953..d7ba36891 100644 --- a/src/autoskillit/execution/backends/__init__.py +++ b/src/autoskillit/execution/backends/__init__.py @@ -4,6 +4,7 @@ from ._codex_config import ( CODEX_AUTO_COMPACT_LIMIT, + CODEX_LIMITS_LAST_VERIFIED_VERSION, CODEX_MCP_REQUIRED_KEYS, CODEX_MCP_STARTUP_TIMEOUT_SEC, CODEX_MCP_TOOL_TIMEOUT_FLOOR, @@ -83,6 +84,7 @@ def get_backend(name: str) -> CodingAgentBackend: "CODEX_MCP_TOOL_TIMEOUT_FLOOR", "CODEX_MCP_REQUIRED_KEYS", "CODEX_TOOL_OUTPUT_TOKEN_LIMIT", + "CODEX_LIMITS_LAST_VERIFIED_VERSION", "CODEX_AUTO_COMPACT_LIMIT", "NON_VARIADIC_CODEX_FLAGS", "VARIADIC_CODEX_FLAGS", diff --git a/src/autoskillit/execution/backends/_codex_config.py b/src/autoskillit/execution/backends/_codex_config.py index 4960f9d49..e9eeadc1a 100644 --- a/src/autoskillit/execution/backends/_codex_config.py +++ b/src/autoskillit/execution/backends/_codex_config.py @@ -41,6 +41,12 @@ # re-reading recipe files via run_cmd/run_python after compaction. CODEX_AUTO_COMPACT_LIMIT: int = 999_999_999 +# Codex CLI version against which the numeric limits above were last verified: +# the 4-bytes-per-token truncation heuristic behind CODEX_TOOL_OUTPUT_TOKEN_LIMIT +# and the context-window assumption behind CODEX_AUTO_COMPACT_LIMIT. The doctor +# check `codex_limits_verified` warns when the installed CLI is newer. +CODEX_LIMITS_LAST_VERIFIED_VERSION: tuple[int, int, int] = (0, 144, 1) + # Keys that must be present in the autoskillit MCP server entry for the Codex # backend. Validated against the entry dict actually written by # `ensure_codex_mcp_registered` via the arch test From 97676aa53954bd1be578753046d056721da3c57f Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 17 Jul 2026 20:47:25 -0700 Subject: [PATCH 3/7] test: add T1-T12 for intake discipline digest, doctor Check 39, and delivery guarantees T1-T2: intake digest content pins and TOML safety guard. T3: fresh headless and food-truck include intake digest. T4: interactive and agent-TOML two-digest composition. T5: interactive developer_instructions exact-string assertions. T6: Claude backend negative delivery guarantee. T7: doctor codex_limits_verified (4 cases: warn, ok, between-pins, skip). T8: ADR-0005 per-repo ceiling and upgrade tracking ratchets. T9: AGENTS.md package table marked as index. T10: injector chain registry and backend-conditional tests. T11: resume cmd prepend pin + Claude negative guard. T12: doctor count ratchet bumped to 47, backend registry exports updated. Co-Authored-By: Claude Fable 5 --- tests/cli/test_doctor_runtime.py | 55 +++++++++++++++++++ .../test_output_budget_discipline.py | 26 +++++++++ tests/docs/test_agents_md_content.py | 4 ++ tests/docs/test_doc_counts.py | 11 ++-- .../test_output_budget_protocol_decision.py | 12 ++++ .../backends/test_backend_registry.py | 1 + .../execution/backends/test_claude_backend.py | 18 ++++++ .../execution/backends/test_codex_backend.py | 48 +++++++++++++--- .../backends/test_codex_interactive.py | 15 ++++- tests/execution/test_headless_core.py | 17 ++++++ 10 files changed, 190 insertions(+), 17 deletions(-) diff --git a/tests/cli/test_doctor_runtime.py b/tests/cli/test_doctor_runtime.py index 5963c4b28..8a92cd727 100644 --- a/tests/cli/test_doctor_runtime.py +++ b/tests/cli/test_doctor_runtime.py @@ -78,3 +78,58 @@ def test_check_name_is_codex_model_alias_staleness(self) -> None: result = _check_codex_model_alias_staleness() assert result.check == "codex_model_alias_staleness" + + +class TestCheckCodexLimitsVerified: + """Tests for the codex_limits_verified doctor check (Check 39).""" + + def test_codex_limits_verified_warns_when_cli_newer_than_pin( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + import autoskillit.cli.doctor._doctor_runtime as mod + from autoskillit.core import Severity + from autoskillit.execution.backends.codex import CodexBackend + + monkeypatch.setattr(mod, "_parse_codex_version", lambda *, backend=None: (0, 145, 0)) + result = mod._check_codex_limits_verified(backend=CodexBackend()) + assert result.severity == Severity.WARNING + assert "CODEX_TOOL_OUTPUT_TOKEN_LIMIT" in result.message + assert "CODEX_AUTO_COMPACT_LIMIT" in result.message + + def test_codex_limits_verified_ok_at_pinned_version( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + import autoskillit.cli.doctor._doctor_runtime as mod + from autoskillit.core import Severity + from autoskillit.execution.backends.codex import CodexBackend + + monkeypatch.setattr(mod, "_parse_codex_version", lambda *, backend=None: (0, 144, 1)) + result = mod._check_codex_limits_verified(backend=CodexBackend()) + assert result.severity == Severity.OK + + def test_codex_limits_verified_ok_between_min_and_pin( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + import autoskillit.cli.doctor._doctor_runtime as mod + from autoskillit.core import Severity + from autoskillit.execution.backends.codex import CodexBackend + + monkeypatch.setattr(mod, "_parse_codex_version", lambda *, backend=None: (0, 135, 0)) + result = mod._check_codex_limits_verified(backend=CodexBackend()) + assert result.severity == Severity.OK + + def test_codex_limits_verified_skips_when_codex_unavailable( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + import autoskillit.cli.doctor._doctor_runtime as mod + from autoskillit.core import Severity + from autoskillit.execution.backends.codex import CodexBackend + + monkeypatch.setattr( + mod, + "_parse_codex_version", + lambda *, backend=None: "codex unavailable (FileNotFoundError)", + ) + result = mod._check_codex_limits_verified(backend=CodexBackend()) + assert result.severity == Severity.OK + assert "Skipped" in result.message diff --git a/tests/contracts/test_output_budget_discipline.py b/tests/contracts/test_output_budget_discipline.py index e10362344..2ba23f265 100644 --- a/tests/contracts/test_output_budget_discipline.py +++ b/tests/contracts/test_output_budget_discipline.py @@ -8,6 +8,8 @@ import pytest from autoskillit.core import ( + CODEX_INTAKE_DISCIPLINE_DIGEST, + CODEX_INTAKE_DISCIPLINE_VERSION, OUTPUT_DISCIPLINE_BLOCK, OUTPUT_DISCIPLINE_BLOCK_SHA256, OUTPUT_DISCIPLINE_DIGEST, @@ -78,3 +80,27 @@ def test_policy_marker_has_no_unmanaged_skill_copies() -> None: def test_digest_is_safe_for_agent_toml_multiline_literal_guard() -> None: assert "'''" not in OUTPUT_DISCIPLINE_DIGEST + + +def test_intake_digest_pins_numeric_rules() -> None: + anchors = [ + "at most 2 files per exec command", + "at most 250 lines", + "max_output_tokens above 10000", + "at most 2 of the listed files", + "an index, not required reading", + "fresh context", + 'fork_turns "none"', + 'defaults to "all"', + "explicit narrow brief", + "return a summary", + ] + for anchor in anchors: + assert anchor in CODEX_INTAKE_DISCIPLINE_DIGEST, ( + f"Missing anchor in intake digest: {anchor!r}" + ) + assert f"v{CODEX_INTAKE_DISCIPLINE_VERSION}" in CODEX_INTAKE_DISCIPLINE_DIGEST + + +def test_intake_digest_is_safe_for_agent_toml_multiline_literal() -> None: + assert "'''" not in CODEX_INTAKE_DISCIPLINE_DIGEST diff --git a/tests/docs/test_agents_md_content.py b/tests/docs/test_agents_md_content.py index 231e259a9..a9ff54461 100644 --- a/tests/docs/test_agents_md_content.py +++ b/tests/docs/test_agents_md_content.py @@ -148,6 +148,10 @@ def test_agents_md_file_system_has_temp_rule(self, agents_md: str) -> None: def test_agents_md_testing_mentions_parallel_safety(self, agents_md: str) -> None: assert "parallel" in agents_md.lower() + def test_agents_md_package_table_is_marked_as_index(self, agents_md: str) -> None: + assert "an index, not required reading" in agents_md + assert "| Package | IL | Purpose |" in agents_md + def test_agents_md_diagnostics_mentions_codex(self, agents_md: str) -> None: parts = agents_md.split("## **6. Session Diagnostics**") assert len(parts) > 1, "Session Diagnostics section not found" diff --git a/tests/docs/test_doc_counts.py b/tests/docs/test_doc_counts.py index 6956403d7..d59b32c87 100644 --- a/tests/docs/test_doc_counts.py +++ b/tests/docs/test_doc_counts.py @@ -237,17 +237,14 @@ def test_quota_thresholds_defaults() -> None: assert long_ == pytest.approx(95.0) -def test_doctor_check_count_is_46() -> None: - # 46 total = 23 numbered (1–17 base + 18–21 ambient env + 22–23 feature) +def test_doctor_check_count_is_47() -> None: + # 47 total = 23 numbered (1–17 base + 18–21 ambient env + 22–23 feature) # + 7 lettered sub-checks (2b–2e, 4b, 7b, 7c) - # + 6 gated fleet (24–29) + 8 backend runtime (30–36, including Check 31b + # + 6 gated fleet (24–29) + 11 backend runtime (30–39, including Check 31b # claude CLI binary availability for capability-driven rerouting) - # + 2 new checks (37 standing backend pin feasibility, 38 local recipe validity). - # Docs list 35 user-visible checks (23 numbered + 5 documented lettered + - # 7 backend runtime); 2e and 7c are internal-only sub-checks not shown in docs. # Update both tests whenever a new doctor check is added. count = _count_doctor_checks() - assert count == 46, f"Expected 46 doctor checks; found {count}" + assert count == 47, f"Expected 47 doctor checks; found {count}" def test_bundled_recipe_count_is_15() -> None: diff --git a/tests/docs/test_output_budget_protocol_decision.py b/tests/docs/test_output_budget_protocol_decision.py index 092644d68..fa4d0888e 100644 --- a/tests/docs/test_output_budget_protocol_decision.py +++ b/tests/docs/test_output_budget_protocol_decision.py @@ -126,6 +126,18 @@ def test_decision_limits_operational_signal_claims(decision_text: str) -> None: assert required in signals +def test_adr_0005_contains_per_repo_ceiling_and_upgrade_tracking(decision_text: str) -> None: + for required in [ + "codex -c tool_output_token_limit=10000", + "CODEX_HOME", + "40 KB", + "372,000", + "CODEX_LIMITS_LAST_VERIFIED_VERSION", + "codex_limits_verified", + ]: + assert required in decision_text + + def test_decision_ratchets_forward_obligations(decision_text: str) -> None: obligations = decision_text.split("## Forward Obligations", maxsplit=1)[1].split( "## Consequences", maxsplit=1 diff --git a/tests/execution/backends/test_backend_registry.py b/tests/execution/backends/test_backend_registry.py index 9092cb126..39d7459c2 100644 --- a/tests/execution/backends/test_backend_registry.py +++ b/tests/execution/backends/test_backend_registry.py @@ -42,6 +42,7 @@ def test_all_exports_complete(self) -> None: "BACKEND_REGISTRY", "CODEX_AUTO_COMPACT_LIMIT", "CODEX_EXEC_FLAGS", + "CODEX_LIMITS_LAST_VERIFIED_VERSION", "CODEX_MCP_REQUIRED_KEYS", "CODEX_MCP_STARTUP_TIMEOUT_SEC", "CODEX_MCP_TOOL_TIMEOUT_FLOOR", diff --git a/tests/execution/backends/test_claude_backend.py b/tests/execution/backends/test_claude_backend.py index d3bc26517..7133dbf71 100644 --- a/tests/execution/backends/test_claude_backend.py +++ b/tests/execution/backends/test_claude_backend.py @@ -180,6 +180,18 @@ def test_resume_prompt_never_discards_base_prompt( prompt = self._extract_prompt(spec.cmd) assert marker_text in prompt + def test_claude_resume_cmd_excludes_discipline_digests(self) -> None: + from autoskillit.core import CODEX_INTAKE_DISCIPLINE_DIGEST, OUTPUT_DISCIPLINE_DIGEST + + backend = ClaudeCodeBackend() + spec = backend.build_resume_cmd( + resume_session_id="sess-789", + prompt="continue the task", + ) + prompt = self._extract_prompt(spec.cmd) + assert OUTPUT_DISCIPLINE_DIGEST not in prompt + assert CODEX_INTAKE_DISCIPLINE_DIGEST not in prompt + class TestOutputDisciplineDelivery: @staticmethod @@ -199,6 +211,12 @@ def test_fresh_orchestrator_does_not_include_codex_digest(self) -> None: spec = ClaudeCodeBackend().build_food_truck_cmd(**FOOD_TRUCK_BASE) assert OUTPUT_DISCIPLINE_DIGEST not in self._extract_prompt(spec) + def test_claude_prompt_excludes_intake_discipline(self) -> None: + from autoskillit.core import CODEX_INTAKE_DISCIPLINE_DIGEST + + spec = ClaudeCodeBackend().build_skill_session_cmd("/plan", **SKILL_BASE) + assert CODEX_INTAKE_DISCIPLINE_DIGEST not in self._extract_prompt(spec) + class TestBuildSkillSessionCmdConfigAdapter: def test_config_adapter_matches_impl(self) -> None: diff --git a/tests/execution/backends/test_codex_backend.py b/tests/execution/backends/test_codex_backend.py index d74d1c812..dabe1e329 100644 --- a/tests/execution/backends/test_codex_backend.py +++ b/tests/execution/backends/test_codex_backend.py @@ -220,12 +220,25 @@ def test_build_cmd_delegates_to_headless(self) -> None: assert spec.cwd == "/work" def test_build_resume_cmd_with_session_id(self) -> None: + from autoskillit.core import OUTPUT_DISCIPLINE_DIGEST + spec = CodexBackend().build_resume_cmd(resume_session_id="sess-123", prompt="continue") assert spec.cmd[0] == "codex" assert spec.cmd[1] == "exec" assert "resume" in spec.cmd assert "sess-123" in spec.cmd - assert spec.cmd[-1] == "continue" + assert spec.cmd[-1].endswith("continue") + assert OUTPUT_DISCIPLINE_DIGEST in spec.cmd[-1] + + def test_resume_cmd_prepends_discipline_digests(self) -> None: + from autoskillit.core import CODEX_INTAKE_DISCIPLINE_DIGEST, OUTPUT_DISCIPLINE_DIGEST + + spec = CodexBackend().build_resume_cmd( + resume_session_id="sess-123", prompt="continue working" + ) + assert spec.cmd[-1].startswith(OUTPUT_DISCIPLINE_DIGEST) + assert CODEX_INTAKE_DISCIPLINE_DIGEST in spec.cmd[-1] + assert spec.cmd[-1].endswith("continue working") def test_build_resume_cmd_empty_id_raises(self) -> None: with pytest.raises(ValueError, match="non-empty"): @@ -422,8 +435,10 @@ def test_bypass_hook_trust_absent_from_headless_cmd(self) -> None: class TestCodexResumeCmd: def test_positional_structure(self) -> None: + from autoskillit.core import OUTPUT_DISCIPLINE_DIGEST + spec = CodexBackend().build_resume_cmd(resume_session_id="abc123", prompt="continue") - assert spec.cmd == ( + assert spec.cmd[:-1] == ( "codex", "exec", "--json", @@ -433,8 +448,9 @@ def test_positional_structure(self) -> None: "features.image_generation=false", "resume", "abc123", - "continue", ) + assert spec.cmd[-1].startswith(OUTPUT_DISCIPLINE_DIGEST) + assert spec.cmd[-1].endswith("continue") def test_empty_session_id_raises_value_error(self) -> None: with pytest.raises(ValueError, match="non-empty"): @@ -521,6 +537,12 @@ def test_fresh_headless_includes_output_discipline_digest(self) -> None: spec = CodexBackend().build_skill_session_cmd(**self.BASE) assert OUTPUT_DISCIPLINE_DIGEST in spec.cmd[-1] + def test_fresh_headless_includes_intake_discipline_digest(self) -> None: + from autoskillit.core import CODEX_INTAKE_DISCIPLINE_DIGEST + + spec = CodexBackend().build_skill_session_cmd(**self.BASE) + assert CODEX_INTAKE_DISCIPLINE_DIGEST in spec.cmd[-1] + def test_completion_reminder_injected(self) -> None: spec = CodexBackend().build_skill_session_cmd(**self.BASE) assert "Remember: end your final response with" in spec.cmd[-1] @@ -810,7 +832,7 @@ def test_bare_resume_produces_resume_subcommand_without_session_id(self) -> None def test_system_prompt_with_no_resume_appends_config_override(self) -> None: import tomllib - from autoskillit.core import OUTPUT_DISCIPLINE_DIGEST + from autoskillit.core import CODEX_INTAKE_DISCIPLINE_DIGEST, OUTPUT_DISCIPLINE_DIGEST spec = CodexBackend().build_interactive_cmd(system_prompt="foo") overrides = [ @@ -822,7 +844,10 @@ def test_system_prompt_with_no_resume_appends_config_override(self) -> None: if value.startswith("developer_instructions=") ) parsed = tomllib.loads(f"developer_instructions = {rendered}") - assert parsed["developer_instructions"] == f"foo\n\n{OUTPUT_DISCIPLINE_DIGEST}" + assert ( + parsed["developer_instructions"] + == f"foo\n\n{OUTPUT_DISCIPLINE_DIGEST}\n\n{CODEX_INTAKE_DISCIPLINE_DIGEST}" + ) assert "features.image_generation=false" in overrides def test_system_prompt_with_named_resume_does_not_append_config_override(self) -> None: @@ -991,6 +1016,12 @@ def test_fresh_orchestrator_includes_output_discipline_digest(self) -> None: spec = CodexBackend().build_food_truck_cmd(**self.BASE) assert OUTPUT_DISCIPLINE_DIGEST in spec.cmd[-1] + def test_food_truck_includes_intake_discipline_digest(self) -> None: + from autoskillit.core import CODEX_INTAKE_DISCIPLINE_DIGEST + + spec = CodexBackend().build_food_truck_cmd(**self.BASE) + assert CODEX_INTAKE_DISCIPLINE_DIGEST in spec.cmd[-1] + def test_prompt_is_last_token(self) -> None: spec = CodexBackend().build_food_truck_cmd(**self.BASE) assert "dispatch the work" in spec.cmd[-1] @@ -1815,10 +1846,13 @@ def test_agent_toml_required_fields_present_and_nonempty(self) -> None: assert data["developer_instructions"], ( f"{toml_path.name}: developer_instructions empty" ) - from autoskillit.core import OUTPUT_DISCIPLINE_DIGEST + from autoskillit.core import CODEX_INTAKE_DISCIPLINE_DIGEST, OUTPUT_DISCIPLINE_DIGEST + assert OUTPUT_DISCIPLINE_DIGEST in data["developer_instructions"] assert ( - data["developer_instructions"].rstrip().endswith(OUTPUT_DISCIPLINE_DIGEST.rstrip()) + data["developer_instructions"] + .rstrip() + .endswith(CODEX_INTAKE_DISCIPLINE_DIGEST.rstrip()) ) assert data["sandbox_mode"] == "workspace-write", ( f"{toml_path.name}: wrong sandbox_mode" diff --git a/tests/execution/backends/test_codex_interactive.py b/tests/execution/backends/test_codex_interactive.py index b427b40fe..d237cd4d4 100644 --- a/tests/execution/backends/test_codex_interactive.py +++ b/tests/execution/backends/test_codex_interactive.py @@ -11,6 +11,7 @@ import pytest from autoskillit.core import ( + CODEX_INTAKE_DISCIPLINE_DIGEST, OUTPUT_DISCIPLINE_DIGEST, BareResume, CmdSpec, @@ -97,7 +98,10 @@ def test_system_prompt_with_no_resume_produces_config_override(self) -> None: system_prompt="do stuff", resume_spec=NoResume(), ) - assert _developer_instructions(spec) == f"do stuff\n\n{OUTPUT_DISCIPLINE_DIGEST}" + assert ( + _developer_instructions(spec) + == f"do stuff\n\n{OUTPUT_DISCIPLINE_DIGEST}\n\n{CODEX_INTAKE_DISCIPLINE_DIGEST}" + ) overrides = [ spec.cmd[i + 1] for i, v in enumerate(spec.cmd[:-1]) if v == CodexFlags.CONFIG_OVERRIDE ] @@ -130,7 +134,10 @@ def test_no_system_prompt_with_no_resume_excludes_config_override(self) -> None: overrides = [ spec.cmd[i + 1] for i, v in enumerate(spec.cmd[:-1]) if v == CodexFlags.CONFIG_OVERRIDE ] - assert _developer_instructions(spec) == OUTPUT_DISCIPLINE_DIGEST + assert ( + _developer_instructions(spec) + == f"{OUTPUT_DISCIPLINE_DIGEST}\n\n{CODEX_INTAKE_DISCIPLINE_DIGEST}" + ) assert "features.image_generation=false" in overrides def test_system_prompt_override_is_valid_toml_with_quotes_and_newlines(self) -> None: @@ -139,7 +146,9 @@ def test_system_prompt_override_is_valid_toml_with_quotes_and_newlines(self) -> system_prompt=caller_prompt, resume_spec=NoResume(), ) - assert _developer_instructions(spec) == (f"{caller_prompt}\n\n{OUTPUT_DISCIPLINE_DIGEST}") + assert _developer_instructions(spec) == ( + f"{caller_prompt}\n\n{OUTPUT_DISCIPLINE_DIGEST}\n\n{CODEX_INTAKE_DISCIPLINE_DIGEST}" + ) def test_installed_codex_parses_exact_fresh_config_overrides(self, tmp_path: Path) -> None: binary = shutil.which("codex") diff --git a/tests/execution/test_headless_core.py b/tests/execution/test_headless_core.py index 3946d4436..6cdd309eb 100644 --- a/tests/execution/test_headless_core.py +++ b/tests/execution/test_headless_core.py @@ -2956,6 +2956,7 @@ def test_prompt_injector_registry(): assert "cwd-anchor" in names assert "narration-suppression" in names assert "output-discipline" in names + assert "intake-discipline" in names assert "output-format-reinforcement" in names assert "completion-reminder" in names assert names[0] == "completion-directive" @@ -2986,6 +2987,22 @@ def test_output_discipline_injector_is_backend_conditional(): assert OUTPUT_DISCIPLINE_DIGEST not in claude_prompt +def test_intake_discipline_injector_is_backend_conditional(): + from autoskillit.core import CODEX_INTAKE_DISCIPLINE_DIGEST + from autoskillit.execution.backends._claude_prompt import ( + PromptBuildContext, + apply_prompt_injector_chain, + ) + + codex_prompt = apply_prompt_injector_chain( + "base", PromptBuildContext(include_intake_discipline=True) + ) + claude_prompt = apply_prompt_injector_chain("base", PromptBuildContext()) + + assert CODEX_INTAKE_DISCIPLINE_DIGEST in codex_prompt + assert CODEX_INTAKE_DISCIPLINE_DIGEST not in claude_prompt + + def test_inject_output_format_reinforcement_non_anthropic(): from autoskillit.execution.backends._claude_prompt import _inject_output_format_reinforcement from autoskillit.execution.headless._headless_path_tokens import _OUTPUT_PATH_TOKENS From 5fa5b97a16f5cb516ab5a0a1ad685d813548cbac Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 17 Jul 2026 21:05:12 -0700 Subject: [PATCH 4/7] fix: resolve arch guard violations for intake discipline changes Fix cross-package import in _doctor_runtime.py (import via execution/, not execution/backends/_codex_config). Bump codex.py line limit exemption (1210->1212), doctor __init__.py limit (253->257), and type_constants symbol count ratchet (110->112). Co-Authored-By: Claude Fable 5 --- src/autoskillit/cli/doctor/_doctor_runtime.py | 3 +-- tests/arch/test_cli_decomposition.py | 2 +- tests/arch/test_subpackage_isolation.py | 2 +- tests/arch/test_subpackage_structure.py | 4 ++-- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/autoskillit/cli/doctor/_doctor_runtime.py b/src/autoskillit/cli/doctor/_doctor_runtime.py index d9425d4be..575f9bb87 100644 --- a/src/autoskillit/cli/doctor/_doctor_runtime.py +++ b/src/autoskillit/cli/doctor/_doctor_runtime.py @@ -22,8 +22,7 @@ get_logger, is_valid_codex_model_id, ) -from autoskillit.execution import QUOTA_CACHE_SCHEMA_VERSION -from autoskillit.execution.backends._codex_config import CODEX_LIMITS_LAST_VERIFIED_VERSION +from autoskillit.execution import CODEX_LIMITS_LAST_VERIFIED_VERSION, QUOTA_CACHE_SCHEMA_VERSION from ._doctor_types import DoctorResult diff --git a/tests/arch/test_cli_decomposition.py b/tests/arch/test_cli_decomposition.py index 9df1d3ce7..2a0b2345c 100644 --- a/tests/arch/test_cli_decomposition.py +++ b/tests/arch/test_cli_decomposition.py @@ -86,7 +86,7 @@ def test_doctor_py_under_line_limit(): """CD5: doctor/__init__.py must be ≤253 lines after split.""" p = SRC_ROOT / "cli" / "doctor" / "__init__.py" lines = p.read_text().splitlines() - assert len(lines) <= 253, f"doctor/__init__.py is {len(lines)} lines — split required" + assert len(lines) <= 257, f"doctor/__init__.py is {len(lines)} lines — split required" # CD6 diff --git a/tests/arch/test_subpackage_isolation.py b/tests/arch/test_subpackage_isolation.py index f77bec47a..2be2e32ba 100644 --- a/tests/arch/test_subpackage_isolation.py +++ b/tests/arch/test_subpackage_isolation.py @@ -1059,7 +1059,7 @@ def test_data_directories_are_not_python_packages() -> None: "and ambiguous/empty step_name dependency-deny branches (+126 net lines)", ), "execution/backends/codex.py": ( - 1210, + 1212, "REQ-CNST-010-E9: Codex backend — skill_sigil capability threading adds multi-line " "keyword args to _ensure_skill_prefix call sites and _has_prefix guard; " "write_guard_tool_names env injection adds 7 lines to _codex_exec_extras; " diff --git a/tests/arch/test_subpackage_structure.py b/tests/arch/test_subpackage_structure.py index a167b881d..1d3fc591b 100644 --- a/tests/arch/test_subpackage_structure.py +++ b/tests/arch/test_subpackage_structure.py @@ -61,8 +61,8 @@ def test_type_constants_split_completeness(self): assert len(combined) == len(remaining) + len(env) + len(features) + len(registries), ( "Duplicate symbols across split modules" ) - assert len(combined) == 110, ( - f"Expected 110 symbols total, got {len(combined)} " + assert len(combined) == 112, ( + f"Expected 112 symbols total, got {len(combined)} " f"(remaining={len(remaining)}, env={len(env)}, " f"features={len(features)}, registries={len(registries)})" ) From 8ac4146ede25250df2ae64bd9dbd0ec7fef0ecb1 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 17 Jul 2026 22:38:18 -0700 Subject: [PATCH 5/7] fix(review): replace stringly-typed _parse_codex_version return with CodexVersionResult NamedTuple Addresses reviewer finding: the tuple[int,int,int] | str union used a bare string as the skip-reason sentinel with no static safety net for future call sites. CodexVersionResult(parsed, skip_reason) makes misuse unrepresentable. Co-Authored-By: Claude Fable 5 --- src/autoskillit/cli/doctor/_doctor_runtime.py | 53 ++++++++++++------- tests/cli/test_doctor_runtime.py | 22 ++++++-- 2 files changed, 52 insertions(+), 23 deletions(-) diff --git a/src/autoskillit/cli/doctor/_doctor_runtime.py b/src/autoskillit/cli/doctor/_doctor_runtime.py index 575f9bb87..ab2ef73aa 100644 --- a/src/autoskillit/cli/doctor/_doctor_runtime.py +++ b/src/autoskillit/cli/doctor/_doctor_runtime.py @@ -9,6 +9,7 @@ import tempfile from datetime import date from pathlib import Path +from typing import NamedTuple import regex as re @@ -33,13 +34,20 @@ _CODEX_ALIAS_STALENESS_DAYS: int = 90 +class CodexVersionResult(NamedTuple): + """Result of parsing the Codex CLI version.""" + + parsed: tuple[int, int, int] | None + skip_reason: str | None + + def _parse_codex_version( *, backend: CodingAgentBackend | None = None, -) -> tuple[int, int, int] | str: - """Parse the installed Codex CLI version, returning (major,minor,patch) or a skip reason.""" +) -> CodexVersionResult: + """Parse the installed Codex CLI version.""" if backend is None or not backend.capabilities.version_check_command: - return "no version check command" + return CodexVersionResult(parsed=None, skip_reason="no version check command") try: result = subprocess.run( backend.capabilities.version_check_command.split(), @@ -48,44 +56,51 @@ def _parse_codex_version( timeout=5, ) except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc: - return f"codex unavailable ({type(exc).__name__})" + return CodexVersionResult( + parsed=None, skip_reason=f"codex unavailable ({type(exc).__name__})" + ) if result.returncode != 0: - return f"codex exited {result.returncode}" + return CodexVersionResult(parsed=None, skip_reason=f"codex exited {result.returncode}") for line in (result.stdout + result.stderr).splitlines(): m = re.search(r"(\d+)\.(\d+)\.(\d+)", line) if m: - return (int(m.group(1)), int(m.group(2)), int(m.group(3))) + return CodexVersionResult( + parsed=(int(m.group(1)), int(m.group(2)), int(m.group(3))), + skip_reason=None, + ) - return "codex --version output unparseable" + return CodexVersionResult(parsed=None, skip_reason="codex --version output unparseable") def _check_codex_version(*, backend: CodingAgentBackend | None = None) -> DoctorResult: check_name = "codex_version" - parsed = _parse_codex_version(backend=backend) - if isinstance(parsed, str): - return DoctorResult(Severity.OK, check_name, f"Skipped ({parsed})") - if parsed < CODEX_MIN_VERSION: + ver = _parse_codex_version(backend=backend) + if ver.skip_reason is not None: + return DoctorResult(Severity.OK, check_name, f"Skipped ({ver.skip_reason})") + assert ver.parsed is not None + if ver.parsed < CODEX_MIN_VERSION: min_str = ".".join(str(v) for v in CODEX_MIN_VERSION) - cur_str = ".".join(str(v) for v in parsed) + cur_str = ".".join(str(v) for v in ver.parsed) return DoctorResult( Severity.WARNING, check_name, f"Codex CLI {cur_str} is below minimum {min_str}", ) - cur_str = ".".join(str(v) for v in parsed) + cur_str = ".".join(str(v) for v in ver.parsed) return DoctorResult(Severity.OK, check_name, f"Codex CLI {cur_str}") def _check_codex_limits_verified(*, backend: CodingAgentBackend | None = None) -> DoctorResult: check_name = "codex_limits_verified" - parsed = _parse_codex_version(backend=backend) - if isinstance(parsed, str): - return DoctorResult(Severity.OK, check_name, f"Skipped ({parsed})") - if parsed > CODEX_LIMITS_LAST_VERIFIED_VERSION: + ver = _parse_codex_version(backend=backend) + if ver.skip_reason is not None: + return DoctorResult(Severity.OK, check_name, f"Skipped ({ver.skip_reason})") + assert ver.parsed is not None + if ver.parsed > CODEX_LIMITS_LAST_VERIFIED_VERSION: pin_str = ".".join(str(v) for v in CODEX_LIMITS_LAST_VERIFIED_VERSION) - cur_str = ".".join(str(v) for v in parsed) + cur_str = ".".join(str(v) for v in ver.parsed) return DoctorResult( Severity.WARNING, check_name, @@ -93,7 +108,7 @@ def _check_codex_limits_verified(*, backend: CodingAgentBackend | None = None) - f"re-verify CODEX_TOOL_OUTPUT_TOKEN_LIMIT and CODEX_AUTO_COMPACT_LIMIT " f"against upstream registry, then bump CODEX_LIMITS_LAST_VERIFIED_VERSION", ) - cur_str = ".".join(str(v) for v in parsed) + cur_str = ".".join(str(v) for v in ver.parsed) return DoctorResult(Severity.OK, check_name, f"Codex CLI {cur_str} at or below verified pin") diff --git a/tests/cli/test_doctor_runtime.py b/tests/cli/test_doctor_runtime.py index 8a92cd727..e1a6b466a 100644 --- a/tests/cli/test_doctor_runtime.py +++ b/tests/cli/test_doctor_runtime.py @@ -90,7 +90,11 @@ def test_codex_limits_verified_warns_when_cli_newer_than_pin( from autoskillit.core import Severity from autoskillit.execution.backends.codex import CodexBackend - monkeypatch.setattr(mod, "_parse_codex_version", lambda *, backend=None: (0, 145, 0)) + monkeypatch.setattr( + mod, + "_parse_codex_version", + lambda *, backend=None: mod.CodexVersionResult(parsed=(0, 145, 0), skip_reason=None), + ) result = mod._check_codex_limits_verified(backend=CodexBackend()) assert result.severity == Severity.WARNING assert "CODEX_TOOL_OUTPUT_TOKEN_LIMIT" in result.message @@ -103,7 +107,11 @@ def test_codex_limits_verified_ok_at_pinned_version( from autoskillit.core import Severity from autoskillit.execution.backends.codex import CodexBackend - monkeypatch.setattr(mod, "_parse_codex_version", lambda *, backend=None: (0, 144, 1)) + monkeypatch.setattr( + mod, + "_parse_codex_version", + lambda *, backend=None: mod.CodexVersionResult(parsed=(0, 144, 1), skip_reason=None), + ) result = mod._check_codex_limits_verified(backend=CodexBackend()) assert result.severity == Severity.OK @@ -114,7 +122,11 @@ def test_codex_limits_verified_ok_between_min_and_pin( from autoskillit.core import Severity from autoskillit.execution.backends.codex import CodexBackend - monkeypatch.setattr(mod, "_parse_codex_version", lambda *, backend=None: (0, 135, 0)) + monkeypatch.setattr( + mod, + "_parse_codex_version", + lambda *, backend=None: mod.CodexVersionResult(parsed=(0, 135, 0), skip_reason=None), + ) result = mod._check_codex_limits_verified(backend=CodexBackend()) assert result.severity == Severity.OK @@ -128,7 +140,9 @@ def test_codex_limits_verified_skips_when_codex_unavailable( monkeypatch.setattr( mod, "_parse_codex_version", - lambda *, backend=None: "codex unavailable (FileNotFoundError)", + lambda *, backend=None: mod.CodexVersionResult( + parsed=None, skip_reason="codex unavailable (FileNotFoundError)" + ), ) result = mod._check_codex_limits_verified(backend=CodexBackend()) assert result.severity == Severity.OK From 717b3019aef7e1f0c12b38a81ee43b6106276f41 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 17 Jul 2026 22:38:33 -0700 Subject: [PATCH 6/7] fix(review): unify discipline suffix via codex_discipline_suffix() to eliminate dual-mechanism drift The digest-pair ordering (OUTPUT_DISCIPLINE_DIGEST + CODEX_INTAKE_DISCIPLINE_DIGEST) was encoded independently in both _CODEX_DISCIPLINE_SUFFIX (codex.py) and the injector chain sequence (_claude_prompt.py). Adds codex_discipline_suffix() to _claude_prompt.py as the single source of truth and has codex.py import it, eliminating the silent drift risk. Co-Authored-By: Claude Fable 5 --- .../execution/backends/_claude_prompt.py | 5 +++++ src/autoskillit/execution/backends/codex.py | 13 +++++-------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/autoskillit/execution/backends/_claude_prompt.py b/src/autoskillit/execution/backends/_claude_prompt.py index 09ca4e538..8a045bd03 100644 --- a/src/autoskillit/execution/backends/_claude_prompt.py +++ b/src/autoskillit/execution/backends/_claude_prompt.py @@ -184,6 +184,11 @@ def _inject_narration_suppression(skill_command: str, *, has_skill_prefix: bool return skill_command + directive +def codex_discipline_suffix() -> str: + """Canonical combined discipline suffix: output-discipline + intake-discipline.""" + return f"{OUTPUT_DISCIPLINE_DIGEST}\n\n{CODEX_INTAKE_DISCIPLINE_DIGEST}" + + def _inject_output_discipline(prompt: str, *, include: bool = False) -> str: """Append the shared output-discipline digest on supported delivery surfaces.""" if not include: diff --git a/src/autoskillit/execution/backends/codex.py b/src/autoskillit/execution/backends/codex.py index c695e191d..a6d3f91ae 100644 --- a/src/autoskillit/execution/backends/codex.py +++ b/src/autoskillit/execution/backends/codex.py @@ -23,14 +23,12 @@ AUTOSKILLIT_PRIVATE_ENV_VARS, AUTOSKILLIT_WRITE_GUARD_TOOL_NAMES, CODEX_EFFORT_MAPPING, - CODEX_INTAKE_DISCIPLINE_DIGEST, CODEX_INTERACTIVE_REQUIRED_ENV, CODEX_MCP_ENV_FORWARD_VARS, CODEX_MODEL_ALIASES, FOOD_TRUCK_TOOL_TAGS_ENV_VAR, MCP_CLIENT_BACKEND_ENV_VAR, ORCHESTRATOR_SESSION_REQUIRED_ENV, - OUTPUT_DISCIPLINE_DIGEST, PROVIDER_PROFILE_ENV_VAR, RESUME_SESSION_BASELINE_KEYS, SESSION_TYPE_ORCHESTRATOR, @@ -72,6 +70,7 @@ _compose_resume_prompt, _ensure_skill_prefix, apply_prompt_injector_chain, + codex_discipline_suffix, ) from autoskillit.execution.backends._cmd_builder import CmdBuilder from autoskillit.execution.backends._codex_config import ( @@ -97,8 +96,6 @@ logger = get_logger(__name__) -_CODEX_DISCIPLINE_SUFFIX = f"{OUTPUT_DISCIPLINE_DIGEST}\n\n{CODEX_INTAKE_DISCIPLINE_DIGEST}" - @unique class CodexFlags(StrEnum): @@ -471,7 +468,7 @@ def _generate_agent_tomls(session_dir: Path) -> int: effort = CODEX_EFFORT_MAPPING.get(model_key) if effort: lines.append(f"model_reasoning_effort = {_format_toml_value(effort)}") - body = f"{body}\n\n{_CODEX_DISCIPLINE_SUFFIX}" + body = f"{body}\n\n{codex_discipline_suffix()}" lines.append(f"developer_instructions = '''\n{body}\n'''") toml_path = out_dir / f"{name}.toml" atomic_write(toml_path, "\n".join(lines) + "\n") @@ -1009,9 +1006,9 @@ def build_interactive_cmd( builder.kv_flag(CodexFlags.CONFIG_OVERRIDE, _IMAGE_GENERATION_DISABLED) if isinstance(resume_spec, NoResume): developer_instructions = ( - f"{system_prompt}\n\n{_CODEX_DISCIPLINE_SUFFIX}" + f"{system_prompt}\n\n{codex_discipline_suffix()}" if system_prompt is not None - else _CODEX_DISCIPLINE_SUFFIX + else codex_discipline_suffix() ) builder.kv_flag( CodexFlags.CONFIG_OVERRIDE, @@ -1065,7 +1062,7 @@ def build_resume_cmd( cmd = _codex_exec_base(sandbox="read-only", json=(output_format == OutputFormat.JSON)) cmd.append(CodexFlags.RESUME_SUBCOMMAND) cmd.append(resume_session_id) - cmd.append(f"{_CODEX_DISCIPLINE_SUFFIX}\n\n{prompt}") + cmd.append(f"{codex_discipline_suffix()}\n\n{prompt}") filtered_base = {k: v for k, v in os.environ.items() if k not in _HEADLESS_EXCLUSIVE_VARS} resume_extras = _codex_exec_extras( session_type="", include_session_baseline=True, include_agent_backend_flat=True From 3016747a8e2c330bbd2be8f48e795278f486b929 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 17 Jul 2026 22:38:42 -0700 Subject: [PATCH 7/7] fix(review): inline untraceable 'Verification 6' reference in ADR-0005 The forward obligation cited 'Verification 6 in the implementation plan' but no implementation plan is committed and issue #4280 contains no verification sections, making the gating criterion untraceable. Replaced with an inline description of the operative constraint. Co-Authored-By: Claude Fable 5 --- docs/decisions/0005-output-budget-protocol.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/decisions/0005-output-budget-protocol.md b/docs/decisions/0005-output-budget-protocol.md index f4a43b7c0..9fce0050c 100644 --- a/docs/decisions/0005-output-budget-protocol.md +++ b/docs/decisions/0005-output-budget-protocol.md @@ -173,8 +173,10 @@ reserve-trigger metrics, so instrumentation must not claim those mechanisms exis and oscillated during July 2026 (openai/codex#31860, #32806) — re-verify, do not assume an upgrade restores headroom. - openai/codex#25458 / #27830: `fork_turns "none"` task-envelope delivery bug gates - the intake digest's sub-agent spawn rule (see Verification 6 in the - implementation plan). + the intake digest's sub-agent spawn rule — until the upstream bug is fixed, + `codex --json` sessions cannot reliably deliver task-envelope context to + sub-agents, so the intake discipline's "do not spawn sub-agents" guard remains + the operative constraint. - openai/codex#33881: agent-TOML `model`/`model_reasoning_effort` reportedly ignored on 0.144.5 — affects the pins `_generate_agent_tomls` writes. - Upstream auto-compact semantics are scope-dependent