Skip to content

release: promote v0.6.0-rc1 to main - #751

Merged
alpCaner merged 131 commits into
mainfrom
dev
Aug 14, 2026
Merged

release: promote v0.6.0-rc1 to main#751
alpCaner merged 131 commits into
mainfrom
dev

Conversation

@alpCaner

Copy link
Copy Markdown
Contributor

Promotes dev (e2f5021) to main for the v0.6.0-rc1 tag. 131 commits.

What this release is

The Rust oracle is retired — crates/tan-core, crates/tan-cli, Cargo.toml and Cargo.lock are deleted, and python/tan/ is the implementation rather than a port. Alongside that: a workflow-injection fix in parity.yml, SHA-pinning across .github/workflows/, and a sweep through the commands that reported success while doing something else.

Version

TAN_VERSION (source of truth) : 0.6.0-rc1
python/pyproject.toml         : 0.6.0rc1
npm-shim/package.json         : 0.6.0-rc1
CHANGELOG.md section          : ## [0.6.0-rc1] — 2026-08-14
git tag                       : v0.6.0-rc1
versions agree

0.6.0, not the 0.5.2 the version files carried before #750: the milestone closed is v0.6.0 with 206 closed issues, no v0.5.2 milestone exists, and the section carries a ### Removed block — pre-1.0 SemVer puts a removal in the minor.

Gates

Full python/tests, parity included, bound against alp-sdk bd8be484680cf5aa1c1ac0e8b38d84128b5a279d:

5126 passed, 64 skipped, 1 xfailed in 734.38s (0:12:14)

Zero failures, run on the exact merged tree. #750 itself merged 31 success / 0 red.

After this

Tag v0.6.0-rc1 on main, which fires release.yml: verify-versiongates + python-gates → four PyInstaller freezes → the published Release with checksums.txt, envelope-contract.json and a provenance attestation.

The tag publishes prerelease: true / make_latest: false, so install.sh and install.ps1 keep resolving latest to v0.5.1 — RC testers install by hand. SUPPORTED_CLI_VERSION in alp-sdk-vscode deliberately does not move for an RC; that travels with GA.

A lean release body is written and will be applied as soon as the page exists — the CHANGELOG auto-slice is 4670 lines.

Not in this cut

#58 (feat(model) edge-AI surface) is CONFLICTING and excluded by maintainer direction.

hkngln and others added 30 commits August 5, 2026 23:49
…479)

