Repository navigation
fix(drift): a runnable, CI-owned drift gate — and the corpus commit it reproduces (#42) - #52
Conversation
…t reproduces (#42) `charly task skills:drift` was red in four different ways at once, and no CI ran it, so nothing could see it. This change makes the gate runnable, gives it a job that owns the GENERATOR side of the corpus contract, and re-points the corpus pin at the commit this generator actually reproduces. THE RECIPE (root `charly.yml`, `skills:drift`) - The parameter was `out` — lower-case. The task runner's expansion grammar matches UPPER-CASE names only (`sdk/kit/checkvars_expand.go:16-17`), so the value never reached the command and the shell dropped the empty word ("--out: expected string value but got EOL"). It is `CORPUS` now. - `--root` named the repo root, but the corpus's ONE generation input — the refs list, `candy/charly-marketplace/charly.yml` — lives INSIDE the corpus checkout. Both flags now name the corpus checkout. - The "the tree is committed" half ran `git status` in THIS repo, so it checked the plugin's tree, not the corpus's. It names the corpus now. - The drift step carried no `timeout:`, and the per-step never-hang bound is 1m30s. The drift resolves EVERY corpus source (the refs list names ~1200 repos at pinned versions) before it compares a single byte — measured 11m48s in the marketplace repo's own deploy workflow — so the step was killed at the ceiling and the gate reported a TIMEOUT where the truth was "slow, and green". The bound is a hang guard, not a budget. THE SDK PIN (candy/plugin-marketplace/go.mod) sdk v0.2026296.2152's `acceptedRef` refuses EVERY `github.com/opencharly/charly` ref. The corpus's refs list RELIES on the BARE repo-root form (`@github.com/opencharly/charly:v2026.297.0032`) being a corpus source. A standalone build of the generator therefore dropped the charly corpus source, the closure twin `layer-charly-internals` won, and a rule-renumbering difference (R2/R3 -> R3/R4) left 9 stale artifacts. sdk v0.2026297.129 admits the bare repo-root form and refuses only the SUBPATH form (opencharly/sdk#468 -> opencharly/sdk#470), so the generator reproduces the corpus again. The declared-refs fix PR #47 adopted (opencharly/sdk#467) is present in both versions — the bump is a strict superset, not a revert. THE CORPUS PIN (`var: MARKETPLACE_REF`, and the literal that duplicates it) The current generator no longer reproduces the Oct-15 corpus commit: at e802da68 the gate is red with 4 stale artifacts (`plugins/.well-known/skills/index.json`, `plugins/DISPATCHER.md`, `plugins/check/skills/openclaw/SKILL.md`, `plugins/tools/skills/crabbox/SKILL.md`). At cc10955e — the corpus's current `main` head, which is the invariant this repo declares — it is green with 0. THE CI JOB (.github/workflows/drift-gate.yml) The marketplace repo's deploy workflow owns the CORPUS side. Nothing owned the GENERATOR side. The job runs the recipe on every merge to `main`, against the corpus commit this repo DECLARES, read out of `candy/plugin-marketplace/charly.yml` rather than repeated as a second literal (one pin, not two that can drift). It runs on `main` and not on `pull_request`, and that is a design constraint. A generator change that alters the output makes the declared corpus commit stale, and the corpus can only be regenerated with a RELEASED generator — so a pull-request gate would be red by construction for exactly the change it should inform, and it would deadlock the chain that repairs it (generator merged and tagged -> the corpus regenerates with that tag -> the pin is bumped here). It would also be a de-facto required check: the org pr-validator's `arm` job arms native auto-merge only when EVERY check on the head is green. THE PARAM IS QUOTED AT ITS USE SITES A task param reaches a command step as shell TEXT, not as a data channel (opencharly/plugin-task#34 owns the class-level fix), so `${CORPUS}` is quoted where it is used. A corpus checkout under a path with a space would otherwise split into two words and the CLI would reject the arguments. TWO DEFECTS FOUND AND FIXED IN PASSING - `task marketplace:pin`'s second step compared the corpus's `main` head against the file's LAST 40-hex string — which is the sha256 prefix of the byte-identical SKILL.md the candy's file records, not a pin. The step could never be true (measured: it compared cc10955e against 7d4d2676). Each occurrence is now found by its ANCHOR (`MARKETPLACE_REF:` and `contains:`), which a third string cannot satisfy. The same anchoring is what the CI job reads the baseline with. - The `setup` launcher and the `.claude/hooks` surface were removed at source in 2026.241.1105, but the README, the box description, the candy's description, the plugin's package comments and the prune boundary still described them as shipped. They are gone from the prose (R5), with the generator's own test (`assertNotWritten(t, out, "setup")`) as the truth they now match. Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (fully tested and validated) Co-authored-by: Andreas Trawoeger <atrawog@gmail.com>
|
STATUS The PR is open and its whole body is written. The gate, the R7 negative control, the R10 bed, the old-pin red measurement, the module's own tests and the pin recipe were all run on this exact head ( What is next: the fresh One note for the reader of the diff: the new Next update due: when the validator's verdict lands, or within two hours, whichever comes first. Agent: |
Review — PASSI re-derived every material claim in the body against the current diff and the commit state. The PR is internally consistent and the changes match their stated purpose. Verification performedFile/line accounting. The body claims 13 files, +273/−67. Summing the per-file diffs gives exactly 119+4+50+23+8+2+3+2+1+2+6+7+46 = 273 insertions and 12+11+7+1+3+3+1+2+4+7+16 = 67 deletions across 13 files. Exact match — a strong honesty signal. The four recipe defects (
Pin bump.
New CI job. Prose cleanup. Security/scope. No secrets, no privilege changes, no new dependency beyond a version bump of an already-declared module; the functional Go surface changes are comments/help text only, with CommentsThe thread is empty (0 comments); nothing to disposition. Minor, non-blocking observations
No contradiction, stale claim, security issue, or missing coverage was found that would justify blocking. Verdict: PASS ollama-cloud/deepseek-v4.1-flash — action-review. |
Summary
charly task skills:driftwas red in four different ways at once, and no CI job ranit, so nothing could see it. This change makes the gate runnable, gives CI a job that
owns the GENERATOR side of the corpus contract, and re-points the corpus pin at the
commit this generator actually reproduces.
Four defects, one root: the recipe was written against an interface that does not
exist.
out— lower case. The task runner's expansion grammarmatches UPPER-CASE names only (
sdk/kit/checkvars_expand.go:16-17), so the valuenever reached the command and the shell dropped the empty word: "--out: expected
string value but got EOL". It is
CORPUSnow.--rootnamed the wrong tree. The generator's ONE generation input — the refslist,
candy/charly-marketplace/charly.yml— lives INSIDE the corpus checkout, so--rootmust name it. Both flags now name the corpus checkout.git statusin THIS repo, so it checkedthe plugin's tree, not the corpus's. It names the corpus now.
timeout:. The per-step never-hang bound is 1m30s, andthe drift resolves EVERY corpus source (the refs list names ~1200 repos at pinned
versions) before it compares a single byte — measured 11m48s in the marketplace
repo's own deploy workflow — so the step was killed at the ceiling and the gate
reported a TIMEOUT where the truth was "slow, and green". The bound is a hang guard,
not a budget.
The SDK pin (
candy/plugin-marketplace/go.mod, v0.2026296.2152 → v0.2026297.129).The old version's
acceptedRefrefuses EVERYgithub.com/opencharly/charlyref, andthe corpus's refs list relies on the bare repo-root form
(
@github.com/opencharly/charly:v2026.297.0032) being a corpus source. A standalonebuild of the generator therefore dropped the charly corpus source, the closure twin
layer-charly-internalswon, and a rule-renumbering difference (R2/R3 → R3/R4) left 9stale artifacts. The new version admits the bare repo-root form and refuses only the
SUBPATH form (
opencharly/sdk#468→opencharly/sdk#470). The declared-refs fix thatPR #47 adopted (
opencharly/sdk#467) is present in both versions, so the bump is astrict superset, not a revert.
The corpus pin (
var: MARKETPLACE_REFand the literal that duplicates it bydesign, e802da68 → cc10955e). The current generator no longer reproduces the Oct-15
corpus commit; cc10955e is the corpus's current
mainhead, which is the invariant thisrepository declares.
A CI job (
.github/workflows/drift-gate.yml). The marketplace repo's deployworkflow owns the CORPUS side of the contract; nothing owned the GENERATOR side. The
job runs the recipe on every merge to
main, against the corpus commit this repoDECLARES, read out of
candy/plugin-marketplace/charly.ymlrather than repeated as asecond literal — one pin, not two that can drift.
It runs on
mainand not onpull_request, and that is a design constraint, not anomission. A generator change that alters the output makes the declared corpus commit
stale, and the corpus can only be regenerated with a RELEASED generator — so a
pull-request gate would be red by construction for exactly the change it should inform,
and it would deadlock the chain that repairs it (generator merged and tagged → the
corpus regenerates with that tag → the pin is bumped here). It would also be a de-facto
required check: the org pr-validator's
armjob arms native auto-merge only whenEVERY check on the head is green.
Two more defects, found in passing.
task marketplace:pin's second step compared the corpus'smainhead against thefile's LAST 40-hex string — which is the sha256 prefix of the byte-identical SKILL.md
the candy's file records, not a pin. The step could never be true. Each occurrence is
now found by its ANCHOR (
MARKETPLACE_REF:andcontains:), which a third stringcannot satisfy. The same anchoring is what the CI job reads the baseline with.
setuplauncher and the.claude/hookssurface were removed at source in2026.241.1105, but the README, the box description, the candy's description, the
plugin's package comments and the prune boundary still described them as shipped.
They are gone from the prose, and the generator's own test
(
assertNotWritten(t, out, "setup")) is the truth they now match.Files:
charly.yml— theskills:driftrecipe (the parameter, both flags, the corpus'scommitted-state step, the timeout) and the
marketplace:pinrecipe (the anchoredgreps).
candy/plugin-marketplace/charly.yml— theMARKETPLACE_REFvar, the literal thatduplicates it, the candy's description, and the box description's stale "setup".
candy/plugin-marketplace/go.mod,go.sum— the SDK bump..github/workflows/drift-gate.yml— the new CI job.candy/plugin-marketplace/command.go,emit_marketplace.go,generate.go,generate_test.go,plugin.go,prune.go— the--roothelp, the packagecomments and the prune boundary.
README.md— the commands section and the new "The drift gate" section..gitignore— the CI worktree directory.Head
9364e0a976e9ee04de9dea715efbac9e7a4c92a7— 13 files changed, 273 insertions(+), 67 deletions(-).Change classification
plugin/candy— the corpus generator's task recipes, its dependency pin, and the CIjob that runs the gate. The generator's emission pipeline, the corpus it generates and
the
marketplace-appbox's shape are untouched.opencharly/sdk#468→opencharly/sdk#470.job; it touches no validator workflow, no branch-protection config, no auto-merge
mutation and no rulebook. The new workflow runs on
mainand on manual dispatch,never on pull requests, so it cannot become a required check.
--param out=<path>was inert, because the value never reached the command, and--param CORPUS=<path>is the one the expansion grammar accepts. The plugin's ownCLI flags (
--root,--out) are unchanged. Rolling back is a revert.How tested
Every command below was run on the tree this PR pushes, on charly v2026.296.2116 —
the same toolchain the marketplace repo's own build action pins, so the two halves of
the contract are compared like for like.
Block 1 — the gate, on the pushed tree.
Block 2 — the negative control: the gate CAN fail (R7). A gate that cannot fail is
not a gate. One byte in the emitter every generated markdown carries was mutated, the
same command was run, and the gate went red; the file was then restored and the tree
came back clean. The paste is the run's own log with the resolver's progress lines and
the runner's truncated stderr excerpt filtered out — the filter is the command shown.
Block 3 — the R10 bed, on the pushed tree. A fresh build of the box, a deploy on the
disposable: truetarget, the live check, the feature run, the update, the rebuild,and the cleanup.
Block 4 — the pin bump is necessary: the SAME generator against the PREVIOUS corpus
pin. The run resolves the same ~1200 sources and then compares against e802da68
instead of cc10955e; the verdict is at the end of the log.
Block 5 — the module builds, its tests pass, the manifest validates, and the pin
recipe passes.
Block 6 — the R5 sweep, claim-keyed. The claim is "nothing in this repository
describes the
setuplauncher or the.claude/hookssurface as a shipped feature".Ten occurrences survive, and every one is classified here:
candy/plugin-marketplace/charly.yml:185— the bed arm's comment, which names thearm it REPLACED. It is history, and the arm it replaced is exactly what the comment
explains.
candy/plugin-marketplace/generate_test.go:378and:397— comments stating thatthe setup launcher is gone.
candy/plugin-marketplace/generate.go:11and:44— the package comments, whichstate that the generator no longer writes charly's harness surface.
candy/plugin-marketplace/generate_test.go:195— the same statement, in the test.candy/plugin-marketplace/generate_test.go:199,:200and:383— the module's ownGUARANTEE, expressed as three
assertNotWrittencalls: those paths are not emitted.candy/plugin-marketplace/generate_test.go:116— a hook path held as DATA, in theJSON fixture the generator must not emit.
No surface presents either as shipped.
The lower-case parameter is gone: the claim-keyed grep for it returns no matches.
Rulebook compliance
/charly-internals:marketplace(the corpus generation and ownership split, and thedrift gate),
/charly-internals:plugin(theplugin:block and the per-plugin CUEschema),
/charly-internals:git-workflow(before any git or PR action) and/charly-build:docs(the prose surface). This repository's ownAGENTS.mdnames thesame four, and it was read first.
the remediation: the four recipe defects, the SDK
acceptedRefrefusal, the pinrecipe's unreachable second step, and the stale
setupprose. Two findings that aregenuinely separable from this change were filed as issues and are linked under
## Filed issuesbelow — the pin-currency comparison is still manual, and themodule's Go tests still run in no CI job. Nothing was filed away as "pre-existing".
read from ONE place (
var: MARKETPLACE_REF), by both the CI job and the pin recipe;the duplicated literal inside the candy file is the one the recipe asserts against,
as this repository's
AGENTS.mdrequires. The generator and the drift command keepsharing the same emission pipeline. The CI job builds the charly host from SOURCE at
the same pinned tag the corpus's own build action uses
(
opencharly/marketplace's.github/actions/build-charly, v2026.296.2116), so thetwo halves of the contract are compared like for like; the build steps are mirrored
rather than referenced, because a composite action's
uses:takes a literal ref andthis repository's corpus commit is READ from its own file — a literal corpus sha
here would be a SECOND corpus pin, which is the duplication this rule forbids more
strictly than one toolchain tag appearing in two repositories.
The never-hang ceiling was raised to the budget the operation actually needs rather
than worked around with a wrapper, and the recipe's inputs are the real ones. The one
place a value crosses a shell boundary — the task parameter — is quoted at its use
site, because the expansion is text and not a data channel
(
opencharly/plugin-task#34owns the class-level fix).CI job are the product; the README's new "The drift gate" section describes what they
do, and it was written after they were green.
and every survivor classified there. The lower-case parameter is gone, with its own
sweep. No legacy path, alias or dual-mode survives.
git statuswas read before every mutation, the work belongs to thissession's own worktree, no submodule was edited, no history was rewritten, no hook
was bypassed, and the branch is a
fix/branch cut from freshorigin/main.from the pushed tree, and the file is restored afterwards. The changed path executes
live: the gate, the bed, the module's own tests and the pin recipe all run against
the real corpus checkout and the real plugin binary.
a real clone at a real commit, the plugin is built from this tree by the charly
host, and the R10 bed runs against the
disposable: truetarget. Nothing is mocked.witness in block 5 names the module and the SDK version it was built against.
disposable: truetarget(
check-marketplace), on the exact tree this PR pushes, with zero warnings.the corpus's bare
github.com/opencharly/charlysource, and it was proved bymeasurement (the 9 stale artifacts, and the direct
acceptedRefbehaviour) beforethe pin moved. ADE: the candy's
plan:is the acceptance test — the twocheck:steps ofskills:driftare exactly the two the drift gate runs, and thebox's checks are the ones the bed runs. SDD: N/A — no schema and no
.cuechange.the prose move together, with no phase, TODO or deferred cleanup left inside the
scope. One commit, one squash.
before the branch was cut, and no other open PR or issue touches this recipe. The
two filed issues below are the ones this change's own findings produced.
Agent:FIRST andAssisted-by:LAST on the issue and on this PR.disposable: truecheck-marketplacetarget, and it went through the owningcharly check runcommand. No other pod, container or VM was touched.
comments and help text; the emission pipeline, the schema and the plugin's
placement are untouched.
gofmtand the module's tests are in block 5.Attribution tier
fully tested and validated— the change-class gate passed on the final tree withzero warnings (block 1), the R10 bed passed on a fresh build of that tree against the
disposable: truetarget (block 3), the changed path executed live (blocks 1, 2, 4and 5), and the coverage was shown to fail when the change is removed from the tree
(block 2).
Filed issues
MARKETPLACE_REFcan go staleagain and nothing would see it. The drift gate compares the generator against the
DECLARED corpus commit; it never consults git, so it cannot answer the
pin-currency question, and the recipe that does answer it (
task marketplace:pin) ismanual. Non-blocking for this change: the pin is current in it, and the fix is a CI
job that REPORTS the
behindstate rather than failing on it — the umbrellarulebook (Part II fix: the setup launcher must not require the removed charly submodule #3) defines a
behindpin as legal and the remedy as a bump PR.repository's only CI is the org-wide pr-validator, whose
lintjob skips thisrepository cleanly because it declares no golangci-lint config, and no job compiles
or tests the module. Non-blocking for this change: the module's tests pass on the
pushed tree, as block 5 shows.
Agent: charly
Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (fully tested and validated)