Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
3e31a11
test: add failing tests for delivery-bound starvation defect
Trecek Jul 20, 2026
5550d6c
fix: priority-tier delivery-bound summary + capability hole closure
Trecek Jul 20, 2026
8c26893
fix: delivery-bound summary content > 0 invariant + capability floor
Trecek Jul 20, 2026
f3cb9cf
chore: commit auto-generated files
Trecek Jul 21, 2026
88a71d3
fix: refresh canonical recipe response maxima
Trecek Jul 21, 2026
6fa8960
feat: bounded envelope + get_recipe_section pull tool (Part B #4304)
Trecek Jul 21, 2026
be02b60
test: document FLEET_DISPATCH_TOOLS as alias-derived registry constant
Trecek Jul 21, 2026
26beed8
fix: refresh test/doc counts after adding get_recipe_section kitchen …
Trecek Jul 21, 2026
a7cb14a
test: add Part A regression guards for envelope persist + two-phase a…
Trecek Jul 21, 2026
daf5ada
feat: unconditional single-call persistence + recipe_name + two-phase…
Trecek Jul 21, 2026
19515c0
feat(recipe+server): route edges reuse canonical extractor, byte-rang…
Trecek Jul 21, 2026
656e439
test(recipe-pull): byte-range + canonical-extractor + get_recipe_sect…
Trecek Jul 21, 2026
5581cbc
fix(resolve-failures): confine yaml imports to core/io.py + chunked-p…
Trecek Jul 21, 2026
9ca0c88
feat(4304-part-c): amend ADR-0004/0005 + sous-chef SKILL.md; rewrite …
Trecek Jul 21, 2026
8af03cb
fix(resolve-failures): add infra/test_pretty_output_recipe.py to exec…
Trecek Jul 21, 2026
101b9a9
fix(review): preserve run_skill projection structure
Trecek Jul 21, 2026
6ee21a5
fix(review): retain non-string run_skill results
Trecek Jul 21, 2026
bd9ec8f
fix(review): search byte-safe result prefixes
Trecek Jul 21, 2026
d1b68c0
fix(review): fail closed on routing extraction errors
Trecek Jul 21, 2026
76e0efc
fix(review): carry recipe artifact identity through pulls
Trecek Jul 21, 2026
1fe8080
fix(review): bound serialized recipe section chunks
Trecek Jul 21, 2026
95d397c
fix(review): move recipe step indexing to recipe domain
Trecek Jul 21, 2026
d03fe7d
fix(review): preserve complete recipe suggestions
Trecek Jul 21, 2026
fd8700e
fix(review): enforce final recipe envelope byte bound
Trecek Jul 21, 2026
eecae19
fix(review): distinguish recipe artifact failures
Trecek Jul 21, 2026
1941a48
test(review): cover load_recipe artifact pulls
Trecek Jul 21, 2026
80a094a
test(review): assert the actual recipe pull byte bound
Trecek Jul 21, 2026
3fee3f9
fix(review): reserve envelope serialization overhead
Trecek Jul 21, 2026
02986ae
test(review): exercise bounded load_recipe producer
Trecek Jul 21, 2026
2f3418a
test(review): refresh shifted pyright allowlist lines
Trecek Jul 21, 2026
fa7b319
fix(review): refresh complete load_recipe response ceiling
Trecek Jul 21, 2026
30e146c
fix(review): refresh open_kitchen response ceiling
Trecek Jul 21, 2026
d3652a3
fix(review): preserve projected fields within exact bound
Trecek Jul 21, 2026
dbe99fe
fix(review): reserve envelope serialization overhead at finalize
Trecek Jul 21, 2026
f77898e
fix(review): compensate test budgets for envelope serialization reserve
Trecek Jul 21, 2026
c231297
fix(test): exercise load_recipe envelope with bound-exceeding payload
Trecek Jul 21, 2026
d57d121
fix(test): force envelope path with tight bound override
Trecek Jul 21, 2026
d2669f1
fix(test): use tight bound with small recipe to exercise envelope path
Trecek Jul 21, 2026
e140d39
fix(test): tight 200-token bound to force envelope
Trecek Jul 21, 2026
d87f2ef
fix(test): set backend attributes explicitly (MagicMock kwarg may not…
Trecek Jul 21, 2026
d07a277
fix(test): bump tight bound to 500 tokens so skeleton fits
Trecek Jul 21, 2026
9b49474
fix(test): bump tight bound to 1000 tokens (4000 bytes)
Trecek Jul 21, 2026
ee8fa15
fix(test): pull step when bounded envelope omits skeleton
Trecek Jul 21, 2026
00ebe9f
fix(test): reconstruct artifact path from deterministic helper when e…
Trecek Jul 21, 2026
1851308
fix(review): require recipe artifact identity for section pulls
Trecek Jul 21, 2026
6998c16
fix(review): enforce post-prune step pull allowlist
Trecek Jul 21, 2026
38d22bc
fix(review): preserve a byte-safe content floor
Trecek Jul 21, 2026
d9ee5a2
fix(review): isolate post-prune allowlist logic
Trecek Jul 21, 2026
2e57b40
fix(review): require digest-bound recipe pulls
Trecek Jul 21, 2026
f158e71
fix(review): reject malformed recipe artifact shapes
Trecek Jul 21, 2026
282d13d
test(review): cover production recipe envelope skeletons
Trecek Jul 21, 2026
9686907
fix(review): preserve bounded recipe pull contracts
Trecek Jul 21, 2026
5d92b46
fix(review): fit production skeletons within delivery bounds
Trecek Jul 21, 2026
acca671
ci: trigger
Trecek Jul 21, 2026
d08014c
fix(test): refresh stale pinned response-ceiling maxima
Trecek Jul 21, 2026
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 docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

AutoSkillit is a Claude Code plugin that runs YAML recipes through a
multi-level orchestrator. The bundled recipes implement issue → plan → worktree
→ tests → PR → merge pipelines using 59 MCP tools and 142 bundled skills.
→ tests → PR → merge pipelines using 60 MCP tools and 142 bundled skills.

## Start here

Expand Down
30 changes: 22 additions & 8 deletions docs/decisions/0004-recipe-redelivery.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,34 @@ knowledge without reading raw files.

## Decision

> **`load_recipe` (the MCP tool exposed by `server/tools/tools_recipe.py`) is the
> sanctioned channel for recipe knowledge re-delivery after context compaction.**

If recipe content is lost, the agent must call `load_recipe` to re-acquire it. The
`recipe_read_guard` enforces that only this path is allowed — raw file access is denied.
> **`load_recipe` / `open_kitchen` (the MCP tools exposed by `server/tools/tools_recipe.py`)
> remain the sanctioned entry points for recipe knowledge re-delivery after context
> compaction. When the response is a bounded envelope (oversized recipes exceeding the
> delivery bound — see ADR-0005), full re-delivery is completed by pulling each step via
> `get_recipe_section` (chunked via `part` / `has_more` / `next_part` for oversized
> sections). Both remain the only channels not blocked by `recipe_read_guard`'s
> deny-list on raw `run_cmd`/`Bash`/`run_python` recipe access — `recipe_read_guard.py`
> has no allow-list keyed on tool name; it blocks the raw-access alternatives rather
> than allow-listing `load_recipe`/`open_kitchen`/`get_recipe_section` by name.**

If recipe content is lost, the agent must call `load_recipe` (or `open_kitchen`) to
re-acquire it; if the response is a bounded envelope, follow up by calling
`get_recipe_section(section=<step_name>)` to pull each step body. The `recipe_read_guard`
deny-list on raw `run_cmd`/`Bash`/`run_python` recipe access remains the operative
constraint — raw file access is denied; the MCP tools above are the unblocked paths.

This is a forward obligation: when the primary defense (unreachable auto-compact limit)
is relaxed, `load_recipe` re-delivery must be tested end-to-end as the recovery path.
is relaxed, the `load_recipe` / `open_kitchen` / `get_recipe_section` end-to-end
recovery path — including envelope re-delivery plus per-step pulls — must be tested as
the recovery path.

## Consequences

- `recipe_read_guard.py` error messages direct the agent to call `load_recipe`.
- `recipe_read_guard.py` error messages direct the agent to call `load_recipe` (and,
for envelope responses, `get_recipe_section`).
- No production code changes are needed today — the channel already exists and works.
- Future work relaxing `CODEX_AUTO_COMPACT_LIMIT` must add integration tests verifying
`load_recipe` re-delivery restores full pipeline execution capability.
`load_recipe` / `open_kitchen` / `get_recipe_section` re-delivery restores full
pipeline execution capability, including envelope re-delivery plus per-step pulls.
- `test_copied_config_has_auto_compact_limit` validates the primary defense path:
`setup_session_dir` preserves the override in the copied `config.toml`.
29 changes: 23 additions & 6 deletions docs/decisions/0005-output-budget-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,28 @@ The raw-text `open_kitchen` and `load_recipe` responses are measured exemptions
universal backstop. `RESPONSE_BACKSTOP_EXEMPTION_REGISTRY` is the closed authority for
their independent character ceiling, UTF-8 byte ceiling, and measurement identity. Its
canonical digest is carried in tool metadata and probe-cache identity. Adding or relaxing
an exemption requires re-measurement and a deliberate registry-digest change.
an exemption requires re-measurement and a deliberate registry-digest change. The raw-text
exemption now applies only to the fits-within-bound fast path; when a recipe payload exceeds
the delivery bound, `maybe_envelope_recipe_response` replaces it with a bounded envelope
(via `build_recipe_envelope`) that fits every backend by construction, with full step
content available on demand via `get_recipe_section`.

## Numeric Limits and Rationale

| Limit | Decision and rationale |
|---|---|
| `load_recipe`: `max_chars = 185_000`, `max_utf8_bytes = 185_000` | The 2026-07-16 independent all-recipe/all-mode pre-backstop measurement reached 183,103 characters and UTF-8 bytes for `remediation` with all truthy ingredients. The 1,897-unit margin makes serving growth explicit. Measurement identity: `bundled-recipes-all-modes-2026-07-16/load-recipe`. |
| `open_kitchen`: `max_chars = 186_000`, `max_utf8_bytes = 186_000` | The matching current-version pre-backstop measurement reached 183,103 characters and UTF-8 bytes for `remediation` with all truthy ingredients. The 2,897-unit margin covers the open-kitchen routing fields without conflating this handler ceiling with the smaller formatted presentation. Measurement identity: `bundled-recipes-all-modes-2026-07-16/open-kitchen`. |
| `CODEX_TOOL_OUTPUT_TOKEN_LIMIT = 54_500` | Derive it from the largest registered exemption as `((186_000 + 3) // 4) + 8_000`: 46,500 tokens under the current client's four-byte heuristic, plus 8,000 tokens for serialized-payload headroom. It is a blast-radius damage bound, not the mechanism that makes evidence lossless. |
| `load_recipe`: `max_chars = 188_000`, `max_utf8_bytes = 188_000` | The 2026-07-21 independent all-recipe/all-mode pre-backstop measurement reached 186,621 characters and UTF-8 bytes for `remediation` with all truthy ingredients. The 1,379-unit margin makes serving growth explicit. Measurement identity: `bundled-recipes-all-modes-2026-07-21/load-recipe`. For payloads that exceed the delivery bound, see envelope note below. |
| `open_kitchen`: `max_chars = 188_000`, `max_utf8_bytes = 188_000` | The 2026-07-21 independent all-recipe/all-mode pre-backstop measurement reached 186,680 characters and UTF-8 bytes for `remediation` with all truthy ingredients. The 1,320-unit margin covers the open-kitchen routing fields without conflating this handler ceiling with the smaller formatted presentation. Measurement identity: `bundled-recipes-all-modes-2026-07-21/open-kitchen`. For payloads that exceed the delivery bound, see envelope note below. |

> **Envelope note (oversized recipes, issue #4304 Part B):** When a recipe's serialized payload
> exceeds a backend's effective delivery bound, `maybe_envelope_recipe_response` replaces the
> raw text with a bounded envelope built via `build_recipe_envelope`. The envelope carries a
> step-flow skeleton plus a `recipe_pull` reference (`pull_tool: get_recipe_section`,
> `artifact_path`, `artifact_sha256`) so the agent re-acquires each step body on demand via
> `get_recipe_section(section=<step_name>)` (chunked via `part` / `has_more` / `next_part`
> for oversized sections). The raw-text ceilings above continue to govern the fits-within-bound
> fast path; the envelope introduces a separate, fit-by-construction delivery channel.
| `CODEX_TOOL_OUTPUT_TOKEN_LIMIT = 55_000` | Derive it from the largest registered exemption as `((188_000 + 3) // 4) + 8_000`: 47,000 tokens under the current client's four-byte heuristic, plus 8,000 tokens for serialized-payload headroom. It is a blast-radius damage bound, not the mechanism that makes evidence lossless. |
| `CODEX_AUTO_COMPACT_LIMIT = 999_999_999` | Retain the unreachable sentinel and the recovery obligation accepted in [ADR-0004](0004-recipe-redelivery.md). This protocol does not relax recipe-preservation policy. |
| `inline_max_chars = 5_000` | Preserve the previous truncation threshold while changing the representation from destructive clipping to an artifact-backed preview. The configured 2,500-character head and 2,500-character tail retain both diagnostic setup and terminal status; a spill marker is added outside those source slices. |
| `response_max_bytes = 90_000` | Bound the exact compact serialized handler payload before a coarser transport can clip it. Bytes are authoritative here; this is not a token or full JSON-RPC-envelope estimate. |
Expand Down Expand Up @@ -181,8 +194,12 @@ reserve-trigger metrics, so instrumentation must not claim those mechanisms exis
addition, or output-discipline policy-version change invalidates the applicable cached
capability probe.
- 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.
- Preserve ADR-0004's end-to-end recipe re-delivery obligation if the
999,999,999 auto-compaction sentinel is ever relaxed. Re-delivery is implemented
as re-sending the envelope (when the original response was an envelope) and
pulling each step body via `get_recipe_section(section=<step_name>)`, chunked via
`part` / `has_more` / `next_part` for oversized sections — not as a replay of
the full raw payload. Reconciles with the ADR-0004 cross-reference amendment.
- 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
Expand Down
12 changes: 6 additions & 6 deletions docs/execution/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ How AutoSkillit runs a recipe end to end: orchestrator, kitchen gating, clone an

## Overview

AutoSkillit is a Claude Code plugin that orchestrates automated workflows using headless sessions. It provides 59 MCP tools and 142 bundled skills, organized into a gated visibility system.
AutoSkillit is a Claude Code plugin that orchestrates automated workflows using headless sessions. It provides 60 MCP tools and 142 bundled skills, organized into a gated visibility system.

## Core Concepts

Expand All @@ -23,12 +23,12 @@ AutoSkillit uses a three-tier tool visibility model:

- **Free-range (4 tools)**: Always visible — `open_kitchen`, `close_kitchen`, `disable_quota_guard`, `reload_session`
- **Headless tools (2 tools)**: Revealed in headless sessions via `mcp.enable({'headless'})` — `test_check`, `unlock_agent_pack`
- **Kitchen-tagged tools (39 tools total)**: Gated behind `open_kitchen` — `run_skill`,
`run_cmd`, `run_python`, `merge_worktree`, `clone_repo`, `push_to_remote`, and 32 more.
- **Kitchen-tagged tools (41 tools total)**: Gated behind `open_kitchen` — `run_skill`,
`run_cmd`, `run_python`, `merge_worktree`, `clone_repo`, `push_to_remote`, and 34 more.
Two kitchen tools (`test_check`, `unlock_agent_pack`) also carry the `headless` tag and are
additionally pre-enabled in headless sessions.

When you call `open_kitchen` (automatically done by `order`), all 39 kitchen-tagged tools become
When you call `open_kitchen` (automatically done by `order`), all 41 kitchen-tagged tools become
available for that session. This keeps normal Claude Code sessions clean — no pipeline tools
cluttering the tool list.

Expand Down Expand Up @@ -60,15 +60,15 @@ AutoSkillit supports four session modes with different tool and skill visibility

- **`$ claude` (plugin, no kitchen)**: Regular Claude Code session with the AutoSkillit plugin
loaded. Sees 4 Free Range MCP tools (`open_kitchen`, `close_kitchen`, `disable_quota_guard`, `reload_session`) and Tier 1 skills only
(`open-kitchen`, `close-kitchen`). After calling `/open-kitchen`, all 39 kitchen-tagged MCP
(`open-kitchen`, `close-kitchen`). After calling `/open-kitchen`, all 41 kitchen-tagged MCP
tools become available.

- **`$ autoskillit cook`**: Interactive development session. Sees all three skill tiers
(Tier 1+2+3) via an ephemeral session directory. MCP tools are initially ungated (same as
`$ claude`); `/open-kitchen` reveals kitchen tools.

- **`$ autoskillit order`**: Pipeline orchestrator session. Kitchen is pre-opened at startup —
all 59 MCP tools are available immediately. All skill tiers are accessible. The orchestrator
all 60 MCP tools are available immediately. All skill tiers are accessible. The orchestrator
delegates work through `run_skill` (headless sessions) and `run_cmd` (shell commands).

- **`run_skill` (headless)**: Worker sessions launched by the orchestrator. Sees 4 Free Range
Expand Down
6 changes: 3 additions & 3 deletions docs/execution/tool-access.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# MCP Tool Access Control

AutoSkillit provides 59 MCP tools organized into three access levels that control which
AutoSkillit provides 60 MCP tools organized into three access levels that control which
session types can see each tool.

## Three Access Levels
Expand Down Expand Up @@ -55,7 +55,7 @@ Server startup sequence:

```
1. mcp.disable(tags={"kitchen"})
→ hides 39 kitchen-tagged tools (including the 2 headless-tagged tools)
→ hides 41 kitchen-tagged tools (including the 2 headless-tagged tools)

2. mcp.disable(tags={subset}) for each entry in config.subsets.disabled
→ e.g. hides all github-tagged tools if "github" is disabled
Expand Down Expand Up @@ -86,7 +86,7 @@ missing kitchen visibility.

## Complete MCP Tool Access Control Map

All 59 tools with their access level, tags, source file, and functional category.
All 60 tools with their access level, tags, source file, and functional category.

**Tag abbreviations**: AS = `autoskillit`, K = `kitchen`, HL = `headless`,
GH = `github`, CI = `ci`, CL = `clone`, TL = `telemetry`, FL = `fleet`
Expand Down
2 changes: 1 addition & 1 deletion docs/skills/catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ you need an exhaustive listing; this catalog groups by purpose.

Plugin-scanned at `src/autoskillit/skills/`:

- `open-kitchen` — reveals the 40 kitchen MCP tools
- `open-kitchen` — reveals the 41 kitchen MCP tools
- `close-kitchen` — re-hides them
- `sous-chef` — internal injection by `open_kitchen`; never appears as a slash command

Expand Down
2 changes: 1 addition & 1 deletion docs/skills/subsets.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ Custom tags behave like built-in categories for filtering purposes: disabling
## FastMCP Mechanics: Why `open_kitchen` Re-Disables Subsets

FastMCP session rules override server rules. When `open_kitchen` calls
`ctx.enable_components(tags={"kitchen"})` to reveal the 40 kitchen-tagged tools, this
`ctx.enable_components(tags={"kitchen"})` to reveal the 41 kitchen-tagged tools, this
operation overwrites the server-level `mcp.disable(tags={"github"})` mark applied at
startup. As a result, `open_kitchen` must immediately re-call
`ctx.disable_components(tags={subset})` for each configured disabled subset to restore
Expand Down
2 changes: 1 addition & 1 deletion docs/skills/visibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ and `/autoskillit:close-kitchen`. Skills in `skills_extended/` are never seen.

Order is similar to cook: AutoSkillit launches Claude Code with access to all tiers.
The key difference is the orchestrator (`sous-chef` skill) is injected and the kitchen
is pre-opened so all 59 MCP tools are available from the start.
is pre-opened so all 60 MCP tools are available from the start.

### Headless session (launched by `run_skill`)

Expand Down
10 changes: 8 additions & 2 deletions src/autoskillit/cli/_prompts_kitchen.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,14 @@ def _build_open_kitchen_prompt(
'"anomaly": display each finding\'s severity, step_group, summary, '
"and evidence to the user so they can act on them.\n\n"
"If the user wants to run a recipe interactively (pipeline execution), "
f"call {mcp_prefix}open_kitchen(name='<recipe>') without ingredients_only "
"to receive the full recipe content and orchestration rules.\n\n"
f"call {mcp_prefix}open_kitchen(name='<recipe>') without ingredients_only. "
"The response is a bounded envelope (step-flow skeleton, orchestration rules, "
"and a pull reference). To read a step's full definition before executing it, "
f"call {mcp_prefix}get_recipe_section(section='<step_name>', "
"recipe_name=recipe_pull.recipe_name, "
"producer_tool=recipe_pull.producer_tool, "
"artifact_sha256=recipe_pull.sha256). "
"Do not read recipe YAML files directly.\n\n"
"OPTIONAL STEP SEMANTICS:\n"
"- optional: true means the step is SKIPPED when its skip_when_false ingredient\n"
" resolves to false. skip_when_false ingredient references are resolved\n"
Expand Down
5 changes: 4 additions & 1 deletion src/autoskillit/config/ingredient_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@
"commit_files",
),
),
("Recipes", ("migrate_recipe", "list_recipes", "load_recipe", "validate_recipe")),
(
"Recipes",
("migrate_recipe", "list_recipes", "load_recipe", "validate_recipe", "get_recipe_section"),
),
("Agents", ("unlock_agent_pack",)),
(
"Clone & Remote",
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 @@ -56,9 +56,11 @@ from .io import _COMMITTED_BY_DESIGN as _COMMITTED_BY_DESIGN
from .io import ReadResult as ReadResult
from .io import YAMLError as YAMLError
from .io import atomic_write as atomic_write
from .io import compose_yaml as compose_yaml
from .io import dump_yaml_str as dump_yaml_str
from .io import ensure_project_temp as ensure_project_temp
from .io import load_yaml as load_yaml
from .io import mapping_entry_byte_ranges_from_yaml as mapping_entry_byte_ranges_from_yaml
from .io import read_versioned_json as read_versioned_json
from .io import resolve_skill_temp_dir as resolve_skill_temp_dir
from .io import resolve_temp_dir as resolve_temp_dir
Expand Down
65 changes: 65 additions & 0 deletions src/autoskillit/core/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,10 @@
"ReadResult",
"YAMLError",
"atomic_write",
"compose_yaml",
"ensure_project_temp",
"load_yaml",
"mapping_entry_byte_ranges_from_yaml",
"dump_yaml_str",
"read_versioned_json",
"resolve_skill_temp_dir",
Expand Down Expand Up @@ -408,6 +410,69 @@ def load_yaml(source: os.PathLike[str] | str) -> Any:
return yaml.load(source, Loader=_Loader)


def compose_yaml(source: str) -> yaml.Node | None:
"""Parse *source* into a mark-annotated YAML node tree (not a data structure).

Unlike :func:`load_yaml`, retains ``start_mark`` / ``end_mark`` character
offsets on every node, which the byte-range tracker in
``server/tools/_serve_helpers.py`` uses to compute per-step byte spans
of the original ``content`` text. Returns ``None`` when the source is
empty (matches :func:`yaml.compose` semantics).
"""
return yaml.compose(source, Loader=_Loader)


def mapping_entry_byte_ranges_from_yaml(
content: str, mapping_path: tuple[str, ...]
) -> dict[str, tuple[int, int]]:
"""Compute UTF-8 byte ranges for entries under a YAML mapping path.

Walks the persisted YAML ``content`` field via :func:`compose_yaml` to read
each selected mapping entry's key/value ``start_mark`` / ``end_mark`` character
offsets, then converts them to UTF-8 byte offsets so the result can be
used directly to slice the payload back at the byte level.

Fails open: returns ``{}`` on any malformed or non-mapping document. The
guards (rather than a bare ``except YAMLError``) handle the documented
case where ``yaml.compose`` succeeds but produces a non-mapping root
(a bare sequence, or a ``steps:`` key whose value is a scalar) — a bare
``except`` would miss ``TypeError`` / ``ValueError`` raised from
tuple-unpacking such a non-mapping node tree.

Centralizes the yaml import: this module is the only place in the
package that imports ``yaml`` directly (REQs in
``tests/arch/test_subpackage_isolation.py`` and
``tests/core/test_io.py::test_only_yaml_imports_yaml_directly``).
"""
out: dict[str, tuple[int, int]] = {}
if not content or not mapping_path:
return out
try:
root = compose_yaml(content)
except yaml.YAMLError:
return out
if not isinstance(root, yaml.MappingNode):
return out
current = root
for segment in mapping_path:
next_node = None
for key_node, value_node in current.value:
if getattr(key_node, "value", None) == segment:
next_node = value_node
break
if not isinstance(next_node, yaml.MappingNode):
return out
current = next_node
for entry_key, entry_value in current.value:
start_idx = entry_key.start_mark.index
end_idx = entry_value.end_mark.index
out[str(entry_key.value)] = (
len(content[:start_idx].encode("utf-8")),
len(content[:end_idx].encode("utf-8")),
)
return out


def dump_yaml_str(data: Any, **kwargs: Any) -> str:
"""Serialize data to a YAML string.

Expand Down
Loading
Loading