Bumps TAN_VERSION off the published v0.5.1 (to 0.5.2-rc1.dev0, with pyproject's PEP 440 rendering and the npm-shim SemVer kept in lockstep) and adds v0.5.1's real published asset list to the installer-layout RELEASES table.

Both gates the v0.5.1 tag turned red were correct and neither is weakened: version-identity refused a dev tree squatting on a published tag's version, and test_sh_bare_latest_installs_whatever_shape_latest_is refused a stale bare-latest snapshot. CI evidence confirms coverage was restored rather than silenced -- skip counts identical across the red and green runs while passes rose by exactly one on both runners (ubuntu 3769 -> 3770, windows 3793 -> 3794).
A UX polish sweep across `tan`'s text surfaces. No JSON envelope moves — every
change here is text-mode only, and that is asserted, not assumed.

## What a user sees differently

**`tan --help` groups its 32 subcommands into six titled panels.** A flat
32-entry list is a wall; the panels say which commands set a project up, which
build it, which talk to hardware, and which are for inspecting what tan
decided.

**`tan build --help` stopped being a changelog.** It carried `v0.4.1`,
`ADDED BY THIS PORT` and `parity gap` — the Rust release this Python port
replaced, which a user has never heard of. The rationale moved into the module
docstring, where the next maintainer reads it; `--help` now describes the flag.
The twelve deferred options stay listed: hiding a flag a user will type, then
refusing it at exit 1, is worse than naming it.

**Every dead-end refusal names a next step.** `tan` with no arguments,
`build` with no SDK, `build` with no `board.yaml`, `sdk` on an unported
command, `init` refusing to overwrite — each used to end at an exit code with
nowhere to go. Each now names the command that resolves it (`tan doctor`,
`tan init`, `tan examples`, `--preview`/`--force`).

**`presets` and `examples` answer by default.** Both used to require a flag
before saying anything useful. `examples` also gains `--category`, and an
unfiltered run ends by naming the categories it could be narrowed to.

**`tan doctor`'s report is wrapped, coloured, and streamed.** Three things:

- The text report wraps to the terminal, on a single fixed continuation rail at
  column 10 — detail bodies and `fix:` lines align regardless of check-name
  length. Floored at 60 columns so a narrow terminal does not fall to one word
  per line.
- The failures footer names the start of each check's own fix instead of a bare
  check name the reader had to scroll back up to resolve.
- Checks print the moment each one completes, rather than the whole report
  printing after the last probe. A wedged probe is now named by the last line
  on screen instead of leaving a blank terminal. `--fix` streams per tool too,
  which is where the wait actually is: `FIX_INSTALL_TIMEOUT_S = 300` per tool
  across up to four of them, against `PROBE_TIMEOUT_S = 15` for the probes.

## The bug found while building it

`wrap_block` passed stdlib `textwrap.wrap`'s defaults through, meaning
`break_on_hyphens=True` and `break_long_words=True`. Measured against the real
report at width 46:

```
  run west sdk install --version 1.0.1 -t arm-
  zephyr-eabi to fix this issue
```

`alp-sdk`, `E1M-AEN801` and `--sdk-root` split the same way. A remedy line a
customer copy-pastes then carries `-t arm-`, which is not the command tan
printed — against this repo's rule that SKUs, part numbers, commands, flags and
paths are reproduced verbatim.

Fixed in the seam rather than in `doctor`, since `tan/core/text_layout.py` is
the shared wrap point the rest of the sweep's commands are queued to adopt.
`break_long_words=False` means a single token wider than `width` now overflows
its line instead of being chopped mid-character: an over-long but intact path
stays copy-pasteable, a silently corrupted one does not. That is a contract
change for every future caller, so it is stated in the docstring and pinned by
a test.

## Notes for review

- **The envelope does not move.** `--format json` passes `on_check=None` to
  both `_collect` and `run_fix`; the streaming path adds no `checks.append`,
  no reorder, and changes no check's `detail`/`fix` string. Verified by hand
  on top of the suite: keys `command/data/exitCode/issues/ok/project`, 14
  checks in declaration order, exit 4.
- **`checked_codes` uses `kebab_check_name`.** The rebase put this branch's
  renderer rewrite on top of #461's kebab-cased issue codes, and the rewritten
  block reproduced the old spelling. Left alone, every multi-word check name
  (`sevenZip`, `hostPython`) would miss the set and the whole report would
  print twice.
- **A print failure no longer discards a finished diagnosis.** Both stderr
  print sites route through one guarded helper. The failure it guards is a
  lone surrogate from a `surrogateescape`-decoded filesystem path, plus a
  closed or broken pipe — `tan.cli.main` reconfigures stderr to utf-8/strict,
  so the codepage story that guard was first written against measurably
  cannot happen.
- **The module-size ratchet was re-measured, not carried over.** Five entries
  this branch raises had moved on `dev` in between (#461 and #464 grew exactly
  these files), so neither side's number nor their sum was correct. Each is
  the merged file's real count, with its cause recorded beside it.
  `_FUNCTION_COUNT_BUDGET` ends at 203 — the pre-sweep baseline — after
  trimming a docstring that had pushed `render_doctor_footer` past the cap.

## Verification

`py -3.12 -m pytest tests -q` from `python/`, on this branch rebased onto
`origin/dev`: **3344 passed, 194 skipped, 18 xfailed, 0 failed** in 612.03s.
`ALP_SDK_ROOT` unbound, so the parity and relocation-freshness suites skip
as they do in CI's default job.
Extends the wrap seam doctor adopted to the two widest text surfaces after it: explain (161 columns) and sdk current (137), measured on a host with no SDK resolved.

Wrapping is gated on stderr being a terminal via a new tan.env.wrap_width(), which returns None off a tty -- and None means do not wrap, so a consumer redirecting the stream still gets one whole record per line. Piped output is byte-identical to before. wrap_width takes no no_color/ci parameters at all, which is what makes --no-color and --ci structurally unable to disable wrapping rather than relying on a runtime check nobody re-reads.

The first draft's record-vs-prose classification was deleted rather than repaired. It kept fixed-field and catalogue lines unwrapped so a piped grep/cut could extract them whole, but it was only ever consulted when wrap_width returned a width -- i.e. when stderr IS a terminal -- and any pipe that could grep those lines makes stderr not a terminal, disabling wrapping wholesale. It never fired for the reader it was written for. Measured at 80 columns it hard-wrapped a 72-column prose line while handing the command's three widest lines (109/91/161) to the terminal to break mid-token: edge-ai-star came out as edge-ai-star / ter, the exact corruption break_long_words=False exists to prevent.

doctor keeps wrapping unconditionally, documented at wrap_width rather than changed: doctor prints a REPORT whose redirected form is a file a human opens and wants wrapped, while explain and sdk current print RECORDS whose redirected form feeds grep/cut. Output shape decides; both are correct.

All three command modules shrink (explain -38, sdk_cmd -54, doctor_cmd -9); the only growth is the shared wrap_lines in tan/core/text_layout and wrap_width in tan/env. Two ratchets go down.

Replaces #481, closed automatically when #480 merged with --delete-branch.
's recurrence (#521)

* fix(planner): re-sync tan/planner against alp-sdk 53557a60, closing #320's recurrence (#485)

Ports the four live defects issue #485 names, each with a regression test
that fails against the unfixed code:

1. slugs.py: add the _DRIVER_STATUS_SUFFIX filter (alp-sdk #1169) so
   nor_flash_driver_status/emmc_driver_status never read as a chip slug --
   unfixed, the literal string "none" reached CONFIG_ALP_SDK_CHIP_NONE=y,
   an undeclared Kconfig symbol that aborted Zephyr configure on every
   V2N-family SKU.
2. loader.py + kconfig.py: refuse cacheable: true on a kind: rpmsg ipc
   entry (alp-sdk #1088) and widen _emit_cross_core_shmem_cache's
   CONFIG_DCACHE=n to cover rpmsg too, not just raw_shmem.
3. loader.py: route _load_yaml/_load_json through a new
   tan/planner/strict_loaders.py (hand-ported from alp-sdk #1127) so a
   board.yaml repeating a mapping key (e.g. duplicate som.sku) refuses
   instead of silently keeping the last value -- tan validate and tan
   build no longer disagree on the same file.
4. project_loader.py: add the SdkRevisionNotBuildable status gate
   (alp-sdk 1a91a232, the other half of #1025) to
   _hwrev_pad_route_overrides, so --target carrier-netlist/
   composed-route-table refuse a reserved/tbd/status-less hw_rev the same
   way tan build already does.

Also ports two small, low-severity companions surfaced by the same audit:
carveout.py's carveout: false memory_map exclusion was already ported
(unaffected); zephyr_board.py and som_metadata.py gain alp-sdk #1048's
case-insensitive TBD match for silicon_variant.

Re-pins python/tests/gates/test_planner_relocation_freshness.py's
PINNED_SDK_COMMIT to alp-sdk 53557a60 (exactly the commit spanning the
six drifted modules named in #485, chosen to stop short of validate.py's
unrelated #1197 curated-library drift). Adds a third, independently
pinned STRICT_LOADERS_PINNED_SDK_COMMIT/HAND_PORT_SDK_ROOT-style track for
strict_loaders.py rather than folding it into HAND_PORT_HASHES, whose
other three files carry real unaudited drift (alp-sdk #1125/#1126's
path-traversal fix in alp_template.py, unrelated to this issue and left
for a follow-up) that a shared pin bump would have silently certified
away -- the exact failure mode #485 exists to name.

parity.yml gains a notify-planner-drift job: on a repository_dispatch
run (the one that tests the alp-sdk ref that just pushed, not the pinned
tag), a red python-tests job now files or refreshes a single tracking
GitHub issue instead of sitting invisible in the Actions tab, which is
how six consecutive red dispatch runs went unactioned before #485. The
pin-freshness warning step stays warn-only by design, unchanged; whether
that warning itself should become blocking is #509's decision, not this
one.

* fix(ci): move sdk_parity's alp-sdk checkout to 53557a60, matching PINNED_SDK_COMMIT

Follow-up to the previous commit: ci.yml's sdk_parity: true path hardcodes
an alp-sdk ref and runs pytest tests -q with no --ignore=tests/gates, so it
hashes scripts/alp_orchestrate/*.py at whatever this ref resolves to and
compares it against test_planner_relocation_freshness.py's PINNED_HASHES.
Left at the old f4d87a1f (== PINNED_SDK_TAG, unchanged), this now fails
test_relocated_planner_modules_match_the_pinned_sdk_audit outright, since
PINNED_HASHES moved to 53557a60's content in the prior commit -- verified
both ways (fails bound to f4d87a1f, passes bound to 53557a60).

Re-verified the artefact-count floor test_the_breadth_layer_still_covers_
every_board (>= 2900, the exact assertion a prior 2bc6b400 bump attempt
tripped and reverted for, per this file's own history) is NOT hit at
53557a60: the full sdk_parity: true invocation (pytest tests -q, bound to
this ref) now runs 1 failed, 4193 passed, 63 skipped, 18 xfailed.

The one failure is KNOWN and NOT fixed here: test_an_unresolvable_board_
preset_is_a_coded_envelope (python/tests/parity/test_planner_emit_parity.py,
frozen for this change) reproduces identically regardless of which alp-sdk
ref is bound -- it is a direct, correct consequence of the duplicate-key
refusal landing in loader.py, catching a duplicate preset: key that test's
own fixture-construction logic creates by appending a second preset: line
onto a board.yaml that already declares one. Flagged in ci.yml's own
comment and in the report for this change; needs a human decision on that
frozen fixture, not a drive-by edit here.

* fix(review): close both #485 review blockers plus five accuracy findings

Two mechanical blockers, both shipping red CI:

1. parity.yml: PINNED_SDK_TAG was left at f4d87a1f while
   PINNED_SDK_COMMIT moved to 53557a60 in the prior commit. Since
   examples/multicore/rpmsg-aen/board.yaml dropped cacheable: true in
   53557a60 -- the same commit that added the refusal this issue ports --
   every pull_request/push python-tests run bound ALP_SDK_ROOT to the
   OLDER f4d87a1f and correctly refused alp-sdk's own (now-stale) fixture:
   31 failures, measured both sides, nothing wrong on either repo, just
   two refs a half-step apart. Bumped PINNED_SDK_TAG to 53557a60 (#485's
   own suggested step 1), re-verifying the three CI-wired byte-parity
   gate scripts (kconfig-fixture, toolchain-lock, scaffold) PASS
   unchanged at the new ref first. ci.yml's sdk_parity checkout ref moves
   to the same commit again for the same reason -- the two pins are back
   in sync after a brief, deliberate divergence earlier in review.

2. test_planner_emit_parity.py: test_an_unresolvable_board_preset_is_a_
   coded_envelope's own fixture appended a second preset: key onto a
   board.yaml that already declared one, expecting last-key-wins to
   reach its intended unresolvable-preset assertion -- instead tripping
   the new duplicate-key refusal first. Confirmed adjudicated correct
   both ways: alp-sdk's own front door (alp_project.py --emit
   carrier-netlist) refuses the byte-identical appended fixture with the
   identical error, so tan and alp-sdk agree and the fixture's own
   premise is what broke. python/tests/parity/ carries no repo-level
   freeze (that's crates/, contract/, and test_oracle_parity.py's CASES
   table specifically) -- fixed directly: substitute the existing
   preset: value instead of appending. Verified both ways (f4d87a1f and
   53557a60) and against the full sdk_parity: true invocation, now 0
   failed.

Plus the five accuracy findings from the same review:

- test_planner_relocation_freshness.py's STRICT_LOADERS_* comment cited
  template.py functions that do not exist (render/default_sku/validate).
  Corrected to the two real, empirically-verified call sites
  (_rendered_bytes:210,214 and render_to_envelope:1091), the actual
  read/write split (fs_confine.resolve_confined covers every write; nothing
  guards these two catalog-driven reads), and a scoped threat model
  (a hostile SDK/catalog checkout, already trusted to run arbitrary
  CMake/west -- narrower than #1126's write-bug severity, but real).
- HAND_PORT_PINNED_SDK_COMMIT's comment claimed zephyr_board.py and
  project_loader.py were frozen at 996937ac; this change forward-ports
  #1048 and #1025's status half into both, strictly after that commit,
  invisible to a gate that hashes only the SDK side. Comment corrected
  to say so plainly instead of implying no gap.
- New test_hand_port_sources_declares_its_one_strict_loaders_exception:
  closes the path where deleting strict_loaders.py's own STRICT_LOADERS_*
  pin would leave it silently unaudited while both existing coverage
  gates stayed green.
- strict_loaders.py's docstring said it was tracked in HAND_PORT_HASHES,
  exactly the opposite of what this change deliberately did -- corrected
  to name STRICT_LOADERS_PINNED_SDK_COMMIT.
- notify-planner-drift's de-dup used gh issue list --search "... in:body",
  which tokenizes on hyphens and ANDs the tokens rather than matching the
  phrase -- measured live returning 5 unrelated issues for a similar
  query. Replaced with a label-scoped list + local exact-substring filter.

One nit taken: restored the dropped "same error type and message shape
as loader's SoM-side refusal" note in _hwrev_pad_route_overrides's
docstring (load-bearing: the function inlines loader._status_repr rather
than importing it), raising _FUNCTION_COUNT_BUDGET to 204 with a reason
rather than re-trimming, per the gate's own invitation.

Re-verified both gate runs after all of the above:
  unbound:              3341 passed, 224 skipped, 18 xfailed, 0 failed
  bound to 53557a60:    4195 passed,  63 skipped, 18 xfailed, 0 failed
…a real tag cut (#527)

alp-sdk cut a real `v0.15.0` tag (e2928b9f, annotated to 3769febe;
`v0.15.0-rc1` at 996937ac still exists too, unaffected). The templates stay
pinned at `v0.15.0-rc1` -- it is still a tag alp-sdk actually has, and
re-pinning to `v0.15.0` would mean re-vendoring 40 links across seven
READMEs plus MANIFEST.md's Ref/Commit lines and its DELIBERATE_EDITS
byte-parity declaration against a commit this tree was never captured at, for
no correctness gain: rc1 and the final tag are the documented, browsable ref
this scaffold's prose describes either way.

What broke: the negative control in
test_the_vendored_ref_is_a_tag_alp_sdk_actually_has derived its dead ref by
stripping the vendor ref's pre-release suffix (`v0.15.0-rc1` -> `v0.15.0`).
That was fine while `v0.15.0` was unallocated; now it is a real tag, so the
same derivation silently produces a ref that legitimately exists and the
control stops asserting anything -- the exact self-disabling failure mode a
negative control exists to prevent (this is the same class of drift
tan-cli#384 itself was: a derived value quietly stopped meaning what it used
to).

Replaced the derived `control` with a hardcoded `_DEAD_CONTROL_REF = "v0.15"`
(no patch component). It keeps the same hazard shape without depending on the
live vendor ref: verified against the live repo, GitHub's exact-ref endpoint
(`/git/ref/tags/v0.15`) 404s, while the plural prefix-matching endpoint
(`/git/refs/tags/v0.15`) still 200s by matching `v0.15.0`/`v0.15.0-rc1` -- so
it still catches `_tag_exists` regressing onto the plural endpoint, which is
the whole point of the file. alp-sdk's tags are always full
MAJOR.MINOR.PATCH (optionally `-rcN`); a bare MAJOR.MINOR has never been cut
and would break that convention if it ever were, so this should stay dead for
a while. The comment on the constant says what would make it stale (a literal
`v0.15` tag, or the vendored line moving far enough past v0.15.x that the
prefix relationship stops applying) so the next person picks a fresh
plausible-prefix ref instead of re-deriving one.

No template content or link changed; only the test's control mechanism.
…on into flashing-tool scripts, and six write-path defects that reported success (#511)

Closes #486. Closes #487.

Bench-verified on real E1M-AEN801 silicon under a held labgrid reservation.
…sign (#525)

Closes #512.

Bench-verified on real E1M-AEN801 silicon under a held labgrid reservation.
…526)

Closes #513.

Bench-verified on real E1M-AEN801 silicon under a held labgrid reservation, with two probes genuinely visible.
…#528)

Closes #520.

Bench-verified on real E1M-AEN801 silicon under a held labgrid reservation.
…530)

Closes #510.

Implements ADR-0020's "never PATH" Security consequence: command.tool is an identity
(alp-sdk#1286), so tan resolves it to an absolute path and spawns that, through one
hardened resolver rather than a check and a spawn that had drifted apart.

The cwd-shadow invariant is verified on real Windows by the windows-latest pytest leg,
and guarded portably on every platform by two resolver-contract tests.
…oldens (#535)

Refs #502.

No golden was re-recorded -- the five debug-config-preview xfails are unchanged.
Defect 1's headline (the published envelope-contract.json shipping stale entries)
is now accurately documented but not fixed, so #502 stays open for that.
…AND_SZ Path destruction, tcsh shadowing (#538)

Closes #490.

Verified by execution rather than inspection: real tcsh 6.24.10 across all 8
csh/tcsh x rc-file combinations, real fish 3.7.0, a real noexec tmpfs via
unshare, and pwsh 7.4.6 driving install.ps1's logic. A data-loss regression
found in review -- an unguarded recursive delete of $HOME/.local -- was
replaced with a non-recursive leaf-to-ancestor walk and re-probed in both
directions.
…e stdin reader to #537 (#539)

Refs #503. Refs #537.

Deliberately does not close #503 -- seven rounds on _read_implicit_stdin each
closed the named defect and introduced another, so the reader is deferred to
#537 with the full history of what has been ruled out. This lands only what is
independently correct.
…claiming a reset that did not happen (#542)

Closes #519. Closes #522. Refs #540. Refs #541.

Per-tool selectors (openocd_usb_location, pyocd_uid) with wrong-arm refusals
hoisted above the arm split, and Flow D's ok_message now reports the observed
reset outcome instead of asserting one. Three review rounds; the tee that
delivers #522's text-mode qualification is tracked for a pty follow-up in #541.
…creating it (#507)

Refs #476.

Half (a) only: a nonexistent --project no longer creates the tree and writes a
native-host launch.json into it. Half (b) -- the native-host default for a
project with no board.yaml at all -- stays open, so #476 stays open.

Original author: hkngln. Finished under maintainer handover 2026-08-08.
…crash (#508)

Refs #477. Refs #476.

Every #477 refusal site now exits 2 rather than 5, and the pairing refusal
reports the target and server the caller actually typed instead of the
zephyr-mcu/none placeholder.

Not a Closes: the --core-without-a-build-manifest half stays open. Guarding it
there breaks two legitimate tests that pass --core with no manifest to select
the SDK-published debug-probe identity (alp-sdk#1026), so #477 keeps a
documented, test-pinned gap rather than a wrong fix.

Original author: hkngln. Finished under maintainer handover 2026-08-08.
…esolves one (#504)

* fix(sdk): disclose a foreign global default from every command that resolves one

tan-cli#464 gave `~/.alp/sdk-default` a `writtenFor` field and taught
doctor/generate/build/presets/examples to warn when the pointer was written
for a DIFFERENT project. Five commands resolved the same foreign checkout and
said nothing -- measured against the shipped v0.5.1 binary, from a real
two-project bootstrap:

    inspect / trace / validate / diff / support-bundle
    sdk.sourceTier = globalDefault, ok = true, issues = []

The silence is not passive. `trace` prints the `alp_project.py` a build would
run, from the other project's checkout. `validate` SPAWNS that checkout's
`scripts/validate_board_yaml.py`, so another project's schemas -- at whatever
revision it sits on -- decide "clean" or "violation" for this board.yaml.
`diff` normalises through it. And `support-bundle`, the one artefact a user
sends to someone else to explain a broken machine, carried the fact nowhere:
not in `issues[]`, and not in the reduced doctor set it embeds -- that set
keeps host checks only (#441), 8 where a standalone doctor emitted ~17, so
depending on it would not have covered this either.

No new mechanism. `resolve_sdk_root_ladder` has always answered with
`foreign_global_default_for`; `sdk_resolution_issues` has always turned it
into the pair. What was missing was the wiring:

  - `ResolvedDebugContext` now CARRIES `foreign_global_default_for` and
    `broken_project_pin` instead of dropping them -- one seam that covers
    inspect, trace and support-bundle, since all three build from it.
  - validate resolves the pair beside `sdk_info`, outside the `--offline`
    branch, so no future early return can skip it (the #464 review's own
    lesson from size/image).
  - diff carries it through `_emit_failure` as well as the success envelope:
    a refusal that hides which SDK it refused against is no better than a
    success that does.

The test drives the REAL two-project sequence rather than hand-writing a
pointer file -- hand-writing one proves only that the reader works. Verified
both ways: 6/6 fail with the source change stashed, 6/6 pass with it.

Two size budgets rise (validate_cmd 1093 -> 1108, support_bundle_cmd 834 ->
842), with the reason recorded inline. Both modules are already over the
800-line cap and named in #408; extracting from them is that issue's job, and
holding a defect fix hostage to it would leave the silence in place for the
sake of a number.

Full suite: 3346 passed, 0 failed, 215 skipped, 18 xfailed.

Closes #478

* test(sdk): compare writtenFor posix-on-both-sides, not native-vs-posix

windows-latest only, and the failure was in the TEST, not the fix under it:
tan writes `writtenFor` posix-normalised on every platform, while
`str(proj_b)` is native, so the precondition compared
`C:/Users/.../projB` against `C:\\Users\\...\\projB` and refused a state
that was actually correct.

Every assertion BELOW it already normalised (`str(new_sdk_b).replace(...)`);
this one was the omission. macOS and ubuntu passed because their separator is
already `/` -- the exact windows-only shape this repo keeps getting bitten by
(reference: the five green-on-macOS/red-on-windows traps).

* fix(sdk): emit the foreign-default pair from one seam, not per command

Redirects #504 from the per-command sprinkle review rejected to the central
seam #478 actually asked for: "from one place, so a new command cannot
forget it". Shape chosen after an independent advisory read; two candidate
mechanisms were rejected on evidence.

REJECTED (a) re-derive at emit() off the envelope's sdk block. That block
carries only {root, sourceTier}; foreignness needs `writtenFor`, so it would
re-read ~/.alp/sdk-default at emit time -- a TOCTOU against a pointer THIS
PROCESS mutates mid-run. `bootstrap` rewrites it with its own `written_for`
before its envelope goes out, and test_bootstrap_command.py pins the
resolution-time semantic ("init surfaces sdk.global-default-foreign-project
BEFORE _pin_sdk writes"). An emit-time read reports the post-mutation state,
so the warning would vanish on exactly the two commands that change it.

REJECTED (b) per-command emission plus a gate enumerating commands. Not
soundly implementable here: "resolves an SDK" flows through five entry
points, and module-level call-site presence is trivially satisfied while
every early-return path stays silent -- this codebase's own RECORDED defect
shape (size_cmd.py:450, image_cmd.py:392, flash_cmd.py:1983 all document
exactly that). The gates that work here gate literals and constructors, not
reachability.

TAKEN: compute at resolution time (the ladders already carry it), carry on
SdkInfo as NON-WIRE fields (as_dict unchanged -- that is the extension
handshake), one blessed SdkInfo.from_resolution(), and append centrally in
Envelope.__init__, deduped by code so the existing hand-call sites keep their
own order. Mirrors _with_sdk_divergence, whose docstring already makes this
argument for #407.

Proof the seam carries rather than decorates: `diff`'s hand-call is DELETED
and it still discloses.

Also closes the two review blockers on the bundle:
  - `sdkResolution` now goes into the payload BEFORE _write_bundle. The
    earlier revision computed it after the write, so #478's own repro
    (`'global-default-foreign-project' in open(BUNDLE).read()`) still
    answered False -- the one item review called worth fixing first.
  - the test named for the file now READS the file. It previously resolved
    bundle_path, asserted is_file(), then asserted on the envelope -- false
    assurance over that miss. Verified: it fails with the payload change
    stashed.

Prerequisite checks both cleared by measurement, not assumption: no committed
golden or parity fixture carries `globalDefault` (3 goldens carry sourceTier,
all sdkRootFlag/none), so the central append cannot move frozen bytes; and
all twelve commands in scope DO construct an Envelope, so the seam reaches
them -- `build`'s text path is the one exception and is called out.

Seven mechanical construction sites converted. Sixteen remain on loose locals
and need the resolution threaded per command; the AST gate that refuses raw
SdkInfo( lands with them.

Full suite 3344 passed. Two failures triaged: size budget (mine, bumped with
the reason inline) and test_the_vendored_ref_is_a_tag_alp_sdk_actually_has
(NOT mine -- zero commits on those paths from this branch).

Refs #478

* fix(validate): keep the SDK advisory out of the board's own documents, and gate the seam

Closes the three regressions the first revision introduced (review majors
3-5), at ONE split rather than four readers: `_emit` now separates this
command's own `validate.*` findings from the `sdk.*` resolution advisories.

  * data.issueCount reads as "how many findings does this board have".
    Counting a host fact made a CLEAN board report outcome "clean",
    exitCode 0, issueCount 1.
  * --format sarif / --format diagnostic-v1 are ported alp-sdk documents in
    which every entry is anchored at board.yaml, region 1:1. A CI job
    uploading the SARIF to code scanning would have annotated line 1 of the
    customer's board.yaml with "the machine-global default SDK was last set
    by a bootstrap relocation in <other project>" -- a fact about the host,
    rendered as a finding about the file.
  * the tan-cli#350 text verdict keyed off `len(issues) == 1`, so prepending
    the advisory pushed it to 2 and an empty directory printed "validate:
    validation failure" -- the wording #350 removed precisely because
    nothing was checked and so nothing was found wrong.

The envelope's issues[] still carries both; only the board-scoped readers are
narrowed. Both new tests fail with the source change stashed.

Plus the enforcement Fable's advisory called the implementable half of a
"command resolves without emitting" gate:
tests/gates/test_sdk_info_is_built_from_a_resolution.py refuses a raw
`SdkInfo(...)` outside the seam, by AST rather than substring so annotations
and imports are not miscounted. It is a RATCHET -- the sixteen legacy sites
are ledgered and can only shrink -- with a staleness half, because a ledger
that silently over-counts stops covering what it names.

It earned itself immediately: on its first run it found clean_cmd.py:738,
a site absent from the hand-written enumeration this work was based on.
Converted rather than ledgered, since its resolution was already in scope.

Full suite 3350 passed. One failure,
test_the_vendored_ref_is_a_tag_alp_sdk_actually_has, is NOT from this branch:
`git log origin/dev..HEAD -- python/tan/core/ python/tests/core/test_template_integrity.py`
is empty.

Refs #478

* fix(sdk): disclose a foreign global default in DEFAULT text output too (#478)

Closes the review's outstanding finding on tan-cli#504/#478. The JSON envelope
now carries sdk.global-default-foreign-project for every command via the
central Envelope seam (already landed on this branch), but five commands
(diff/inspect/trace/support-bundle/validate) and the three west-forwarding
verbs (migrate/lock/quality) write text mode straight to stderr or hand stdio
to a spawned child, bypassing that seam entirely -- so a plain `tan trace`
(no --format json) stayed silent about resolving another project's checkout,
the literal repro #478 opened with.

- diff_cmd.py: sdk_context_issues was left permanently empty after the prior
  review round deleted diff's hand-call (reasoning the JSON seam alone was
  enough); recomputed for real and printed in both text branches.
- inspect_cmd.py / trace_cmd.py: print the sdk.* issues before their own text,
  unconditionally (not suppressed by --quiet).
- support_bundle_cmd.py: prints from outcome.issues (NOT outcome.sdk, which is
  resolve_debug_project_context's legacy bare SdkInfo(sdk_root, sdk_tier) and
  drops both facts -- caught by the new test, first attempt printed nothing).
- validate_cmd.py: the "no board.yaml to validate" branch printed findings[0]
  alone (per #350's narrow verdict wording) but dropped the sdk.* advisories
  the other two text branches already show by looping over unfiltered issues.
- west_forward_cmd.py: _run_text now takes the resolved SdkInfo and prints
  before spawning west -- once stdio is handed to the child there is no
  stream left of tan's own to warn on.

Module-size and function-length ratchets re-measured and updated with reasons
(diff_cmd.py newly crosses 800; support_bundle_cmd.py/validate_cmd.py grew;
one new >50-line function crossing, diff_cmd._emit_failure).

Tests: a default-text-mode counterpart to the existing JSON-mode parametrized
case in test_foreign_global_default_coverage.py (all 5 commands), plus a
west_forward unit test. Full suite: 3637 passed, 0 failed, 236 skipped,
18 xfailed.

* fix(sdk): thread the SDK-resolution pair through support-bundle's early-return paths

PR #504 review MAJOR 1: `_internal_failure`/`_server_incompatible` built
their `_Outcome` from a bare `issues=[Issue(...)]` list, dropping
`sdk.global-default-foreign-project`/`sdk.project-pin-unresolved` on the
three early-return failure paths (a --target-kind/--server parse refusal,
an unsupported target/server pairing, and a bundle-write OSError) --
exactly the runs a customer hits when something has already gone wrong.
Both helpers now take the same broken_project_pin/sdk_tier/
foreign_global_default_for fields _run's success path already threads,
keyword-only and defaulted so the outer exception guard (which never
resolves a project context) keeps its prior empty-list behaviour.

MAJOR 2: the CHANGELOG's Fixed entry claimed the warning "now reaches
every command that resolves an SDK" in default text output -- not true of
every one of tan's 32 commands. Narrowed to name the eight commands this
PR actually wires for text-mode disclosure (the five #478 commands plus
the three west-forwarding verbs), on top of the five already wired since
#464. Added a second bullet documenting today's early-return fix.

Re-measured test_module_size_budget.py's support_bundle_cmd.py entry
(876 -> 935, AST-walked, not computed) and added a regression test that
drives both early-return shapes from inside a real foreign-global-default
state, verified to fail without the fix and pass with it.

Full suite: 3638 passed, 0 failed, 236 skipped, 18 xfailed.

* fix(sdk): convert four more construction sites, and assert the invariant across the whole CLI

Continues the seam. Four sites whose resolution was already in scope now
build through it -- inspect, doctor, flash, validate -- and leave the gate's
ledger, taking it from 13 raw constructions to 9. The rest hold loose locals
and need the resolution threaded per command; the ratchet keeps them from
growing meanwhile.

The important addition is the invariant itself:
`test_no_command_reports_a_foreign_global_default_without_saying_so`
enumerates `cli.app.registered_commands` rather than a hand-written tuple,
and asserts that no envelope may report `sdk.sourceTier == "globalDefault"`
without `sdk.global-default-foreign-project` in issues[]. A hardcoded list
cannot express "a new command cannot forget" -- it is precisely what the 33rd
command would not be added to. This covers it the day it is registered.

That also answers the sixth-silent-command finding (`west`) structurally
rather than by name: it takes its `sdk` from `build_output`, which now builds
through the seam, so it discloses without knowing this issue exists.

Commands that cannot be driven bare -- bootstrap and init (they MUTATE the
pointer under test), new-som, flash, monitor, completion, faultdecode, west
-- are named with the reason in NOT_DRIVABLE_BARE, never skipped silently.

And a vacuity floor, because a loop that reaches no `globalDefault` command
would assert nothing while reporting green -- the exact failure this file
exists to prevent, and one this session has now caught three times. The test
records how many commands it actually verified and fails below five.

Full suite 3351 passed. The single failure,
test_the_vendored_ref_is_a_tag_alp_sdk_actually_has, is NOT from this branch
(zero commits on those paths).

Refs #478

* docs(changelog): drop a stray blank line the dev-merge conflict resolution left between two #476/#478 entries

* fix(sdk): disclose a foreign global default from west's required-flag refusal too

`_refuse_required` is what a bare `tan quality` and a bare `tan migrate`
reach: `--profile` and one-of `--check`/`--preview`/`--apply` are REQUIRED
by the child's own argparse, so the refusal fires before `west` is ever
spawned. Its `--format json` branch disclosed the SDK-resolution pair through
`Envelope.__init__`'s seam; its text branch printed `<verb>: <message>` and
nothing else -- so the DEFAULT invocation, from a workspace resolving another
project's checkout, said nothing about whose SDK `_plan` had just pointed
`west` at. Measured on the two-project fixture:

    quality: `--profile` is required (`west alp-quality --profile ...`).
    # ... and nothing else, while --format json carried
    # ['quality.profile-required', 'sdk.global-default-foreign-project']

Both text paths now go through one `_echo_sdk_resolution` helper rather than
open-coding the loop, so a third text path cannot be added that discloses in
JSON and stays silent on the screen -- which is exactly how this one shipped.

The CHANGELOG asserted this was already true for all three west verbs; it is
now, and the entry names the refusal path it had glossed over.

Also in this change, all found reviewing it:

* `doctor_cmd`/`clean_cmd`/`inspect_cmd` had their one-line `SdkInfo`
  ternary wrapped in parens at a broken indentation (statement at 8,
  continuation at 8, closing paren at 4) for no width reason -- 97/92/93
  chars against the 100-char `[tool.ruff] line-length`. Restored to one
  line; `doctor_cmd.py` and `clean_cmd.py` return to dev's own budget
  numbers and `flash_cmd.py` (101 chars, genuinely too long) keeps a
  two-line form at +2 instead of +4.
* `support_bundle_cmd`'s text-path comment justified reading
  `outcome.issues` by claiming `context.sdk` is a bare
  `SdkInfo(sdk_root, sdk_tier)` ledgered for `inspect_cmd.py`. Both halves
  are false as of this branch: that resolver builds through
  `from_resolution` now, and `inspect_cmd.py` is not in the ledger.
* the constructor gate skipped `envelope.py` by `path.name`, exempting any
  future `tan/<pkg>/envelope.py` from the gate for the sake of its
  filename. Anchored to the real path.

---------

Co-authored-by: Caner Alp <109098482+alpCaner@users.noreply.github.com>
…four pins together (#582)

`parity` had failed on eight consecutive `repository_dispatch` runs, 08-07T21:10
through 08-08T09:18, one per alp-sdk commit. It runs on `repository_dispatch`,
not `pull_request`, so every open PR showed green over it.

The live divergence was one Kconfig line. alp-sdk#1241 added a `_chip_has_driver`
filter; the mirror predated it, so tan emitted `CONFIG_ALP_SDK_CHIP_DP83825=y`
for every AEN board. That symbol is declared nowhere -- every entry in
`zephyr/kconfigs/chips.kconfig` reads "Compile chips/<part>/<part>.c" and there
is no `chips/dp83825/` -- and `ethernet_phy: dp83825` made an undriven chip
reachable from `on_module:` for the first time. It is not parity-only: with the
pin moved, a real `tan build` aborts at Zephyr's Kconfig stage with "attempt to
assign the value 'y' to the undefined symbol ALP_SDK_CHIP_DP83825". Third
recurrence of this failure mode (#320, then #485's `CONFIG_ALP_SDK_CHIP_NONE=y`).
The mirror is not hand-edited: the upstream shape is ported, so the next sync
does not bring the line back.

Sixteen upstream commits were classified by reading each diff rather than its
subject line -- two `docs(accuracy)` ones turned out to be behavioural, and one
`fix(docs)` one turned out to be docstring-only. Ten behavioural ports landed;
four were already present and are re-confirmed by measurement in the pin
comments; two are not applicable. The four pins move together because a port
without the bump, or a bump without the port, reds a seam either way:
`PINNED_SDK_COMMIT`/`PINNED_HASHES`, `HAND_PORT_PINNED_SDK_COMMIT`/
`HAND_PORT_HASHES`, `parity.yml`'s `PINNED_SDK_TAG`, and `ci.yml`'s `sdk_parity`
checkout ref, which that file's own comment requires to agree with the third.
`STRICT_LOADERS_HASH` is deliberately untouched -- `scripts/strict_loaders.py`
has not moved since its own pin (0 commits in the range).

alp-sdk#1289 is the highest-stakes one. SETOOLS top-anchors the ATOC
application package at the App MRAM window end (`0x80580000` on the E8) and
grows it DOWNWARD at provisioning time, so the layout must reserve a band, not
an address. Bench-observed on E1M-AEN801 2026-08-08: ATOC magic `ckBS`
(`0x53426B63`) intact at `0x8057EA50` while a Zephyr app erased `0x80560000`
inside the SAME `storage` partition. Either direction leaves an unbootable
part, silently, until the next boot. `_AEN_STORAGE_KIB` 128 -> 96 with a new
`_AEN_ATOC_KIB = 32`, so the reserved total still sums to 256 KiB, `image_kib`
is unchanged and no committed AEN board's slot geometry moves. The measured
evidence is kept verbatim from alp-sdk rather than summarised.

alp-sdk#1331 closes a silent-corruption default in the storage resolver: it
checked page alignment, device capacity and sibling overlap, and nothing else,
so a littlefs mount resolved onto MCUboot at offset 0 of `mram_main` with
`status` clean and no `offset_kib:` declared anywhere. The bump allocator now
advances PAST the SoM's own regions; the device origin is derived then VERIFIED
(`origin + capacity == highest region top`) and the conversion refused with a
warning rather than guessed.

`python/tan/templates/vendored/` is re-vendored in the same commit, since
alp-sdk#1266 and #1287 change `--emit scaffold` bytes. tan-cli#384's seven
`DELIBERATE_EDITS` entries are RETIRED rather than re-pointed: they existed
because the emit rendered links at a `v0.15.0` tag alp-sdk had never cut, and
alp-sdk has since cut it (`e2928b9f`), so the divergence is healed and that
module's own doctrine makes a stale excuse a hard failure. `edge-ai`'s
`testcase.yaml` is RETAINED -- its catalog record still carries
`testcase_yaml: ["examples/ai/cold-chain-monitor/testcase.yaml"]`.

tan-cli#425's recorded `seam1_field_diff.py` divergence was re-measured and no
longer reproduces: `multicore_rpmsg-imx93` now reports "alp-sdk and tan both
refuse this board", both sides quoting the same `status: 'tbd'` refusal, because
tan-cli#485 had already forward-ported alp-sdk#1025's status half. That issue's
body is stale in two more places (it names `f4d87a1f` as the pin, moved since);
the corrections are written into the pin comment rather than left to the reader.

Verified against alp-sdk `f30f4d4b`:
  - `--emit build-plan` byte-identical for `examples/aen/*` (the DP83825 line
    present before, absent after); 0 of the AEN Zephyr slices emit
    `CONFIG_ALP_SDK_CHIP_DP83825`, with the neighbouring chip lines intact
  - `seam1_field_diff.py` rc 0, `scaffold_byte_parity.py` rc 0 (9/9 pairs),
    `kconfig_fixture_parity.py` rc 0 (814 bytes), `toolchain_lock_parity.py`
    rc 0 (6592 bytes)
  - `python -m pytest tests -q`: 4483 passed, 77 skipped, 21 xfailed, 0 failed
  - `crates/`, `contract/envelopes/` and `python/tests/parity/oracle_fixtures/`
    untouched: `git diff origin/dev...HEAD` over them is 0 bytes
  - every size-budget pin re-derived by measurement, including
    `_FUNCTION_COUNT_BUDGET` via the gate's own AST walk over all of `tan/`

Closes #543. Closes #531.
Covers #544. Covers #545. Covers #425 -- these three overlap PR #548, which
carries a subset of this change; the maintainer decides which supersedes.

Co-authored-by: Caner Alp <contact@alplab.ai>
…#475)

* test(surface): walk every tan command in order against a real project

scripts/e2e-full.sh is a release regression harness -- hermetic $HOME, tree
wiped every run, seven commands driven deeply to prove already-fixed bugs stay
fixed. Measured 2026-08-05: those seven (--version, bootstrap, init, doctor,
build, generate, examples) are its whole surface, out of the 32 `tan --help`
lists. The other 25 had no end-to-end coverage at all.

This adds the other axis -- every command, in dependency order, against a REAL
project, repeatably, with no $HOME hijack. Neither replaces the other: the
regression suite needs a hermetic home to mean anything, and this one needs the
operator's actual machine to mean anything.

  scripts/tan-surface/run.sh --sdk-root <alp-sdk>                     # full walk
  scripts/tan-surface/run.sh --sdk-root <alp-sdk> --phase discovery   # ~20s
  scripts/tan-surface/run.sh --project <my-app> --sdk-root <alp-sdk>  # read-only

Known defects are pinned to their issues with xstep/xstep_out: while the bug
stands the run is green, and the day it is fixed the harness reports XPASS,
exits non-zero, and names the entry to retire. Nobody has to remember to
re-check by hand.

That mechanism has already paid for itself. Written against 0.5.0, first run
against 0.5.1 reported 8 XPASS -- #453, #454, #455, #456, #457, #469 and #470
all fixed -- and those are now positive assertions, so the fixed behaviour is
what gets defended. Writing the harness also FOUND two of them: #470 (renode
accepted --project and resolved the build root from the CWD) and #469 (the
workspace-orphan refusal printed a stringified None).

Two lessons are encoded in the comments because both cost a wrong result here:
assert the MESSAGE and not only the exit code (under #470 two distinct renode
refusals collapsed into the same CWD-lookup failure, so an exit-code check
passed while measuring nothing), and never write an xstep_out pattern the fixed
output can still match (#458's fix ADDED a line below the one the pattern
matched, and grep is line-oriented, so the harness reported a false XFAIL).

Safety, because a surface walk runs destructive commands:
  - `tan flash` is never run and there is no flag to enable it
  - --project is read-only unless --allow-mutate
  - bootstrap is opt-in and runs on a COPY of the SDK, because it MOVES the
    checkout (#185); ~/.alp/sdk-default is saved and restored
  - an already-bootstrapped workspace is detected rather than rebuilt
  - new-som is always given an explicit --output-root, since it defaults to
    writing into the SDK checkout

Verified against the v0.5.1 release asset on macOS arm64 with alp-sdk v0.14.0:
86 pass, 0 fail, 1 xfail (#448, renode), 0 xpass, 0 skip -- including a real
ARM build, a Renode boot, and the JSON envelope contract on nine commands.

* fix(tan-surface): close CHANGES_REQUESTED -- data-loss guards, honest verdicts, re-derived expectations

Closes every finding in the two alpCaner reviews on PR #475:

Blockers:
- run.sh: --work is deleted at exit ONLY when this run created it (a
  WORK_CREATED flag gates the trap), instead of unconditionally rm -rf'ing
  whatever path was passed -- a pre-existing --work directory (or $HOME)
  used to be destroyed.
- build/diag: build --materialise, a real build, run, lock, and
  support-bundle are now gated on --allow-mutate exactly like
  generate/clean, closing the false "read-only against a real project"
  claim (they ran unconditionally before).

Majors:
- envelope(): also asserts envelope.exitCode against the REAL process exit
  code (#327), not only its own internal ok/exitCode consistency -- the
  producer-computed tautology could never fail on its own.
- xstep/xstep_out: a timeout (RC 124) can no longer score XPASS -- "not the
  broken exit code" is not evidence a defect is fixed, and a hang is #448's
  most likely failure shape.
- step_out_rc: new primitive asserting BOTH exit code and output pattern in
  ONE invocation, applied at every step_out call site the review measured
  scoring a false PASS on the wrong exit code (renode/run/debug-config),
  without doubling the cost of the multi-minute build/run/renode steps a
  separate step+step_out pair would need.
- summary(): SKIP is now visible and, under a new --strict flag, fails the
  run -- "0 fail" no longer silently reads as "walked the whole surface".
- cases.sh: an init that wrote no board.yaml now records a SKIP instead of
  a bare warn+return that dropped the whole project phase from the counts.
- check_command_surface(): proves KNOWN_COMMANDS against tan --help's own
  output every run, in both directions, so a 33rd command lands as a loud
  FAIL instead of a silent gap in this hand-kept list.

Re-derived against dev (16 commits, not assumed from 0.5.1): pinmux on an
unknown --family exits 0 (a warning-only "I don't know", not 2 -- a
different code path from a real-but-empty table); renode's no-build and
multi-slice refusals both exit 1 (RUNTIME_FAILURE); run without --flash and
debug-config --preview post-build both exit 0. swd_probe's new
openocd_usb_location/pyocd_uid flags and build's new cores.<id>.app
refusal (#523) are confirmed unreachable from this harness (flash is out of
scope by design; every app: this harness scaffolds resolves to a real
directory).

Minors closed in the same pass: the harness now proves $TAN is actually tan
(--version must print "tan <ver>") before running ~90 steps against it, and
documents the identical Windows bash.exe/WSL-stub trap one level up; a
missing timeout/gtimeout is now announced instead of silently making every
--timeout inert; the faultdecode PRECISERR|BFARVALID alternation is split
into two anchored assertions so losing BFARVALID cannot still pass; the
debug-config pre-build pattern no longer also matches --help's own options
table; the "run stops short of flashing" regex is the exact printed line.

shellcheck -S warning and bash -n clean on all three scripts (both were
already clean pre-change). python -m pytest tests -q: 3620 passed, 236
skipped, 18 xfailed, 0 failed (unchanged -- this PR touches no Python).

Refs #448, #470

* fix(tan-surface): stop model build writing outside the sandbox, close the last review pass

- diag phase: `model build` ran un-gated against `--project`. On a project
  whose board.yaml carries a `models:` block, model_cmd.py resolves a
  relative `--out` against the project root, not the harness's scratch dir
  (model_cmd.py:352-354) -- a real write into an operator's own --project
  tree with no --allow-mutate gate. Pin `--out` to an absolute path inside
  $WORK so the write always lands in scratch, regardless of mode or whether
  the board declares models.

- envelope(): gained the same optional `--timeout N` every other primitive
  here already had. "build envelope" was silently re-running the real build
  under envelope()'s fixed 900s default where the plain `step "build"` right
  above it was given 1800 -- pinned to --timeout 1800 at that call site.

- run.sh: the bootstrap probe checked only that `.west/config` + `.venv`
  exist beside --sdk-root, never that `[manifest] path` in that config
  actually names this checkout. An unrelated west workspace sitting next to
  the SDK checkout would match on presence alone and turn every
  workspace/build step into a confusing FAIL. Now reads the manifest path
  back and requires it to match.

- README: declares the bash/coreutils/python3 host requirement and a
  Windows note (no native path -- WSL/Git Bash), and states the full-walk
  runtime (under a minute without a bootstrapped workspace; several minutes
  plus bootstrap's own 10+ once one exists) that was previously undeclared.

- getting-started.yml: shellchecks scripts/tan-surface/*.sh the same way the
  existing step already does for install.sh -- nothing linted these before.

shellcheck -S warning and bash -n clean on all three scripts (unchanged).
python -m pytest tests -q (python/, py -3.12 venv): 3620 passed, 236
skipped, 18 xfailed, 0 failed -- unchanged from the prior commit, as
expected: this touches no Python.

Refs #448, #470 -- both remain open; neither is closed by this pass.

* fix(tan-surface): gate model build on can_mutate against a real --project

`model build` no longer hard-asserts exit 0 unconditionally against a real
`--project`: a board.yaml's `models:` block is content the harness cannot
predict, and a real project's models may name sources the harness's
SDK/toolchain cannot compile. Hard-asserting success against that unknown
shape is the same "reports a verdict it did not earn" failure mode the
envelope/xstep fixes already closed. The pinned absolute `--out` (already
landed) keeps the write itself confined to $WORK regardless of mode or the
board's `models:` block; this closes the remaining half -- requiring
--allow-mutate here matches every other step that runs real commands against
the operator's own project, and SKIPs rather than FAILs without it, same as
its `support-bundle` sibling right below.

Verified against a real `dev`-built tan (0.5.2-rc1.dev0, pip install -e .)
and a real alp-sdk checkout, not only shellcheck/bash -n:
- sandbox mode (can_mutate always true): "model build" PASSes, unchanged.
- `--project <real dir>` without `--allow-mutate`: "model build" now SKIPs
  ("real project without --allow-mutate"), matching support-bundle exactly.
- Every phase reachable without a bootstrapped west workspace or renode
  (discovery, project, generate, workspace's pre-bootstrap steps, build's
  pre-bootstrap steps, diag, teardown) run clean: 124 pass, 0 fail, 4 skip
  (all expected -- no bootstrapped workspace, no renode on PATH). The
  bootstrap-dependent steps (quality/migrate/lock/kconfig, the real
  build/run/renode) were not re-run end-to-end here for the same reason the
  prior pass didn't: no Zephyr toolchain in this environment.

shellcheck -S warning and bash -n clean on all three scripts.
python -m pytest tests -q (python/, py -3.12 venv, dev merged in): 3623
passed, 236 skipped, 18 xfailed, 0 failed.

Refs #448, #470

* fix(tan-surface): name the real cause in the workspace probe, stop earning verdicts the project owns

The not-bootstrapped diagnostic stated a FALSE cause. Measured on a real host
with --sdk-root /home/caner/alp-sdk: ~/.west/config carries `path = alp-sdk`
and basename($SDK) IS alp-sdk, yet run.sh printed "found .west/config or .venv
there, but manifest path=alp-sdk does not name this checkout". The manifest DOES
name it; the missing piece was ~/.venv. The verdict was right and the reason was
wrong, which sends the reader at west's manifest instead of the absent venv. The
one elif is now four distinct arms, each printing its own cause: venv missing,
manifest naming something else, .venv with no .west/config, neither present.

`quality --profile quick` and `migrate --check` hard-asserted exit 0 against an
operator's real --project with no gate. Neither writes, but both report a
verdict on the PROJECT'S content, not on tan -- `tan migrate --help` documents
--check verbatim as "Report version drift; nonzero on drift" -- so a drifted
real project failed the harness spuriously. Gated on can_mutate, the same
treatment the `model build` step already carries and for the same reason. The
two refusal halves (exit 2) stay ungated: argument validation decides before
either command reads the project.

The #448 rationale said "slated for removal", contradicting the issue's current
title ("emit a support-paused warning; retain the command, modules, fixtures and
CI models") and the maintainer's 2026-08-04 reframe ("renode is PAUSED, not
removed"). Corrected, so nobody deletes the case expecting the command to go.

Two nits: a value-taking flag given with no value died on bash's own `$2:
unbound variable` (exit 1, no usage) instead of the documented exit 2 -- now
`ABORT: --sdk-root needs a value` plus usage, exit 2, measured; and a
pre-existing --work accumulated every run's SDK copy, scratch trees and
.out/.err captures forever, since "never delete what the operator gave us" had
become "never clean up at all" -- an existing --work is now the PARENT of a
per-run tan-surface.XXXXXX sandbox, which is what gets cleaned, while the
directory passed in is still never removed.

CHANGELOG entry added for the harness and its shellcheck CI step.

Gates: python -m pytest tests -q -> 3620 passed, 249 skipped, 19 xfailed, 0
failed (275.23s). shellcheck -S warning scripts/tan-surface/*.sh rc=0; bash -n
clean. Surface walk against a real alp-sdk: sandbox 72 pass / 0 fail / 2 skip;
--project read-only 52 pass / 0 fail / 7 skip, with the project tree proven
byte-identical (md5sum of every file) before and after.

Refs #448
Refs #470

---------

Co-authored-by: Caner Alp <109098482+alpCaner@users.noreply.github.com>
…y knew (#578)

* fix(cli): stop kconfig/model/run/image/size dropping what they already knew

Eight defects across three issues, all the same shape: a fact the command had
already computed never reached the user.

  - kconfig built `SdkInfo(sdk.path, sdk.tier)` from an `ActiveSdk` that
    carries `broken_project_pin`/`foreign_global_default_for` and read
    neither. `_fail` hardcoded a one-element issue list and the success emit
    passed a literal `[]`, so a run with a broken `.alp/sdk-path` answered
    `ok: true, issues: []` with a full symbol menu solved out of a checkout
    the pin does not name -- and this command is the alp-sdk-vscode
    `prj.conf` LSP's live feed.
  - model build was the only `resolve_project_context` caller that read none
    of the three resolution facts, dropping both warnings in JSON and text
    alike. Resolution moved to the caller so the warnings survive a
    `ModelError` too.
  - run computed the pair into `issues` but text mode prints `text_lines`, a
    separate list they never reached.
  - image and size computed the pair into `issues` and never into `text`, on
    both the error and the happy path.
  - image's `helper-missing` message said "no --sdk-root" even when
    `--sdk-root` WAS given and simply failed the loader-marker check.

  - image dropped every non-`ok` slice with no notice -- the only exclusion
    in `_assemble_bundle` that reported nothing -- while writing a
    `bundle-manifest.json` whose `boot_order` still named a core `slices[]`
    carried no artefact for.
  - size never read a slice's manifest `status`, so a slice recorded `failed`
    was measured from run 1's leftover `zephyr.elf` and reported as a current
    measurement, with `--fail-over-budget` computing its verdict from those
    stale bytes.

  - kconfig's second `except UnicodeDecodeError` was unreachable and raised a
    CONTRADICTORY issue code. Re-measured against the frozen oracle before
    deleting -- `tan 0.4.1` answers `kconfig.board-yaml-missing` for invalid
    UTF-8, so the live arm was right and the dead one was the pre-#421
    explanation. Deleted, not reordered. A new AST gate makes the shape
    (ruff's B025) impossible to reintroduce without a ruff dependency CI does
    not install.

The two #499 fixes are deliberate oracle divergences, measured on
`target/debug/tan` first and recorded in `tests/parity/
test_image_size_oracle.py`'s divergence register. Both are narrowed to a
DECLARED non-`ok` status, and size's only fires when a measurement was
actually suppressed -- so each materialises exactly where its defect does and
every frozen fixture stays byte-identical.

Closes #440.

Deliberately NOT closing the other two: this branch owns five modules, and
both issues name defects outside them. #497 keeps defects 1, 7 and 8
(`sdk_cmd.py`, `examples_cmd.py`); #499 keeps defects 3-10 (`clean_cmd.py`,
`support_bundle_cmd.py`, `inspect_cmd.py`, `core/size.py`,
`core/image_bundle.py`, `core/system_manifest.py`; defect 7's `consent.py`
already landed via #536). Covers #497 defects 2-6 and #499 defects 1-2.

* fix(cli): stop the internal-failure catch-alls dropping the SDK-resolution pair

The first pass at #497 defect 2/5 threaded `sdk_resolution_issues` into the
refusal paths INSIDE each command's `_run`-style inner function and left the
OUTER `<command>.internal-failure` catch-all -- which runs strictly after the
ladder has already answered -- reporting the crash alone.

kconfig: `_fail` has NINE call sites, not eight. Seven took the pair; the
eighth, `kconfig.internal-failure`, did not. Reproduced against a broken
`.alp/sdk-path` workspace with a discoverable sibling and `_resolve_zephyr_base`
(outside every `try:` in `_run_kconfig`) raising OSError [Errno 24]: exit 5,
`issues: [kconfig.internal-failure]`, `sdk.project-pin-unresolved` dropped. The
ninth site is `kconfig.no-sdk-root`, which by construction has nothing to
report.

image, size: the same site, the same drop, in JSON and text alike.

model, run: already clean -- both resolve in the OUTER function, so their
handlers can read the pair directly. Pinned with tests so they stay that way.

The three that could not resolve in the outer function (their ladder needs
paths the inner function resolves) now pass an `SdkDisclosure` down and record
into it the instant the facts exist. Recorded rather than recomputed in the
handler: the resolver is itself one of the things that can raise, and that
handler must not.

---------

Co-authored-by: Caner Alp <contact@alplab.ai>
… Ctrl-C from lying on stdout (#580)

* fix(envelope): stop a lone surrogate, a forwarded --format json and Ctrl-C from lying on stdout

Three defects in the one serialisation/dispatch path, all of which end with
stdout either empty or carrying an envelope that does not describe what
happened -- and that envelope is alp-sdk-vscode's only channel.

1. A lone surrogate killed stdout AFTER `_serialise()` had already succeeded.
   `ensure_ascii=False` writes a surrogate straight through into the returned
   `str`, so the encode failed at `emit()`'s `print(text)` -- past the guard
   whose own comment says no payload may ever crash stdout. `tan inspect
   --format json` from a directory created as `proj\xffx` exited 1 with zero
   bytes on stdout and a Rich `UnicodeEncodeError` traceback. Every surrogate
   is now replaced with U+FFFD on the finished document, which is exactly what
   the frozen v0.4.1 oracle emits for the same directory
   (`Path::to_string_lossy`): the two `clean --format json` envelopes are now
   byte-for-byte identical, measured. Not an `ensure_ascii=True` fallback
   re-serialisation -- that escapes every other non-ASCII character in the same
   document too.

2. A `--format json` that is not tan's own flag flipped the whole run into JSON
   mode. `_wants_json` is Rust's parse-FAILURE-arm scan, and the port promoted
   it to a process-wide switch, so `tan quality -- --format json` answered
   `command:"cli"` / `cli.parse-error` in place of the real coded refusal and
   `tan lock -- --format json` reported a west child's exit as a parse error.
   Fixed by recording the PARSE OUTCOME (`_DispatchedCommand`, the missing half
   of Rust's `match Cli::try_parse()`) rather than by making the scan cleverer:
   `_wants_json`'s behaviour is unchanged. Two earlier attempts rewrote the scan
   and each reopened the defect a different way; a third textual shape is ruled
   out by measurement -- the oracle answers `quality -- --format json` in TEXT
   and `build -- --format json` in JSON, and the only thing separating them is
   whether the parser accepted the argv, which no scan of argv can know. This
   also settles the second vector, `tan --format json build --format text`.

3. Ctrl-C was reported as an invalid command line. Typer re-raises
   `KeyboardInterrupt` as `Exit(130)`, so an interrupted run was
   indistinguishable from any other non-zero exit with no envelope and fell
   into the `cli.parse-error` fallback -- at an `exitCode` of 130, outside the
   contract's fixed 0-5 set, for a run that was already spawning. Now the new
   `cli.interrupted` at `RuntimeFailure` (1), with the process exiting 1 to
   match. Fixed in `tan/cli.py`, not in any one command; text mode is untouched
   and still exits 130.

`tan/cli.py`'s module-size pin is re-measured at 1008 (`wc -l`) and the
function-count budget re-measured at 219 by the gate's own AST walk over all of
`tan/` -- unchanged, since `main()` was already over the 50-line cap.

Closes #546.

Refs #491 -- deliberately NOT closing it. Its ten defects were split across
concurrent lanes; this one owns `python/tan/cli.py` and the envelope
serialisation path, so defects 1, 3 and 4 land here and defect 2 re-verified
clean on current dev (tan-cli#488 already routed every stderr `isatty` probe
through `tan.env.stderr_is_tty`). Defects 5-10 live in `init_cmd.py`,
`monitor_cmd.py`, `core/build_plan.py` and `bootstrap_cmd.py` and are untouched
here.

* test(envelope): probe the filesystem for a non-UTF-8 name instead of guessing from os.name

`test_a_surrogate_in_the_cwd_still_answers_one_envelope` was guarded by
`skipif(os.name != "posix")` whose own `reason` named the real requirement
correctly -- "needs a filesystem that accepts an arbitrary non-UTF-8 byte in a
name" -- while testing the OS family instead. macOS IS `os.name == "posix"`,
so the guard passed there and APFS (which validates filenames as UTF-8, unlike
ext4, which stores raw bytes) refused the `mkdir`:

    OSError: [Errno 92] Illegal byte sequence:
    '/private/var/.../test_a_surrogate_in_the_cwd_st0/proj\udcffx'

reddening `python -- pytest across python/ (macos-latest)` while ubuntu and
windows stayed green. Same mistake class tan-cli#530 closed by making a check
portable, and the one `tan/env.py`'s `stderr_is_tty()`/`stdin_is_tty()` exist
to avoid: probe the capability, never infer it.

The `non_utf8_project_dir` fixture now attempts the exact `mkdir` the test
performs -- shared helper, so probe and test cannot drift onto different
operations -- inside `tmp_path`, which is the filesystem the test will use
(`/tmp`, `$HOME` and the repo can be three different mounts). It also reads
the name back as bytes and refuses a filesystem that ACCEPTS the mkdir and
quietly rewrites the byte, since that would let the case pass while exercising
nothing. The skip names what refused and where, so a genuine skip is
distinguishable from a silent hole.

Nothing the test asserts is weakened: it still spawns a real `tan inspect
--format json` at the process boundary, which is the only place `emit()`'s
`print` runs and where the surrogate defect manifested.

---------

Co-authored-by: Caner Alp <contact@alplab.ai>
…ported (#494, #495) (#583)

* fix(init,bootstrap): stop resolving and reporting against a workspace that was never chosen (#494, #495)

Six defects across the two commands, each with a test that fails on dev.

tan init (#494 defect 10) -- `Path.cwd()` was unguarded, so a cwd removed
out from under the process turned the whole command into
`init.internal-failure` / exit 5 with an empty envelope. Measured against
the frozen oracle on identical argv in an identical removed cwd: it exits 0
with the full preview, and so does this port's own `tan clean`. Falls back
to "." like `clean_cmd`/`presets_cmd` already do.

tan bootstrap (#495 defects 2, 3, 4, 5, 7):

* The Python floor was read from $ZEPHYR_BASE unconditionally, so a tree
  `_select_workspace` was about to DISCARD still set the enforced floor and
  its reported source -- attributing it to a `python.cmake` inside the tree
  the very next warning said was being ignored, and hard-refusing
  `bootstrap.python-too-old` before the envelope ever said the tree was
  being dropped, with a remedy naming the wrong fix. Now gated on
  `_zephyr_base_will_adopt`, which the run already computed 130 lines
  earlier. An ADOPTED tree still sets the floor.
* The auto-relocation target's occupied check tested raw non-emptiness, so
  the `.venv` `rollback_relocation_after` deliberately leaves behind read
  as foreign content and refused the identical retry of the documented
  quickstart's first command. Now uses the module's own
  `parent_needs_workspace_guard` predicate. No `.west` exemption: see
  `_relocation_target_occupied`'s docstring for the two cuts of one that
  were tried and reverted.
* `--print-env` keyed its stdout branch on the FLAG, so every refusal
  computed before the short-circuit was written to STDOUT with stderr empty
  -- the terminal showed nothing at rc=2 while `env.sh` received prose with
  parens and backticks. The oracle puts these on stderr, stdout empty.
* `FORCE_GIT_LONG_PATHS_ENV` was merged with `dict.update`, claiming
  `GIT_CONFIG_KEY_0` and resetting `GIT_CONFIG_COUNT` to 1 -- deleting a
  corporate host's proxy override and stranding its mirror `insteadOf` past
  the reset count. Appended at index N instead.
* `--print-env` rendered the venv activation hint from the HOST's bin-dir
  name, disagreeing with the same run's `Next steps:` block on a workspace
  whose `.venv` uses the other layout.

* refactor(bootstrap): give --print-env's venv-layout resolution its own helper

Keeps `_print_env_outcome` at its recorded 50-line span rather than growing
the function-count ratchet by one, and puts the tan-cli#495 defect 7 rationale
next to the resolver instead of inline in the short-circuit.

* fix(init,bootstrap): stop scaffolding and reporting facts the tree cannot support (#494, #495)

Six more defects, each with a test that fails on dev, plus a layering fix.

tan init (#494 defects 1-5):

* `--from-example` globbed every regular file, so an example someone had run
  `west build` in was copied wholesale into the customer's new project.
  Measured on `examples/v2n/v2n-brd-i2c-bringup`: 613 files on disk, 607 of
  them under `build/`, 6 tracked. The first binary among the 607 also aborted
  the run with `init.example-unreadable`, so the visible symptom was a hard
  failure and the silent one was a 613-file project. Pruned to alp-sdk's own
  five `.gitignore` patterns and no more -- an earlier cut also pruned
  `build-`, which is in none of them and would have dropped a hand-written
  `build-utils/`. Symlinks are now skipped on both the file and directory
  side, matching the oracle's `DirEntry::file_type()`: `Path.is_file()`
  FOLLOWS a link, so one pointing out of the example inlined that file's
  content into the new project.
* A vendored tree is picked by FAMILY but only its `som: sku:` line was
  retargeted, so every SKU outside the two representative ones inherited that
  tree's core ids. `--som E1M-AEN301` wrote `a32_cluster` for an Ensemble E3
  that has none -- `ok:true`, exit 0 -- and `tan validate` hard-errored
  `unknown core id ['a32_cluster']` on the next command. `retarget_board_yaml_
  cores` renames the app entry to `app_core_for_sku` and drops the companion
  cluster; both edits only ever REMOVE wrong facts, since `tan init` is
  SDK-free and has no topology to consult. Byte-exact passthrough for the
  trees' own SKUs, so every fixture is untouched.
* The vendored-tree guard fired only at ZERO files, so a partially delivered
  tree read as complete: five files written, `ok:true`, and a `CMakeLists.txt`
  still naming the `src/main.c` that was not there. The expected set is
  derived from the tree's own `target_sources`, so it tracks a template that
  gains a source with no edit here.
* `_read_verbatim` re-raised a bare `UnicodeDecodeError` -- a byte offset and
  no path -- straight into `init.example-unreadable`.
* `tan scaffold` wrote a module NO shipped template compiles and said nothing.
  Measured against the frozen `scaffold_trees.json`: not one of the six
  templates names `modules` in any `CMakeLists.txt`, and only `minimal-app`
  sets an include directory. The README now carries a `## Wiring` section with
  BOTH real build shapes -- the `app` target for five templates, `alp_app` via
  `ALP_APP_SOURCES` for `minimal-app`, whose paths are relative to `src/` and
  whose include directory is already set.

tan bootstrap (#495 defects 1, 6, 8):

* `find_workspace_venv`'s upward walk had no manifest guard, while
  `west_workspace_dir` rejects the same ancestor via its #307 guard -- so on a
  dirty host the `west` BINARY came from a foreign `zephyrproject/.venv` and
  its `cwd` from the correct workspace, and that interpreter is what tan bakes
  into every Zephyr slice as `-DPython3_EXECUTABLE`. Keyed on `.west/config`,
  not the `.west` DIRECTORY: tan writes its own `tan-workspace-sdk` record
  into `.west/`, and testing the directory took `tan doctor`'s
  `venvProvenance` check out of the report entirely.
* `manualInstallHints.posix.note` was dropped at parse, at render AND in the
  fallback, so no Linux/macOS customer was told the Zephyr SDK is a separate
  `west sdk install`. Optional on the wire where `windows` is required, since
  every SDK before v0.14.0 declares `windows` alone.
* `--workspace C:ws` returned `C:ws` UNJOINED -- a relative path out of a
  function whose contract is to absolutise one, `cwd` ignored --  because
  `ntpath_isabs`'s `^[A-Za-z]:` regex says True where `ntpath.isabs` says
  False. Refused as ambiguous at the call site, not in the predicate: its
  other caller, `is_plain_relative`, needs the opposite answer for `C:ws`.

Layering: `manifest_points_at` and `same_directory` move down to
`tan/core/bootstrap.py`. `tan/core/venv.py` was reaching UP into
`tan.commands.bootstrap_cmd` four times behind deferred `# noqa: PLC0415`
imports whose only job was to dodge the resulting cycle.

Budgets re-measured on this tree with the gate's own AST walk, not summed:
five functions crossed 50 lines (219 -> 224, each itemised) and four module
entries move. `_FUNCTION_WORST_BUDGET` is NOT raised -- `_run` measured 704
against the 707 ceiling.

---------

Co-authored-by: Caner Alp <contact@alplab.ai>
…, and stop calling a real emit a failure (#576)

* fix(validate,generate): make the two dead structural checks reachable, and stop calling a real emit a failure

Five defects from tan-cli#498, all measured against the frozen v0.4.1 oracle
and alp-sdk `99e47476` before and after.

1. `validate_board_text`'s two v2 structural checks were UNREACHABLE. The
   `_effective_schema_version(doc) >= 2` gate can never open -- alp-sdk pins
   `LATEST = 1` with an empty migration registry, `board.schema.json` calls an
   absent `schemaVersion` "version 1 permanently", and 0 of the 100 board.yaml
   files under `alp-sdk/examples` declare the key. Worse, the gate was wrong
   about the SDK: `board.schema.json` conditions nothing on `schemaVersion`,
   carrying `required: [som, cores]` and `not: {required: [os]}` at its root.
   Both checks are unconditional now, so `--offline` stops answering
   `clean`/exit 0 on boards the real validator refuses with 3 errors. The one
   conformance case this costs (`validate-offline-clean`, whose input has no
   `cores:` and which the SDK answers `error[ALP-B001]` on) is DECLARED in
   `DELIBERATE_DIVERGENCE` rather than re-recorded: the golden is the
   cross-language contract the frozen Rust binary is also held to.

2. Every rich `error[ALP-Bxxx]` block lost its code, its `= hint:` and its
   `= see:` page on the way through `parse_validator_stderr`. Findings are a
   `_Finding` record now, so the code, hint, documentation URI and the arrow's
   `line:col` plus the caret span reach `issues[].message` and reach
   diagnostic-v1/SARIF as the structured `code`, `hint`, `documentationUri`,
   zero-based `range` / one-based `region` and rule `helpUri` fields alp-sdk's
   own exporter emits.

3. A board.yaml `--offline` could not read (cp1252, a directory) exited 5
   `validate.internal-failure` -- "tan crashed" -- while the same file on the
   spawn path exited 2. It is `validate.board-yaml-unreadable` at exit 2 now,
   with its own text verdict line, since nothing was validated.

4. `_missing_emit_output` treated a zero-byte artefact as proof of failure.
   `alp_project.py`'s unscoped per-core emits legitimately write 0 bytes and
   exit 0 when no core matches the mode's OS class, so the subprocess engine
   answered exit 3 about files that were on disk -- and under `--output` then
   unlinked them. The writability probe removes the file it creates, making
   plain existence honest evidence; `_output_stamp` keeps tan-cli#397's
   guarantee for a destination the emitter left untouched.

5. `--all --target <mode>` silently discarded the named target, and with
   `zephyr-board` let a `--core` past the guard whose own message says it does
   nothing for the `--all` set (measured: `alp.conf` 3799 -> 1867 bytes). The
   contradictory pair is refused with `generate.invalid-target` at exit 2.

Frozen paths (`contract/envelopes/`, `python/tests/parity/oracle_fixtures/`,
`crates/`) are byte-identical to origin/dev.

Closes #498.

* fix(generate): record the coarse-timestamp limit of the untouched-destination check

`_output_stamp` compares (st_ino, st_size, st_mtime_ns) so an emitter that
exits 0 having left an already-present destination exactly as it found it is
still refused -- tan-cli#397's guarantee, kept through #498's widening. On a
filesystem with coarse timestamps (FAT 2s, HFS+ 1s; not ext4/APFS/NTFS) an
in-place rewrite of byte-identical content inside one tick is indistinguishable
from no write, and is reported as a failed emit. That is the deliberate side to
err on -- a false failure is loud and costs a re-run, while the false success it
replaces fed a previous board's alp.conf into the next `tan build` silently --
so it is written down rather than left for a reader to rediscover.

Module budget re-pinned by measurement: generate_cmd.py 1295 -> 1303.

* test(gates): record the function-count re-walk after the rebase onto dev

The rebase onto #582's dev landed the budget on a tree neither side had
measured. Re-walked with the gate's own AST walk over all of `tan/`
including `tan/planner/`: 225, of which 49 crossings are in `tan/planner/`.
Set-differenced against dev's 221, the four new crossings are this change's
own -- `validate_board_text` 89 (not the 82 the pre-rebase note recorded),
`resolve_targets` 75, `_missing_emit_output` 75, `_ensure_writable` 60 --
and nothing dropped below the cap. Every `_MODULE_BUDGET` entry re-measured
clean, and the worst function is unchanged at `bootstrap_cmd.py:_run` 701.

* gate: re-walk the function count on the merged tree (230)

---------

Co-authored-by: Caner Alp <contact@alplab.ai>
… tee a pty (#575)

* fix(flash): stop swd_probe claiming an unobserved flash, and give the tee a pty

Two intent-vs-observed defects on the write path, both in flash_cmd.py.

tan-cli#540 -- swd_probe's J-Link arm asserted `{device} flashed via J-Link @
{base}` on JLinkExe's exit code ALONE, on the one backend whose Commander
script carries no `verifybin` to fall back on. #522 measured on real
E1M-AEN801 silicon that a halt failure does not move that exit code even with
`-ExitOnError 1` (which this arm passes), so a load into a core that never
halted reported a clean flash with nothing having checked the bytes landed.
The entry message is now qualified from the transcript -- `write attempted via
J-Link ...; the core did not halt (J-Link reported "...") and this backend
runs no verifybin` -- and a new `flash.swd-probe-write-unconfirmed` warning
carries the same fact to `issues` and to default text output. Status stays
`ok` with rc 0: there is no bench evidence a GD32 halt failure means a failed
write, and turning a possibly-fine flash into a hard failure would trade an
overstatement for a false negative.

tan-cli#541 -- the `_Tee` #522 needs handed the child `subprocess.PIPE` for
both streams, so the child stopped seeing a tty and pyocd/west/openocd dropped
the \r-redrawn progress bar an operator watches through a multi-minute write.
`_spawn`'s live-console branch now tees through a pty when the console is a
real terminal, so the child keeps its `isatty()` and the transcript is still
captured. Pipes are kept unchanged on Windows (no `pty` module and no
stdlib ConPTY), on a non-terminal sink (piped to a file, or CI -- where the
pipe path is the correct one), and on a host that cannot allocate a pty. A pty
is one device, so the whole transcript lands in `_Outcome.stdout`; both
downstream consumers already read it that way.

* fix(flash): read swd_probe's halt markers positionally, and keep the pty out of the diagnostic

Review follow-up on tan-cli#540/#541, both defects measured before and after.

MAJOR 1 (#540): the detector reported a SUCCESSFUL write as unconfirmed.
`_swd_probe_halt_markers` did a positionless substring search over the whole
transcript, but `jlink_commander_script` emits TWO halt-capable stages -- the
pre-load `r`/`halt` and, after the load, `r`/`g`, which is ON BY DEFAULT
(`do_reset = _default(fa_bool_checked(fa, "reset"), True)`). A resident image
such as the GD32 bridge firmware starts the instant `loadbin` finishes, so
that second `r` cannot halt it and JLinkExe prints `Failed to halt CPU` /
`CPU is not halted` at exit 0 on a write that landed. tan then told the
operator to redo it on hardware and raised
`flash.swd-probe-write-unconfirmed`, which alp-sdk-vscode renders as a
warning.

The transcript ordering was established by RUNNING the Commander script
through a capturing stub on PATH, not assumed: the load is reported finishing
(`Downloading file [...]` then `O.K.`) before any of the reset chatter. Only
markers BEFORE that point can speak to whether the write happened; markers
after it speak to the reset, which is all Flow D ever claims them for. When
the tool never reports a load COMPLETING -- the pre-load halt failed, the
wording differs, or `_Tee`'s bounded join truncated it -- there is no
boundary, every marker counts, and the unconfirmed verdict stands. Covers
`loadfile` (ELF/HEX) as well as `loadbin`, which is the arm that keeps living
on the transcript alone.

MAJOR 2 (#541): the pty merge was degrading the customer-visible diagnostic,
and the PR body called it harmless. `_capture_tail` feeds the text-mode
`FAIL:` line and `data.entries[].message` on the pty path too, and
`str.splitlines()` splits on `\r`, so a `\r`-redrawn progress bar -- which the
child now correctly draws, because #541 gave it a tty back -- took three of
the tail's four slots and pushed the real diagnosis out, while raw `\x1b[31m`
shipped verbatim inside a customer-visible string. `_capture_tail` now reads
the lines a terminal would be showing (`_console_lines`): split on `\n` alone,
keep the segment after the last `\r`, strip CSI/OSC escapes. Measured, same
child, driven from a process whose stderr is a real pty:

  before  'swd_probe[gd32_bridge]: [40%] writing image | [70%] writing image |
           [100%] writing image | Error: could not connect to target'
  after   'swd_probe[gd32_bridge]: [100%] writing image | Error: could not
           connect to target'

The lost stderr-first preference is addressed in `_capture_tail`'s own
docstring rather than reversed: a pty is ONE device, so there is no second
stream to prefer, and giving the child two ptys would hand it two different
terminals and would cost the #540 marker search above the chronological
ordering its positional reading depends on. The pipe path is untouched.

Minors: adds the missing CHANGELOG entries, and makes the suite honest under
`pytest -s`. Three tests asserted the pipe path's stream split while reading
the ambient `sys.stderr`, so `-s` on a terminal took the pty path and failed
them; two now assert the transport-agnostic `stdout + stderr` their consumers
actually read, and the one that genuinely needs a non-terminal stderr skips
with the reason named. `_open_console_pty`'s own decision is asserted against
a file this test owns rather than against however pytest was invoked.

Budgets re-walked with the gate's own AST walk over all of `tan/` INCLUDING
`tan/planner/` on the final rebased tree, never by arithmetic:
`_FUNCTION_COUNT_BUDGET` 221 -> 224 (three crossings, all docstring: two of
them functions that already existed and grew), `flash_cmd.py` 2953 -> 3124.

Refs #540.
Refs #541.

* fix(flash): read a CRLF line ending as a line ending, not a progress redraw

The Windows CI leg of this PR (`python -- pytest across python/
(windows-latest)`) failed
`test_execute_message_text_mode_now_surfaces_a_real_spawn_diagnosis` with
`'swd_probe[e1]: exited rc=3'` where the diagnosis belonged. The transcript
was captured -- that test's first assertion, on `outcome.stdout +
outcome.stderr`, passed -- so what broke is `_console_lines`, the helper this
PR added for #541's MAJOR 2.

It splits on `\n` alone and then keeps only the segment after the LAST `\r`,
which is right for a `\r`-redrawn progress bar and wrong for the `\r\n` a
Windows child writes. After the `\n` split every row ends in `\r`, the
segment after that final `\r` is the empty string, every row fails the blank
test, and the tail is empty. Measured on the shipped source, byte sequences
run through the function directly:

  _console_lines('Error: could not connect to target\r\n')          -> []
  _console_lines('[40%] x\r[70%] x\r[100%] x\r\n Error: y\r\n')     -> []
  _console_lines('Error: could not connect to target\r')            -> []

This is not a test-only defect. `_Tee` reads the raw pipe and decodes it
itself rather than letting `text=True` translate, precisely so the live
console stays live -- so on Windows the `\r\n` reaches `_console_lines`
intact and every flash failure diagnostic blanks to a bare rc. That is the
exact surface #541's MAJOR 2 exists to protect, for every Windows operator.

The distinction: `\r\n` is a LINE ENDING; a bare `\r` not followed by `\n`
is a redraw. The terminator's carriage return is stripped first, per
`\n`-delimited segment, and only what remains is read for redraws.
`rstrip("\r")` rather than removing exactly one, because a redraw is only
observable when content FOLLOWS the `\r` -- so a trailing one drew nothing
and can erase nothing, which also settles the last segment (no `\n` after
it, hence no terminator): a transcript that ends mid-row keeps the row the
terminal is still showing, rather than discarding the diagnosis of the run
that failed. Measured after:

  'Error: could not connect to target\r\n'        -> ['Error: could not ...']
  '[40%] x\r[70%] x\r[100%] x\r\n Error: y\r\n'   -> ['[100%] x', ' Error: y']
  'Error: could not connect to target\r'          -> ['Error: could not ...']
  '[40%] x\r[70%] x\r'                            -> ['[70%] x']

The LF-only redraw is unchanged, asserted as a negative control end to end:
this PR's own measured `'swd_probe[gd32_bridge]: [100%] writing image |
Error: could not connect to target'` still holds, so the CRLF repair was not
bought by disabling the collapse.

Tests drive both ways (4 fail before, 5 pass after; the 5th is that negative
control, which passes on both). They feed the byte sequences explicitly
rather than spawning a child, because this box is Linux and the host's
line-ending translation would make them vacuous -- `_console_lines` is pure,
so the same code path runs either way, and the Windows CI leg is the
end-to-end confirmation.

Swept the rest of this PR's diff for the same assumption. The only other
transcript readers are `_swd_probe_halt_markers`, `_jlink_load_completed_at`
and `_flow_d_reset_qualified_message`, all substring `find`/`in` over
`stdout + stderr` with markers that contain no line terminator (`Failed to
halt CPU`, `CPU is not halted`, `Downloading file`, `O.K.`). Verified by
running both endings through them: identical verdicts, the indices differing
only by the extra `\r` bytes, which the comparisons are relative to.

Budgets re-walked with the gate's own AST walk over all of `tan/` INCLUDING
`tan/planner/`, both ways, never by arithmetic: 224 before, 225 after, the
one new crossing being `_console_lines` itself going 35 -> 63 on two lines of
body and 26 of docstring. `flash_cmd.py` 3124 -> 3152 by `wc -l`.

Refs #540.
Refs #541.

* gate: re-walk the function count on the merged tree (230)

* gate: re-walk the function count on the merged tree

---------

Co-authored-by: Caner Alp <contact@alplab.ai>
…stituted app dir (#577)

* wip: toolchain-root resolver + app-dir substitution notice

* fix(build): resolve ${TOOLCHAIN_ROOT} on the host, and announce a substituted app dir

* gate: re-derive the module-size and long-function pins by measurement

* docs: changelog for the toolchain-root resolver and the app-dir substitution notice

* style: drop a stray blank line and re-pin build_cmd at its measured 1908

* test: assert the verbatim ZEPHYR_SDK_INSTALL_DIR spelling, not its posix form

* gate: re-walk the function count on the merged tree (227)

* gate: re-walk the function count on the merged tree

* gate: re-walk the function count on the merged tree

---------

Co-authored-by: Caner Alp <contact@alplab.ai>
alpCaner and others added 28 commits August 13, 2026 11:25
* feat(contract): publish a doctor family in envelope-contract.json (#664)

doctor's `data` values are host facts (installed tool versions, absolute
paths, which checks even apply on this machine), so unlike the other 17
families it cannot be a byte golden. contract/doctor-data-keys.json is the
new single source for the required `data` KEY SET only; the release
workflow's "Bundle the envelope contract" step folds it into
envelope-contract.json's envelopes.doctor as `dataKeys` (never `envelope`/
`exitCode`, which don't apply to a non-deterministic command).

Enumerated 2026-08-12 by reading the one place doctor's envelope is
assembled -- python/tan/commands/doctor_cmd.py's doctor() (the `data = {...}`
literal), Check.as_dict(), summarise(), next_steps(), and
tan.core.bootstrap.reported_missing -- then cross-checked against a real
`tan doctor --format json` run (13 checks, exit 4 on this host). Re-run the
same reading to re-enumerate. `scope` is included even though the issue's
own worked example predates it (tan-cli#549 added it after); `status` and
`scope` are typed as free strings, not enums, per the issue's own reasoning
that a value added later must survive the trip.

Added python/tests/conformance/test_doctor_contract_key_set.py so the
published file cannot drift silently from what the command emits: it spawns
a real `tan doctor --format json` and fails on either an emitted key nobody
declared or a declared key the command stopped emitting. Verified it
actually catches drift by deleting a declared key and re-running (failed
with the expected diff), then restored and re-ran (passed).

Left open, not decided here: whether `data.nextSteps` being identical to the
ordered list of non-null `checks[].fix` values is guaranteed or incidental
(the issue's own open question) -- noted in contract/README.md rather than
resolved.

* fix(contract): close the doctor key-set lockstep gap and false claims (#664)

Three review findings against be3e394 (feat(contract): publish a doctor
family in envelope-contract.json), all re-measured before fixing:

1. The lockstep gate only half-existed. test_doctor_contract_key_set.py
   checked checks[]'s keys against hardcoded _CHECK_REQUIRED_KEYS/
   _CHECK_OPTIONAL_KEYS frozensets, never against the published file's own
   declaration -- so contract/doctor-data-keys.json's checks entry could be
   corrupted in either direction (a declared key deleted, an unemitted key
   added) with the test still green. Reproduced: deleted `scope`+`detail`
   from checks.requiredKeys and added a never-emitted key -- 1 passed.
   Restructured checks into {requiredKeys, optionalKeys} and
   missingPrerequisites into {nullable, items}, and now derive
   _CHECK_REQUIRED_KEYS/_CHECK_OPTIONAL_KEYS/_MISSING_PREREQ_KEYS from the
   JSON file itself. Re-ran the same corruption: now fails with the exact
   diff. Restored, re-ran clean: 1 passed.

2. contract/README.md's claim that "on every run measured so far,
   data.nextSteps has been byte-identical to the ordered list of non-null
   checks[].fix values" is false on the very run be3e394 cites: measured via
   the test's own _run_doctor() helper, this host emits 13 checks / 8
   non-null fix values / 7 nextSteps entries (two checks share the fix `tan
   bootstrap`, collapsed by next_steps()'s dedup). Replaced with the real,
   deterministic rule -- nextSteps is the DISTINCT fix values of non-pass/
   non-unknown checks, in check order, and is NOT a 1:1 index into checks[]
   -- in contract/README.md, contract/doctor-data-keys.json's _comment, and
   changelog.d/664.added.md.

3. Two of five published dataKeys values were English prose, not type
   tokens: missingPrerequisites was a full sentence, and checks[].fix's
   optionality was expressed as a parenthetical inside a type string. Since
   this contract has not shipped yet, restructured both to be pure
   structural tokens: checks is {requiredKeys, optionalKeys} (each a map of
   key -> type token) and missingPrerequisites is {nullable: true, items:
   {tool, command}}. A consumer now validates the whole shape without
   parsing English.

Also, tied to the same file:
- Drove the spawned `tan doctor` invocation from DOCTOR_DATA_KEYS["args"]
  instead of a second hardcoded argv, so a typo/rename in the published
  `args` fails the test that's supposed to catch it.
- Dropped the unused `family`/`schemaVersion` keys from doctor-data-keys.json
  -- nothing reads either (release.yml keys off the literal "doctor" map
  entry; the test reads only `dataKeys`).
- Fixed the stale ".github/workflows/release.yml" and "docs/release-contract.md"
  header prose that still described the release asset as "one golden envelope
  per command family" -- no longer true for `doctor`.
- Moved the doctor-family entry out of CHANGELOG.md's Unreleased section and
  into changelog.d/664.added.md, per this repo's own changelog.d/README.md
  convention (already merged to dev via #676 before be3e394 landed, but
  be3e394 edited CHANGELOG.md directly).

Verified locally: re-ran the release workflow's "Bundle the envelope
contract" Python heredoc verbatim against the changed files -- bundles 18
envelopes / 313 issue codes, doctor entry is exactly {args, dataKeys}, no
_comment/family leak. python -m pytest tests -q from python/: 4078 passed,
284 skipped, 1 xfailed, 0 failed.

* fix(contract): close the remaining doctor lockstep gap for optional keys (#664)

Round-5 review of 2bd70bd found the lockstep gate was still one-directional:
adding a bogus key to contract/doctor-data-keys.json's checks.optionalKeys
alone, or renaming missingPrerequisites.items.tool, both passed unnoticed
(1 passed in each case), because a normal test host either never emits
`fix` on every check or reports missingPrerequisites: null, so the
per-entry assertions never ran. Reproduced both holes exactly as described
before fixing.

test_doctor_contract_key_set.py now spawns a SECOND, deterministic
`tan doctor --format json` with PATH stripped entirely, so every tool the
built-in fallback manifest requires (git/cmake/python3/ninja on POSIX,
git/cmake/python/ninja on Windows) is unconditionally reported missing:
hostPrerequisites always fails with a `fix`, and data.missingPrerequisites
is always a non-empty list, regardless of what the running host actually
has installed. That run lets the test assert every declared optionalKeys
member was actually observed on at least one checks[] entry, and validate
missingPrerequisites[]'s item key set against real, populated data instead
of only ever matching vacuously against None.

Re-measured both fixed holes: adding a bogus checks.optionalKeys entry now
fails (`unobserved_optional`); renaming missingPrerequisites.items.tool now
fails (`entry key set ... !=  declared ... items`). Reverted each mutation
and confirmed the file is restored (sha256 unchanged) and the test green.

This also closes the round's second major: contract/README.md's claim that
the gate "fails ... in EITHER direction, at every level" (mirrored in
changelog.d/664.added.md and doctor-data-keys.json's own _comment) was false
against the prior, one-directional gate. It is not rewritten here because
closing the gate above makes the existing claim true rather than needing to
be narrowed -- every key set the published contract declares (top-level
dataKeys, summary, checks required+optional, missingPrerequisites.items) is
now checked in both directions by a real run.

No production code changed; contract/doctor-data-keys.json is byte-identical
before and after (verified by sha256).

Covering lanes, from python/: `pytest tests/gates tests/conformance
tests/commands/test_doctor_command.py -q` -> 650 passed, 18 skipped,
1 xfailed (was 649 before this commit's one new test).

* fix(contract): close the 7 remaining minors from round-5 review of #664

Re-verified both round-5 majors are already closed on this branch (HEAD
374fe3e): reproduced the checks.optionalKeys and missingPrerequisites.items
holes from be3e394/2bd70bd, then confirmed both now fail
(`unobserved_optional` / entry key-set mismatch) with the forced-missing
second run 374fe3e added. Nothing further needed there.

Closed the 7 minors that survived:

- contract/README.md's self-contradiction: :337-340 still said the release
  workflow folds doctor-data-keys.json into `envelopes.doctor` "verbatim",
  while :240-243 (written by 2bd70bd) already said the opposite. Reworded
  :337-340 to match.
- contract/README.md's :240-243 comparison to `issue-codes.json` was itself
  wrong: `issue-codes.json` is not folded whole-file verbatim either --
  release.yml:644 reads only its `issueCodes` array, dropping that file's own
  `schemaVersion`/`_comment`. Reworded to state both sources are folded
  partially.
- doctor-data-keys.json's restructure (2bd70bd) lost the arrayness signal
  the old `"checks": [{...}]` notation carried, and left three incompatible
  notations (literal field map / meta-descriptor / array literal) with
  nothing distinguishing them. Documented all three explicitly in the file's
  own `_comment`.
- test_doctor_contract_key_set.py hardcoded
  `== declared_summary == {"pass", "warn", "fail"}`, contradicting its own
  docstring's "never hardcoded here". Dropped the literal; the separate pin
  already lives in test_doctor_command.py::
  test_unknown_is_counted_in_no_summary_bucket. Proved: adding a
  summary.unknown bucket still fails, now on the derived-set assertion.
- `missingPrerequisites.nullable: true` was a published token nothing read
  (measured: flipping it to false still passed). Added an assertion that
  requires `nullable: true` on any run where the command actually emits
  `null`. Proved: flipping it to false now fails.
- doctor-data-keys.json dropped `schemaVersion` while issue-codes.json keeps
  one, with no explanation for the asymmetry. Documented why in the
  `_comment` (neither is read by anything today; issue-codes.json's own copy
  exists for symmetry with the bundle's `schemaVersion`, this file's
  args/dataKeys need no independent one).
- Re-checked README:137/:431's "fails on either an undeclared emitted key or
  a declared key the command stopped emitting" against the now-closed
  majors: the claim is true today (both directions are enforced), so left
  as-is rather than narrowing a now-accurate statement.

No change to the published `dataKeys` schema itself -- every edit here is
either `_comment`-only (doctor-data-keys.json) or test/doc prose. Verified
by diff: the only lines touching `"dataKeys": {...}` are additions inside
`_comment`.

Covering lane, from python/: `pytest tests/gates tests/conformance
tests/commands/test_doctor_command.py -q` -> 650 passed, 18 skipped,
1 xfailed (unchanged from pre-edit baseline -- no test added, removed, or
newly skipped).

* fix(gates): resolve the ledger conflict marker inherited from dev

`python/tests/gates/MODULE_SIZE_BUDGET_LOG.md` arrived on this branch already
carrying `<<<<<<< HEAD` / `=======` / `>>>>>>> origin/dev` at lines 53-65. The
markers are not this branch's: they are on `dev` itself, landed by 3bd08fd (the
squash of PR #702), and this branch inherited them when it merged dev.

tan-cli#703 / PR #704 fixes them at the source and adds
`tests/gates/test_no_conflict_markers.py`. This commit applies the same
resolution here so the branch is clean whichever lands first. Both sides are
real entries in an append-only ledger and both are kept, dev's first.

Refs #703

* ci: suppress cache-poisoning on the step, not by line number

The `zizmor · workflow security` gate went red on this branch with one HIGH
finding -- `cache-poisoning` on `actions/setup-node` in release.yml's npm-shim
publishing job -- without this branch changing a single line of that workflow.

`.github/zizmor.yml` baselined the finding as `release.yml:850:9`. This branch
added 14 lines of comments earlier in the file, which moved the step to line
864, and the anchor stopped matching. The suppression did not become wrong; it
became misaddressed.

That is the second time. The removed comment block recorded the first: `790 ->
850 (tan-cli#500)`, when a step added to the `build` job pushed this one down.
An anchor that has to be re-derived every time an unrelated line count changes
is not a baseline, it is a recurring tax -- and the failure mode is worse than
the noise, because a stale line number can come to rest on a DIFFERENT step and
silently suppress a real finding there.

Moved to an inline `# zizmor: ignore[cache-poisoning]` on the `- uses:` line,
which travels with the step. The rationale that lived in the config -- why it is
baselined, and the two changes that would invalidate it -- moved with it, so the
argument sits where the next person to edit that step will read it.

Note for anyone repeating this: the inline comment must be on the `- uses:` line
itself. Placing it on the line above does not suppress; that was measured, not
assumed.

Verified with zizmor 1.29.0, the version ci.yml pins:
  - with the suppression:    rc=0, "No findings to report. Good job!
    (12 ignored, 49 suppressed)"
  - suppression removed:     rc=14 -- the gate still fires, so this is a
    relocated suppression and not a disabled check.

`.github/zizmor.yml` still carries one line-anchored entry,
`artipacked: planner-resync.yml:140:9`, with the same fragility. It is left
alone deliberately: this branch does not touch that workflow, so its anchor is
currently correct, and converting it here would put an unrelated workflow into
a PR about the doctor family envelope contract.

---------

Co-authored-by: Caner Alp <contact@alplab.ai>
… the spawned interpreter (#665) (#708)

* test: prove the tan_under_test hijack refusal actually fires (#665)

tests/conftest.py's tan_under_test fixture (tan-cli#423) already refuses to
run against a `tan` resolved from outside this repo's python/, but nothing
exercised the refusal path itself -- every existing consumer only ever hits
its silent-pass branch, so a green suite said nothing about whether the
refusal would fire the day it matters. tests/gates/test_tan_under_test_guard.py
plants a decoy tan package outside python/, puts it ahead of the real one on
sys.path (the same externally observable shape a hijacked editable install
produces), and proves the fixture refuses loudly with its own named message --
and stays silent for this repo's own correctly-resolved tan.

Also documents the hazard in README.md's Development section: install into a
venv you create, never a bare or --user `pip install -e ./python`.

* test: probe the interpreter the suite spawns, not just this process (#665)

tan_under_test asserted only on this process's `import tan`. The spawned
children run `[sys.executable, "-m", "tan", ...]`; `tan/__init__.py` is
empty, so `import tan` alone proves nothing about that chain.

  PYTHONPATH=$PWD bare-venv/bin/python -m tan --version   -> exit 1
      ModuleNotFoundError: No module named 'typer'
  PYTHONPATH=$PWD bare-venv/bin/python -c "import tan"     -> exit 0

---------

Co-authored-by: Caner Alp <contact@alplab.ai>
…K_COMMIT (#691) (#712)

Nothing said a word when a locally-bound ALP_SDK_ROOT was not the commit
tan's own pins declare, so a run against the wrong alp-sdk tree produced
real-looking failures that were neither pre-existing nor caused by the
branch under test. Measured on the same node IDs against an unmodified
origin/dev, varying only ALP_SDK_ROOT:

    ALP_SDK_ROOT=a317330595f744d35f4d785869517110f3678f70  ->  30 passed
    ALP_SDK_ROOT=c07254b2589406acb3fcb5556bf1e995395431e3  ->   5 failed, 25 passed

A full suite reported 9 failed, 4986 passed and it took a three-way
comparison by hand to establish that five of the nine were the bound tree.

python/tests/conftest.py now compares the bound checkout's HEAD to
PINNED_SDK_COMMIT once per session, naming the variable that bound it, both
SHAs and the direction and distance between them, and compares
PINNED_SDK_COMMIT against parity.yml's PINNED_SDK_TAG in the same check --
the pair ci.yml says "MUST be bumped together", which has drifted twice.

It WARNS, never fails: binding a newer tree deliberately is how the next
planner re-sync's workload is discovered, and the defect was that it was
silent. It stays silent when nothing is bound (ci.yml's python job, a bare
pytest tests/), when the bound root is not a git checkout, and when a
non-git tree merely sits nested inside some other repository -- git -C
would otherwise answer with the enclosing repo's HEAD.

tests/gates/test_sdk_pin_disagreement_warning.py builds real git checkouts
in a tmp dir and proves both arms, both emission points and the live pins'
readability -- a warn-only check has no CI status of its own, so nothing
else can tell "it warned" from "it was silent".

Co-authored-by: Caner Alp <contact@alplab.ai>
…ry stage (#573) (#711)

* fix(planner): make load_board_yaml's metadata_root override reach every stage (#573)

`load_board_yaml(path, metadata_root=...)` is documented as the tree the load
resolves against, and two callers pass it: `tan/core/doctor_libraries.py`'s
`_resolve` and `tests/core/test_sdk_revision_gate.py`. Stages 1-3 honoured it.
Stages 4 and 5 did not -- they read the module-level bound `paths.METADATA_ROOT`
instead:

    loader.py  _resolve_storage        -> _known_flash_devices(..., METADATA_ROOT)
    loader.py  _validate_cross_fields  -> resolve_memory_map(..., METADATA_ROOT)

The mismatch was PARTIAL, which is what hid it. `som_preset` itself still came
from the caller's tree, so an alternate tree's explicit `memory_map:` override
and its `on_module.ospi_memories:` keys WERE honoured; only the SoC-JSON-derived
branch of `resolve_memory_map` read the wrong tree. So a `storage[].flash_device`
naming an SRAM bank the requested tree's SoC JSON declares was refused -- and the
message blamed the customer's board.yaml while listing the OTHER tree's devices:

    board.yaml `storage[userdata].flash_device: sram6x` does not resolve to any
    flash device on SoM E1M-AEN301.  Known devices: [..., 'sram6', ...]

Fixing only those two lines is not enough, and that is the wider half of this
change. The resolvers the loader hands the project to read the bound root too --
`partition.resolve_storage_partitions` (`_resolve_flash_device`,
`_reserved_spans`), `carveout.resolve_carve_outs` (`resolve_memory_map`), and
`kconfig`'s three `resolve_capabilities` calls -- and `BoardProject` carried no
root at all, so a loader-only fix makes the loader ACCEPT a flash device the
resolver then BLOCKS (status='blocked', "flash device 'sram6x' is neither a
memory_map region nor an on_module.ospi_memories key"). The root therefore
travels on `BoardProject.metadata_root`, and those five call sites read it back
through `BoardProject.effective_metadata_root()`.

`metadata_root` defaults to `None` on the dataclass rather than to
`paths.METADATA_ROOT`, and `effective_metadata_root()` imports `paths` lazily:
`paths` evaluates `REPO = sdk_root()` at module scope and raises when no SDK root
is bound, and `models` is deliberately a bound-root-free leaf that
`tests/core/test_sdk_revision_gate.py` loads with no SDK at all. A hand-built
`BoardProject` therefore still falls back to the bound root, unchanged.

Latent, not live: every production load takes the default, and the one caller
that passes the parameter (`doctor_libraries._resolve`) binds the same root it
passes, so its two roots agree. No behaviour changes on a default load -- the
recorded root IS `paths.METADATA_ROOT` there.

Not in scope, and still bound-root-only: the library-manifest readers
(`kconfig._library_alias_table`, `kconfig._per_core_library_kconfig`,
`libraries.py`, `project_emit/west_libs.py`, `validate._CURATED_LIBRARIES`),
none of which have a project in hand at the point they read.

The same two lines exist upstream in alp-sdk `scripts/alp_orchestrate/loader.py`;
that side is untouched here.

`python/tests/gates/module_size_budget.generated.json` re-measured via
`scripts/regen_module_size_budget.py --reason ...` for loader.py's +3 lines.

* docs(changelog): fragment for the metadata_root override fix (#573)

---------

Co-authored-by: Caner Alp <contact@alplab.ai>
…ver it (#689) (#713)

* ci(getting-started): retry west sdk install, the GH_TOKEN does not cover it

The step already carries GH_TOKEN to survive the GitHub API rate limit on
fetch_releases. That is only the first network call it makes. west sdk install
then runs setup.sh -t arm-zephyr-eabi, which fetches the GNU toolchain as a
separate download no token affects:

    Installing 'arm-zephyr-eabi' GNU toolchain ...
    ERROR: GNU toolchain download failed
    FATAL ERROR: command ".../setup.sh -t arm-zephyr-eabi -h" failed

Observed 2026-08-12 on PR #688, whose diff touches only python/tan/planner/**
and two pins. The 70.7 MB SDK archive completed in the same step at 219 MB/s
immediately before, so the runner's network was neither slow nor blocked.

Three attempts with backoff, failing loudly on the third -- not a skip and not
an `|| true`, because this step tests the remedy tan itself prints and a
genuinely unavailable network must still red the job. Same policy
clean-host.yml already reasoned out for `sdk list --online`: a gate that goes
red for a reason unrelated to its subject stops being read.

Closes #689.

* ci(getting-started): retry west sdk install, the GH_TOKEN does not cover it

The step already carries GH_TOKEN to survive the GitHub API rate limit on
fetch_releases. That is only the first network call it makes. west sdk install
then runs setup.sh -t arm-zephyr-eabi, which fetches the GNU toolchain as a
separate download no token affects:

    Installing 'arm-zephyr-eabi' GNU toolchain ...
    ERROR: GNU toolchain download failed
    FATAL ERROR: command ".../setup.sh -t arm-zephyr-eabi -h" failed

Observed 2026-08-12 on PR #688, whose diff is confined to
python/tan/planner/{kconfig,libraries,validate}.py, the ci.yml and
parity.yml SDK-ref pins, and test_planner_relocation_freshness.py's matching
hash re-pin (`gh pr view 688 --repo alplabai/tan-cli --json files`) -- none
of that reaches a toolchain download.

Three attempts with backoff, failing loudly on the third -- not a skip and not
an `|| true`, because this step tests the remedy tan itself prints and a
genuinely unavailable network must still red the job. Same policy
clean-host.yml already reasoned out for `sdk list --online`: a gate that goes
red for a reason unrelated to its subject stops being read.

Closes #689.

* test(ci): pin the west sdk install retry loop so it cannot regress silently

getting-started.yml's install step (tan-cli#689) now retries `west sdk
install` 3x with backoff, but nothing proved the shape stays that way. This
gate reads getting-started.yml's "install the Zephyr SDK" step directly and
fails if the retry loop, the third-attempt hard exit, or the invocation
itself (-t arm-zephyr-eabi) is edited away back to the bare one-shot call
#689 was filed against.

test_the_invocation_still_carries_the_flag_under_test does not count with
the loop-membership check's `_INVOCATION` regex
(`west\s+sdk\s+install\b.*-t\s+arm-zephyr-eabi`, re.S): with re.S its greedy
`.*` swallows two duplicated invocations into a single match, so
`len(hits) == 1` would still hold on a duplicate -- verified: constructing a
loop body with the invocation copy-pasted twice, that regex still reports
exactly 1 hit. It uses `_EXACT_INVOCATION` instead, anchored end to end from
`--version` through the `--personal-access-token` continuation line, so a
duplicate cannot be absorbed into one match and a mangled flag breaks the
anchored shape instead of fuzzily matching anyway.

Verified anti-vacuously, three defects injected into getting-started.yml in
turn and reverted after each (`python3.12 -m pytest
python/tests/gates/test_getting_started_west_sdk_install_retries.py -q`):
  * dropped (`-t arm-zephyr-eabi` removed from the invocation): 2 failed,
    2 passed -- test_west_sdk_install_is_wrapped_in_a_retry_loop and
    test_the_invocation_still_carries_the_flag_under_test both fail.
  * duplicated (the `if west sdk install ...; then break; fi` block
    copy-pasted immediately after itself): 1 failed, 3 passed --
    test_the_invocation_still_carries_the_flag_under_test fails on
    `assert 2 == 1`.
  * mangled (`arm-zephyr-eabi` -> `arm-zephyr-eab1` on the invocation
    line): 2 failed, 2 passed -- the same two tests as the dropped case.
Restoring the unmodified step made all four tests pass again after each of
the three injections.

The other two tests (retry-loop shape, third-attempt hard exit) were also
re-verified against this branch's parent commit e01d8be's step body (the
pre-fix, bare one-shot invocation): test_west_sdk_install_is_wrapped_in_a_
retry_loop and test_a_third_failure_still_reds_the_job both fail with the
expected message; restoring the fixed step passes all four again.

python3.12 -m pytest python/tests/gates -q: 434 passed, 9 skipped, 2 failed
(test_tan_under_test_guard.py's two subprocess-guard tests; reproduced
identically checking out e01d8be, this branch's parent commit, so they
predate this change and are not a regression from it).

* test(ci): bind the third-attempt exit assertion to the -eq 3 branch itself

test_a_third_failure_still_reds_the_job scanned the WHOLE step body for
`"${attempt}" -eq 3` and, separately, for `exit\s+[1-9]\d*` -- nothing bound
the second match to the first, so a hard exit anywhere else in the step
satisfied it just as well as the real one inside the exhaustion branch.

Proved by injection (throwaway edit to getting-started.yml, reverted after):
replacing the branch's `exit 1` with `break` and appending
`if false; then exit 1; fi` right after the branch's `fi` still passed all 4
tests before this change -- the exhausted retry would have ended the step
green (`break` falls out of the loop with no output between it and the
step's end) while the gate kept reporting the loop unbreakable.

Fix: `_EXHAUSTION_BRANCH` captures the `-eq 3 ]; then ... fi` body itself
(non-greedy, stopping at the branch's own `fi` -- there is no nested `if`
inside it) and both the `|| true` and `exit` assertions now run against that
captured body, not the full step. Re-ran the same injection after the fix:
`python -m pytest tests/gates/test_getting_started_west_sdk_install_retries.py -q`
now reds on `test_a_third_failure_still_reds_the_job` with
`assert re.search(...) ... where None = ...`, confirming the branch body is
`echo ...\n    break` with no `exit`. Restored the workflow file
(`git checkout -- .github/workflows/getting-started.yml`); tree clean.

python -m pytest tests/gates -q (venv with tan+typer installed against this
worktree): 436 passed, 9 skipped, 0 failed.

---------

Co-authored-by: Caner Alp <contact@alplab.ai>
… west crash (#697) (#716)

* fix(build): refuse a cross-drive project/workspace instead of letting west crash (#697)

tan-cli#307's `_pin_west_workspace` redirects `west build`'s spawned cwd to
the resolved Zephyr workspace so west's ancestor-`.west` walk lands on the
right one -- but west's own `_sanity_check_source_dir`
(`scripts/west_commands/build.py:534`, upstream, frozen) then calls
`os.path.relpath(self.source_dir)` with no explicit `start`, which defaults
to that same pinned workspace cwd. On Windows, `os.path.relpath` raises
rather than returning a path when its two arguments live on different drive
letters (`ValueError: path is on mount 'E:', start on mount 'C:'`, exactly
as reported), and a raw traceback reached the user with only the generic
`build.slice-failed` in the envelope.

There is no fix reachable from tan's own side of the seam -- `west` is an
upstream dependency, not `crates/`/`tan/planner` -- so `execute.py`'s new
`_cross_drive_source_refusal` refuses the slice, naming both mounts, before
`west` is ever spawned; `build_cmd.py`'s new `_cross_drive_issues` promotes
that refusal into a coded top-level `issues[]` entry,
`build.cross-drive-workspace`, registered in `contract/issue-codes.json`.

Verified by reading, not by a live Windows repro: this box has no second
drive to reproduce the ValueError against, so the fix and its tests are
built against the mechanism (west's own relpath-with-no-start-arg call, and
the tan-cli#307 pin that feeds it a different-drive cwd) established by
reading `scripts/west_commands/build.py` and `_pin_west_workspace`, with
`ntpath.splitdrive` making the drive-letter comparison itself exercisable
and unit-tested on this POSIX host. Module-size budget regenerated
(`--reason`) for the two modules this pushed over their recorded ceiling.

* fix(build): locate the real source dir for the tan-cli#697 cross-drive refusal

The tan-cli#697 refusal never fired on a real plan. `orchestrator.py`'s
zephyr command builder always appends `["--", *defines]` after the source
dir -- `defines` starts with `-DPython3_EXECUTABLE=...` and is never empty
-- so `args[-1]` landed on that CMake define, not the source dir, on every
real plan (verified against `tests/parity/oracle/multicore_rpmsg-aen.
build-plan.json`'s own emitted argv). A sysbuild slice additionally inserts
`--sysbuild` between the source dir and that `--` separator, so "last token
before `--`" is not a safe substitute either.

Replaced with `west_build_source_dir`, which locates the source dir by its
actual position in the argv `orchestrator.py` emits (the token immediately
after `-b`'s own value) -- correct for both the plain and sysbuild shapes.
Moved alongside `cross_drive_source_refusal` into `tan.core.plan_exec`
(pure argv/path logic, no IO), out of `execute.py`, which also relieves the
module-size budget it had pushed over.

The refusal now also sets a short `manifest_message` (naming both mounts,
without the absolute paths) so `system-manifest.yaml`'s persisted reason
matches the peer missing-tool refusal's own split.

Tests: the nine tan-cli#697 tests were re-parametrised over a real oracle
argv (`--`/defines tail included, plus a dedicated sysbuild-shape case) so
they fail against the old `args[-1]` guess -- verified by reintroducing it
and re-running. `test_cross_drive_refusal_names_both_mounts` no longer
asserts a literal `"C:/ws"` substring, which stringifies with backslashes
on a real Windows host's `Path`. The pre-fix reversion test's spawnable
`west` stand-in now reuses the `_plant_spawnable_west` recipe (branches on
`os.name`) instead of a POSIX-only `#!` shebang script. Added a `_build`-
level test proving `_cross_drive_issues`'s promotion into `issues[]` is
actually wired, not just correct in isolation -- deleting that call site
left the rest of the suite green. `contract/issue-codes.json`'s note and a
stale test comment updated for the function's new home.

Module-size budget regenerated (shrunk, no `--reason` needed): execute.py
1687 -> 1643, build_cmd.py 2107 -> 2106.

---------

Co-authored-by: Caner Alp <contact@alplab.ai>
…717) (#718)

* fix(e2e): refuse a freeze that cannot run instead of reporting it OK (#717)

The gate was `[ -x dist/tan/tan ]` plus a version read inside $(...): -x
passes on the PyInstaller bootloader whatever state the app inside it is in,
and a command substitution discards the exit status.

  ModuleNotFoundError: No module named 'typer'
    freeze OK:
  FREEZE-RC=0

Now reads --version's status and output, aborts 2 on either a non-zero status
or an empty version, and prints the captured stderr.

* test: do not run the linux freeze-harness cases on Windows (#717)

Git Bash puts a bash on PATH on windows-latest, so the presence check alone
let these cases run a script whose own header gives its invocation as
`MSYS_NO_PATHCONV=1 wsl -d Ubuntu-24.04 -- bash <this file>`. The windows
shards went red on PR #718; ubuntu and macos still run every case.

Non-vacuity re-checked on linux after the change, with
scripts/e2e-linux-freeze.sh restored to origin/dev's version:

  FAILED tests/test_e2e_linux_freeze_script.py::test_a_freeze_that_cannot_import_its_entry_point_is_refused
  FAILED tests/test_e2e_linux_freeze_script.py::test_a_freeze_that_prints_no_version_is_refused
  2 failed, 1 passed in 0.60s

---------

Co-authored-by: Caner Alp <contact@alplab.ai>
… claim (#698) (#707)

* docs(readme): give Windows its own row in 'What a build needs'

#687 added the section but only ever enumerated Linux and macOS
prerequisites, so a Windows reader -- a supported host with its own
installer and CI leg -- found nothing there for them. Restructure the
section into a per-OS list (Linux / macOS / Windows as siblings, not
a Linux list with a macOS parenthetical) and leave the Windows entry
as an explicit TODO(#698) placeholder rather than a guessed list; a
separate session is deriving the real Windows list from an actual
build. Point at `tan doctor` as the live authority for Windows the
same way the section already does for the other two hosts.

Verified: `python -m pytest python/tests -q` from a clean checkout of
this branch -- 4107 passed, 270 skipped, 1 xfailed, 0 failed,
including the four README/install gates in
tests/gates/test_readme_install_prerequisites.py and the
changelog.d assembler tests.

* docs(readme): fill in the Windows build-prereqs row and close the ungated gap (#698)

The prior commit left `- **Windows:** TODO(#698)` because the orchestrator
said to wait for a Windows host to measure the list. That was unnecessary:
three in-repo sources already agree, verified at HEAD before writing this --
alp-sdk `metadata/bootstrap.json` prerequisites.windows, tan.core.bootstrap's
fallback_facts().prerequisites_windows, and contract/fixtures/bootstrap/
manifest.json all give ["git", "cmake", "python", "ninja"].

Also fixes a scope regression: the `west sdk install` needs `file` sentence
used to sit in a POSIX-only paragraph and read as global once the section
gained a Windows sibling. `file` is scoped to Linux/macOS now, and the
Windows counterpart (7-Zip, from prerequisites.install.windows -- present in
the SDK's install-command map but NOT in its required Windows tool list, a
real gap the note now describes rather than invents past) is documented in
its place.

Closes the ungated defect class: `test_readme_install_prerequisites.py`'s
"names every tool a build needs somewhere" check only ever read
`prerequisites_posix`, so the Windows TODO placeholder passed it outright --
measured 4 passed against the pre-fix README. Checking "named anywhere in the
README" for macOS/Windows would have still passed, since `git`/`cmake`/
`ninja`/`python` all show up elsewhere in the doc for unrelated reasons, so
the new checks are scoped to each OS's own bullet under "What a build needs".
Proven both ways: reverting only README.md back to the TODO version reddens
the gate (1 failed, 3 passed, naming the missing Windows tools) with the new
test code in place; restoring the fix greens it again (4 passed).

Verified locally (python/, lane venv, ALP_SDK_ROOT/ALP_SDK_HAND_PORT_ROOT
bound to alp-sdk pin a3173305, ALP_SDK_STRICT_LOADERS_ROOT bound to pin
26b0040e):
  pytest tests/gates/test_readme_install_prerequisites.py -q -> 4 passed
  pytest tests/gates tests/commands/test_doctor_command.py \
    tests/commands/test_bootstrap_command.py -q -> 792 passed, 3 skipped
  pytest tests/scripts/test_assemble_changelog.py \
    tests/gates/test_version_check_refuses_an_empty_changelog_section.py -q \
    -> 17 passed

Removes the repo's only tracked-markdown TODO (README.md and
changelog.d/698.fixed.md both carried TODO(#698)).

* docs(readme): drop the `file` prerequisite claim, scope the POSIX gate arm

README.md
  - removed "on Linux and macOS, `west sdk install` also needs `file`".
    alp-sdk metadata/bootstrap.json manualInstallHints.posix.note[2] says the
    opposite: "patool's extension-based fallback works fine without it; this is
    WARN-only, not a bootstrap.sh prerequisite". Mirrored in this repo at
    python/tan/core/bootstrap.py manual_install_posix, and patoolib/mime.py:186
    logs that string and falls through to extension-based guessing.
  - describes `tan doctor`'s real `sevenZip` check (doctor_cmd.py:1172-1206,
    wired at :3520-3521) instead of stating doctor would not flag 7-Zip.

changelog.d/698.fixed.md
  - dropped the `file` sentence and the "not just macOS/Windows" claim.

python/tests/gates/test_readme_install_prerequisites.py
  - POSIX arm scoped to the Linux bullet, as macOS and Windows already were.

Measured:
  pytest tests/gates/test_readme_install_prerequisites.py     4 passed
  Linux bullet replaced with "- **Linux:** `xz`."             1 failed, 3 passed
  restored                                                    4 passed
  + tests/commands/test_doctor_command.py                     204 passed, 1 skipped

Not addressed: README.md:168 claims a missing `file` makes the SDK host-tools
step fail with "Host tools installation failed". That contradicts the
bootstrap.json note above, but it names a different step and this branch does
not touch it. Filed separately.

Refs #698.

---------

Co-authored-by: Caner Alp <contact@alplab.ai>
…rites (#560) (#714)

* fix(planner): give a zephyr slice's artifacts the build/ level west writes (#560)

alp-sdk#1360/#1401 (merged d00dbdc1): _slice_artifacts reported
<buildDir>/zephyr/zephyr.elf, a path west never creates -- the slice's
command runs `west build` with cwd=<buildDir> and no `-d`, so west
appends its own default `build` level and the tree lands at
<buildDir>/build/. Port the fix into tan/planner/buildplan.py: all six
zephyr artifact paths (elf/map/bin/sizeReport/symbols/compileCommands)
now carry that level; outputDir stays null. orchestrator.py's matching
change in the same alp-sdk commit is comment-only.

ALL FOUR SDK PINS move together, per this repo's own lockstep rule:
ci.yml's sdk_parity ref:, parity.yml's PINNED_SDK_TAG, and both
PINNED_SDK_COMMIT / HAND_PORT_PINNED_SDK_COMMIT in
test_planner_relocation_freshness.py, a317330595f7 -> d00dbdc12449.

Two HAND_PORT deltas in the same alp-sdk range are folded into this
re-sync rather than left as separate drift:

  - scripts/gen_zephyr_board.py (alp-sdk#1373/#1407): every AEN board's
    generated Kconfig.defconfig gains a `choice LOG_MODE / default
    LOG_MODE_MINIMAL` block (Zephyr's inherited LOG_MODE_DEFERRED
    starves the log thread under the non-yielding busy-loop main() the
    AEN bench procedure requires). Cherry-picked from the existing
    fix/690-hand-port-gen-zephyr-board branch (787aa2d), which had this
    ported but deliberately left the pin unmoved pending the
    alp_template.py audit below.
  - scripts/alp_template.py (alp-sdk#1394/#1399 + #1400): a collision
    guard on _derive_pin_doc_renames (two pins: entries re-deriving a
    SHARED doc: string to two different targets now raises instead of
    silently keeping whichever ran last), and
    _rewrite_stale_sdk_root_comment -- _scaffold_cmakelists now loops
    instead of subn so each guess block's own preceding comment
    paragraph is rewritten with it, instead of teaching a ../../..
    fallback the hardened block no longer has. tan's two deliberate
    divergences from the oracle (the idempotence guard, and the
    TemplateError raise where upstream returns best-effort unchanged)
    are preserved. #1400 re-vendors four CMakeLists.txt files under
    python/tan/templates/vendored/ (edge-ai/minimal, both SKUs) --
    captured bytes reused from the existing
    fix/1400-revendor-scaffold-templates branch (ffc814e) and
    independently re-verified 9/9 PASS against the real d00dbdc1
    checkout.

BLOCKER 1 (found in review): the pin move above took the whole suite
red. alp-sdk dad5b35a ("fix(faultdecode): lead with the escalated
fault, not the escalation", #1389) is also inside a3173305..d00dbdc1
and ADOPTED BOTH of tan-cli#616's declared divergences from
scripts/alp_cli/faultdecode.py verbatim -- the LSPERR/MLSPERR
root-cause branches and the negative-CFSR refusal. The two tests that
pinned those divergences against a live oracle went red, each on its
own documented "the day upstream adopts this, delete/rewrite it"
instruction:
  - tests/core/test_faultdecode.py::
    test_decode_diverges_from_the_sdk_original_only_where_tan_cli_616_declares
    rewritten as a plain byte-equality sweep,
    test_decode_matches_the_sdk_original_byte_for_byte (this is what
    the test asserted before #616 -- there is no longer a live SDK
    build to diverge from).
  - tests/commands/test_faultdecode_command.py::
    test_the_sdk_original_decodes_a_negative_cfsr_and_tan_deliberately_does_not
    deleted, per its own docstring's instruction.
tests/fixtures/faultdecode_golden.PROVENANCE.txt's divergence prose
now records the divergence as CLOSED, citing dad5b35a/#1389 by name.
Reverting these two test files and re-running against ALP_SDK_ROOT
bound to d00dbdc1 reproduces the pre-fix failure exactly (2 failed, 76
passed); restored and reconfirmed green (77 passed -- one test fewer,
the deleted one).

BLOCKER 2 (found in review): the freshness gate's audit surface could
not have caught dad5b35a landing with a real behavioural delta.
scripts/alp_cli/faultdecode.py -- what tan/core/faultdecode.py is
hand-ported from -- was never a key in PINNED_HASHES (scoped to
scripts/alp_orchestrate/) or HAND_PORT_HASHES (scoped to
tan/planner/**), so `git log <pinned>..<new> -- scripts/alp_orchestrate`
came back empty and reassuring while this file drifted, unaudited, the
whole time. Added to HAND_PORT_HASHES (its sha256 taken directly from
the d00dbdc1 checkout HAND_PORT_PINNED_SDK_COMMIT already names, so
this re-freezes nothing unaudited) with a comment explaining why it is
deliberately NOT also in HAND_PORT_SOURCES (its port lives under
tan/core/, not tan/planner/, so it has no tan/planner/-relative path
that table's coverage half could name -- same treatment as
scripts/sentinels.py already gets there). Proved the gate now catches
this class: corrupted the new hash, confirmed
test_hand_ported_planner_modules_match_their_pinned_sdk_source fails
and names the file, restored, reconfirmed all 5
test_planner_relocation_freshness.py tests pass.

MAJOR 3 (found in review): changelog.d/560.fixed.md and this message
used to claim "nothing in tan reads slice.artifacts after
core/build_plan.py parses it" -- false.
python/tan/commands/build/execute.py:995 reads
sl.artifacts.get("outputDir") (the os: baremetal staleness-disclosure
check). Harmless to this fix -- outputDir is untouched and that reader
never runs for a Zephyr slice -- but the changelog fragment now names
the one reader instead of denying one exists.

MAJOR 4 (found in review): changelog.d/560.fixed.md claimed the
renode zephyr_elf_from_manifest gap was "filed as a follow-up". No
such issue exists; the sentence is dropped. Confirmed but out of
scope, same as before -- the reviewer will file it.

MAJOR 5 (found in review): python/tan/core/system_manifest.py:756 and
python/tan/core/flash_plan.py:656 both asserted, in prose, that the
plan's `artifacts` block still names the un-nested
<slice-cwd>/zephyr/zephyr.elf for every Zephyr slice -- true before
this fix, false at d00dbdc1 for a plan tan's own in-process planner
produces. Both docstrings corrected: the nested/un-nested probe stays
(an older cached plan, the alp-sdk subprocess fallback pinned to a
stale SDK, or a hand-authored manifest can still carry the un-nested
spelling), but the reason it is still needed is now stated accurately.
flash_plan.py grew 4 lines past its recorded module-size budget from
this correction; re-pinned via
`scripts/regen_module_size_budget.py --reason` (3079 -> 3083),
system_manifest.py's growth stayed under its 800-line default cap.

MINOR (found in review): test_planner_relocation_freshness.py's
STRICT_LOADERS_* rationale said scripts/strict_loaders.py "does not
exist at all at HAND_PORT_PINNED_SDK_COMMIT" -- true when that split
was written (then 996937ac), false now that HAND_PORT_PINNED_SDK_COMMIT
is d00dbdc1, where the file exists (blob
d4b6ce64850acb7893ecb894a96988636cc32324, confirmed). Comment corrected
to say so and name the real reason strict_loaders.py stays split (the
unaudited fs_confine read-escape gap, unchanged).

MINOR (found in review): the freshness gate is SDK-side-only and does
not prove tan's own port applied the delta -- reverting all four
ported hunks (buildplan.py, orchestrator.py, template.py,
zephyr_board.py) back to their a3173305 shape while leaving the pins
and hash tables at d00dbdc1 still yields 5 passed on
test_planner_relocation_freshness.py (measured). The real port-side
proof is tests/parity/test_planner_emit_parity.py::
test_the_subprocess_entry_points_agree_too, which DOES catch that same
revert (measured: FAILS, naming the exact un-nested-vs-nested elf/map
diff) and passes again with the port restored. Neither this message
nor the changelog fragment cites the freshness gate as port-coverage
proof any more.

tests/parity/seam1_field_diff.py's vendored comparator gains a third
hand-reviewed allowance, _NESTED_ARTIFACT_TAILS, mirroring alp-sdk's
own: keyed on the six named artifact fields and the exact one-segment
build/ insertion before each field's fixed Zephyr tail. Without it
every zephyr-slice board in the frozen 97ad481b oracle fails seam-1
against a live emit at the new pin.

module_size_budget.generated.json regenerated (not worked around):
template.py and zephyr_board.py both grew past their recorded budgets
from real, reasoned line growth in the artifacts port; flash_plan.py's
budget moved separately for the Major 5 docstring fix above --
extraction would fight the whole point of a hand-port module
(mirroring alp-sdk's source structure) or a load-bearing docstring.

Separate, unfixed defect, confirmed but out of scope here: `tan
renode`'s core/renode_plan.py::zephyr_elf_from_manifest has its own
<build_dir>/zephyr/zephyr.elf fallback for a slice with no
output_artefact, independent of the plan's artifacts block -- see
MAJOR 3 above for the one place that block IS still read. Called
directly with build_dir "m55_he-zephyr" it returns
build/m55_he-zephyr/zephyr/zephyr.elf, the same one-level-short path
this fix removes from the SDK-side contract.

Verified this session:
  - tests/core/test_faultdecode.py + tests/commands/test_faultdecode_command.py,
    ALP_SDK_ROOT=<d00dbdc1>: 77 passed. Reverted just these two files back
    to their pre-fix content and re-ran: 2 failed, 76 passed, reproducing
    the exact review-reported failure; restored, reconfirmed 77 passed.
  - test_planner_relocation_freshness.py, ALP_SDK_HAND_PORT_ROOT=
    ALP_SDK_ROOT=ALP_SDK_STRICT_LOADERS_ROOT=<d00dbdc1>: 5 passed. Corrupted
    the new faultdecode.py hash and re-ran: 1 failed, naming the file and
    the exact sha256 mismatch; restored, reconfirmed 5 passed.
  - tests/parity/test_planner_emit_parity.py::
    test_the_subprocess_entry_points_agree_too, ALP_SDK_ROOT=<d00dbdc1>:
    1 passed. Reverted the four ported planner files (buildplan.py,
    orchestrator.py, template.py, zephyr_board.py) to their pre-fix content
    while leaving the pins/hashes at d00dbdc1, and re-ran BOTH this test and
    test_planner_relocation_freshness.py: this test FAILED (naming the exact
    un-nested-vs-nested elf/map diff), the freshness gate still showed 5
    passed -- confirming the minor finding that the freshness gate alone does
    not prove the port landed. Restored, reconfirmed both green.
  - tests/core/test_flash_plan.py + tests/core/test_system_manifest.py: 81
    passed (docstring-only edits, no behavioural change expected or seen).
  - test_module_size_budget.py: 6 passed.
  - python -m pytest tests -q, ALP_SDK_ROOT=<d00dbdc1> (the full suite):
    5012 passed, 59 skipped, 1 xfailed, 0 failed, in 938s. Pre-fix baseline
    measured the same way this session, same pin, same command: 2 failed
    (the two named in Blocker 1), 5011 passed, 59 skipped, 1 xfailed -- the
    ONLY two failures anywhere in the suite at this pin, both closed.

* fix(tests): gate the faultdecode oracle sweep on its pinned SDK vintage

test_decode_matches_the_sdk_original_byte_for_byte (python/tests/core/
test_faultdecode.py) byte-diffed against whatever alp-sdk sibling checkout
_resolve_oracle_path found, with no vintage check: a checkout older than
alp-sdk dad5b35a (#1389) still carries the pre-fix _root_cause ladder with
no LSPERR/MLSPERR branch, so the sweep reported 18 false mismatches with no
indication the SDK checkout, not the port, was stale. The test now calls
_require_pinned_oracle_vintage, which sha256-checks the resolved oracle
against HAND_PORT_HASHES["scripts/alp_cli/faultdecode.py"] (the same pin
test_planner_relocation_freshness.py's own hand-port gate tracks) and skips,
naming the required commit and both hashes, on a mismatch.

test_planner_relocation_freshness.py's HAND_PORT_HASHES gains eight more
scripts/alp_cli/*.py entries (diagnostic_format, validate, new_som, doctor,
explain, monitor, model, validator) at HAND_PORT_PINNED_SDK_COMMIT
(d00dbdc1) -- each is named as a hand-port source in a comment under
python/tan/ and had no freshness-gate coverage at all.

test_zephyr_board_aen_log_mode_default.py's slot0_base fixture value moves
from the fabricated 0x80080000 to 0x80010000, the real E1M-AEN801 he_slot0
base (metadata/e1m_modules/E1M-AEN801.yaml).

---------

Co-authored-by: Caner Alp <contact@alplab.ai>
… (#710)

* fix(flash): correct select_flash_method's stale Flow D docstring (#700)

The "Consequence, stated plainly" paragraph claimed
_slice_flash_recipe (tan/planner/orchestrator.py) returns
("zephyr_west_flash", {}) for every Zephyr slice, so no entry ever
carries FLOW_D_KEYS and every AEN slice still takes Flow A. That is
false against the code it cites: _slice_flash_recipe already
populates jlink_flash_device (and, when the SoC variant publishes
them, the expect_dpidr/jlink_device preflight pair and
slot0_load_address) from SoM-preset metadata resolved via
tan/planner/loader.py. A real E1M-AEN801 project's Zephyr slice
already carries FLOW_D_KEYS on emit and dispatches to Flow D today.

Rewrite the paragraph to describe the current emit; the ADR-0017/I-26
rationale above it is unchanged and still correct.

* fix(flash): correct 8a988c3's own inverted Flow D provenance claim

8a988c3 ("correct select_flash_method's stale Flow D docstring", #700)
was itself wrong, in both the code and its own commit message. Its
"Consequence, stated plainly" paragraph, and the message that
introduced it, claimed slot0_load_address comes from the SoC variant's
`debug:` block alongside jlink_flash_device/expect_dpidr/jlink_device.
That inverts tan/planner/loader.py::_resolve_slot0_load_address's own
docstring (loader.py:299-301): slot0_load_address is deliberately
sourced from the SoM preset's `memory_map:`, NOT from the debug: block
jlink_flash_device lives in -- it is SDK/module build POLICY, not a
silicon fact (alp-sdk#1069), because two SoMs on the same silicon part
can pick different slot0 windows. loader.py's own call site
(_resolve_slot0_load_address(som_preset, core_id)) never touches
variant_debug. This commit rewrites the paragraph to attribute each
field to its real source.

Also fixes a second, older false claim the first commit left standing
sixty lines above its own fix: the FLOW_D_KEYS comment still said
slot0_load_address "does not exist in any alp-sdk branch today", which
is false against the same emitter (tan/planner/orchestrator.py) since
alp-sdk#1374/tan-cli#353, and was now self-contradicted by the
paragraph 8a988c3 itself added.

Net +14 lines pushed tan/core/flash_plan.py from 3079 to 3093 against
its recorded budget of 3079 (dev's own gate is unaffected -- this
module was already sitting exactly on its ceiling before 8a988c3's own
+4 lines pushed it over); regenerated
module_size_budget.generated.json and logged the reason in
MODULE_SIZE_BUDGET_LOG.md.

Adds changelog.d/700.fixed.md, missing from 8a988c3.

* fix(flash): stop attributing jlink_flash_device to "SoM-preset metadata"

select_flash_method's docstring said jlink_flash_device/expect_dpidr/
jlink_device come "from SoM-preset metadata resolved via
tan/planner/loader.py". They come from the SoC variant's debug: block
(soc_spec, e.g. metadata/socs/alif/ensemble/e8.json), selected via but
not carried by the SoM preset's silicon_variant
(loader.py::_resolve_variant_debug). Reworded that sentence and a
nearby one with the same framing ("did the SoM preset hand me a
part-number J-Link profile").

Also dropped two literal Alif part numbers (AE822FA0E5597BS0/
AE822FA0E5597LS0) and an E1M-AEN801 SKU name the docstring had picked
up, which failed
test_flash_command.py::test_flow_d_holds_no_part_number_of_its_own.

Net -4 lines on tan/core/flash_plan.py (3093 -> 3089); no budget
regen needed.

python -m pytest tests/core/test_flash_plan.py
tests/gates/test_module_size_budget.py
tests/commands/test_flash_command.py::test_flow_d_holds_no_part_number_of_its_own
tests/scripts/test_assemble_changelog.py -q
-> 25 passed in 3.70s

python -m pytest tests/commands/test_flash_command.py
tests/commands/test_flash_pipeline.py tests/core/test_setools.py
tests/core/test_helper_flash_policy.py
tests/core/test_swd_probe_shipped_preset_shape.py
tests/core/test_sdk_revision_gate.py -q
-> 461 passed, 8 skipped in 108.30s

---------

Co-authored-by: Caner Alp <contact@alplab.ai>
…#720) (#723)

`tan init` spelled the SoM SKU `--som`; `tan pinmux` and `tan new-som` spelled
the identical value `--sku`. A customer who scaffolds with

    tan init --som E1M-AEN801 ...

and then runs `tan pinmux --som E1M-AEN801` is rejected for the flag `init`
just taught them, and vice versa. The value is the same string in all three
places: `tan presets` prints it under `skus=`, and `board.yaml` nests it as
`som.sku`.

Each command keeps its existing flag FIRST and gains the other as an alias, so
help text, error messages and every existing script are unchanged -- this only
widens what is accepted:

  tan init      --som  (canonical)  + --sku  (alias)
  tan pinmux    --sku  (canonical)  + --som  (alias)
  tan new-som   --sku  (canonical)  + --som  (alias)

Verified on Windows against the full suite: 37 failed / 4082 passed / 307
skipped, and every one of those 37 reproduces on clean origin/dev at the same
path -- this change adds no regressions. The 37 are host-environment failures
(15 shell-completion, 10 install.ps1 layout, 8 process-spawn, 2 faultdecode
SDK-parity resolving against a sibling alp-sdk checkout that is not at the
pinned commit, plus 2 more present in the dev baseline).

Module size budget regenerated: tan/commands/init_cmd.py 1247 -> 1258,
tan/commands/new_som_cmd.py 1353 -> 1361.

Co-authored-by: Caner Alp <contact@alplab.ai>
…724)

comports() reports the bare COM<n> from the registry PortName, so a
membership test against it refused a port pyserial would have opened -- while
listing that same port in the not-found message.

Measured on a real Windows host:
  serial.Serial(r"\\.\\COM38")            -> opens, is_open = True
  comports() device string                -> 'COM38'
  tan monitor --port "\\.\\COM38"          -> monitor.no-port : not found

_port_aliases() resolves the device-namespace prefix back to the bare name
before the membership test. Normalised, not widened: a UNC port whose bare
form is absent is still refused.

  $ pytest tests/commands/test_monitor_command.py -q
  30 passed in 0.46s
  $ pytest tests/gates -q
  465 passed in 14.19s

With the alias resolution removed, tests unchanged:
  FAILED ...::test_the_unc_spelling_of_a_present_port_is_accepted
  FAILED ...::test_a_posix_device_path_is_not_rewritten
  2 failed, 28 passed in 0.36s
the still-refused case stays green under that neuter, so the fix is specific.

Co-authored-by: Caner Alp <contact@alplab.ai>
…ro failures (#725) (#726)

* fix(tests): absorb a probe timeout instead of aborting collection (#725)

`_bash_available()` bounded its probe with `timeout=10` and caught only
`OSError`. `subprocess.TimeoutExpired` derives from `SubprocessError`, NOT from
`OSError`, so the one failure mode the budget existed to bound was the one the
handler did not absorb.

The probe runs at MODULE scope -- the `@pytest.mark.skipif(...)` decorators
call it at import time -- so the escaping exception did not fail a test, it
aborted COLLECTION of the whole file:

    tests\commands\test_completion_command.py:479: in <module>
        not _bash_available(), reason="no real bash on this host ..."
    E   subprocess.TimeoutExpired: Command '['bash', '-c', 'echo tan-bash-ok']' timed out after 10 seconds
    !!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!

pytest then exits 2 having run nothing, printing zero FAILED lines -- so a
branch-vs-baseline failure diff reads every known failure as newly PASSING.
Measured while doing exactly that: a comparison that should have read
"37 failures, 0 regressions" instead read "0 failures, 37 newly passing", from
a run that executed no tests.

Nothing is wrong with bash -- it resolves to Git Bash and works. It is cold
process start-up under load: 10.6s cold against the 10s budget, 0.10s warm.
Any budget can be exceeded, so the handler is the fix, not the number.

`_noexec_probe()` in tests/installers/test_installer_release_layout.py had the
identical shape (timeout=15, `except OSError`, called at module scope by
`noexec_capable`) and is fixed with it. Two neighbours were checked and are
already correct: `_bash_setlocale_warning_probe` catches
`subprocess.SubprocessError`, and conftest.py's `_git()` catches
`(OSError, subprocess.SubprocessError, UnicodeDecodeError)`.

Both are narrowed to `TimeoutExpired` rather than `SubprocessError`
deliberately. For a host-capability probe a timeout IS an answer -- it means
the same as "no bash on PATH": this host cannot usefully run these tests, so
skip. That is the opposite of the rule for production code, where
test_diff_command.py's
`test_sdk_validator_timeout_refuses_instead_of_reporting_clean` records a
blanket `except ... SubprocessError` swallowing a timeout as a MAJOR defect.
The narrower catch keeps that distinction visible at the seam.

tests/gates/test_capability_probes_absorb_timeouts.py covers both probes three
ways: a timed-out probe returns False instead of raising, a missing tool still
returns False, and a working host still returns True (a fix that made every
host look incapable would skip these suites everywhere). It patches
`subprocess.run` rather than running anything slow, so it is hermetic.

Verified it can fail: against the pre-fix probes the new file cannot even be
COLLECTED -- importing test_completion_command runs the module-scope probe,
which raises TimeoutExpired -- which is the defect reproducing itself. With the
fix, 6 passed.

`_bash_available` is `@lru_cache(maxsize=1)` and already called at import time,
so the first cut of these tests read that one cached verdict and exercised none
of the code they named. They now clear the cache before each call, and an
autouse fixture clears it afterwards so a verdict computed against a faked
`subprocess` cannot leak into the rest of the session.

* fix(tests): retry the bash probe once so a cold spawn does not skip 15 tests (#725)

Absorbing the timeout stopped the collection abort but was not enough on its
own, and the measurement says so: with only the handler in place the full suite
went from

    37 failed, 4082 passed, 307 skipped

to

    20 failed, 4088 passed, 325 skipped

Zero regressions -- but the 17 "fixed" failures were not fixed. Skips rose by
18. The 15 bash-completion tests moved from FAILED to SKIPPED because the probe
timed out and the new handler answered "this host cannot", so a loud collection
abort had simply been traded for quietly untested code -- on exactly the loaded
hosts that provoke the bug in the first place.

The only observed cause is cold process start-up (10.6s cold, 0.10s warm), so
`_bash_available` now makes a second attempt: the first spawn pays the cold
cost, the second is warm and answers. Measured after this change, the 15 tests
run again (15 failed, 32 passed, 3 skipped -- the known Windows-environment
failures) instead of skipping.

A healthy host never reaches the second attempt, so this costs nothing where
nothing is wrong. `_noexec_probe` deliberately does NOT retry: an `unshare`
namespace probe that times out is reporting a genuinely restricted host, not a
warm-up cost, and retrying it would just double the wait before the same answer.

`test_bash_probe_retries_once_so_a_cold_spawn_does_not_silently_skip` pins it
by counting spawns: a first-call timeout followed by a success must return True
after exactly 2 calls, so a future simplification back to a single attempt fails
the test rather than silently reintroducing the skip.
…709)

* fix(flash): stop hard-failing tan flash on an os: "off" core (#699)

board.yaml cores declared os: "off" are correctly excluded from tan
build's buildable-slice set (there is no app, no board target), but
their build/system-manifest.yaml entry never advances off the
plan-time status: pending default, since nothing ever overlays a real
outcome onto it. plan_flash_targets read that as an incomplete/failed
build and hard-refused the whole run with flash.slice-not-built ("...
Rebuild it first"), advice the core can never satisfy. tan image,
reading the same manifest entry, already degraded gracefully
(image.slice-skipped, ok: true) -- the two commands disagreed about
one piece of state.

Fixed in flash's reader, not the manifest emitter: plan_flash_targets
now checks the slice's os field (already present on the parsed
manifest, independent of whatever never-advanced status the emitter
left behind) and routes an os: "off" slice into the same non-fatal
refused_skipped/flash.slice-skipped bucket status: "skipped" already
uses, with its own accurate message. The emitter (planner/models.py,
Slice.to_manifest_entry) was deliberately left alone: it is a
byte-identical relocation of alp-sdk's own scripts/alp_orchestrate,
governed by tests/parity/test_planner_emit_parity.py, and alp-sdk's
upstream source carries the identical status = "pending" default for
os: "off" cores -- diverging tan's copy would silently break that
relocation-parity contract the moment any board.yaml declares an off
core, without fixing anything upstream.

Reproduced against a real tan init --cores m55_hp:zephyr,m55_he:off
scaffold (the exact shape tan init --help documents) plus a simulated
post-build manifest, both via the pure planner/flash_plan functions
and the real tan flash --dry-run CLI; confirmed the fix by reverting
flash_plan.py alone and watching flash.slice-not-built reappear on
m55_he, then restoring it.

Regenerated the module-size budget ratchet for the two files this
grew (flash_plan.py, flash_cmd.py's matching divergence-note
docstring).

* fix(flash): correct false operator-facing off-core claims (#699 follow-up)

The behavioural fix in the previous commit is right, but three sentences it
left behind are contradicted by the manifest tan itself emits:

- flash_plan.py's refused_skipped warning for an os:"off" core, and the
  matching comment, both asserted "there is no app and nothing was ever
  built for it, by design" / "no app, no board target". False: board.yaml
  may declare app:/board:/toolchain: for an off core independent of os:,
  and the manifest carries them. Reworded to say only what's true: tan
  build never builds a core declared off, by design (tan/planner/
  orchestrator.py:36). Same correction applied to changelog.d/699.fixed.md.
- flash_plan.py:322-339 and flash_cmd.py's matching docstring referred, in
  the present tense, to "the shipped Rust plan_flash_targets" and to
  tests/parity/test_flash_oracle_parity.py's deliberate lack of a
  status:skipped case. Both crates/ and that test file were deleted in
  2883cdf, before this branch's base commit. Moved to past tense and named
  the retiring commit.
- flash_cmd.py's refused_skipped Issue-append comment explained the bucket
  only via executionPolicy, which no longer covers the os:"off" shape this
  branch added. Extended it to name both.
- Documented, rather than silently relying on, why checking found.os
  before found.status is safe today (an off core never receives a
  SliceRunResult, so its status can only ever be the pending default) and
  what would need to change if that ever stopped being true.

Regenerated the module-size budget ratchet for the two files this grew.

Verified: tests/commands/test_flash_command.py, tests/core/test_flash_plan.py
and tests/gates all green (812 passed, 9 skipped); tests/gates/
test_planner_relocation_freshness.py green with ALP_SDK_ROOT bound at the
pinned commit.

* docs(planner): correct the "byte-identical relocation" claim about models.py

2a4093b's commit message called tan/planner/models.py "a byte-identical
relocation of alp-sdk's own scripts/alp_orchestrate", defending why the
os:"off" flash fix left the emitter alone. Measured false at the branch's
declared pin (alp-sdk a317330595f744d35f4d785869517110f3678f70):
tan/planner/models.py is 460 lines against upstream's 452, diverging in the
relative-vs-absolute import in Slice.to_manifest_entry and three exception
docstrings (SdkRevisionUnsupported and its two siblings) reworded because the
alp-sdk caller they named has no tan-cli counterpart.

The decision the commit message defended is still correct: system-manifest
emit IS in test_planner_emit_parity.py's mode list (:89) and is diffed
byte-for-byte against alp-sdk's own front door, and
test_planner_relocation_freshness.py -- which hashes only the alp-sdk side of
the comparison, by its own docstring, "deliberately... not a byte-identical
port comparison" -- passes clean with ALP_SDK_ROOT bound at the pin. What was
wrong was the claim's wording, not the reasoning. Added a module-header note
to models.py stating the actual guarantee (byte-identical OUTPUT, not
byte-identical source) so a future reader doesn't inherit the same
overstatement from a commit message a source-diff would have to go find.

Verified: tests/gates/test_planner_relocation_freshness.py,
tests/core/test_flash_plan.py and tests/commands/test_flash_command.py green
with ALP_SDK_ROOT/ALP_SDK_HAND_PORT_ROOT bound at a3173305 and
ALP_SDK_STRICT_LOADERS_ROOT at 26b0040e (both pins this branch declares);
tests/gates/test_module_size_budget.py green (models.py stays under the
800-line default cap, no ratchet entry needed).

* fix(flash): remove remaining false off-core claims 8f7490f missed

- test_flash_command.py: test_an_off_core_does_not_fail_flash's docstring
  still said "there is no app, no board target" for an os:"off" core.
  False -- a real `tan build --native` manifest emit for E1M-AEN801
  m55_he carries `app: alp-stock-shim` and
  `board: alp_e1m_aen801_m55_he/ae822fa0e5597ls0/rtss_he`.
- flash_plan.py (x2), flash_cmd.py: "(the oracle predated os:\"off\"
  cores)" / "(and predated os:\"off\" cores entirely)". False -- the
  os:"off" concept already existed in crates/tan-core/src/
  system_manifest.rs (commit 2a37bee, 2026-07-19 17:41) before
  flash/mod.rs was even split out of flash.rs (commit fad81ab, same day
  21:26) and before its last edit (42525b0, 2026-07-21); flash/mod.rs
  just never added an os carve-out.
- flash_plan.py (x2), flash_cmd.py: "already made and reported" /
  "tan build made and reported" describing an os:"off" refusal. tan
  build's own output (JSON slices list, --plan text recap) never
  mentions an off core at all -- confirmed via a real `tan build
  --native --format json` run, whose `data.slices` list contains only
  the non-off cores. Reworded to "already made" (board.yaml declares
  it), dropping the unproven "reported" half.
- models.py: header comment listed exactly two divergences from
  upstream as if exhaustive; a real diff against the pinned alp-sdk
  checkout shows at least two more (an `#1069`-referencing comment
  block dropped entirely, an issue-number prefix added). Reworded to
  "including (not limited to)".

Verified: tests/commands/test_flash_command.py, tests/core/test_flash_plan.py,
tests/gates/test_module_size_budget.py, tests/gates/test_planner_relocation_freshness.py
green (387 passed) with ALP_SDK_ROOT/ALP_SDK_HAND_PORT_ROOT bound at
a317330595f744d35f4d785869517110f3678f70 and ALP_SDK_STRICT_LOADERS_ROOT at
26b0040e9a762c16aff5c7c53b2e19cc7583b2a4.

---------

Co-authored-by: Caner Alp <contact@alplab.ai>
…#729)

The planner picks chip symbols from `chips/<slug>/` on disk
(`_chip_has_driver`). That is the right intent -- the directory is what the
declaration compiles -- but it is an INFERENCE, while Zephyr resolves the
Kconfig DECLARATION. When a tan and an alp-sdk disagree about which chips have
drivers, the emitted line surfaces three layers downstream as:

    alp.conf:28: warning: attempt to assign the value 'y' to the undefined symbol ALP_SDK_CHIP_DP83825
    error: Aborting due to Kconfig warnings
    0 of 3 slice(s) built

taking out `tan build` for the whole SoM and blaming a generated file the
customer never wrote.

Measured shape of the skew: released `tan 0.5.1`, whose vendored planner
predates alp-sdk#1241/#1322, against alp-sdk `dev`, whose `ethernet_phy:
dp83825` made an undriven chip reachable from `on_module:` for the first time.
`metadata/chips/dp83825.yaml` records `driver_status: none` and the SDK
declares no `ALP_SDK_CHIP_DP83825` anywhere, so the symbol genuinely does not
exist -- the old planner emitted it regardless.

Each symbol is now verified against the declarations parsed from the bound
SDK's `zephyr/**/*.kconfig` before any line is written. A mismatch raises
`OrchestratorError` naming both the symbol and the chip, and the SDK it was
read from, so the reader learns which pair disagrees instead of hunting a
`CONFIG_` line they never authored.

This does NOT repair tan 0.5.1 -- a released binary cannot be retro-fixed, and
that half is release sequencing (ship tan and alp-sdk together, see #728). It
stops the NEXT skew of this class from being discovered by Zephyr.

Two deliberate non-behaviours:

  - Silent when it cannot verify. An empty declaration set means the kconfig
    tree could not be read or is structured differently, not that nothing is
    declared. Refusing every build on a layout assumption would be a worse
    failure than the one being prevented.
  - No change on a healthy pair. Measured on alp-sdk dev: 80 chips/<slug>/
    directories, 80 declared ALP_SDK_CHIP_* symbols, zero divergence either
    way. A real `tan build` after this change still reports `2 of 3 slice(s)
    built` with DP83825 emitted 0 times.

tests/planner/test_chip_symbol_declared_guard.py covers the refusal by name,
the pass-through when the sets agree, the stay-silent case, and a live
assertion against the bound SDK that every symbol this tan would emit is
declared -- the check that would have caught #728 at plan time.
…ly note (#706) (#731)

#706 read the README and alp-sdk metadata/bootstrap.json as contradicting each
other on whether west sdk install needs `file`. They do not: bootstrap.json
manualInstallHints.posix.note[2] is written for the --no-hosttools invocation
in its own note[0], and the README's command does not pass that flag. The
host-tools step is what fails without `file`.

The README sentence is correct and stays -- doctor_cmd.zephyr_sdk_check records
the experiment (pristine ubuntu:24.04, `file` the only variable, with it exit 0
"All done", without it exit 1 "Host tools installation failed"). The README now
names the --no-hosttools difference so the two documents stop reading as
opposites, and two gate cases tie the claim to the command.

Neuter, deleting the sentence while keeping the command:

  2 failed, 4 passed in 0.24s

Neuter, adding --no-hosttools while keeping the sentence:

  1 failed, 5 passed in 0.46s

Restored:

  465 passed, 9 skipped in 14.25s

Co-authored-by: Caner Alp <contact@alplab.ai>
…e a flag (#719) (#722)

* fix(flash): refuse a run that wrote nothing, and give the confirm gate a flag (#719)

tan flash returned ok:true / exit 0 with every slice status:planned, so
`tan flash && echo flashed` printed flashed over an untouched device. The
flash.confirm-required warning was in issues[], which a caller checking $? or
ok never reads.

- a run with >=1 planned target and no written target appends
  flash.nothing-flashed and exits non-zero; --dry-run is excluded
- new --confirm flag, OR-ed with ALP_FLASH_FORCE=1 and flash_args.confirm
- confirm_gate_note() single-sources the remedy; two of the three sites that
  composed it never named ALP_FLASH_FORCE=1

  $ pytest tests/commands/test_flash_command.py -q
  371 passed in 29.35s

With the verdict neutered (`if False and ...`), tests unchanged:
  10 failed, 361 passed in 29.66s
the dry-run and --confirm cases stay green under the neuter, so the refusal is
specific rather than a gate that rejects every run.

* test(gates): raise the ratchet for the #719 flash-command growth

  tan/commands/flash_cmd.py: 3922 -> 3966
  tan/core/flash_plan.py: 3079 -> 3102

Full suite before the raise: 1 failed, 5062 passed, 57 skipped, 1 xfailed in
747.81s. tests/gates after: 465 passed in 14.83s.

---------

Co-authored-by: Caner Alp <contact@alplab.ai>
…733)

The gate tested set membership against comports(), refusing two classes of
working port: /dev/serial/by-id/... symlinks (pyserial reports raw nodes, never
the by-id path) and pyserial's URL handlers (socket://, rfc2217://), which have
no local device path at all. The module docstring stated the rule as refusing a
port that does not exist; the code was stricter than that.

Measured on this host with a real Artery AT32 adapter: serial.Serial() opens
the by-id symlink, comports() omits it, and tan refused it while listing its
own raw node /dev/ttyACM0 in the refusal.

_port_is_usable now has three accepting arms -- enumerated (including #701's
alias), a character device, or a pyserial URL scheme. Scheme set read from
serial.urlhandler via pkgutil, not hardcoded; measured on pyserial 3.5 as
alt/cp2110/hwgrep/loop/rfc2217/socket/spy. No pyserial yields an empty set,
leaving the gate as strict as before.

Neutered to return True:

  3 failed, 31 passed, 1 skipped in 0.41s

Restored:

  497 passed, 10 skipped in 14.07s

Co-authored-by: Caner Alp <contact@alplab.ai>
… (#730)

doctor reported [pass] sdk for a path with no scripts/alp_project.py, while
tan build refused the same path. --sdk-root is the terminal tier, so
resolve_sdk_root_ladder hands it back unvalidated; build_cmd, run_cmd,
validate_cmd, clean_cmd and flash_cmd each guard at their own call site with
is_sdk_root, and doctor did not.

Detail comes from shapes.rejected_sdk_root_message, the spelling five other
commands already use, not a sixth one. sdkProvenance is skipped for the same
path -- it rendered a missing directory as
pass | alp-sdk at <path> (no git checkout / metadata/sdk_version.yaml).

Neutering the guard to dangling_flag_root = None fails both end-to-end tests
and leaves the positive control green:

  2 failed, 4 passed, 200 deselected in 0.76s

With the guard:

  668 passed, 10 skipped in 23.84s

Co-authored-by: Caner Alp <contact@alplab.ai>
…) (#735)

A SoC variant declaring `"jlink_flash_device": null` -- a schema-declared
"this variant has no known J-Link flash profile" -- was dropped by the
emitter's truthiness test, so the key arrived at flash_plan ABSENT,
flow_d_available() returned False, and tan flash silently downgraded Flow D to
Flow A over the SE-UART with no diagnostic. flash_plan decides on key presence
(_fa_has_key) precisely to prevent that; the emitter destroyed the distinction
first.

Root cause is the loader: _resolve_jlink_flash_device returns
debug.get(...), which collapses declared-null and absent to the same None.
Adds _jlink_flash_device_declared() and Slice.jlink_flash_device_declared to
carry presence alongside the value:

  declared null  value=None                         declared=True
  absent         value=None                         declared=False
  real string    value='AE722F80F55D5LS_M55_HE'     declared=True

Four sites move to presence: the emitter, slot0_load_address, and
_enforce_flow_d_preflight_pair's scope guard -- whose docstring already said
presence promotes while the code tested truthiness.

Neuter back to truthiness:

  2 failed, 7 passed in 0.31s

Neuter to emit unconditionally:

  3 failed, 6 passed in 0.29s

Restored:

  9 passed in 0.28s
  494 passed, 16 skipped in 14.07s

Co-authored-by: Caner Alp <contact@alplab.ai>
scripts/alp_orchestrate/ had moved 19 commits past the audited pin and the
dispatched parity suite was red. The behavioural delta is one file --
secure.py, +82/-1 -- which is alp-sdk#1413.

emit_sysbuild_conf defaulted boot.swap_algorithm unconditionally to scratch.
On an AEN SKU declaring per-role <role>_slot0 windows (alp-sdk#1069) the DT
has no slot1 and no scratch partition, so SB_CONFIG_MCUBOOT_MODE_SWAP_SCRATCH=y
described a boot that cannot happen. Single-slot targets now default to
SINGLE_APP; an explicit scratch/move/overwrite there is a loud
OrchestratorError. _boot_target_is_single_slot reuses
zephyr_board._aen_role_slot0_map rather than scanning memory_map region names,
which disagree in general.

Re-pinned all four pins together (the gate warns a split measures tan against
two alp-sdks at once): PINNED_SDK_COMMIT, HAND_PORT_PINNED_SDK_COMMIT,
parity.yml PINNED_SDK_TAG, ci.yml ref:.
STRICT_LOADERS_PINNED_SDK_COMMIT stays -- strict_loaders.py is not in the diff.

The re-pin also brings in alp-sdk#1439, which removed flash_method: swd_probe
from the four gd32_bridge entries. Four assertions measuring that block now
skip visibly, and ONLY when all four presets agree -- a partial removal still
fails as one-PCB drift (proven: 4 failed with swd_probe back on E1M-V2N101
alone).

Neuter to the unported code:

  5 failed, 6 passed in 0.30s

Restored:

  11 passed in 0.29s
  1396 passed, 8 skipped in 22.28s

Co-authored-by: Caner Alp <contact@alplab.ai>
The check rode the zephyrSdk Fail (`and not zephyr_sdk_ok`), so a Windows host
that already has a Zephyr SDK but no 7-Zip got no signal, and its next
`west sdk install` died with `Zephyr SDK setup requires '7z'`.

Measured on Windows with 7z stripped from PATH and the SDK present:

  overall ok: True
  any check mentioning 7z: []

The failed premise is in seven_zip_check's own docstring -- "a host that
already has the SDK never reaches this". Adding a second architecture's
toolchain is ordinary and zephyrSdk passes throughout. Docstring corrected
rather than left stale beside changed code.

Severity unchanged: still warn, not fail. Missing 7-Zip blocks the remedy, not
the build. Only the gate was wrong.

An existing test asserted the opposite on the same false premise; inverted,
and it now also asserts zephyrSdk really passed so it cannot succeed for the
old reason. A second case pins the warn severity.

Neuter back to `and not zephyr_sdk_ok`:

  2 failed, 4 passed, 201 deselected in 0.60s

Restored:

  6 passed, 201 deselected in 0.49s
  671 passed, 10 skipped in 23.63s

Co-authored-by: Caner Alp <contact@alplab.ai>
…only setools claim (#739) (#740)

* fix(doctor): drop the unverified J-Link firmware floor and the Linux-only setools claim (#739)

Two flash-readiness messages stated hardware requirements the AEN EVK silicon
run contradicts. Both are customer-facing and both would have shipped in
v0.6.0 as written.

1. `jlink_check` asserted Flow D needs "a probe on matched J-Link V13
   firmware", and its docstring stated it as fact. Measured on the AEN EVK, a
   probe reporting `Firmware: J-Link V11 compiled Apr  1 2025 10:02:30` /
   `Hardware version: V11.00` connected WITH the part-number profile
   (AE822FA0E5597LS0_M55_HE; Flow D refuses the generic Cortex-M55, so the
   profile is not in question) and programmed MRAM repeatedly, including a
   96 KiB loadbin + verifybin at 0x80560000 that byte-verified.

   The claim is DROPPED, not restated as "V11+": where the true floor sits is
   untested, and swapping one unmeasured minimum for another is the same
   defect. What remains are the two established requirements -- the
   part-number device profile and the V9.46 DLL floor.

2. The `setools` check's scoping sentence was right (SE-UART flashing IS
   Linux-only -- alif_flash.py hard-codes app-release-exec-linux) but left a
   Windows operator believing the `-linux` bundle is what `--setools-dir` must
   point into. Measured on the same host, Flow D signed with the WINDOWS
   SETOOLS build (app-gen-toc.exe under a Windows app-release-exec,
   SETOOLS_version_SE_FW_1.110.00_DEV), producing an ATOC the part booted
   from. The two paths are now separated explicitly.

The new test asserts the ABSENCE of any firmware claim across all four
jlink_check arms rather than the presence of a number: the retired claim rode
in the shared `requirements` string plus two `fix` hints, so a single-arm
assertion would not have caught it. The existing test that pinned
`"V13" in blob` now pins the DLL floor.

Both corrections come from the alp-sdk#1380 bench runs, so the doctor text and
the silicon now agree.

* chore(gates): re-ratchet doctor_cmd.py after the #739 evidence docstrings

---------

Co-authored-by: Caner Alp <contact@alplab.ai>
…nned one (#741) (#742)

The check asserted equality between `doctor.sdk.root` (where the checkout IS)
and `bootstrap --dry-run`'s `data.sdkRoot` (where it WOULD BE). Those answer
different questions, and relocating the checkout into the workspace is
deliberate and announced (tan-cli#185) -- so the assertion failed on every host
where bootstrap can succeed, and passed only where it refused for missing
prerequisites and therefore planned no move.

Measured, same script and same tan build: a pristine ubuntu:24.04 container
PASSed with bs2 exiting 1, while a provisioned Linux host FAILed with bs2
exiting 0 and doctor=.../proj/alp-sdk against
bootstrap=.../proj/alp-workspace/alp-sdk. The container's PASS was not the two
agreeing -- it was one side not running, which the harness's own #323 records
in the very next block as "no 'would' verb (no relocation planned)".

The assertion is now the invariant that holds on BOTH shapes: no relocation
planned -> the roots must be identical; relocation planned -> the destination
must sit inside the `workspaceDir` reported in the same envelope.
Post-bootstrap agreement is a separate assertion already covered by "#299
doctor AFTER a successful bootstrap", which passes on both hosts.

tan-cli#358 had already tightened this from non-emptiness to equality. That
was right to reject non-emptiness; the pair it chose to compare is the half
still wrong.

Verified falsifiable by exercising every branch: 2 pass (both real host
shapes), 3 fail (planned root outside the reported workspace, planned move
with no workspaceDir, either side resolving nothing).

No shipped tan behaviour changes -- this is the harness.
…ive Zephyr cores (#744) (#745)

* fix(planner): scope the slot0 collision guard to live Zephyr cores (#744)

Ports alp-sdk#1445. _enforce_slot0_disjoint_across_roles compared
slot0_load_address on BOTH m55 roles regardless of os. alp-sdk#1295
populated debug.jlink_flash_device for the E3/E5/E6/E7 variants, making the
guard reachable for the first time; a core parked with os: "off" produces no
flashable artifact, so its resolved address is moot and a collision with it
cannot physically happen.

Mirrors _enforce_flow_d_preflight_pair's own os != zephyr guard.

Neuter (scoping removed):

  2 failed, 10 passed in 0.35s

Restored:

  12 passed in 0.29s

* test(planner): re-pin to alp-sdk bd8be484 and move the metadata-root test to V2N (#744)

alp-sdk#1447 gave all six AEN SoMs an explicit memory_map:, which returns
verbatim and never consults the metadata root -- so the E3 bank rename in
test_metadata_root_override.py became invisible and four of its five tests
failed against the new pin.

Retargeted E1M-AEN301/e3.json/SRAM6 -> E1M-V2N101/n44.json/ocram_low. V2N/V2M
are the only SKUs published preliminary:false AND partial_hw_config:false, and
they are now the only family taking the SoC-JSON-derived branch this test
guards -- the branch whose bug blamed the customer's board.yaml while listing
another tree's device names.

Rename walks memory_regions[] rather than variants[].sram_banks_kb; the RZ/V2N
SoC JSON declares regions at the top level and its variants carry no bank map,
so the Alif-shaped walk would have renamed zero entries silently.

Coverage preserved: reproducing the original bug (metadata_root ignored) still
gives 3 failed, 2 passed. Restored: 5 passed.

Re-pinned all four pins 56dea6b5 -> bd8be484 plus three PINNED_HASHES.
STRICT_LOADERS_PINNED_SDK_COMMIT unchanged -- not in the diff.

  1402 passed, 8 skipped in 22.50s

* fix(planner): gate slot0_load_address on the VALUE, matching alp-sdk (#744)

tan-cli#737 made slot0_load_address ride the presence promotion used for the
flash_args emit. alp-sdk gates it on the value. The two agreed until a variant
declared jlink_flash_device: null -- alp-sdk#1447 made e4.json do that, and tan
then emitted a slot0_load_address alp-sdk does not.

Caught by the byte-parity suite on examples/peripheral-io/usb-host-storage
(E1M-AEN401):

  18a19
  >     slot0_load_address: '0x802b0000'
  27a29
  >     slot0_load_address: '0x80010000'

A variant with no J-Link profile cannot run Flow D, so there is no slot0-XIP
address to publish. After the revert the emit is byte-identical to alp-sdk's.

* chore(gates): re-baseline the module ratchet after the slot0 revert (#744)

  2169 passed, 16 skipped in 505.93s (0:08:25)

with tests/parity included -- the suite that compares tan's emitters
byte-for-byte against alp-sdk's, and the one whose omission let the
slot0_load_address divergence reach CI.

---------

Co-authored-by: Caner Alp <contact@alplab.ai>
…rched PATH twice (#747, #746) (#748)

* fix(size,build): budget FLASH per slot0 window; stop printing PATH twice (#747, #746)

Both found in one clean-room customer e2e on Windows -- fresh clones of
both repos at dev, isolated HOME, PATH stripped of every Python3x\Scripts
and Programs\tan entry, tan installed from dev source into its own venv,
building a scaffolded E1M-AEN801 project.

whole 5.5 MB part) while each M55 links into its own 2688 KiB slot0
(alp-sdk#1445/#1069). Against the same image the linker had just
reported: 113.5K/5.50M 2.0% where the linker said 4.22%. Worse, the whole
part is mcuboot + BOTH slot0s + reserved + storage + atoc, so over_budget
could never fire -- an image cannot exceed it, and the failure that does
happen (overflowing one 2688 KiB window) went unflagged.

resolve_budget now prefers this core's own <role>_slot0 from the SoM
memory_map, matched on the same core_id.split("_")[-1] the SDK's
gen_zephyr_board uses and enforcing accessible_from, and falls back to
mram_mb unchanged when no per-role window is declared. The RAM half of
that function already resolved per core; FLASH now does too.

searched-PATH message twice, 5608 characters for one benign outcome,
pushing the ok: lines and the build summary below it. Both producers are
deliberate (tan-cli#510 put the PATH in the message, tan-cli#283 promoted
it into issues[]); they only collide in text rendering. _text_issues
drops an issues[] line the per-slice recap repeats verbatim, text mode
only -- the JSON envelope still carries the full entry, verified.

Module size budget regenerated through scripts/regen_module_size_budget.py
--reason, not hand-edited.

Full suite: 17 failed, 4152 passed. All 17 are pre-existing on this
Windows host -- 15 test_completion_command (slow bash spawn), 1
test_flash_command, and 1 test_monitor_command which was verified by
stashing these changes and reproducing it on clean dev. Zero added.

* chore(gates): recompute the module size budget after the #745 rebase

Both sides of the rebase bumped function_count_budget to 256 independently;
the merged tree needs 257. Regenerated through regen_module_size_budget.py
--reason, not hand-edited.

---------

Co-authored-by: Alp Lab AB <dev@alplab.ai>
…omments (#749)

Both installers explained the two asset shapes with an aside that v0.4.1 is
what `latest` resolves to. It has not been since v0.5.0; `latest` is v0.5.1,
measured from install.ps1's own resolution during a clean-room install.

A reader following the old comment would expect the no-argument install to
take the raw-executable path. It takes the archive path.

Comments only -- zero non-comment lines changed in either file, verified.
The shape detection itself never depended on the claim; it asks the release
which asset name it carries via the checksums.txt fetched first.

Found by a documentation sweep after the recent metadata and doctor merges.
docs/release-contract.md was also flagged, and deliberately NOT changed: every
claim in it is about alp-sdk-vscode (SUPPORTED_CLI_VERSION, the
releaseAssetForTarget map, HOSTS_WITHOUT_RELEASE_ASSET), none of which is
verifiable from this checkout. Two of the proposed corrections had no
supporting evidence here at all.

Co-authored-by: Alp Lab AB <dev@alplab.ai>
* release: v0.6.0-rc1

Folds 42 changelog.d/ fragments into the release section and bumps the three
version files to 0.6.0-rc1.

The number is 0.6.0, not the 0.5.2 the version files carried: the milestone
being closed is v0.6.0 with 206 closed issues, no v0.5.2 milestone exists, and
the section carries a Removed block (the Rust oracle) -- pre-1.0 SemVer puts a
removal in the minor, not a patch.

  TAN_VERSION (source of truth) : 0.6.0-rc1
  python/pyproject.toml         : 0.6.0rc1
  npm-shim/package.json         : 0.6.0-rc1
  CHANGELOG.md section          : ## [0.6.0-rc1]
  git tag                       : v0.6.0-rc1
  versions agree

Also repairs the section hierarchy: seven fragments this cycle were written
with a '### Fixed -- <title>' header instead of the bullet changelog.d/README.md
specifies, so the fold produced seven stray '###' sections where v0.5.1 has
exactly one '### Fixed'. Demoted to '####' under the canonical heading; no
content changed.

* release: fold #749 into the v0.6.0-rc1 section

The assembler only folds into an 'Unreleased' header, so a PR landing after
the header is dated cannot be folded by the tool. Renamed back, folded,
re-dated.

  assemble_changelog.py --require-empty  rc=0

---------

Co-authored-by: Caner Alp <contact@alplab.ai>
@alpCaner
alpCaner merged commit 93d97d0 into main Aug 14, 2026
60 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants