| name | cwcli-core-axi |
| description | The UI-pure logic core boundary and the cwcli axi agent-facing frontend - core purity (core/*.py imports NO rich/questionary/typer, returns a typed Result envelope or raises CwcliError, never prompts or exits), no live Docker object crossing a core.<verb> return boundary, the thin CLI wrappers over core resolvers, the backup pre-resolve-then-spinner pattern, axi's TOON-only structured output and exit-code mapping, the migrated read-only ls/list + where slices, and the AXI cross-cutting shell (the `cwcli axi setup` SessionStart-hook installer and the generated installable skill). Use this whenever you edit or debug src/caffeinated_whale_cli/core/, commands/axi.py, utils/toon.py, commands/list.py, commands/where.py, utils/agent_hooks.py, scripts/build_skill.py, skills/cwcli/SKILL.md, or migrate another command onto the logic core. Each note guards a locked design decision or a real shipped bug - do not silently re-break it.
|
| metadata | {"internal":true} |
cwcli logic core + cwcli axi boundary (sharp edges)
Each note guards a locked design decision or a real shipped bug. Keep the root-cause "why" so a later change does not silently re-break it. The one-line contract lives in the always-loaded AGENTS.md "Important Components" section; this skill is the deep detail.
Logic core + cwcli axi: the UI-pure core boundary (2026-07-12)
The foundation of the "logic core" rework (OpenSpec core-logic-foundation; the seven locked decisions are in openspec/changes/core-logic-foundation/design.md - do NOT reopen them). What a later agent touching core/ or migrating the next command must not silently re-break:
- Core purity is test-enforced, and TRANSITIVELY, not just directly.
tests/test_core_envelope.py AST-scans every core/*.py and fails if it imports rich/questionary/typer or confirm_or_exit, or references typer.Exit - but a direct-only scan missed a core module reaching UI THROUGH a tainted util (the old core -> utils.docker_utils -> typer and core -> utils.port_utils -> utils.console -> rich holes), so the test also walks the intra-package load-time import graph from every core module and fails if any path reaches a banned root. That closure is why the UI-free Docker primitives (utf8_stream_decoder, get_project_containers, get_project_volumes) live in core/docker.py rather than utils/docker_utils.py, and why utils/port_utils.py lazy-imports stderr_console inside its verbose/report branches instead of at module load. So the core can neither prompt nor exit - it RETURNS Result (NEEDS_CHOICE for a fork it can't resolve) or RAISES CwcliError. Add any new core module knowing this scan will police it, transitively.
- No live Docker object crosses a
core.<verb> return boundary. core/docker.py:get_frappe_container keeps the Container internal for its own execs; DTOs (BackupOutcome, ContainerState) carry only str/bool. dataclasses.asdict on any returned DTO must yield plain data (a test asserts it). The envelope's Status/Choice stay enums/dataclasses - the TOON serializer (utils/toon.py) converts enums/Path at the stdout boundary, NOT asdict.
- Single resolution implementation, thin wrappers.
commands/utils.py:ensure_containers_running/resolve_bench_path and docker_utils.get_frappe_container are now thin CLI wrappers over core/resolvers.py / core/docker.py: they translate the typed return/error into today's exact prints and exit codes. Their external behavior (messages, exit codes, on_ambiguous="first" note) is UNCHANGED and used by ~4 non-E2E-covered commands (unlock/update/restore/start) - preserve it byte-for-byte. (run and open no longer call this CLI wrapper - both resolve via the core's own resolvers.resolve_bench instead.) The wrapper re-reads the cached bench list (_cached_benches) purely to render the ambiguity/not-found text via bench_labels.format_bench_list, so that text stays identical; the resolver owns only the decision.
cwcli backup re-seat = frontend PRE-RESOLVES, then calls core.backup under the spinner. The reason it pre-resolves (rather than looping on returned choices) is the repo's known spinner-over-questionary deadlock: a questionary prompt must NEVER run while a console.status/TipSpinner owns the terminal. So the frontend resolves container (ensure_containers_running) + bench (resolve_bench_path) + default-site (with the exact old prints) as a NO-SPINNER prologue, then runs the single, straight-through core.backup call inside the TipSpinner. core.backup still returns NEEDS_CHOICE (exercised by axi + unit tests), and the frontend keeps a defensive re-invoke loop for the rare confirm_start race - capped at ONE re-invoke (commands/backup.py's started flag): a second confirm_start after an attempted start means the start was claimed but the container still is not running (crash-loop / teardown race), so it fails closed with a non-zero exit instead of spinning forever - but the prompt in it fires only AFTER the with TipSpinner block exits. Do not move the interactive resolution inside the spinner.
axi never handle_docker_errors-decorates its verbs. A daemon-down is surfaced as CwcliError(DOCKER) from the core and rendered as a structured stdout error (exit 1); decorating would leak the decorator's rich stderr instead. This now includes the axi home and axi ls, which consume core.list_instances and catch CwcliError(DOCKER) into a structured error: line (the old note that the home "reuses the decorated _list_instances as-is" is obsolete; _list_instances no longer exists - see the read-only migration below).
- Known simplification:
cwcli backup -v on SUCCESS no longer echoes bench's own success chatter (the exec moved into core.backup, which returns a DTO, not raw output); a follow-up fix restored the $ bench --site <site> backup command echo and the backup_dir.created warning text (when core.backup had to create the backup directory) as -v diagnostics, but the raw bench stdout itself is still not echoed. The FAILURE path still surfaces bench output via CwcliError.detail. Marked with a ponytail: comment in commands/backup.py; re-add a capture channel only if the success chatter is actually wanted. Separately, axi's exit-code mapping treats Status.WARNING the same as Status.OK (exit 0) - a WARNING is a completed operation with a non-fatal note, never an error.
Regression coverage: tests/test_core_envelope.py (DTO/error contract + purity bans), tests/test_core_resolvers.py (split resolvers + wrapper exit-code preservation), tests/test_core_backup.py (every core.backup branch on a fake container), tests/test_axi.py (verb exit-mapping, home, TOON encoder). The real-Docker proof is the unchanged tests/e2e/test_backup_e2e.py (refactor-under-green, both modes).
Read-only migration: ls/list + where onto the core (2026-07-13)
The two clean read-only commands followed the backup pattern exactly. What not to re-break:
core.list_instances() -> Result[list[InstanceDTO]] (core/list.py) lifts the old _list_instances/_get_container_ports (both DELETED from commands/list.py). InstanceDTO is a frozen (project_name, status, ports: list[str]). It is the ONE Docker toucher: it raises CwcliError(DOCKER) on a DockerException (no live container in the DTO). commands/list.py:default is a thin frontend that keeps the --quiet/--json/rich-table renderers + _format_ports_as_ranges (presentation stays CLI-side). Queued fix folded in: ls --json on an empty set emits [] (JSON branch runs BEFORE the "no instances" message; test in tests/test_list.py). The human --json shape is preserved verbatim ({projectName, ports, status}, that key order).
cwcli ls keeps @handle_docker_errors for the "Docker is not installed" vs "daemon down" split; decorator order is load-bearing: @app.callback(...) OUTERMOST, @handle_docker_errors INNER, so Typer registers the wrapper and (via functools.wraps/__wrapped__) still introspects the real signature. The core's DOCKER raise is then belt-and-suspenders for the ping->list race, caught into an Error: line + exit 1.
core.where(search, *, apps_only, sites_only, installed_only) -> Result[WhereResult] (core/where.py) lifts the search/dedup/sort (read-only cache DB) into typed WhereMatch rows; the --apps+--sites conflict is a CwcliError(USAGE) (human maps to exit 1 with the historical Error: ...; axi maps to exit 2). where is NOT the "resolved registry paths" the task brief loosely called it - it searches cached apps/sites by NAME (pin the real behavior). The human where --json keeps its ASYMMETRIC historical shape (app records = 8 keys, site records = 4) via commands/where.py:_match_to_json; do not "unify" it.
axi is TOON-ONLY, never JSON (captain, superseding an earlier JSON-on-axi idea). Do NOT add --json/-j to any axi verb or the home; JSON lives only on the human commands' existing --json (and there is NO -j alias on the human commands either, unless separately asked). The shared axi emitters emit uniform TOON: emit_axi_error/help route the message/hint through toon.kv (a plain message stays the unquoted error: <message>/help: <hint> the spec pins; a message carrying a TOON-special character - : " ' ,, edge whitespace, or a bare number - is quoted so a strict TOON reader can't mis-split it, e.g. error: "Project 'proj' not found."), and emit_axi_choice_as_usage_error renders the benches as a proper options[N]: toon.block (the old bare-indented lines were the one non-TOON emission - this cleared the TOON-consistency hardening item). axi ls empty-state is the definitive instances: 0 Frappe instances found; axi where empty is matches[0]:.
Regression coverage: tests/test_core_list.py (fake docker client - empty/aggregate/DOCKER-raise/json-safe), tests/test_core_where.py (throwaway sqlite - dedup/scoping/installed-only/USAGE/json-safe), tests/test_list.py + tests/test_where.py (human frontends - the [] fixes, shapes, exit codes), and the TestAxiLs/TestAxiWhere + updated TestAxiHome in tests/test_axi.py.
Latent-hardening pass (2026-07-14)
Six defense-in-depth fixes on the already-migrated core/axi surface, surfaced by earlier reviews; none closed an open hole. What not to re-break:
core.backup's container execs are argv lists, not sh -c strings. The test -d/mkdir -p/bench --site <site> backup execs (core/backup.py) pass frappe_container.exec_run([...]) directly - no shell, so no shlex.quote to reason about. The ONE exception stays sh -c with shlex.quote: the newest-dump lookup genuinely needs a shell (glob, 2>/dev/null, pipe) - do not "fix" that one to match the rest.
core.resolvers.resolve_container_state wraps frappe_container.reload(). A container removed between resolution and the reload (docker.errors.APIError/NotFound) is caught and re-raised as CwcliError(DOCKER, "container.reload_failed") - the core boundary must only ever emit CwcliError, never a raw docker-py exception. Any new core code that calls a live Docker method on a handle that could have gone stale since resolution needs the same wrap.
core.where wraps the peewee cache reads. A corrupt/locked SQLite cache or a schema mismatch raises peewee.PeeweeException from _search_apps/_search_sites; it is caught and re-raised as CwcliError(INTERNAL, "cache.read_failed") rather than leaking past the core boundary.
core.list_instances sorts ports numerically (sorted(..., key=int)), not lexicographically. A string sort put "10000" before "8000" and mis-ordered the 9->10 boundary; ports are always numeric strings so key=int is safe. Do not revert to a bare sorted(...).
commands/backup.py's confirm_start retry loop is capped at ONE re-invoke - see the updated note above.
Regression coverage: tests/test_core_backup.py (argv execs, still-sh -c newest-dump lookup), tests/test_core_resolvers.py (reload APIError/NotFound -> CwcliError(DOCKER)), tests/test_core_where.py (peewee exception -> CwcliError(INTERNAL)), tests/test_core_list.py (9-vs-10 numeric ordering), tests/test_backup_command_cap.py (fails closed after one retry, succeeds when the second attempt takes), tests/test_axi.py + tests/test_axi_start_status.py (quoted vs. plain error:/help: lines).
unlock + stop onto the core - the generality proof (2026-07-15)
The batch after start/status: openspec/changes/migrate-unlock-stop-core.
Its point was not the two verbs but the question the foundation left open - were the primitives general, or built for backup?
unlock was migrated with ZERO new primitives, and that is the load-bearing result.
commands/unlock.py was backup's near-twin: the same eight steps in the same order, with the shell-metacharacter denylist duplicated character-for-character.
core/unlock.py is built from core.docker.get_frappe_container, resolvers.resolve_container_state / resolve_bench / resolve_default_site, the shared validation + probe helpers, and the envelope - nothing bent, nothing widened.
So the pattern is confirmed NOT overfitted to its first caller.
A later bench-verb migration that needs a primitive bent is a real signal about the foundation: report it, do not quietly widen the primitive to fit.
- The shared bench-op steps live in
core/resolvers.py, extracted on the SECOND caller. resolve_default_site, validate_site_name, validate_bench_path, require_bench_dir, require_site_dir, plus the single _INVALID_CHARS list.
One caller is not evidence of a shared concern; two identical ones are. core/backup.py calls the same helpers, so the two verbs cannot drift apart on what they reject.
The denylist is defense in depth only - the real injection guard is that every container command is an argv list (no shell), which is what tests/test_unlock.py pins.
unlock's rm -rfv is BUFFERED, never streamed - do not "fix" this back.
The recon called unlock "the typed event iterator case", and the rework defers streaming machinery to logs/update. The premise was wrong: the payload is a locks directory (a handful of small files, gone in milliseconds), and it streamed only so verbose mode could echo removed paths.
core.unlock runs one buffered exec_run and parses the paths into UnlockOutcome.removed, which is strictly more useful to an agent than a byte stream.
Disclosed cost: --verbose prints the paths at completion rather than incrementally (sub-second, indistinguishable). Both anticipated consumers have since settled: update designed the contract's exec-and-decode loop against its own needs (batch 4), and logs REFUSED the contract on measured orphan-tail evidence (batch 6) - core/logs.py is a plan-only resolve (logs_plan(...) -> Result[LogsPlan]) and the interactive docker exec -it ... tail stays in commands/logs.py.
- An absent locks dir is
already_unlocked=True, a CLEAN SUCCESS - never NOT_FOUND. rm -rf always exited 0 on a missing target, and a site that simply is not locked is what the caller asked for. core.unlock probes test -d explicitly rather than inferring "nothing removed" from rm's -v output, so the flag stays independent of rm's human-readable format.
cwcli axi unlock deliberately carries NO --yes, even though the human cwcli unlock has one. This is an intentional deviation from the change proposal's draft signature, not an oversight: axi unlock mirrors axi backup (the reference bench-op verb) exactly, where a stopped container is a confirm_start NEEDS_CHOICE rendered as a structured usage error with a help line pointing at cwcli start <project> - never a prompt, never an auto-start. Adding --yes to axi unlock alone would open a new start-from-axi code path that diverges from its twin for no reason; if agent-driven auto-start of a stopped container is ever wanted, it belongs on both bench-op verbs at once, not bolted onto one.
core.stop exists because a rich helper was load-bearing substrate (a real latent bug).
commands/stop.py:_stop_project printed rich markup to stdout on its not-found and already-stopped branches, and commands/axi.py called it from axi start --yes's port-conflict resolution. The comment there claimed "running -> stdout-silent", which was an assumption about which internal branch the callee took: a teardown race between conflict detection and the stop (the same race the adjacent recheck already guards) reached those prints and corrupted the one-TOON-document contract.
A core callee that cannot print closes it structurally. axi now calls core.stop directly.
_stop_project's None sentinel became CwcliError(NOT_FOUND), and a dead daemon is now honestly DOCKER rather than being misreported as "project not found".
core.stop deliberately does NOT route through core.docker.get_frappe_container: stop is project-wide and must still stop a project that has containers but no frappe service.
commands/stop.py:stop_project_best_effort is a frontend rendering adapter (stderr only, no logic) mapping the typed error back to the None two callers were written against; axi and rm do not use it.
Regression coverage: tests/test_core_unlock.py (every branch, the buffered single-exec_run, already-unlocked), tests/test_unlock.py (argv guards, re-pointed at the core), tests/test_core_stop.py (typed errors, names-not-objects, and that core.stop prints nothing at all), tests/test_axi_unlock_stop.py (TOON + exit codes), tests/test_rm_stopped_backup.py::TestRmOrchestration::test_not_found_during_return_to_stopped_still_fails_closed (the rm gate still fails closed under the typed error), tests/e2e/test_unlock_e2e.py (both modes, real removal).
label on the core, and the bench-discovery verb (batch 2)
cwcli axi benches closes a real agent dead end, and that is why this batch shipped. Every bench-scoped axi verb takes --bench <index|label>, and an ambiguous multi-bench project is a usage error telling the agent to pass one - but before this, NO verb could say what the valid values were. core/list.py's InstanceDTO has no bench data, and core/where.py's WhereMatch only yields a bench path from a search you must already know an app/site name to run. An agent hitting pass --bench had to fall back to the human cwcli inspect. Do not "simplify" axi benches away as a duplicate of axi ls: it is the only structured answer to a question the rest of the surface asks constantly.
- An uninspected project is a structured
NOT_FOUND naming a remedy, NOT an empty list. "Never inspected" and "has zero benches" are different facts, and only one has a remedy the agent can act on. An empty list would be a confident lie. At the time this batch shipped the remedy named the human cwcli inspect (there was no agent-native equivalent yet); since migrate-inspect-core (batch 7) the axi frontend rewrites that hint to cwcli axi inspect instead - see that section below.
label split into THREE core functions, deliberately. list_benches is separate because a missing selector is a legitimate read request, not a NEEDS_CHOICE - that status is for decisions the core cannot make from its params, and "list the benches" is one the caller already made. set_label/clear_label are separate rather than one label=None-means-clear function, because that is exactly the sentinel _stop_project's None was retired from; db_utils.set_bench_label keeps None-clears at the storage layer, where it is a storage detail, and it must not escape into an API the CLI, axi, and a future GUI all consume.
A fourth, set_labels, was added later (fm/cwcli-inspect-label-leak-i7) as the batched sibling of set_label backing inspect -i - not a fourth deliberate split of THIS batch's design, but the same one-owner principle applied to the last thin-renderer leak. See the AGENTS.md label entry for its contract.
- Listing is NOT a mode of
axi label. Mode-switching a verb on whether an argument exists makes its output schema depend on argv (defeating the minimal predictable schema the axi guidelines ask for) and hides discovery inside a mutation verb's name. --set/--clear are mutually exclusive and exactly one is required; neither is a usage error pointing at axi benches.
- The
not_running_hint widening: the batch's one honest disclosure. label is the FIRST production caller of resolvers.resolve_container_state(offer_choice=False) - a branch the foundation shipped on spec and never used, since backup/unlock/commands/utils.py all pass offer_choice=True. It fit structurally with zero changes, but its hardcoded hint said "Pass --yes to auto-start it", and label has no --yes and never will: the hint would tell a user (and, via axi's help: line, an agent) to pass a flag that does not exist. It is now a not_running_hint parameter defaulting to the old string, so every existing caller is byte-identical.
Why this was reported rather than absorbed: batch 1's falsifiable claim ("implementation MUST report if any primitive needed changing") only has value if a small flat spot gets reported. A batch that quietly widened a string and claimed "zero primitives touched" would destroy the signal the exercise exists to produce. Verdict for batch 2: zero new primitives, zero structural bends, ONE hardcoded caller-specific string parameterized - the foundation generalized with one flat spot, exactly where a hardcoded English string would be expected to have one.
resolvers.cached_benches moved DOWN a layer; it is not a new abstraction. (cached_data or {}).get("bench_instances") or [] was written four times, and one of the four was already a named private helper (commands/utils.py:_cached_benches). The core duplicated it not by oversight but because core/ must never import commands/ - the helper was one layer too high to be shared. Moving it into core/resolvers.py is a change of address, not an invention. (Consequence to know: commands/utils.py no longer imports db_utils at all, so a test reaching the shared cache through cmd_utils.db_utils must patch resolvers.db_utils instead.)
axi self-update --check exits 0 when an update IS available - a deliberate divergence. The human cwcli self-update --check exits 1 so shell scripts can gate on it. Mirroring that would make it the ONLY axi read verb where a successful read exits non-zero, training agents to treat exit 1 as sometimes-not-an-error and corroding the contract for every verb. On the agent surface a non-zero exit means error; a read that successfully answers "you are outdated" has not failed. The answer rides in is_outdated. Precedent: axi status exits 0 while reporting a fully offline project. --check is REQUIRED on the verb, so it can never mutate by omission; the mutating form stays deferred (rationale never recorded, and an agent upgrading the tool it is executing from mid-session is the half that plausibly had a reason). The requiredness is now ASSERTED, not just true by construction: tests/test_axi.py::TestNoMutatingAxiSelfUpdate checks the --check OptionInfo carries the ... (required) sentinel and that invoking axi self-update without it is a usage error (exit 2).
Regression coverage: tests/test_core_label.py (every branch, the clear ordering pinned directly, the offer_choice=False refusal, the widened hint), tests/test_bench_label_db_and_command.py (the 15 pre-migration tests, re-pointed at the core with assertions untouched), tests/test_axi_label.py (TOON + exit codes + the exit-0-on-outdated divergence), tests/e2e/test_label_e2e.py (the two-store invariant against a real container).
The exec-stream contract, and run as its reference slice (batch 3, 2026-07-15)
OpenSpec add-exec-stream-contract. The first batch to ADD architecture rather than apply it, so its decisions carry more weight than a migration's. Do not silently re-break these.
- The contract is PER-EXEC, not per-verb (
core/exec_stream.py). run is the ONLY consumer with one exec per resolve; update has 13 and init has 10 downstream of a SINGLE resolve, with real logic between them (enable maintenance mode, discover affected sites, disable it in a finally). A verb-shaped core.run(...) -> Iterator[RunEvent] cannot express that: the resolve would repeat per exec and the maintenance try/finally would have nowhere to live. run PROVES the contract; it must never SHAPE it - on both questions that mattered (decode, exit code) run was the worst of the four consumers and update the best.
- Two-phase because GENERATORS ARE LAZY. This is NOT plan/apply. A generator body does not execute until first iteration, so a core function returning
Iterator[...] cannot raise CwcliError at call time (the frontend's try would wrap a call that merely constructs a generator, and the error would erupt from inside the rendering loop after output may already be on stdout) and cannot return NEEDS_CHOICE at all (a choice would have to be yielded as an output event - a category error; Result.choice is where the architecture puts it). Hence run_plan(...) -> Result[RunPlan] then run_stream(plan). Plan/apply previews a DESTRUCTIVE action for confirmation and arrives with restore/rm. Same two-call shape, unrelated motivation - this batch did NOT settle plan/apply.
- Live containers: the ban is on RETURNS, not on PARAMETERS - and getting this wrong cost a rework mid-implementation.
RunPlan carries container_id: str because a plan crosses a core.<verb> return boundary, which is exactly what locked decision 4 forbids. But exec_stream(container, ...) ACCEPTS a live container, like resolvers.resolve_container_state and resolvers.require_bench_dir already do. The proposal originally specified exec_stream(container_id, ...), over-applying the return rule to a parameter; that forced the primitive to build its own Docker client, which discarded the client every caller already held and broke 24 tests in tests/test_apps.py whose fakes supply a container. The existing suite caught it. core/docker.py:get_container is the id-to-handle bridge, used by run_stream so the frontend never touches a container and the exec lands on the container that was PLANNED rather than on whatever a re-resolution by project name would find.
run_plan deliberately resolves LESS than unlock does, and that is load-bearing. No default site, no validate_site_name, no validate_bench_path, no require_bench_dir probe. Every one of those primitives exists and is one import away, and reaching for them because they are there would ADD failures cwcli run does not have today - a behaviour change wearing a migration's clothes. run passes bench_path to exec_create(workdir=...) and never interpolates it into a shell string, so there is no injection for metacharacter validation to prevent. Pinned by test_plan_does_not_probe_or_validate_the_bench_path.
- The honest exit code is the batch's whole Why. See the
AGENTS.md "exec-stream contract" entry for the mechanism (ExitCode: None while running, the dead .get(..., 1) default, CancellableStream swallowing a dropped connection into a silent StopIteration). The rule: never coerce an unknown code to a number. 0 claims a success that did not happen; 1 claims a command failure that may not have happened. Raise DOCKER instead. ExecDone.exit_code is typed int so the fail-open is unrepresentable.
- The poll is BOUNDED, and that changed
update's behaviour. update.py's old loop polled unbounded, so a dropped connection during a still-running bench migrate hung forever; it now raises. Tests pin the BOUNDARY (does it settle, or give up?) by shrinking _EXIT_CODE_POLL_TIMEOUT, never the number - the value is an implementation choice.
- NO
axi run verb, deliberately - this batch shipped no verb at all. There is no DTO (arbitrary bench stdout is an opaque blob; emit_result is toon.encode(asdict(data))); it would re-open the escape hatch apps was built to close (README.md:839); and (AT THE TIME) run's passthrough could not pass ANY bench flag, because typer claimed the argv first. That third reason is now GONE - the passthrough was fixed on 2026-07-15 (see "run's argv passthrough" below), so do not cite it again. The decision STANDS on the other two, which the fix does not touch: there is still no DTO, and it would still re-open the escape hatch apps was built to close. A verb-less core function is established: core/supervision.py and, for two batches, core/version.py. The batch's agent-surface payoff is deferred but real: the contract is the precondition for axi apps update, the only apps subcommand with no --json, blocked precisely by streaming. The decision is now ASSERTED against the registry, not just argued in prose (tests/test_axi.py::TestNoAxiRunVerb, the axi open non-verb precedent), and exec is asserted alongside it since it is the same escape hatch by another name.
apps/update are re-pointed but NOT migrated. Only the exec-and-decode loop moved; they keep their typer signatures, rich rendering, fan-out, maintenance-mode safety, and exit-code aggregation. tests/test_apps.py passing UNCHANGED is the refactor's proof and the way the maintenance finally is re-verified - by test, never by inspection. (One exception, disclosed: _FakeAPI.exec_start had to learn demux=True, because it accepted the parameter and then ignored it, always yielding the non-demuxed shape. No assertion changed.)
decode_exec_stream is GONE and must not come back. core/exec_stream.py supersedes it; its only three callers were the three sites re-pointed here. utf8_stream_decoder is the real primitive; it now lives in core/docker.py, not utils/docker_utils.py (moved there by the transitive import-ban closure - see the "UI-pure logic core" entry in AGENTS.md - so the core never imports the typer/rich-laden utils/docker_utils.py), and keeps two consumers: core/exec_stream.py and init.py.
init is the one consumer still off the contract (secrets ride in its exec environment=; its own batch). The primitive supports environment= so that migration is unblocked. init.py:193 does NOT raise UnboundLocalError despite what batch 3's brief claimed - raw_output = output if not stream_output else b"" short-circuits and never evaluates output. The real, milder bug (the ENOSPC hint was DEAD whenever streaming, which is exactly when a long bench build would exhaust disk) is FIXED: core/init.py:_run_exec now retains a bounded 8KB tail of streamed chunks and scans it, so init.disk_full fires on both consumption modes. See cwcli-lifecycle/references/init.md.
- A landmine removed in passing, stated precisely because overclaiming here is the failure mode. The deleted
update.py:_stream_command had import time INSIDE its if verbose: branch, which makes time a LOCAL name for the whole function - so a verbose=True, status_msg=None caller reaching the exit-code poll would have raised UnboundLocalError: cannot access local variable 'time'. It was NOT reachable: all four verbose=True call sites pass status_msg, so the local import always ran first. A landmine waiting for the first caller that omitted status_msg, not a live bug - verified by running the original's scoping, not inferred. The re-point removed it incidentally (the module-level time is now the only one). Recorded at its true severity deliberately: batch 3's brief overclaimed init.py:193 as an UnboundLocalError when the conditional short-circuits, and that claim had already been relayed to the captain as fact before the audit caught it.
- Review-fix landed after the batch's first pass: the poll can fail two ways, not one, and both are now typed.
_poll_exit_code originally only handled exec_inspect returning an ambiguous result (no code, or Running: True past the bound). It did not wrap exec_inspect ITSELF raising DockerException mid-poll (the same dropped-daemon-connection case, just caught one call earlier) - that escaped as a raw SDK exception. It is now caught and re-raised as the same CwcliError(DOCKER, "exec.stream_lost") as the other lost-stream case, so every path out of the primitive on a lost connection is typed. Pinned by test_exit_code_poll_daemon_exception_is_a_typed_error_not_a_guess.
- The same review fix caught
apps.py/update.py letting a CwcliError from exec_stream escape as a raw traceback. _capture_bench/_stream_bench (apps.py) and _stream_command (update.py) now wrap their consumption loop in try/except CwcliError, rendering it the way run.py already did and exiting 1 - always to stderr, so --json's stdout purity holds even on this path. update.py's wrap sits inside the maintenance-mode try/finally, so the typer.Exit it raises still lets finally disable maintenance mode. Where apps.py's half lives now: batch 5 moved _capture_bench/_stream_bench into core/apps.py as _capture/_run_step; the guard held across the move (commands/apps.py:_exit_on_exec_error still catches CwcliError and exits 1 to stderr) - see the batch 5 section below.
commands/run.py gained the SAME capped confirm_start retry-once-then-fail-closed pattern as backup.py/unlock.py (see the note under cwcli backup re-seat above) - it did not have it at first. The initial reseat treated confirm_start as just another choice to print-and-exit-1 on, so the rare start-race the interactive prologue is supposed to absorb would surface as a raw "select one" error instead of a retry. The review fix re-invokes ensure_containers_running once and retries run_plan once; a second confirm_start after that fails closed with "Frappe container ... failed to start" rather than looping. Pinned by test_run_retries_once_on_confirm_start_then_succeeds and test_run_fails_closed_on_a_repeated_confirm_start.
run's argv passthrough (2026-07-15)
cwcli run passes its argv through to bench, so its parser is configured differently from every other cwcli command: main.py registers it with context_settings={"ignore_unknown_options": True}.
Before that, typer claimed the argv first and cwcli run p get-app --branch develop <url> exited 2 - with click suggesting --bench, cwcli's bench SELECTOR, for what the user meant as a git BRANCH: two unrelated concepts one letter apart, where taking the suggestion silently retargets a different bench.
- THE TRAP, and the reason this note exists: adding a new option to
cwcli run STEALS that flag from bench. ignore_unknown_options only passes through flags cwcli does not define. The moment run grows a --force, bench build --force stops reaching bench and silently means something else, with no error - the failure is invisible at the parser. Before adding an option to run (and ONLY run), check it against the bench flags users actually pass; prefer a name bench does not use. This is why run's option list should stay as small as it is.
-- is the escape hatch and must keep working. It is what shields a bench flag whose name collides with one of cwcli's own (--verbose, --bench, --path, -p, --yes). Documented in README.md's run section and in the command's own --help.
ignore_unknown_options, NOT a mandatory --, and NOT allow_interspersed_args=False. Two documented forms put cwcli's OWN flags AFTER the bench args (cwcli run p migrate --bench staging, ... --path /workspace/custom-bench). Both alternatives break those shipped invocations; ignore_unknown_options keeps them working, because a flag cwcli defines stays cwcli's wherever it appears.
- The argv tests drive the REAL
main.app through Typer's parser, deliberately. The parser CONFIG is what is under test, and it lives in main.py's registration - so a test that builds its own typer.Typer() (as test_where.py does) tests a different app and cannot catch this. Every other run frontend test calls run_mod.run(**kwargs) directly, skipping parsing entirely, which is exactly how the bug shipped. See the argv surface section of tests/test_core_run.py.
update onto the core, and the decision-4 counter-example (batch 4, 2026-07-15)
OpenSpec migrate-update-core. The largest thing the rework has done, and the batch where a locked decision was deliberately READ rather than applied. The cwcli-apps-update skill holds the command-level detail; these are the boundary decisions.
core.update is a PLAIN FUNCTION with an optional typed-event callback, NOT -> Iterator[UpdateEvent] - deliberately, against locked decision 4's letter. Decision 4 ("streaming operations return typed event iterators") governs genuine STREAMING ops cwcli itself streams: raw exec output, which core.exec_stream (core/exec_stream.py) already is and remains. (logs was once the other expected case; batch 6 settled it as a deliberate NON-consumer - core/logs.py is a plan-only resolve and its --follow bytes never enter the Python process, so decision 4 does not govern it.) update is a state machine that emits progress and whose terminal value is a report. The evidence, not the taste: a try/finally inside a generator does NOT run when a consumer breaks early while holding a reference, or when the generator lands in a reference cycle - cleanup defers to the garbage collector. Probed, five shapes: exhaust -> ran; break (refcount drops) -> ran; break holding a reference -> DID NOT RUN; reference cycle -> DID NOT RUN; contextlib.closing -> ran. A GUI pumping events from an event loop holds the iterator on self and lives in a widget cycle - cases 3 and 4 exactly - so a window closed mid-update strands a site in maintenance mode until GC happens to run. Enabling that GUI is locked decision 1, the rework's own goal, so the generator shape is hostile to it on the single most safety-critical invariant cwcli has. contextlib.closing was rejected because it relocates a safety-critical guarantee into every frontend's hands, forever, including frontends not yet written. run_stream has the same GC exposure and is FINE: an abandoned run_stream leaks a socket, not a stuck site. A later agent reading decision 4 alone will reach for the generator. Do not let an elegant-looking refactor reopen this.
update is ONE call (core.update(...)), not update_plan + update_run. Batch 3's two-phase split exists because generators are lazy; a plain function has no laziness to work around, so the forcing function is absent. core/backup.py is the shipped precedent and the same shape: a minutes-long bench backup, one call, NEEDS_CHOICE at call time, a terminal DTO. The tell that a plan phase would have been structure without a job: --site-matched-nothing is a resolve-shaped refusal that CANNOT live in a resolve phase, because it needs the sites the discovery pass finds. (The dispatch brief specified update_run(plan, ...); the divergence was raised in the proposal and the captain confirmed one call. Raise, do not quietly build around.)
- The exit code reads
report.ok, NOT result.status - the one place axi apps update breaks every other verb's pattern. Every shipped verb does raise typer.Exit(0 if result.status in (OK, WARNING) else 1). Copying that here is a FAIL-OPEN: the closed Status set has no ERROR member by design (hard failures raise; update's partial failures must not, because reporting them ALL is the job), so a partial failure is a WARNING-shaped envelope carrying ok=False - and the shipped pattern maps WARNING to 0. It would report success for an update that half failed, on the exact surface the batch exists to make honest. Pinned by test_a_partial_failure_exits_one_rather_than_zero.
cwcli axi apps update carries NO --yes, for the same reason as axi unlock above - and a first cut got this wrong. A first cut threaded auto_start=yes straight into core.update with no start prologue: resolvers.resolve_container_state(auto_start=True, ...) only REPORTS ContainerState(start_requested=True) back to a caller that is supposed to perform the real, UI-coupled .start() itself - core.update never did, so exec against a still-stopped container raised a raw docker.errors.APIError instead of a clean TOON error. commands/update.py:run_app_update is safe only because its ensure_containers_running(auto_start=yes) prologue actually starts the container BEFORE calling core.update. A core-only frontend cannot promise a start nobody performs, so --yes was removed rather than wired to a start: auto_start now defaults to False, resolve_container_state returns NEEDS_CHOICE/confirm_start, and it renders through the same emit_axi_choice_as_usage_error path as every other stopped-project fork - a TOON usage error (exit 2) naming cwcli start. See the cwcli-apps-update skill for the full incident.
- One surface difference, deliberate:
--site matching nothing. The core raises CwcliError(USAGE); the human CLI maps it to exit 1 (it always has, and a migration does not get to change that), while axi maps it through exit_for(USAGE) to 2. A difference between surfaces, not a regression on either. Both pinned.
core/update.py WAS the first core module to reach back into the CLI layer at runtime; SETTLED by migrate-inspect-core (batch 7), see that section below. cache.recache_project used to lazily import the inspect COMMAND. It could not be hoisted into the frontend - it runs mid-fan-out, between the pull and the discovery that depends on it - so the fix needed inspect's own migration rather than a local workaround, and it landed: recache_project's body now calls core.inspect(refresh="full", offer_choice=False), so this is an ordinary core-to-core call and no module under core/ reaches into commands/ at runtime any more. utils/auto_inspect.py's daemon fallback (which used to call the inspect Typer command directly, for the same reason) was re-pointed the same way in the same batch.
- Zero new primitives, one near-miss REPORTED rather than bent.
resolvers.require_bench_dir probes {bench}/sites ONLY; update probes apps/ AND sites/. Three resolutions, one right: reusing the resolver silently DROPS a check (a behaviour change in the quiet direction); widening it ADDS a check to backup/unlock, which the batch does not touch (the bending the standard exists to catch); keeping update's own two-directory probe inside core/update.py changes nothing and shares a few lines less. The third. It did adopt the resolver's shell-free ["test", "-d", path] argv form over _dir_exists's ["sh", "-c", ...] - a simplification, not a behaviour change.
- Batch 3's Decision 8 trap, applied again and holding.
core.update does NOT call validate_site_name or resolve_default_site: update uses neither today (it takes --site as a FILTER and never resolves a default), and adding them would ADD failures cwcli apps update does not have.
- Refactor-under-green, made auditable rather than claimed. The characterization tests were written FIRST against unmigrated
develop (the state machine was 71.06% covered, with only 2 of 7 aggregation branches - the untested ones are exactly where PR #81's bug shipped), and git diff over tests/test_update_characterization.py across the migration commit is empty. That was engineered: the file names no module attribute that moves, patching every internal through shared helpers in test_apps.py, so the move re-pointed the HELPERS and no test's call or assertion. When a migration forces test edits, say which changed BY DESIGN (here: the stream-loss tests, per the unknown-vs-failed decision) and which merely moved with their subject.
apps finished onto the core, and the duplication that was judged and declined (batch 5, 2026-07-15)
OpenSpec migrate-apps-core. list/install/uninstall joined update, so apps.py no longer carries two contracts. It was ONE PR because the code already made it one unit: three subcommands of one file sharing _run_bench/_capture_bench/_stream_bench/_report_and_exit, 195 statements at 91.28% with no state machine - contrast update, which earned its own PR at 376 statements / 71% / a maintenance-mode state machine. Different risk, different unit.
- The honest framing, kept honest. All three already had
--json and honest exit codes. The batch bought architectural conformance plus a TOON spelling of output agents could already get; the ONLY new capability is axi apps list. Genuine, not urgent - which is exactly why it was deferred behind update. Do not retro-sell it as more.
- Zero new primitives, zero bends - the falsifiable claim came back clean. Batch 1 came back zero-bent, batch 2 parameterized one string, batch 3's own primitive needed correcting, batch 4 reported one near-miss and refused to bend it. Batch 5:
core/apps.py is built from core.docker.get_frappe_container, resolvers.resolve_container_state/resolve_bench/DEFAULT_BENCH_PATH, core.exec_stream, bench_sites.list_sites and the envelope, with NOTHING widened. Decision 8's trap held again: no resolve_default_site, no validate_site_name, no validate_bench_path, no require_bench_dir - apps uses none of them today (--site is a FILTER, not a site to resolve; bench_path goes to exec_create(workdir=...), never into a shell), and reaching for them because they exist would ADD failures cwcli apps does not have.
AppsAnnounce vs AppsCommand: two events, because they INTERLEAVE. The pre-migration code announces "Fetching x...", THEN reads apps/ (echoing that read under --verbose), THEN echoes get-app. One fused event reorders --verbose stderr. A byte-for-byte migration is where this kind of detail is the whole job.
- The command trace rides the EVENT channel, not
warnings - the batch's one amended decision. The proposal said list_apps takes no callback ("nothing to report progress about"). Right about progress, WRONG about the trace: warnings is for notes a consumer should act on, and routing $ ls -1 apps through it put a debug echo inside axi apps list's structured document. list_apps now takes on_event like every other verb here (and like core.update); axi passes nothing and the trace is discarded for free. Reported as an amendment rather than quietly re-specced.
- THREE tests changed by design, not the two the design predicted - and the miscount is the point.
test_apps.py:1151/:1161 plus test_exec_stream_decode.py::test_apps_install_streams_split_character_intact, all bound to _capture_bench/_stream_bench. The third lived in a file the audit never opened. Each was REPLACED by an equivalent driven through the new path with its assertions intact - which is exactly what batch 4 did for _stream_command in both of those same two files, so the precedent was sitting in the diff the whole time. Separately, 15 container patches + one bench_sites patch merely re-pointed to the module the subject now lives on (core_apps.core_docker.get_frappe_container, mirroring the core_update.core_docker patch batch 4 already left at test_apps.py:559); no call or assertion changed. When a migration forces test edits, say which changed BY DESIGN and which merely moved with their subject - and if your own pre-implementation count was wrong, say that too, or the falsifiable claim is worth nothing.
axi apps list ONLY; the mutations are captain-locked deferred (2026-07-15). bench uninstall-app drops the app's tables, and letting an agent do that is a product decision on its own evidence, not a consequence of moving code. Precedent: batch 3 migrated run and shipped NO verb at all. The deferral is TASKS (migrate-apps-core/tasks.md §7) plus a test asserting install/uninstall are not registered, so "deliberately not built" can never be misread as "forgotten". Decision 4 makes the deferral cheap: the core already exposes destructive consent separately from auto-start, so the verb will wire the one it means rather than re-open a fused flag.
axi apps checkout SHIPPED 2026-07-20 (add-axi-apps-checkout-verb), and it is the worked example of how to reverse a deferral assertion. Its absence used to be asserted alongside install/uninstall on the reasoning that it "mutates the in-instance checkout, so LIKE install/uninstall it is a HUMAN verb only" - inheriting a rationale (dropping an app's tables) that a git fetch/git checkout -B inside apps/<app> is not covered by. Two checks settled it and both generalize: read what the inherited rationale ACTUALLY names, and check the principle you are about to cite is one the repo follows. It is not: TWELVE of twenty-one live axi verbs mutate (see the AGENTS.md apps checkout entry for the current enumeration), axi init provisions a whole instance and axi apps update migrates live site schemas. Enumerate the live registry before writing "the agent surface is read-only" anywhere - it is not true and citing it blocks work for the wrong reason. This does NOT weaken the install/uninstall or axi restore deferrals: those name data destruction, which still stands on its own evidence. Zero core/ delta; the verb is a renderer, its guards each name a threat, and the redundant dirty-tree pre-check was deliberately NOT built because git already refuses and fails closed (probed).
- The
update/apps "duplication" was judged and DECLINED. See AGENTS.md's apps entry: inverse questions, opposite failure semantics, different data shapes. The one genuinely shared line (a first-token parse) stays duplicated on purpose - two callers at different layers reading from different sources is not a shared concern, and extracting it would add an import edge between two core modules to save one comprehension.
- The defect that hunt turned up is REPORTED, deliberately kept dead (
core/update.py:291, tasks.md §8). A migration does not get to change behaviour, and making a dead cache branch live on the input to a migration fan-out is a behaviour change with a real staleness risk. The tell worth remembering: the tests took the cache branch (fakes emit bare names) while production takes the live one (a real bench prints frappe 16.26.3), so the suite exercised the opposite branch from reality. tests/test_core_apps.py seeds real multi-token lines for exactly this reason; TestSitesWithAppLiveFallback in tests/test_core_update.py later closed the live path's own 0%-coverage gap directly, with the captain choosing to keep the branch dead-but-covered rather than fix it.
Regression coverage: tests/test_core_apps.py (the forks the CLI pre-resolves away - confirm_start, select_bench, no-cache default, confirm_uninstall, plus "the core prints nothing at all" and asdict-is-plain-data), tests/test_apps_characterization.py (the green-before net, committed as its own commit so refactor-under-green is auditable), tests/test_apps.py (unchanged except the moves above), tests/test_axi_apps_list.py (TOON, exit mapping, and the deferral).
The AXI cross-cutting shell: hook, skill, and the content-first home (2026-07-16)
The narrowed cwcli-axi-pass-x9, deferred by core-logic-foundation until the core plus several commands existed (it shipped at thirteen migrated commands). It ADDS no verb logic and touches no core/: it is only the machinery that makes the surface DISCOVERABLE. What not to re-break:
- The content-first home already existed and already conformed; the brief's fourth deliverable was ~90% delivered before this batch started.
commands/axi.py:home has emitted bin:/description:/live instances/help[N]: since the foundation. The ONE real gap was discovery: it curates FOUR verbs out of fourteen, so nothing routed an agent to start/status/restart/stop/label/apps/self-update. Fixed with ONE line (Run \cwcli axi --help` to see every verb`), not by listing every verb: the home is the per-session HOOK PAYLOAD, and axi rule 7 says that budget is spent ruthlessly. Rule 9's "reveal truncated lists" is the precedent - point at the full set, do not inline it. Do not "helpfully" expand the home's help block into a verb catalogue; that cost is paid on every session of every agent, and the skill already teaches the full surface for free.
- The home is PROVEN to be one TOON document, not asserted. The brief asked whether a home that emits guidance violates the one-TOON-document contract. It does not, and
tests/test_axi.py:assert_is_one_toon_document now walks every line (a key: value, or a name[N]: header whose declared count matches the indented rows that follow) across the populated, empty, and Docker-error homes. It has teeth - it rejects Fetching data..., an undercount, and an overcount. The home is the one verb emitting guidance rather than a DTO, so it is the one place a prose line could reach stdout; that is why the check is structural rather than a startswith assertion.
agent_hooks.install(home=...) exists so tests never touch the real home - and the discipline is not theoretical. Running the live cwcli axi setup once during development wrote real entries into the captain's ~/.claude/settings.json, ~/.codex/hooks.json, and ~/.config/opencode/plugins/ (reverted; the entries were correct and clobbered nothing). Verify hook work against a tmp_path home ONLY.
- The three target shapes were COPIED from working
axi-sdk-js installs on the captain's box, not invented (~/.claude/settings.json, ~/.codex/hooks.json + [features] hooks = true, ~/.config/opencode/plugins/axi-*.js). Codex mirrors Claude's JSON shape exactly, which is why _sync_session_start serves both. The OpenCode plugin was verified by EXECUTION, not inspection: driven through node against the real cwcli, it injected real TOON, and with a bad binary it injected nothing rather than throwing (a broken ambient hook must never break a session).
- Three rules guard files the USER owns, each a real damage case. (1) An undetected harness is
skipped, never bootstrapped - cwcli does not scatter config dirs for agents the user does not run. (2) _is_cwcli_hook matches on SHAPE (basename in the console-script names, plus the axi subcommand), so a hook written when cwcli lived elsewhere is REPAIRED in place rather than duplicated; an exact-string match would silently accumulate a stale entry per reinstall. (3) ~/.codex/config.toml is TEXT-appended, and a [features] section that exists WITHOUT hooks reports manual instead of being rewritten - toml (not tomlkit) does not preserve comments, so toml.dump would silently strip a user's hand-maintained config. Never "simplify" that branch into a dump.
- The skill's verb table walks Typer's own
registered_commands; there is deliberately NO verb registry. A hand-maintained list is a thing to forget to update, and the staleness gate would then be checking one hand-written list against another. Prose stays hand-written in scripts/build_skill.py (prose is what a skill is FOR); only what the CLI already knows about itself is generated. Docstrings are RST, so `x` is collapsed for Markdown.
- The
--check staleness gate is a unit test, NOT a new CI workflow step (tests/test_axi_skill.py). Rule 7 asks for "a --check build step in CI"; test.yml's Pytest job already IS one, so a new step would be a second thing to maintain for the same guarantee. The gate is driven, not asserted in prose: test_a_new_verb_makes_the_committed_skill_stale injects a fake verb and proves the committed file goes stale.
- NO root
skills-lock.json - captain-decided 2026-07-16, and the brief that demanded it was WRONG. The task brief and the captain's standing rule both name a root skills-lock.json as part of "the agentskills.io skills CLI layout". That rule describes CONSUMING skills, where the lock records what a project installed from elsewhere; it does not describe PUBLISHING one. The evidence, checked on both sides: skills experimental_install "restores skills FROM skills-lock.json"; every real lock on the captain's box lists OTHER repos as source with a computedHash; kunchenguid/gh-axi (the AXI reference tool, by the standard's own author) and the axi standard repo itself both ship skills/<name>/SKILL.md with NO root lock; and karotkriss/glab-axi DOES have a skills-lock.json that is untracked local residue from someone running a skills install in that clone - it is not published, which is why it never appears in a git tree listing. No AXI publisher ships a lock. cwcli publishes a skill and consumes none, so a publisher lock would be a fabricated no-op whose source/computedHash point at itself. scripts/build_skill.py is modelled on glab-axi/scripts/build-skill.ts; the layout mirrors gh-axi. If a future task asks for the lock again, this is the evidence - do not add one until cwcli actually INSTALLS a skill via the skills CLI, which is the only thing that would make a lock mean anything.
- The
.claude/skills/ deep-dives carry metadata: internal: true because the skills CLI scans them too. skills add karotkriss/caffeinated-whale-cli walks EVERY directory for SKILL.md (.claude/skills/ included), so without the marker it installed all six internal deep-dives into every user's agent config alongside the public cwcli skill - reproduced first-hand 2026-07-16. The mechanism was verified in the CLI source (v1.5.18 parseSkillMd: metadata?.internal === true drops the skill from discovery) AND by a reproduced install: a bare skills add on the marked repo finds exactly 1 skill. Two edges: explicitly naming a skill (--skill x, and --all which expands to --skill '*') OPTS BACK IN to internal skills by design, and INSTALL_INTERNAL_SKILLS=1 does the same - so the marker scopes the default path, not a determined user. The marker is invisible to project-local loading: Claude Code still loads all six by relevance inside this repo. tests/test_axi_skill.py:TestInternalSkillsAreNotPublished fails if a new .claude/skills/*/SKILL.md lands without the marker, or if the public skill ever gains one.
inspect onto the core, the cache write with it, and axi inspect (batch 7, 2026-07-16)
OpenSpec migrate-inspect-core. The largest un-migrated read command, and the pivot three standing problems hung on. What not to re-break:
core.inspect owns the tier machine AND the cache write, and that placement is a locked decision (design Decision 2), not a convenience. The write IS the product for five of seven consumers (recache_project, auto_inspect, update's mid-fan-out recache, the open/update/restore no-cache fallbacks), and the freshness semantics are inseparable from the write decision: T2 never writes, T3 writes on success only, a drift escalation that rediscovers nothing serves the remembered cache WITHOUT persisting (degraded=True, a WARNING envelope). Splitting decision (core) from write (frontend) would let each frontend diverge on exactly that invariant. Redaction stays inside db_utils.cache_project_data - redacting at the inspect layer would strip in-memory dicts the same run serves.
- TWO public entry points, one implementation - and the raw one exists for byte-identity, not convenience.
inspect(...) -> Result[InspectReport] is the typed, secrets-free surface (site configs cross it only as has_site_config); inspect_raw(...) -> Result[RawInspect] returns the CACHE-SHAPED dicts, and ONLY commands/inspect.py should consume it: the human --json preserves each path's own key order (a gathered bench dict orders current_site before label; a cache-read dict the reverse - both byte-pinned by test_inspect_characterization.py), the -i loop mutates those dicts and bulk-persists them, and the tree reads the full configs. Do not "simplify" the pair into one function returning the DTO - the renderer cannot reproduce the bytes from it - and do not put the raw dicts INTO InspectReport: asdict would put live-gathered configs (secrets included) into every axi/GUI document.
core/update.py's CLI reach-back is DEAD; keep it dead. cache.recache_project kept its signature and its never-prompt/False-when-stopped contract, but its body now calls core.inspect(refresh="full", offer_choice=False), so rm.py/apps.py/core/update.py were untouched and no module under core/ imports commands/ at runtime. Grep before you add one; the ledger calls this settled.
- The frontend resolves
confirm_start AFTER the first core call, not in a prologue - inverted from backup, for a reason. backup always needs a running container, so it pre-resolves. inspect only needs one if the TIERS say so (a cache-hit read against a stopped project must serve the cache, never prompt), and only the core knows which tier runs. So the CLI calls inspect_raw under the TipSpinner, and on NEEDS_CHOICE resolves it OUTSIDE the spinner via ensure_containers_running(prompt=prompt_to_start, auto_start=yes) - the exact call the old T3 made, so prompts/refusals/exit codes are byte-identical - then re-invokes ONCE (capped, fail closed).
- T2 passivity is enforced IN the core:
resolve_container_state(auto_start=False, offer_choice=False) hardcoded, so --yes/auto_start=True cannot wake a stopped project on a cache hit. A missing project on a cache hit is still a hard NOT_FOUND (matching the old run-state prologue), while failures AFTER the container resolves degrade to the cache.
cwcli axi inspect <project> [--update] [--no-refresh] is ONE tiered verb, deliberately not a read/refresh pair - the tiers exist to hide that orchestration. NO --yes (the axi apps update incident class); a stopped project on the refresh path renders confirm_start as a usage error, exit 2, naming cwcli start. Degrade-to-cache is WARNING -> exit 0. axi benches' not-inspected hint is re-pointed AT THE AXI FRONTEND (axi_benches rewrites the benches.none_cached hint to cwcli axi inspect); the core's own hint still names the human command because commands/label.py renders it too.
- The TOON encoder gained a nested-record list form for
InspectReport - the first DTO whose rows carry lists. A table cell is a scalar token, so benches rows (nested sites, app lists) previously rendered as Python reprs. toon._encode_value now emits name[N]: with - -marked items when any cell is non-scalar; the gate means every pre-existing emission is byte-identical. tests/test_axi.py:assert_is_one_toon_document was upgraded to a recursive walker to match (tables, counted blocks, nested dicts, items) - it still rejects prose, undercounts, and overcounts.
- Zero new core primitives; the verdict came back clean again (batch 7 of the falsifiable claim).
resolve_container_state covered both the prompting (T3, offer_choice=True) and spinner-borne (offer_choice=False) callers as-is; resolve_bench is not needed (whole-project verb); the moved helpers take containers as parameters. The one novelty - the first core verb whose PRIMARY side effect is the cache write - is covered by the core/label.py two-store precedent. The TOON list-item form is a UTIL extension at the stdout boundary, not a core-primitive bend, and is disclosed in the batch PR.
Regression coverage: tests/test_inspect_characterization.py (the green-before net: byte-identical --json per tier, the persisted cache shape, T2 passivity under --yes, the T3 --yes/non-TTY contract - all through seams that survived the migration), tests/test_core_inspect.py (every tier branch at the core, the forks, the write discipline, both disclosed hardenings, core silence, plain-data DTOs), tests/test_axi_inspect.py (one TOON document with nested records, exit 0/1/2, no --yes, the benches hint re-point), and the re-pointed tests/test_inspect_partial_refresh.py/test_inspect_label_recovery.py/test_inspect_apps_error.py (two changed BY DESIGN and named in the batch: the T2-passivity seam moved onto the core resolver, and the list-apps stderr assertion became an InspectWarning event assertion with a new frontend-rendering test).
open onto the core - the plan-only slice behind the handover (batch 8, 2026-07-16)
OpenSpec migrate-open-core, implementing the cwcli-open-handover-design-o9 recon's settled shape (captain-endorsed): open was never a handover command - one of four editor branches execvp's (--docker), the other three return normally - so it is RunPlan minus run_stream. What not to re-break:
core.open_plan(...) -> Result[LaunchTarget] is a PLAIN function, and LaunchTarget is four strings carrying a container NAME. Nothing streams (no generator-laziness to split around; open has no phase 2 in the core at all), and NEEDS_CHOICE must be returnable at call time for all three forks. LaunchTarget (project, container_name, working_dir, editor) is declarative, never an argv - a GUI must spawn detached, never execvp itself away - and carries a NAME (not an ID) because both handover mechanisms consume the name (the vscode-remote URI hex-encodes it) and there is no phase-2 core call to bridge an ID back into a handle.
- The
select_editor choice kind is NEW; the flags, the prompt, and the refusal stay frontend. Editor detection runs ONCE in the core via stdlib shutil.which (the core/version.py host-side precedent; it retired the old double detection), requested-but-absent is CwcliError(NOT_FOUND, "editor.not_installed") with the install-URL hint, unknown is USAGE, none-requested-none-installed silently picks "docker" as always. The four boolean flags fuse to one editor param in commands/open.py (the apps fused---yes precedent); the CLI renders select_editor as the questionary prompt on a TTY and the flag-naming refusal on a non-TTY, then re-invokes ONCE - the second pass reads the now-populated cache, preserving prompt-last error ordering.
- The fallback populate is core-to-core and its abort/degrade contract is deliberate (PR #93 preserved).
resolve_bench returning None -> OpenNotice -> core.inspect(refresh="auto", auto_start=..., offer_choice=False) for the cache side effect -> re-resolve (so a freshly-inspected multi-bench project still surfaces select_bench) -> only then DEFAULT_BENCH_PATH + bench.default_used. A hard CwcliError from the fallback inspect PROPAGATES (never the guessed default path); a non-CwcliError exception degrades (the nearly-dead belt-and-braces residue, disclosed as such); succeeds-but-still-nothing degrades. One disclosed hardening: offer_choice=False turns the race-window discarded-confirm_start (which used to open the guessed default against a stopped container) into a typed NOT_RUNNING abort.
- NO
axi open verb, now ASSERTED against the Typer registry (tests/test_core_open.py:TestNoAxiOpenVerb, the axi apps install/uninstall non-verb precedent). The reason is structural, not serializability: the plan is four strings and would serialize fine, but execvp destroys the process that owes axi its one-TOON-document contract, and the editor branches are meaningless to an agent with no desktop. Do not reopen the question from the stale "not serializable" premise o9 corrected.
- Zero new primitives held (batch 8 of the falsifiable claim).
get_frappe_container, resolve_container_state(offer_choice=True), resolve_bench + DEFAULT_BENCH_PATH, core.inspect/core.partial_refresh, and the OnEvent idiom covered every need; select_editor is a new value in Choice.kind's open token set, not a primitive change. The --app pass moved verbatim (match-by-path never [0], in-memory partial_refresh degrade-on-error never-persist, {bench}/apps/{app}); open_plan deliberately resolves exactly what open resolved (the run_plan lesson - adjacent primitives would ADD failures).
- The dead
vscode_utils selection helpers are GONE (select_vscode_editor, is_vscode_installed/is_vscode_insiders_installed/is_cursor_installed): grep-proven caller-free after the reseat, deleted in their own commit. exec_into_container and open_in_vscode (the mechanisms) are untouched; the one execvp in the codebase stays in utils/docker_utils.py, called only by the frontend.
Regression coverage: tests/test_open_characterization.py (the green-before net, written through migration-surviving surfaces - shutil.which, questionary.select, sys.stdin, the core.inspect module attrs, db_utils.get_cached_project_data, the frontend's own prologue/handover - so it passes unchanged on both sides), tests/test_core_open.py (every plan branch, all three choice kinds, the fallback matrix, core silence, plain-data DTO, the no-axi open assertion), and the re-pointed tests/test_open_inspect_fallback.py / test_inspect_partial_refresh.py open classes (patch targets moved with their subject, assertions untouched; the fakes gained status="running" for the plan's run-state re-check). The one named drift: select_bench is rendered by the run.py renderer wording, disclosed in the design.
init onto the core - the two-call slice and the contract's last consumer (batch 9, 2026-07-16)
OpenSpec migrate-init-core. The largest un-migrated command (1292 lines) and the one consumer the exec-stream contract explicitly deferred ("secrets ride in its exec environment=; its own batch" - the parameter was built for this). What not to re-break:
- TWO sequential plain functions, and the seam is a THIRD disclosed two-call motivation - do not conflate it with the shipped two.
core.init_instance(...) -> Result[InstanceUp] (host footprint + containers up) then core.init_bench(...) -> Result[InitReport] (bench + site). Not run_plan's generator laziness (nothing returns an iterator), not plan/apply (nothing is previewed; both calls mutate). The forcing function is a MID-FLOW user decision on the COMMON path: the existing-bench question needs a running container (stage 1's own product) and fires on essentially every default interactive init (the devcontainer image ships /workspace/frappe-bench). Under one call, resolving that NEEDS_CHOICE by the doctrine re-runs host setup, a Docker Hub query, compose pull, and compose up (~5-15s of redo) on every "yes, reuse" answer; with the seam it re-invokes only stage 2's subsecond probes. A choice-resolver callback into the core was REJECTED (a prompt by proxy - blocks the core on user input, the GUI-hostile shape update's generator analysis killed).
- The secret env-transport is byte-exact through
exec_stream(environment=), and the event surface is AUDITED, pinned by test. The command string carries only unexpanded $CWCLI_* refs; the values ride environment=. No InitOutput/InitNotice/InitTrace event, no warning, no InitReport field carries a value; the command-echo trace (InitTrace(code="exec.command")) carries the refs. InitReport has NO password field at all - the frontend generated or received the password, so it needs nothing back. Generation, the _is_interactive_session gate, the non-interactive refusal, and print-once (now gated on report.site_created) stay FRONTEND: TTY-coupled secret UX a GUI would reimplement as a form field. The docker top leaf-argv residual exposure is inherited and out of scope (bench's flag-only interface).
- The
stream_output consumption-mode flag still selects HOW the ENOSPC scan gets its input, but no longer whether the hint fires at all. Pre-migration the hint fired only on DRAINED execs (raw_output = b"" when streaming); the design's Non-Goals recorded that as a non-item at the time ("stays dead on the streamed path... exactly as today"), and it has SINCE been fixed (fire disk_full hint during streaming init): _run_exec now retains a bounded 8KB tail of streamed chunks and scans it too, so init.disk_full fires on both consumption modes - see cwcli-lifecycle/references/init.md. The core cannot know whether a renderer is consuming InitOutput live, so both functions still take stream_output: bool (today's _exec_in_container parameter name); it also drops --quiet from compose pull in verbose mode, preserving that argv byte-for-byte. The same flag gates commands/init.py:_render_error_exit's compose.failed branch (a review-gate fix): verbose mode already streamed compose's stderr live via InitOutput events, so re-printing e.detail["output"] there too would duplicate it - only non-verbose (which drops InitOutput events) prints the captured stderr, and only once.
- The
add_path command-import edge is dead - the LAST frontend-calling-frontend. init.py:50 imported the add_path Typer COMMAND from commands/config.py and called it mid-flow (printing another command's stdout inside init). The core now calls config_utils.add_custom_path (the util the command wrapped) AFTER the reuse decision on every proceeding outcome, and the added/already-present outcome rides an InitNotice the frontend renders as the old stdout line. Batch 7 killed this class at the seven core-layer edges; this was the command-to-command straggler.
confirm_start fires on BOTH stages with a structural cap. Stage 1 ends in the bounded silent poll (resolve_container_state(auto_start=False, offer_choice=False), the NOT_RUNNING raise caught per attempt - never prompting, which is what the old prompt=False achieved); timeout returns confirm_start with auto_start=False but RAISES typed NOT_RUNNING with auto_start=True (the caller claimed the start was handled and the containers are still down - fail closed, never loop). The frontend always calls stage 1 with auto_start=False first, resolves via ensure_containers_running, then re-invokes ONCE with auto_start=True; the rare-redo cost of that idempotent stage-1 re-run on the wedge path is accepted (unlike the reuse question, it is the exceptional path). Stage 2 opens with resolve_container_state(offer_choice=True) - run_plan's exact race backstop - and the frontend keeps the backup/run capped once-retry.
axi init SHIPPED as its own change (add-axi-init-verb, captain-approved 2026-07-16 in principle, go 2026-07-17; the former TestNoAxiInitVerb deferral assertion is now TestAxiInitVerbIsRegistered). It is a thin TOON-rendering frontend over the UNCHANGED init_instance then init_bench (zero core/ changes) - see the dedicated section below. The batch-9 migration deliberately shipped no verb; it was the right call NOT to build it as a rider on the largest refactor PR. The two design questions the captain owned (a 10-20-min single-document wait; a required secret on the agent's argv) were resolved in the verb's own proposal: block-and-emit-one-document with coarse stderr progress, and CWCLI_ADMIN_PASSWORD env var (recommended) or --admin-password (flag wins), omitted = USAGE exit 2, never generated/prompted.
- Zero new primitives, two flat spots REPORTED (batch 9 of the falsifiable claim).
exec_stream(environment=) was built for this batch; get_frappe_container, resolve_container_state (both offer_choice modes), the envelope, and the OnEvent idiom covered the rest; confirm_reuse_bench is a new token in Choice.kind's open set (the select_editor precedent). Flat spots: (1) the closed ErrorKind set has no network kind, so the compose-download hard failure maps to PRECONDITION (exit codes identical either way; widening a closed enum is a primitive change a migration must not smuggle); (2) init is the first core verb shelling out to a host CLI (docker compose has no docker-py API) - _run_host_command stays PRIVATE to core/init.py until a second caller exists (the extract-on-second-caller rule). Decision-8 trap held again: no resolve_bench (init CREATES benches), no resolve_default_site, no require_bench_dir (init probes with its own semantics: absence is normal), no resolvers.validate_site_name (an injection guard, not init's naming policy - the public validate_project_slug/validate_bench_slug/validate_new_site_name are deliberately distinct).
- The characterization discipline, applied through seams that PROVED migration-proof.
tests/test_init_characterization.py was committed green against unmigrated HEAD and passes UNCHANGED against the migrated code. Its seams: core.docker.get_frappe_container (the CLI wrapper resolves it as a module attribute at call time), config_utils/db_utils module attributes, stdlib subprocess.run/urllib.request, the SDK exec surface itself, and a GENUINELY OCCUPIED SOCKET for the port conflict (check_ports_in_use really binds - no patch target to move). Tests changed BY DESIGN, each named in the reseat commit: the resolve_frappe_ref ValueError->CwcliError retype, the ref-seam assertion in test_init_frappe_version.py, the env-transport re-point in test_init_admin_password.py, the reuse-suite split (core decision matrix vs frontend prompt loop), and the two test_exec_stream_decode.py init cases re-pointed at core._run_exec feeding the CLI's verbose renderer (the batch 4/5 precedent).
- Two disclosed drifts, both cosmetic-or-hardening: verbose compose output is captured plain lines instead of inherited-stdio TTY progress bars (the core must not assume a terminal; structured frontends must not be corrupted), and a lost stream raises the contract's typed
DOCKER error where Command failed with exit code None used to print (both paths exited non-zero; the old message was fail-closed by accident with a dishonest message).
Regression coverage: tests/test_init_characterization.py (the green-before net, unchanged on both sides), tests/test_core_init.py (every branch: the three choice surfaces, the tri-state matrix, the Decision 3 secret audit, exec order, version gating incl. the v13 pin, the bounded poll, the lost-stream DOCKER error, core silence, plain-data DTOs, and TestAxiInitVerbIsRegistered since the verb shipped), the re-pointed test_init_reuse_bench.py/test_init_admin_password.py/test_init_frappe_version.py/test_init_mariadb_flag.py, and the unchanged tests/e2e/test_init_e2e.py (refactor-under-green on real instances, both modes).
axi init - the agent-facing create verb (add-axi-init-verb, 2026-07-17)
The one deferred axi verb from batch 9 that the captain approved and shipped. A thin TOON-rendering frontend in commands/axi.py:axi_init over the UNCHANGED core.init_instance then core.init_bench - ZERO core/ changes; it is axi apps update-shaped (block, one terminal document), not a new core slice. What not to re-break:
- Progress = block, ONE terminal
InitReport TOON document on stdout, coarse phases to stderr (the design's headline decision 1). init runs 10-20 minutes; streaming N documents would break the one-TOON-document contract, so the verb blocks and emits a single document at the end (the axi apps update precedent). _init_narrate wires on_event to write ONLY InitStepStart.message and InitNotice.text to sys.stderr - never InitOutput (raw bench bytes, cwcli logs's job) or InitTrace. stdout stays pure TOON; the help text points the agent at cwcli logs/cwcli status from a second shell. The core guarantees no event field carries a secret, so the narrator is secret-free by construction (pinned).