Skip to content

chore: pin labelle-assembler 0.105.0 - #378

Merged
apotema merged 1 commit into
mainfrom
chore/pin-assembler-0.105.0
Sep 6, 2026
Merged

apotema merged 1 commit into
mainfrom
chore/pin-assembler-0.105.0

Conversation

@apotema

@apotema apotema commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

Routine pin bump, same shape as #374 and #377.

0.105.0 finishes the nested-checkout audit (assembler#692). #695's .git-existence probe covered the scanner; 0.105.0 covers the walks it didn't reach — and two are worse than the duplicate build steps the issue was filed for:

  • flow_scanner deletes what it collects, so the gap meant deleting files out of another branch's checkout;
  • scripting_splice / scripting_transpile @embedFiled a parked branch's scripts into the shipped binary.

This bump is what carries that to projects with no assembler_version pin and no LABELLE_ASSEMBLER override. Relevant to anyone using git worktree inside a project — including this toolkit's own agent workflow.

Verified before pinning: all five 0.105.0 assets return 200.

Local: 6/6 steps succeeded; 786/811 tests passed (25 skipped), zero compile errors, zero failures — identical to main.

Known gap still open upstream, tracked as assembler#717: native staging live-links the whole script dir, so a nested checkout inside scripts/ can still reach rustc. Not a regression, and fixing it trades away the live edit loop — its own decision.

https://claude.ai/code/session_01AM5hJ7sWCZNrKWo1xMJDEA


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

0.105.0 finishes the nested-checkout audit (assembler#692): the walks that
#695's `.git`-existence probe did not reach are guarded, including two worse
than the duplicate build steps the issue was reported for — `flow_scanner`
DELETES what it collects, so it was deleting files out of another branch's
checkout, and `scripting_splice` `@embedFile`d a parked branch's scripts into
the shipped binary.

Anyone using `git worktree` inside a project — a normal workflow, and the one
this toolkit's own agents use — gets that fix through this pin. All five
0.105.0 release assets were checked for 200 before pinning.

Claude-Session: https://claude.ai/code/session_01AM5hJ7sWCZNrKWo1xMJDEA
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 6, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-06T16:48:12.199638Z 7ce5180 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@coderabbitai

coderabbitai Bot commented Sep 6, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 40160176-e40f-43f3-b90a-c55c37ae568f

📥 Commits

Reviewing files that changed from the base of the PR and between a8d9e35 and 7ce5180.

📒 Files selected for processing (1)
  • src/cli/assembler.zig

Included review availability: 1 review is currently available. Your included PR review attempts over the past 7 days set your current allowance at 2 reviews per hour.


📝 Walkthrough

Walkthrough

The CLI default assembler version changes from 0.104.0 to 0.105.0. Other assembler resolution and handling logic remains unchanged.

Changes

Assembler Version

Layer / File(s) Summary
Update default assembler version
src/cli/assembler.zig
DEFAULT_ASSEMBLER_VERSION changes from 0.104.0 to 0.105.0.

Estimated code review effort: 1 (Trivial) | ~2 minutes

Merge Risk: ⚪ Minimal · up to 7ce51

Projects using the default assembler will use version 0.105.0, while explicit version pins and environment overrides remain unchanged. No current merge-blocking risk is identified.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description check ✅ Passed The description clearly explains the assembler version bump, its nested-checkout protections, verification results, and known upstream gap.
Title check ✅ Passed The title clearly and concisely identifies the main change: pinning labelle-assembler to version 0.105.0.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 0…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/pin-assembler-0.105.0

Comment @coderabbitai help to get the list of available commands.

@apotema
apotema merged commit fc9f8f6 into main Sep 6, 2026
8 checks passed
apotema added a commit that referenced this pull request Sep 26, 2026
…solution (RFC #406) (#421)

* feat(providers): hook manifest rules, planner and dispatch refactor (RFC #406 phase 3a)

Manifest: a `replace` hook must name a target its own package declares
(ReplaceRequiresOwnedTarget); `after_hooks` entries are `<package>/<id>`
references, never self (InvalidHookReference); `ownsTarget`, `splitHookRef`,
`qualifiedId`.

Planner (`provider_hooks.zig`): `validateAll` checks the cross-provider
graph once at discovery — DuplicateReplaceHook, MissingHookReference,
HookReferenceMismatch, HookPhaseOrder, HookCycle — and `plan` orders each
(step, target) as before → replace → after, Kahn's algorithm on the
`after_hooks` edges with ties by qualified id, so the plan is independent of
manifest read order. `stepOutputDir` is the step output-layout contract;
`runPhase`/`finishRun` execute a phase and propagate a failing hook's exit
code.

Dispatch: `Provider`/`discover` are pub and `discover` validates the hook
graph; `execute` is split into `resolveHost` + `runTool` over a `ToolRun`
(command dispatch is a thin wrapper, its e2e unchanged); the lock read, pin
check and integrity gate move into `requirePinned`; `resolveSettings` and
`canonicalDir` are pub.

Progress: `Reporter.beginPhaseOrStep` renames the sub-step inside the live
phase, since the machine rejects a phase re-entering itself and hooks share
the phase of the core step they wrap.

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* feat(providers): run lifecycle hooks in the pipeline; move the .app default (RFC #406 phase 3a)

Discovery runs once per pipeline invocation (only when `.plugins` is
non-empty), before the reporter, the assembler or any compiler, and a failed
discovery exits 1 with `provider discovery failed: <error>`. The four plans
are computed up front; a project without hooks never resolves the host
compiler.

Hook points, each `before` → core or unique `replace` → `after`, stopping at
the first failure and exiting with the hook's own code:
- generate: around assembler generate + fingerprints + emsdk activation,
  output_dir = the generated tree;
- build: around docker/host `zig build` + DLL staging, output_dir = zig-out;
- bundle: around `bundle.createFromBuild`, output_dir = zig-out/bundle/<target>
  or the resolved --output;
- run: `before` once ahead of every branch, `after` at each success exit via
  `finishRun` (only on a clean game exit, before the terminal `done` record;
  `wasm serve` runs them once the server returns).

`labelle.lock` is written right after `validatePluginCoreCompat`, before
generation, so a `before generate` hook already sees it. Core steps report
through `beginPhaseOrStep` so a hook and the core step can share a phase.

The desktop `.app` default moves to `<target_dir>/zig-out/bundle/desktop/`,
the step output directory bundle hooks receive, so hooks and the core
packager agree; `--output` is unchanged. Help text names the hooks doc.

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* docs+test(providers): hooks doc and hermetic subprocess regression (RFC #406 phase 3a)

`docs/provider-hooks.md` covers attach rules, discovery-time graph errors,
ordering (after_hooks edges only, ties by qualified id), the output-layout
table, the hook context, failure semantics and the limitations (no JSON
relay, legacy platform handlers, bundle still macOS-only). The dispatch,
contract and GitHub-pins docs point at it.

`test/provider_hooks_e2e.py` drives the real CLI with a fake assembler (the
plugin_compat pattern; a `.cmd` shim on Windows) and two fixture providers
declared in reverse order: order regardless of declaration order, an
`after_hooks` edge inverting the tie, context fields, a failing before hook's
exit code with no core build and no after hook, a failing core build
skipping after, ReplaceRequiresOwnedTarget / DuplicateReplaceHook /
MissingHookReference / HookPhaseOrder at `labelle help` before any compiler,
generate hooks seeing the lock, run hooks around the game, bundle hooks and
the .app location on macOS, the refusal elsewhere. The fixture tool appends
one JSON line per invocation to `<output_dir>/hooks.log` and fails on
`PROVIDER_PROBE_FAIL=<hook id>`. Wired into CI next to the other provider
suites on all three hosts.

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* feat(providers): resolve --platform against provider-declared targets (RFC #406 phase 3b)

`--platform=<t>` is a target NAME resolved by the pipeline: `desktop` is
core, every other name must be declared in a pinned provider's `.targets`
(`provider_targets.resolve`), and an undeclared one fails before the
assembler, a compiler or the cache is touched with
`labelle: no provider for target '<t>' in this project; add and pin the
package that declares target '<t>'`. The `(registry: <package>)` line is
added only when the cached registry document — kept by `providers resolve
--accept` — lists a package whose verified cached archive declares the
target (`provider_github.cachedRegistryOwner`: no network, no extraction,
no package code); the registry record carries no targets, so a name is
never invented. The resolved string names the target dir, is passed to the
assembler and to every hook context.

`project_config.Platform` stays the strict schema type (documented as the
legacy codegen platform). The labelle-assembler#378 boundary is explicit:
a provider target outside that enum is generated for only by its
provider's `replace` hook on `generate`; without one the command stops
with `... but the pinned assembler cannot generate for it yet
(labelle-assembler#378)`. Schema-named provider targets reach the
assembler as `--platform <name>` unchanged.

Parsers: `ParsedArgs.platform_override` is a string; `parseTargetValue`
checks identifier shape only (`invalid target '<t>' (targets are lowercase
identifiers; run 'labelle targets')`); `astc --platform` passes the name
through and rejects one without a legacy capability table.

`labelle bundle --platform=<t>`: the macOS host gate moves from cli.zig
into the pipeline after resolution — refusing only the core desktop
packager off macOS, still before any build — the "bundling desktop
instead" override is gone, and a provider target needs a `replace` hook
on `bundle` (`NoBundleReplacement`), which then runs on any host into
`zig-out/bundle/<t>/`.

Breaking (RFC "Migration", #410): `labelle wasm serve|export`, `labelle
android …` and `labelle ios …` set the same override and go through the
resolver, so without the pinned provider they fail with the no-provider
error; the words stay reserved. `labelle targets` lists `desktop (core)`
then `<t>  provided by <package>` from discovery, replacing the hardcoded
backend table.

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* docs+test(providers): targets doc and hermetic subprocess regression (RFC #406 phase 3b)

`docs/provider-targets.md` covers the resolution rules, the registry hint,
the labelle-assembler#378 boundary, `labelle bundle --platform=<t>`,
`labelle targets` and the migration note (web/mobile projects add and pin
their provider; `--platform=wasm` is unchanged in name). The hooks,
dispatch, contract and RFC docs point at it.

`test/provider_targets_e2e.py` drives the real CLI with a fake assembler
that reports the target it was asked for: the no-provider error for
`--platform=wasm`, the declared `.platform` and every legacy subcommand
with no assembler invocation and nothing created; malformed values; the
#378 message before the assembler runs; `NoBundleReplacement`; a provider
replacing generate/build/bundle for `probe-target`, including `labelle
bundle --platform=probe-target` running the replacement on every host with
`output_dir == …/zig-out/bundle/probe-target` (and `--output`); a provider
owning `wasm` making the assembler receive `--platform wasm`; and the
`labelle targets` listing under a dead compiler and assembler. Wired into
CI next to the hooks suite. The hooks suite's off-macOS bundle refusal now
also asserts the cache stays untouched, since the gate runs after
discovery.

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* fix(progress): report a compile-phase hook as a sub-step while in link

An `after build` hook reports under the build's phase (`compile`), but
when Zig's progress stream has already advanced the reporter to `link`
that request was a backward transition the machine silently rejected: the
phase stayed `link`, the detail kept the linker's name, and no record was
written — JSON/status consumers never saw the advertised
`hook <package>/<id>` sub-step (Codex P2 on #420).

`beginPhaseOrStep(.compile, …)` while in `link` is now a detail update of
the live `link` phase (never a backward move); a genuinely earlier phase
is still refused so the fold cannot mask a programmer error. The reporter
spec drives `compileUpdate(saw_link)` into `link` and asserts the status
file record carries `phase: link` with the hook detail.

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* ci: run pull_request CI for PRs based on feat/** branches

A PR stacked on a feature branch (#421 on feat/provider-hooks) has that
branch as its base, so `branches: [main, development]` gave it no
pull_request run at all. Add 'feat/**'; `push` is unchanged.

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* fix(providers): validate the hook graph at resolve --accept before the lock

`provider_github.resolve --accept` parsed every selected manifest and
validated ownership, but the cross-provider hook validation only ran
through `provider_dispatch.discover`, so an accept could write
`labelle.providers.lock` for providers with missing references,
phase-order violations, duplicate replacements or cycles, and the freshly
accepted project then failed on its next help/build (Codex P2 on #420).

The accepted set — remote pins plus local providers, as `discover` would
see it — is now run through `provider_hooks.validateAll` after the
ownership check and before the lock is written. The GitHub e2e adds a
registry provider whose `after_hooks` names a hook nobody declares:
`--accept` fails with `MissingHookReference` (naming both hooks) and the
working lock is byte-identical afterwards.

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* fix(wasm): run the generate/build hook phases on every watched rebuild

The generate/build plans were used only for the initial `wasm serve
--watch` build; `WasmRebuildCtx.rebuildStaged` called the assembler, the
fingerprint pass and Zig directly, so `before`, `replace` and `after`
hooks were all skipped on every subsequent file change — projects whose
hooks generate inputs or post-process the WASM output reloaded stale or
incomplete artifacts after the first watched edit, and a `replace` hook's
step silently fell back to the core operation (Codex P2 on #420).

The context now carries the cold pipeline's hook site, the `generate` and
`build` plans and their step output directories, and wraps the watched
core steps in the same before / core-or-replace / after phases. A failing
hook is a new `HookFailed` stage: reported, server kept alive, browser not
reloaded — like a failing core step. The feed is already terminal during
the loop, so the hooks' sub-step records are no-ops there.

The serve loop is interactive, so the subprocess e2e cannot drive it; the
phase runner is an injectable field (production default asserted to be
`provider_hooks.runPhase`) and a unit test on the context proves the
plumbing: phases run in contract order with the right output directories,
a `replace` plan stands in for the core step, and a failing or unstartable
hook stops the rebuild before the next stage.

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* fix(run): launch the built binary without a second warm `zig build`

The desktop run path ran an unconditional `zig build` right before
spawning the game, AFTER the `after build` hooks had run. Those hooks were
therefore not after the final build: a hook that signs, strips or patches
`zig-out/` had its work overwritten by the warm build's install steps
before launch, and a failure in that second build surfaced only after
publishing-style after hooks had already run (Codex P2 on #420).

Decision: remove the build rather than move the hook site. The core
`build` step above (`core_build`, or its `replace` hook) always runs for
`labelle run`, so the second build was truly redundant — a leftover of
translating `zig build run` into build-then-exec in cli#265, kept only as
a "warm-cache no-op". It was not a no-op for the install steps (Zig's
`updateFile` re-copies any destination whose size/mtime differ, i.e.
exactly a hook-patched file), it re-ran `zig build` even after a
`replace` hook had stood in for the core build, and neither
`enforceCliNotStale` nor the prebuild path depended on it. Nothing built
in the run branch could be newer than the core build's output, so there
is nothing for a warm build to pick up. Moving the hooks around it instead
would have kept a second build whose only observable effect was undoing
the hooks. The `ok()` doc no longer describes the removed build's exit
code path.

E2E: the fixture game's build now INSTALLS `data.txt` to `zig-out/bin/`
and the game prints it at launch; with `PROVIDER_PROBE_PATCH=a-post` the
`after build` hook overwrites that file and the launched game must print
the patched contents. The mechanism is asserted too: a manual `zig build`
on the same tree afterwards DOES revert the file, so the launch proving it
intact means no build ran between the hook and the game, and "build ok"
appears exactly once.

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* fix(providers): discover providers after the package cache is populated

On a clean checkout the pipeline discovered providers BEFORE the
assembler's `install`, and `provider_dispatch.discover` treated a declared
remote package with no cached `plugin.labelle` as runtime-only. The
installer then fetched the provider and the build proceeded without its
declared lifecycle hooks or integrity requirement, while the same command
on a warm cache discovered the provider and refused it as unpinned
(Codex P1 on #420).

Discovery (and the hook plans) now run right after `gateThenInstall`, still
before the plugin/core compatibility check, the lock write, generation and
any compiler; a failure marks the progress feed `failed` ("provider
discovery failed") since the reporter is live at that point. `discover`
takes a `CacheState`: the pipeline passes `.populated`, under which a
non-local package that is neither pinned nor in the ordinary cache is
`ProviderPackageMissing` — a broken install, never runtime-only. A package
DIRECTORY without a manifest stays runtime-only, because the assembler's
light packs legitimately ship none (the stricter "no manifest = error"
would refuse every light pack). `labelle help` and command dispatch, which
run before any installer, pass `.unknown` and keep listing what exists.

E2E (test/provider_hooks_e2e.py): the discovery-error cases now assert the
install line IS present and the generate line is NOT (LABELLE_ZIG still
points nowhere), plus the `failed` status record. New cold-cache cases with
a fresh LABELLE_HOME and a remote `example/fixture-c`: (1) the installer
delivers nothing → `ProviderPackageMissing` after install, before
generation; (2) the installer populates the cache → the provider is
discovered and its unpinned hook is refused with
`RemoteProviderIntegrityRequired`, no core build; (3) a warm cache gives the
identical outcome; (4) pinned via `labelle.providers.lock` + a seeded
archive with a cold ordinary cache → the hook runs from the verified
extraction. Never a silent skip.

test/plugin_compat_e2e.py: the "absent manifest" fixture now lands the
package directory at install (as the real assembler does) and omits only
the manifest, which is what the case name means; an absent directory is
the new fail-closed path.

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* docs(providers): document the round-1 review fixes on hooks

Discovery timing (after the package cache is populated, before
generation), the fail-closed `ProviderPackageMissing` rule and the
light-pack exception, `--accept` validating the hook graph, the single
build on the run path, the `link`-phase sub-step, watched wasm rebuilds
running the hook phases, and the e2e coverage list.

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* fix(providers): defer hook references into an unread package at metadata-only discovery

Under CacheState.unknown (labelle help, command dispatch) a declared remote
package absent from both caches was skipped on purpose, but validateAll then
reported MissingHookReference for any reference into it — hiding every
provider's commands from help and refusing dispatch until the package
happened to enter the cache (Codex P2 on #420). discover now records such
packages as unresolved and validateAll defers only references into them; a
reference into an undeclared package or into a present package without the
hook is still missing, and .populated still fails closed on the absence.

Unit-tested at the graph and at discover against a real cache layout in both
states; e2e covers help staying intact, the pipeline failing on the absence
rather than the reference, and the typo still reported.

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* fix(wasm): stop the serve loop on Ctrl+C / SIGTERM so after-run hooks are reachable

serveAndOpen blocked in an unconditional accept loop and the process died on
Ctrl+C, so the after run provider hooks that follow it never ran (Codex P2
on #420). A signal handler (POSIX) or console control handler (Windows,
SetConsoleCtrlHandler, best-effort: compile-checked only) sets an atomic
flag; a waker thread pokes the listener with one loopback connection so the
blocked accept returns; the loop observes the flag and returns cleanly. A
repeat Ctrl+C forces the exit. The connection-based wake is the one
mechanism that behaves alike on every platform (std.posix.poll is a compile
error on Windows; shutdown of a listener wakes accept on Linux but not macOS).

The loop's flag and the waker's poke are unit-tested; the handler itself is
interactive and not driven end to end.

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* fix(hooks): run each phase on a scratch arena freed on return

A wasm serve --watch session runs the generate and build hook phases on every
saved edit through one long-lived Site whose allocator was the pipeline
arena, so every hook's lock parse, settings, workspace paths, environment and
serialised context stayed live until the server exited (Codex P2 on #420).
Site gains a backing allocator; runPhase carves a scratch arena from it and
frees it when the phase returns. Only the resolved host outlives a phase, on
the long-lived arena; plans were already there.

A counting allocator wrapper proves two phases on one site each allocate
through the scratch and leave nothing live.

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* fix(run): run after-run hooks only when the game itself exited clean

A zero status was not a clean exit: runZigInheritWithEnv returns 0 after the
--timeout watchdog kills the game (cli#390), and the iOS/Android deploy
paths return as soon as simctl launch / am start does, with the app still
running — so publishing or cleanup after run hooks ran after a forced
timeout or right after a mobile launch (Codex P2 on #420).

runner gains runInheritTerm returning an explicit Termination (exited /
timed_out); runZigInheritWithEnv keeps its 0-on-timeout contract on top of
it. provider_hooks gains RunOutcome (exited_clean, exited_error, timed_out,
launched_detached); finishRun runs the after hooks only on exited_clean and
otherwise prints one "labelle: after-run hooks skipped: <reason>" line, with
the CLI's exit status unchanged.

E2E: a --timeout kill of a hanging fixture game runs no after hook and prints
the skip line; a clean exit still runs them.

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* docs(providers): document the round-2 review fixes on hooks

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* fix(hooks): validate each phase group once; check pins before the compiler

validateAll ordered the whole (step, target, phase) group once per hook,
so N hooks in one phase ran the quadratic sort N times with every
temporary left on the discovery arena. Track visited groups and order
each distinct group once; a test seam counts orderPhase invocations.

runPhase resolved the host compiler (running `zig version`, creating
cache dirs) before requirePinned, so an unpinned remote hook on a
machine without the pinned compiler reported ProviderCompilerMissing.
Check every hook's pin first, as the command path does; a resolve_host
seam proves the resolver is never reached for an unpinned provider.

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* fix(hooks): run before-generate hooks ahead of the ASTC and --bake pre-passes

Both pre-passes read the declared PNGs, so a `before generate` hook that
produces one ran too late: `generate --bake` failed on the missing file
and a hook-generated PNG never got its `.astc` sibling. Move both
pre-passes into the core generate step, after the before hooks; a
`replace generate` hook now stands in for them too (documented).

The fixture tool gains PROVIDER_PROBE_COPY so a hook can produce a
generation input; the e2e drives `generate --bake` with the hook writing
the declared PNG (baked, then generated) and proves the mechanism by
running the same command without the hook's file (bake fails first).

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* fix(hooks): run after-build hooks once the build's finalization is done

`labelle build` finalizes the compiled tree (the Linux .desktop entry
and the installable package on the platform that produces one) AFTER
the `after build` hooks ran, so a signing/inspecting/publishing hook
never saw the final artifact and reported success even when packaging
then failed. Move the finalization inside the core build step, ahead of
the after hooks; a `replace build` hook now owns it (documented).

The fixture tool snapshots the step output directory in each hooks.log
line; the e2e drives `build --linux-desktop` and asserts the after
hooks list the entry while the before hooks do not.

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* test(hooks): compare the cache before and after the off-macOS bundle refusal

The targets slice added `assert not home.exists()` to the off-macOS
`bundle` case, but by that point the cold-cache section has legitimately
populated the cache (the pinned-provider archive, the remote package the
fake install delivered), so the assertion failed on ubuntu-latest and
cancelled the other hosts. The pipeline's core-desktop gate already sits
before the install; what the test means is that the refusal changes
nothing in the cache — so snapshot it before and compare after.

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* ci(docker): declare a fixture provider that owns wasm for the Docker WASM build

`--platform=wasm` now resolves only to a declared provider that owns the
target (docs/provider-targets.md "Migration"), and `labelle-web` is not
extracted yet, so the Docker job's scaffolded project failed on every
attempt with `no provider for target 'wasm'` — the retry loop's
"dependency fetch" warning was misleading. Add test/fixtures/wasm-provider,
a module plugin (the assembler wires every declared package as
`b.dependency("labelle_<name>").module("labelle_<name>")`) whose v2
manifest owns `wasm` and nothing else, and splice it into the scaffold's
`.plugins` before the build; the grep on the empty `.plugins` makes a
template change fail loudly here rather than as a no-provider error.

Verified locally against the assembler at origin/main: `labelle build
--platform=wasm` resolves the target through the fixture, the assembler
receives `--platform wasm`, the lock pins `wasm_fixture`, the plugin is
copied into `.labelle/deps/labelle-wasm_fixture` — inside the tree the
container mounts, like core/engine/gfx — and the emcc link produces
`zig-out/web/game.wasm`.

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* fix(targets): reject a Windows reserved device name as a target

A provider could declare `con`, `nul`, `com1`… as a target: identifier-
shaped, so every check passed, but the default bundle directory
`zig-out/bundle/<t>/` (and `.labelle/<backend>_<t>/`) cannot be created on
Windows, so `labelle bundle --platform=<t>` failed after generation and
build unless `--output` was given (Codex on #421).

Move the device-name rule from the archive validation in provider_github
into provider_contract (`windowsReservedDeviceName`), define
`contract.targetName` = identifier and not a device name, and apply it
at every entry: the manifest's `targets` and each hook's target
(`ReservedDeviceTarget`, at validation), `args.parseTargetValue` with its
own reason in the diagnostic, and `provider_targets.provisional`.
Unit tests for the rule, the manifest, the parser and near misses
(`console`, `nul0`, `com10`, `lpt`).

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* fix(providers): scan the registry hint on a scratch arena, bounded

`cachedOwner` read every cached release's archive on the caller's arena —
the pipeline's — so a 128 MiB archive plus up to 512 MiB of unpacked tar
per inspected release stayed live until the command exited, and a registry
with several cached releases could exhaust memory for a diagnostic (Codex
on #421). Each release now decompresses on its own scratch arena, freed
before the next, and the scan stops at the first owner or after
`registry_hint_scan_limit` (16) cached releases; an uncached release costs
one failed open and is not counted. `cachedManifest` reports a missing
archive as an error so the scan can tell "nothing read" from "read, no
owner". Unit test with limit+1 cached decoys ahead of the owner.

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* fix(targets): refuse an unpinned remote package as a target owner

`provider_dispatch.discover` falls back to the ordinary package cache for
a remote plugin with no `labelle.providers.lock` pin and returns it with
`verified = false`; the resolver accepted its target declaration anyway.
For a schema-named target such as `wasm` with no hooks nothing ever called
`requirePinned`, so generation and build proceeded on an unverified
cached manifest, past the provider integrity boundary (Codex P1 on #421).

`resolve` now returns `UnverifiedTargetOwner` when the owner is
unverified — owning a target means generating, building or bundling for
it, the same boundary as a hook that runs — and the pipeline reports it
naming the package and the `providers resolve` / `--accept` way out.
`labelle targets` still lists the declaration, marked `(unpinned)`.

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* fix(targets): decide target ownership before any side effect when it can be

With `.plugins` present, an identifier-shaped typo (`--platform=waasm`)
reached the ownership check only after `.prebuild`, the assembler
resolution, the ASTC prepass and `install` had run (Codex on #421). Restore
an early verdict: for a provider target the pipeline first runs the same
metadata-only discovery `labelle targets` does (`.unknown`: cached
manifests, no installer). `discoverAll` now also reports the declared
remote packages it could not read; when that list is empty every declared
package was readable and the verdict — no owner, or an unpinned owner — is
the one the post-install check would reach, so it lands here with the
same diagnostics and nothing run. A manifest that fails discovery fails
closed here too: the install cannot mend a manifest it can already read.
Only a partial view (cold cache, no pin) defers to the post-install
discovery, which stays the authoritative check with its `failed` record.

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* fix(astc): skip the prepass for a provider target with no capability table

A provider target outside the `Platform` enum derives `parsed.platform =
.desktop` for the legacy sites, so with desktop atlases set to ASTC the
prepass ran `cmdAstc --platform desktop` for e.g. `probe-target`,
encoding siblings by desktop capabilities for a provider's own `generate`
to pick up and bypassing `cmdAstc`'s rejection of such a name (Codex on
#421). Key the prepass on `provisional.legacy`: only `desktop` and the
schema-named provider targets have a table; a provider owns its target's
asset pipeline.

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* test(targets): cover the round-1 fixes end to end, and document them

The targets e2e now checks: a local provider's non-ownership refused
before anything runs; an identifier-shaped typo leaving a `.prebuild`
marker step unrun while a resolving target runs it; a remote package on a
cold cache deferring the verdict to after the install (the `failed`
record stays the authoritative path) and, unpinned, refused as an owner
both after the install and — warm — before it, with the cache untouched;
the `(unpinned)` mark in `labelle targets`; and the ASTC prepass running
for `desktop` but not for `probe-target`. The fake assembler's `install`
can populate the cache (the hooks-suite pattern).

docs/provider-targets.md describes the early verdict, the pinned-owner
rule, the reserved-device-name rule, the bounded registry-hint scan and
the Docker fixture; the contract's name rule gains the device-name clause.

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* fix(hooks): keep the run hooks on wasm serve|export --no-build

The --no-build branch returned before provider discovery, so every
declared before/replace/after run hook was silently skipped although
serving or exporting the existing artifact is the same run step the
building path hooks. Discover (metadata-only, nothing is installed on
this path) and run the run plan around the export or the serve loop,
skipping only generate and build.

E2E: `wasm export --no-build` over a pre-made web dir runs the wasm
run hooks around the export with no install/generate/build, and a
failing before hook stops the export with the hook's exit code.

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* fix(hooks): replan the provider hooks on every watched rebuild

`wasm serve --watch` captured the hook plans and the site's config once
at startup and reused them for every rebuild, so a watched edit to
project.labelle, a provider manifest or provider_config never reached
the lifecycle: removed hooks kept running and added ones were skipped
until the server restarted.

WasmRebuildCtx gains a per-rebuild `replan` seam, run after the
prebuild steps and before any phase; production installs WatchReplan,
which re-reads the project, rediscovers with the cache populated and
replans both phases on a fresh arena, installing the result only on
success (a failed replan stops that rebuild and keeps the last good
plans). Unit tests cover the seam (invoked once per rebuild, the fresh
plans are what run, failure stops before any phase) and the production
replan against a real temp project (manifest edits, a broken manifest,
a dropped plugin).

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* test(hooks): e2e for the pin-before-compiler order; document rounds 3 fixes

fixture-c gains a `before generate` hook so the cold-cache unpinned
build refuses it before any compiler: with LABELLE_ZIG pointing
nowhere the error is RemoteProviderIntegrityRequired, never
ProviderCompilerMissing, and nothing is generated; once pinned, the
same dead compiler is the failure (the mechanism). docs/provider-hooks.md
covers the once-per-group validation, the pin order and the new
e2e/unit coverage.

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* fix(targets): bound manifest-less hint reads; name the unpinned refusal in status

Round-2 Codex findings on #421:

- provider_github.cachedOwner: a cached archive that verifies but carries
  no `plugin.labelle` was read, verified and decompressed whole yet not
  counted against `registry_hint_scan_limit`, so a registry of such
  releases could read past the bound. Count every archive read; unit
  test seeds `limit` manifest-less releases ahead of the owner and shows
  the owner is not read (and is, one release fewer).

- pipeline.confirmTarget: an unverified (unpinned) remote owner returned
  the same null as an absent owner, so build/run/bundle persisted
  `detail: "no provider for target"` for both. The refusal kind now
  survives to the `failed` progress record as
  `"unpinned provider for target"`; the e2e reads both records.

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* fix(bundle): find the host-built game as <name>.exe on Windows

resolveBuiltExe looked for '<name>' and 'game' in zig-out/bin, but Zig
installs '<name>.exe' on a Windows host, so 'build --linux-desktop' (and
bundle) failed with BinaryNotFound there. Append the host exe suffix to
the declared name and both fallbacks. Fixes the Windows CI job on #420.

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* fix(bundle): keep the resolved exe name bare; only the path carries .exe

0004dc8 put the suffix into ResolvedExe.name, so the Linux desktop entry
became 'game.exe.desktop' on Windows. The name stays '<name>'.

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* style(bundle): keep ResolvedExe's doc comment on the struct

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP

* fix(targets): revalidate the watched target, scratch the early discovery, hand bundle hooks --build-number

- WatchReplan.run confirms ctx.hooks.target against the rediscovered
  providers before installing anything: an edit that drops the owning
  package or unpins a remote owner fails that rebuild with
  NoProviderForTarget / UnverifiedTargetOwner (and the cold pipeline's
  diagnostic) and keeps the previous generation (Codex P1 on #421).
- The pre-install ownership check runs in earlyTargetCheck on a scratch
  arena freed before it returns; only the verdict leaves, so pinned
  archives are no longer held twice or for a --no-build server's lifetime
  (Codex P2 on #421). Tested by counting the backing allocator.
- LABELLE_CONTEXT gains an optional build_number, set only for bundle-step
  hooks when `bundle --build-number` was given and omitted (not null)
  otherwise; documented in provider-contract-v1.md and provider-hooks.md;
  e2e asserts the bundle replacement's captured context carries it and the
  build hook's does not (Codex P2 on #421).

Claude-Session: https://claude.ai/code/session_01R2K5sJDL5Msn1uRTkybiDP
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.

1 participant