Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|---|
Expand Down
2 changes: 1 addition & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<project>/.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
Expand Down
38 changes: 38 additions & 0 deletions docs/decisions/0005-output-budget-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<project>/.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
Expand Down Expand Up @@ -145,6 +163,26 @@ 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 — 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
(`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

Expand Down
2 changes: 1 addition & 1 deletion docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion src/autoskillit/cli/doctor/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# doctor/

Diagnostic health checks for the autoskillit installation (36 checks).
Diagnostic health checks for the autoskillit installation (47 checks).

## Files

Expand Down
4 changes: 4 additions & 0 deletions src/autoskillit/cli/doctor/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
90 changes: 61 additions & 29 deletions src/autoskillit/cli/doctor/_doctor_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import tempfile
from datetime import date
from pathlib import Path
from typing import NamedTuple

import regex as re

Expand All @@ -22,7 +23,7 @@
get_logger,
is_valid_codex_model_id,
)
from autoskillit.execution import QUOTA_CACHE_SCHEMA_VERSION
from autoskillit.execution import CODEX_LIMITS_LAST_VERIFIED_VERSION, QUOTA_CACHE_SCHEMA_VERSION

from ._doctor_types import DoctorResult

Expand All @@ -33,10 +34,20 @@
_CODEX_ALIAS_STALENESS_DAYS: int = 90


def _check_codex_version(*, backend: CodingAgentBackend | None = None) -> DoctorResult:
check_name = "codex_version"
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,
) -> CodexVersionResult:
"""Parse the installed Codex CLI version."""
if backend is None or not backend.capabilities.version_check_command:
return DoctorResult(Severity.OK, check_name, "Skipped (no version check command)")
return CodexVersionResult(parsed=None, skip_reason="no version check command")
try:
result = subprocess.run(
backend.capabilities.version_check_command.split(),
Expand All @@ -45,39 +56,60 @@ 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 CodexVersionResult(
parsed=None, skip_reason=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 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:
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 CodexVersionResult(
parsed=(int(m.group(1)), int(m.group(2)), int(m.group(3))),
skip_reason=None,
)

return DoctorResult(
Severity.OK,
check_name,
"codex --version output unparseable; skipping version check",
)
return CodexVersionResult(parsed=None, skip_reason="codex --version output unparseable")


def _check_codex_version(*, backend: CodingAgentBackend | None = None) -> DoctorResult:
check_name = "codex_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 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 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"
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 ver.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 ver.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:
Expand Down
2 changes: 2 additions & 0 deletions src/autoskillit/core/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions src/autoskillit/core/types/_type_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/autoskillit/execution/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/autoskillit/execution/backends/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading