Skip to content
Merged
3 changes: 3 additions & 0 deletions src/autoskillit/cli/session/_cook.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,8 @@ def cook(

_max_reloads = 10
seen_reload_ids: set[str] = set()
from autoskillit.execution import assert_interactive_ordering

while True:
spec = backend.build_interactive_cmd(
plugin_source=plugin_source,
Expand All @@ -236,6 +238,7 @@ def cook(
resume_spec=current_resume_spec,
env_extras=_cook_env_extras,
)
assert_interactive_ordering(spec=spec)
reload_session_id = _run_cook_session(
cmd=[*spec.cmd],
env=spec.env,
Expand Down
3 changes: 3 additions & 0 deletions src/autoskillit/cli/session/_session_launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ def _run_interactive_session(
plugin_source = None
tools_arg = ()

from autoskillit.execution import assert_interactive_ordering

spec = backend.build_interactive_cmd(
initial_prompt=initial_message,
resume_spec=resume_spec if resume_spec is not None else NoResume(),
Expand All @@ -113,6 +115,7 @@ def _run_interactive_session(
plugin_source=plugin_source,
tools=tools_arg,
)
assert_interactive_ordering(spec=spec)
cmd = [*spec.cmd]
with terminal_guard():
result = subprocess.run(cmd, env=spec.env)
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 @@ -154,6 +154,7 @@ from .types import KNOWN_CI_EVENTS as KNOWN_CI_EVENTS
from .types import LABEL_LIFECYCLE_REGISTRY as LABEL_LIFECYCLE_REGISTRY
from .types import LABEL_TRANSITIONS as LABEL_TRANSITIONS
from .types import LAUNCH_ID_ENV_VAR as LAUNCH_ID_ENV_VAR
from .types import NON_VARIADIC_CLAUDE_FLAGS as NON_VARIADIC_CLAUDE_FLAGS
from .types import PACK_REGISTRY as PACK_REGISTRY
from .types import PIPELINE_FORBIDDEN_TOOLS as PIPELINE_FORBIDDEN_TOOLS
from .types import PR_TELEMETRY_SECTIONS as PR_TELEMETRY_SECTIONS
Expand Down Expand Up @@ -214,6 +215,7 @@ from .types import CloneGateUnpublished as CloneGateUnpublished
from .types import CloneManager as CloneManager
from .types import CloneResult as CloneResult
from .types import CloneSuccessResult as CloneSuccessResult
from .types import CmdOrigin as CmdOrigin
from .types import CmdSpec as CmdSpec
from .types import CodexEventData as CodexEventData
from .types import CodingAgentBackend as CodingAgentBackend
Expand Down
13 changes: 13 additions & 0 deletions src/autoskillit/core/types/_type_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
__all__ = [
"BackendCapabilities",
"CLAUDE_CODE_CAPABILITIES",
"CmdOrigin",
"CmdSpec",
"SkillSessionConfig",
"ClaudeEventData",
Expand Down Expand Up @@ -108,13 +109,25 @@ class BackendCapabilities:
)


@dataclass(frozen=True, slots=True)
class CmdOrigin:
"""Provenance metadata for a CmdSpec, capturing the structural role of each element."""

binary: str
mode_flags: tuple[str, ...] = ()
kv_flags: tuple[tuple[str, str], ...] = ()
positional: tuple[str, ...] = ()
variadic_pairs: tuple[tuple[str, str], ...] = ()


@dataclass(frozen=True, slots=True)
class CmdSpec:
"""Fully-resolved subprocess command specification passed to the runner."""

cmd: tuple[str, ...]
env: Mapping[str, str]
cwd: str = ""
origin: CmdOrigin | None = None


@dataclass(frozen=True, slots=True)
Expand Down
15 changes: 15 additions & 0 deletions src/autoskillit/core/types/_type_enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"RecipeSource",
"ClaudeFlags",
"VARIADIC_CLAUDE_FLAGS",
"NON_VARIADIC_CLAUDE_FLAGS",
"OutputFormat",
"Severity",
"TerminationReason",
Expand Down Expand Up @@ -164,6 +165,20 @@ class ClaudeFlags(StrEnum):

VARIADIC_CLAUDE_FLAGS: frozenset[str] = frozenset({ClaudeFlags.ADD_DIR, ClaudeFlags.TOOLS})

NON_VARIADIC_CLAUDE_FLAGS: frozenset[str] = frozenset(
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[warning] cohesion: NON_VARIADIC_CLAUDE_FLAGS (core/types/_type_enums.py) and _CLAUDE_VALUE_BEARING_FLAGS (_headless_helpers.py) both classify ClaudeFlags members by argument-taking behavior but are defined separately with overlapping intent and inconsistent membership. The flag-classification vocabulary is split across two layers with neither referencing the other.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid observation β€” flagged for design decision. NON_VARIADIC_CLAUDE_FLAGS and _CLAUDE_VALUE_BEARING_FLAGS overlap in intent but serve different validation gates. Unification requires deciding on a single taxonomy for flag properties.

{
ClaudeFlags.ALLOW_DANGEROUSLY_SKIP_PERMISSIONS,
ClaudeFlags.DANGEROUSLY_SKIP_PERMISSIONS,
ClaudeFlags.PRINT,
ClaudeFlags.MODEL,
ClaudeFlags.PLUGIN_DIR,
ClaudeFlags.OUTPUT_FORMAT,
ClaudeFlags.VERBOSE,
ClaudeFlags.RESUME,
ClaudeFlags.APPEND_SYSTEM_PROMPT,
}
)


class OutputFormat(StrEnum):
"""Claude CLI output format with declared data capabilities.
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 @@ -60,6 +60,7 @@
)
from autoskillit.execution.headless import (
DefaultHeadlessExecutor,
assert_interactive_ordering,
run_headless_core,
)
from autoskillit.execution.linux_tracing import (
Expand Down Expand Up @@ -181,6 +182,7 @@
# headless
"run_headless_core",
"DefaultHeadlessExecutor",
"assert_interactive_ordering",
# testing
"parse_pytest_summary",
"check_test_passed",
Expand Down
1 change: 1 addition & 0 deletions src/autoskillit/execution/backends/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ IL-1 backend abstraction layer β€” concrete `CodingAgentBackend` implementations
| `_codex_config.py` | TOML serialization with `[[key]]` array-of-tables support, MCP registration (`ensure_codex_mcp_registered`, `_serialize_toml`) |
| `_codex_parse.py` | `CodexStreamParser`, `CodexResultParser`, `_scan_codex_ndjson`, `_CodexParseAccumulator` |
| `codex_scenario_player.py` | `CodexScenarioPlayer`, `make_codex_scenario_player`, `CodexStepRecord`, `CodexScenario`, `_load_manifest`, `_FakeCodexCLI`, `_write_shim_script` β€” scenario replay data layer for Codex backend |
| `_cmd_builder.py` | `CmdBuilder`, `CmdOrderingError` β€” typed builder that enforces positional-before-variadic ordering by construction |
78 changes: 78 additions & 0 deletions src/autoskillit/execution/backends/_cmd_builder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Typed command builder that enforces positional-before-variadic ordering by construction."""

from __future__ import annotations

from collections.abc import Mapping

from autoskillit.core import CmdOrigin, CmdSpec

__all__ = ["CmdBuilder", "CmdOrderingError"]


class CmdOrderingError(ValueError):
"""Raised when a positional argument is added after a variadic pair."""


class CmdBuilder:
"""Build a CmdSpec with structural enforcement of argument ordering.

Sections are assembled in a fixed order:
binary β†’ mode_flags β†’ kv_flags β†’ positional β†’ variadic_pairs

Calling ``positional()`` after ``variadic_pair()`` raises ``CmdOrderingError``
immediately, making argument-ordering bugs impossible by construction.
"""

def __init__(self, binary: str) -> None:
if not binary:
raise ValueError("binary must be a non-empty string")
self._binary = binary
self._mode_flags: list[str] = []
self._kv_flags: list[tuple[str, str]] = []
self._positional: list[str] = []
self._variadic_pairs: list[tuple[str, str]] = []
self._has_variadic = False

def mode_flag(self, flag: str) -> CmdBuilder:
self._mode_flags.append(flag)
return self

def kv_flag(self, flag: str, value: str) -> CmdBuilder:
self._kv_flags.append((flag, value))
return self

def positional(self, value: str) -> CmdBuilder:
if self._has_variadic:
raise CmdOrderingError(
f"Cannot add positional arg {value!r} after variadic pairs have been added. "
"Positional args must precede all variadic flag pairs."
)
self._positional.append(value)
return self

def variadic_pair(self, flag: str, value: str) -> CmdBuilder:
self._variadic_pairs.append((flag, value))
self._has_variadic = True
return self

def build(self, env: Mapping[str, str] | None = None, cwd: str = "") -> CmdSpec:
cmd: list[str] = [self._binary]
cmd.extend(self._mode_flags)
for flag, value in self._kv_flags:
cmd.extend([flag, value])
cmd.extend(self._positional)
for flag, value in self._variadic_pairs:
cmd.extend([flag, value])
origin = CmdOrigin(
binary=self._binary,
mode_flags=tuple(self._mode_flags),
kv_flags=tuple(self._kv_flags),
positional=tuple(self._positional),
variadic_pairs=tuple(self._variadic_pairs),
)
return CmdSpec(
cmd=tuple(cmd),
env=env if env is not None else {},
cwd=cwd,
origin=origin,
)
24 changes: 14 additions & 10 deletions src/autoskillit/execution/backends/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
_inject_cwd_anchor,
_inject_narration_suppression,
)
from autoskillit.execution.backends._cmd_builder import CmdBuilder
from autoskillit.execution.process import _marker_is_standalone
from autoskillit.execution.session import parse_session_result

Expand Down Expand Up @@ -356,40 +357,43 @@ def build_interactive_cmd(
an interactive session is determined at runtime by kitchen state, not by a
``SESSION_TYPE`` env variable.
"""
cmd: list[str] = ["claude", ClaudeFlags.DANGEROUSLY_SKIP_PERMISSIONS]
builder = CmdBuilder("claude")
builder.mode_flag(ClaudeFlags.DANGEROUSLY_SKIP_PERMISSIONS)
match resume_spec:
case NamedResume(session_id=sid):
cmd += [ClaudeFlags.RESUME, sid]
builder.kv_flag(ClaudeFlags.RESUME, sid)
case BareResume():
cmd.append(ClaudeFlags.RESUME)
builder.mode_flag(ClaudeFlags.RESUME)
case NoResume():
pass
if system_prompt is not None and isinstance(resume_spec, NoResume):
cmd += [ClaudeFlags.APPEND_SYSTEM_PROMPT, system_prompt]
builder.kv_flag(ClaudeFlags.APPEND_SYSTEM_PROMPT, system_prompt)
if model:
cmd += [ClaudeFlags.MODEL, model]
builder.kv_flag(ClaudeFlags.MODEL, model)
match plugin_source:
case DirectInstall(plugin_dir=p):
cmd += [ClaudeFlags.PLUGIN_DIR, str(p)]
builder.kv_flag(ClaudeFlags.PLUGIN_DIR, str(p))
case MarketplaceInstall():
pass
case None:
pass
if initial_prompt is not None:
cmd.append(initial_prompt)
builder.positional(initial_prompt)
for d in add_dirs:
cmd += [ClaudeFlags.ADD_DIR, str(d)]
builder.variadic_pair(ClaudeFlags.ADD_DIR, str(d))
for t in tools:
cmd += [ClaudeFlags.TOOLS, t]
builder.variadic_pair(ClaudeFlags.TOOLS, t)
merged: dict[str, str] = dict(_SESSION_BASELINE_ENV)
if env_extras:
merged.update(env_extras)
interactive_base = {
k: v for k, v in os.environ.items() if k not in _INTERACTIVE_ENV_EXCLUSIONS
}
partial = builder.build()
return CmdSpec(
cmd=tuple(cmd),
cmd=partial.cmd,
env=build_agent_env(base=interactive_base, extras=merged, required=required_env),
origin=partial.origin,
)

def build_resume_cmd(
Expand Down
26 changes: 15 additions & 11 deletions src/autoskillit/execution/backends/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
_inject_cwd_anchor,
_inject_narration_suppression,
)
from autoskillit.execution.backends._cmd_builder import CmdBuilder
from autoskillit.execution.backends._codex_config import ensure_codex_mcp_registered
from autoskillit.execution.backends._codex_parse import CodexResultParser, CodexStreamParser

Expand Down Expand Up @@ -566,29 +567,32 @@ def build_interactive_cmd(
"codex_tools_ignored",
extra={"tools": list(tools)},
)
cmd: list[str] = []
builder = CmdBuilder("codex")
match resume_spec:
case NoResume():
cmd = ["codex", CodexFlags.DANGEROUSLY_BYPASS]
builder.mode_flag(CodexFlags.DANGEROUSLY_BYPASS)
case NamedResume(session_id=sid):
cmd = ["codex", CodexFlags.RESUME_SUBCOMMAND, sid, CodexFlags.DANGEROUSLY_BYPASS]
builder.mode_flag(CodexFlags.RESUME_SUBCOMMAND)
builder.positional(sid)
builder.mode_flag(CodexFlags.DANGEROUSLY_BYPASS)
Comment thread
Trecek marked this conversation as resolved.
case BareResume():
cmd = ["codex", CodexFlags.RESUME_SUBCOMMAND, CodexFlags.DANGEROUSLY_BYPASS]
builder.mode_flag(CodexFlags.RESUME_SUBCOMMAND)
builder.mode_flag(CodexFlags.DANGEROUSLY_BYPASS)
if model:
cmd += [CodexFlags.MODEL, model]
# plugin_source: explicit no-op for Codex
builder.kv_flag(CodexFlags.MODEL, model)
if system_prompt is not None and isinstance(resume_spec, NoResume):
builder.kv_flag(CodexFlags.CONFIG_OVERRIDE, f"developer_instructions={system_prompt}")
if initial_prompt is not None:
cmd.append(initial_prompt)
builder.positional(initial_prompt)
for d in add_dirs:
cmd += [CodexFlags.ADD_DIR, str(d)]
if system_prompt is not None and isinstance(resume_spec, NoResume):
cmd += [CodexFlags.CONFIG_OVERRIDE, f"developer_instructions={system_prompt}"]
builder.variadic_pair(CodexFlags.ADD_DIR, str(d))
base_env = {k: v for k, v in os.environ.items() if k not in _HEADLESS_EXCLUSIVE_VARS}
merged_extras: dict[str, str] = dict(env_extras) if env_extras else {}
if add_dirs:
merged_extras.setdefault("CODEX_HOME", str(add_dirs[0]))
env = CodexEnvPolicy().build_env(base_env, extras=merged_extras, required=required_env)
return CmdSpec(cmd=tuple(cmd), env=env)
partial = builder.build()
return CmdSpec(cmd=partial.cmd, env=env, origin=partial.origin)

def build_resume_cmd(
self,
Expand Down
2 changes: 2 additions & 0 deletions src/autoskillit/execution/headless/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
_resolve_session_log_dir,
_session_log_dir, # noqa: F401
_stat_snapshot, # noqa: F401
assert_interactive_ordering,
)
from autoskillit.execution.headless._headless_path_tokens import ( # noqa: F401
_INTENTIONALLY_EXCLUDED_PATH_TOKENS,
Expand Down Expand Up @@ -91,6 +92,7 @@
__all__ = [
"DefaultHeadlessExecutor",
"PostSessionMetrics",
"assert_interactive_ordering",
"run_headless_core",
]

Expand Down
Loading
Loading