Round-two remediation of the four blockers gate-ultron-01 raised on f8d04d1b. All
four were confirmed by my own measurement before being touched; none is taken on
the reviewer's word.
1. The --config/-K refusal never ran. It sat nested inside `if API_SHAPED`, and
API_SHAPED is a test for a provider URL in the command text — which is exactly
what a config file removes. The check was guarded by the condition that the
capability it guards against defeats, so `curl --config /tmp/write.cfg` walked
past it. It now keys on curl itself, ahead of the URL gate, and covers the
attached (`-K/tmp/f`) and bundled (`-sK`) spellings a space-separated test
cannot see.
2. Percent-encoded endpoints are a live route, not a theoretical one. Measured
against the provider: `…/issues/1174` and `…/iss%75es/1174` both return HTTP
200 for the same object. A write carrying any percent-escape is now refused
rather than decoded — a decoder has to be exactly right about depth
(%2569 -> %69 -> i) and about the provider's own normalisation, and being
approximately right there is indistinguishable from not checking. Scoped to
writes: a read is never this hook's business and a query string carrying %20
is an ordinary URL.
3. HOME was still expanded unguarded at the `W=` fallback, which runs before any
of the new HOME adjudication — so a guard deployed without its siblings still
died on an unset HOME, upstream of the fix that was supposed to survive it.
Moving a fail-open earlier in the file is not closing it. HOME is now resolved
once, above every use, and every later site reads the resolved value.
The existing harness could not have caught this: it runs the guard beside its
siblings, so `[ -x "$W/pr-review.sh" ]` always succeeded and the fallback was
never reached. A test's blind spot can be a property of the harness rather
than of the code. The new lone_case() block copies the guard alone into an
empty directory and re-asserts the four behaviours there.
4. test-mosaic-worktree-large-repo.sh shipped at mode 100644 and appeared in no
CI step, so the enumeration guard (#1017) redded pipeline 2386 — correctly.
Committed mode is now 100755 and the test is enumerated in the sanitization
step. My own process miss: I verified the CI queue before pushing and never
verified terminal CI after.
Controls: the 25 fixtures added here all FAIL against 029af418 (rc 0 or 1 where 2
is required) and all pass at this head, 143/143.
Two defects found by running the guard rather than reading it.
1. The guard resolved its sibling wrappers through a hardcoded
$HOME/.config/mosaic/tools/git. On a host with no installed mosaic home — a
CI container, a bare checkout — every wrapper lookup missed, `[ -x ]` failed,
and the guard fell through allowing the raw API write it exists to block. It
failed OPEN, silently, in exactly the environment least likely to notice.
It now resolves relative to its own path, so it names the wrappers from the
install it was launched from, with $HOME as the fallback.
2. There was no test. Adding one surfaced the guard's other sharp edge
immediately: it matches the literal text of the Bash command, so a harness
that embeds a blocked pattern inline trips the guard on itself rather than on
the fixture. That is the correct fail-closed posture and it is now recorded in
the test's own comments, because the next person will hit it too.
test-wrapper-guard.sh asserts twelve fixtures and asserts the ALLOWED cases as
hard as the blocked ones. A guard that over-blocks gets routed around and a guard
that under-blocks is decoration; only pinning both edges keeps it useful. It is
hermetic — no network, no credentials, no repository — so it joins the CI
sanitization step directly rather than the exclusions file.
An undocumented tool is, from inside an agent session, indistinguishable from a
tool that was never written. The framework shipped 26 git wrappers and named 6 of
them in its resident index docs — 23% discoverability, with pr-review.sh among the
missing. The observable consequence was an agent obeying Constitution gate 7 as
best it could see it, reaching for raw curl, sending GitHub's APPROVE to a Gitea
host, and getting HTTP 200 with the review silently filed PENDING. Three times.
That is not a discipline failure and no amount of prose fixes it.
Four changes, each converting a rule that decayed into a mechanism that cannot:
- check-tools-index.sh (new, CI-blocking): every tool in an enforced suite must be
named in a resident index doc, and every tool an index names must exist. The git
suite is enforced now; other suites report coverage without failing, so the
ratchet tightens one reviewed PR at a time instead of landing as one sweep. The
enforced list is framework-owned rather than a marker inside operator-owned
TOOLS.md — a doc marker would let an operator silence the gate on exactly the
host where it matters most. Carries --self-test, because a checker that only
ever passes is indistinguishable from one that is not running.
- TOOLS-REFERENCE.md: complete 28-entry git index, plus the APPROVED/APPROVE
dialect note that explains why pr-review.sh is not a formality.
- mosaic-worktree.sh + wrapper-guard.sh (upstreamed): the rule "big work goes on a
work filesystem" already existed in prose, and 255 GB accumulated in $HOME across
842 directories anyway, under five simultaneous placement conventions on one
host. The helper therefore exposes no placement decision — given a branch name,
every path is derived from `git worktree list --porcelain`. Worktrees rather than
clones because enumerability is the only thing that makes reclaim safe, and
reclaim is by evidence (clean tree + no unpushed commits), never by size or age.
The guard blocks three mechanically-detectable mistakes and nothing else:
a checkout into $HOME, a raw provider-API write to an endpoint that has a
wrapper, and the literal APPROVE event. Reads pass untouched.
- STANDARDS.md: model tiering as a standard, named by capability class so it
survives a model generation. Start cheapest, escalate on evidence, benchmark
before demoting a task class, and keep the class->model binding in operator
config with the DB-backed config service as the end state.
Registering the guard in runtime/claude/settings.json is the point of upstreaming
it: ~/.claude/settings.json is a framework-managed copy, so a hand-added hook there
is destroyed by the next upgrade. In the template it survives, and it reaches every
host instead of one.
Publish pipeline:
- Add publish-npm step to .woodpecker/publish.yml — publishes all
@mosaic/* packages to Gitea npm registry on main push/tag
- Requires gitea_npm_token Woodpecker secret (package:write scope)
- publish-npm runs after build, parallel with Docker image builds
- pnpm publish resolves workspace:* to concrete versions automatically
Package configuration:
- All 20 packages versioned at 0.0.1-alpha.1
- publishConfig added to all packages (Gitea registry, public access)
- files field added to all packages (ship only dist/)
- @mosaic/forge includes pipeline/ assets in published package
Meta package (@mosaic/mosaic):
- Now depends on @mosaic/forge, @mosaic/macp, @mosaic/prdy,
@mosaic/quality-rails, @mosaic/types
- npm install @mosaic/mosaic pulls in the standalone framework
Build fixes:
- Fix forge and macp tsconfig rootDir: '.' -> 'src' so dist/index.js
resolves correctly (was dist/src/index.js)
- Exclude __tests__ and vitest.config from build includes
- Clean stale build artifacts from old rootDir config
Required Woodpecker secret:
woodpecker secret add mosaic/mosaic-stack \
--name gitea_npm_token --value '<token>' \
--event push,manual,tag
Each step was re-running pnpm install independently, and all quality
steps (typecheck, lint, format, test) ran in parallel. On merge commits
with more accumulated code this pushed the CI runner over its memory
limit (exit code 254 = OOM kill).
Fix:
- install once, share node_modules via Woodpecker workspace volume
- sequential execution: install → typecheck → lint → format → test → build
- corepack enable in each step (fresh container) but no redundant install