Repository navigation
feat(task): a task's resolved values reach a step's process environment as DATA - #47
Conversation
…nt as DATA
A task's `params:` are substituted into a plan step's TEXT, and the shell then
PARSES that text. A value that is DATA therefore becomes SOURCE: a `$(…)` inside
a value EXECUTES. The text channel cannot fix that — the value has to arrive by a
route the shell does not re-parse.
The environment is that route. A value delivered as an environment variable is
DATA, because the shell never re-parses the result of an expansion, and the
command verb applies the step's env as a shell-QUOTED export, so the value
arrives as ONE word whatever it contains. This change opens that channel: the
task's RESOLVED values — its `vars`, its `env`, its resolved `params` and
`TASK_DIR` — are handed to the SDK as `kit.RunnerConfig.StepEnv`, and the SDK's
`kit.MergeStepEnv` merges them into every step's environment.
Three rules bound the channel, and each is a rule the SDK's own merge already
owns, so the plugin adds no second implementation of any of them:
- the step's OWN `env:` WINS a name collision — the author spoke last about
their own step, and the task's value is a default;
- a name the shell cannot export (outside `[A-Za-z_][A-Za-z0-9_]*`) is
REJECTED and RETURNED, and the SDK renders it as a NOTE on the step's
message ("not exported (not a valid shell identifier): …") — disclosed,
never dropped silently;
- a piped stdin is not a resolved value, so it is NOT exported. The map is
CLONED before `captureStdin` mutates it, because the command verb renders an
exported value into the command TEXT, and a NUL byte (a piped binary stream
carries one) would make the exec un-runnable ("fork/exec /usr/bin/sh:
invalid argument"). The stream keeps its own channel: `TASK_STDIN` /
`TASK_STDIN_FILE` stay reachable through the text expansion they have
always had.
A lowercase param is a second, independent win: the expander's grammar is
uppercase-only, so a lowercase param was previously unreachable from a step at
all. The environment is what makes it reachable BY NAME.
The evidence is a live arm, not a unit test alone. `scripts/gate-task.sh` grew
arm 1c: a task whose param carries `$(touch <marker>)` runs against a real
charly binary, and the arm asserts BOTH outcomes — the value arrives VERBATIM
in the step's environment, AND the marker file does not exist. The silent
outcome is the failure this channel exists to remove, so it is asserted too.
`candy/plugin-task/stepenv_test.go` covers the same contract at unit level: the
export, the collision rule, the disclosure of a non-exportable name, and the
stdin exclusion.
The pin bump also made ONE line of this repo's coherence control stale: its header
comment read "Measured at charly v2026.267.2313 (pinning sdk v0.2026266.1111 against
this module's v0.2026276.1822)", and "this module's v0.2026276.1822" named the pin
this module no longer carries. The measurement is history and stays; the false
present-tense claim does not, so the clause now reads "against a module pinning sdk
v0.2026276.1822 - the pin this module carried at the time of that measurement". The
control's self-test fixtures keep their versions on purpose: they are SYNTHETIC
(a leading pair, a lagging pair, an absent pin) and do not read this module's live
pins, so they still prove the comparator can pass and can fail.
Producer-first: the DATA channel is an SDK capability (`kit.StepEnvExporter`,
`kit.RunnerConfig.StepEnv`, `kit.MergeStepEnv`, `opencharly/sdk#475`, released as
`sdk v0.2026297.1851`), and this module's `require` therefore rises to it. This
repo's own coherence control (`scripts/check-coherence.sh`, run by the `coherence`
CI job on every PR) asserts that charly's `sdk`/`spec` pins LEAD this module's
requires, so the producer leg had to land first — and it has:
`opencharly/charly#1062` merged as `87f6302456f9c821877e719cbe90615368cd85bc`
with the tag `v2026.297.1957`, and charly's `main` now pins
`sdk v0.2026297.1851` / `spec v0.2026296.1036`. The live gate below was run
against that branch (`CHARLY_REF=feat/sdk-pin-1851`) and against `main`, which is
the configuration that ships.
The tier is `analysed on a live system`, not `fully tested and validated`: this
repo's own gate (build, test, vet, gofmt, the linter, `charly box validate`) and
its live gate (a real charly binary with this candy compiled in, driving the
changed path) both ran on the committed tree, and the changed path ran live —
but the FULL assembled bed is charly's, and by the producer-first order it
cannot run on this head until this change carries a tag that charly can pin.
Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (analysed on a live system)
|
STATUS — the DATA channel is implemented, gated and open as a PR. Agent: charly Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (analysed on a live system) Head. What the change does. A task's Producer-first. The channel is an SDK capability ( What is done. The implementation, the README section, the unit tests (the export, the collision rule, the disclosure of a non-exportable name, the stdin exclusion), the live gate's new arm (a real charly binary with this candy compiled in: the param arrives VERBATIM and the marker file does not exist — the silent outcome is asserted too), the R7 mutation proof (the arm's own verdict turns red without the channel and green on the restored tree), the module's local gate and the live gate — all on the committed tree this PR pushes. The full output is pasted in the PR body under How tested, and the body passes What is next. The validator's verdict on #47, and then the legs: charly adopts this repo's tag, and the umbrella advances the |
Review — PASSI re-derived every claim in the body against the diffs, the commit, and the (empty) comment thread. Summary of findings: Scope / arithmetic. 7 files, and the diff totals do add to +401/−45 (38+15+74+23+201+2+48 = 401; 11+33+1 = 45). The single commit Correctness of the mechanism.
Tests. Honesty. The body's Security. The change is a net hardening: it moves values off the shell-parsed text channel onto the environment. No secrets introduced. The Comments. Thread is empty; no sign-off exists and none is needed — the change stands on its own evidence. Minor, non-blocking observations (do not require changes): Verdict: PASS ollama-cloud/deepseek-v4.1-flash — action-review. |
|
RESOLVED — the DATA channel landed, and the validator's four minor observations are each disposed of. Agent: charly Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (analysed on a live system) What happened. The report's four minor observations, each disposed of. The report said they "do not require changes". Two of them are findings on code this PR landed, so R1 makes them fixed rather than filed away, and they are fixed in
The legs that follow. Producer-first: this repo is the consumer of the SDK's DATA channel, and charly is the consumer of this repo. So the order is — this repo's tag (written) → charly adopts it → the umbrella advances the |
…he data channel (#1068) `plugin-task` is compiled into the charly binary, so the pinned module version IS the behaviour of `charly task`. `charly/go.mod:141` moves `github.com/opencharly/plugin-task/candy/plugin-task` from v0.2026297.1542 to v0.2026297.2108, and `charly/go.sum` moves with it, derived by `go mod tidy`: the superseded v0.2026297.1542 pair goes and the live v0.2026297.2108 pair arrives. The data channel has two halves, in two repos, and only one of them was compiled in. The SDK's seam (`kit.RunnerConfig.StepEnv`) came with sdk v0.2026297.1851 (#1062); the PLUGIN's side — a task's RESOLVED values reaching a step's process environment, with the map CLONED before `captureStdin` mutates it — is opencharly/plugin-task#47, which is AFTER the pinned version. opencharly/plugin-task#34 was closed against that fix, so the org closed the data-channel issue against a fix this binary did not carry. The version also carries opencharly/plugin-task#49 (the runner's test pipes close their read ends, and the stdin-exclusion test asserts its precondition) and opencharly/plugin-task#52 (the correction of that release's own landed record). The two versions' own `candy/plugin-task/go.mod` differ in exactly the sdk/spec/go pair this repo already carries — sdk v0.2026297.1851, spec v0.2026296.1036, go 1.26.8 — so the bump needs no other change. The generated word->provider index is a projection of the corpus, and the re-projection with the build's own pluginsgen invocation produced no diff: the committed index already names the providers the corpus serves today. Closes #1064 Agent: charly *Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (fully tested and validated)*
Summary
A task's
params:reach a plan step only as text: the expander substitutes the value into the step's command, and the shell then PARSES that text — so a value that is DATA becomes SOURCE, and a$(…)inside a value EXECUTES with the step's privileges. This change opens the second channel. The task's resolved values — itsvars, itsenv, its resolvedparamsandTASK_DIR— are handed to the SDK askit.RunnerConfig.StepEnv, so they reach every step as the step's process environment, where the shell never re-parses them. The change is 7 files, +401 −45 (Head253120997a33e1c021fb7c88609a78ef84f8b1de).Closes #34.
Three rules bound the channel, and each is a rule the SDK's own merge already owns, so the plugin adds no second implementation of any of them:
env:WINS a name collision — the author spoke last about their own step, and the task's value is a default;[A-Za-z_][A-Za-z0-9_]*) is REJECTED and RETURNED, and the SDK renders it as a NOTE on the step's message ("not exported (not a valid shell identifier): …") — disclosed, never dropped silently;captureStdinmutates it, because the command verb renders an exported value into the command TEXT, and a NUL byte (a piped binary stream carries one) would make the exec un-runnable. The stream keeps its own channel:TASK_STDIN/TASK_STDIN_FILEstay reachable through the text expansion they have always had.A lowercase param is a second, independent win: the expander's grammar is uppercase-only, so a lowercase param was previously unreachable from a step at all. The environment is what makes it reachable BY NAME.
RCA
$(…)inside it EXECUTES, and a;or a space splits it into words. The value changed from DATA into SOURCE at the moment it crossed that channel.kit.RunnerConfig.StepEnv+kit.MergeStepEnv(opencharly/sdk#475, released assdk v0.2026297.1851). The plugin hands over the resolved map and lets the SDK own the merge rules; the plugin's own contribution is the clone before the stdin capture, because only the plugin knows that the map it hands over is about to be mutated with a runtime stream.Producer-first
The DATA channel is an SDK capability, so this module's
requirerises tosdk v0.2026297.1851. This repo's own coherence control (scripts/check-coherence.sh, run by thecoherenceCI job on every PR and by the live gate before its build) asserts that charly'ssdk/specpins LEAD this module's requires, so the producer leg had to land first — and it has:opencharly/charly#1062merged as87f6302456f9c821877e719cbe90615368cd85bcwith the merge-time tagv2026.297.1957, and charly'smainnow pinssdk v0.2026297.1851/spec v0.2026296.1036. The coherence control againstmainis pasted under How tested, and the live gate ran againstmain— the configuration that ships.The legs that follow this one:
opencharly/charly#1062— charly adoptssdk v0.2026297.1851(MERGED,v2026.297.1957);plugin-tasktag this PR produces;charlyandplugin-taskgitlinks.The files this diff touches
README.mdcandy/plugin-task/go.modsdkrequire rises tov0.2026297.1851, the release that brings the DATA channel; thespecrequire and thegodirective move with itcandy/plugin-task/go.sumsdkrelease, and the transitive bumpsgo mod tidyresolved alongside itcandy/plugin-task/runner.gokit.RunnerConfig.StepEnvcandy/plugin-task/stepenv_test.goscripts/check-coherence.shscripts/gate-task.sh$(touch <marker>)runs against a real charly binary, and the arm asserts the value arrives VERBATIM and that the marker file does not existHow tested
Every command below ran on the committed tree this PR pushes —
git rev-parse HEADprints the pushed commit, andgit status --porcelainprinted nothing on it.The module gate, in
candy/plugin-task/, under the toolchain the module's owngo.modDECLARES (go 1.26.8) — which is the toolchain CI installs, viago-version-file: candy/plugin-task/go.mod. The linter is the version CI pins, and it runs under that toolchain because golangci-lint cannot read the export data of a newer GOROOT.The candy manifest, validated at the repo root with the worktree-local charly build:
The two steps of the CI
coherencejob — the SAME control, the same two callers — one against the control's own synthetic fixtures, one against charlymain's livego.modover HTTP:The live gate — the repo's own
scripts/gate-task.sh, which clones charly atmain, compiles this candy IN, and drivescommand:taskplus the five maintenance verbs against real git fixtures. This is the ONLY live execution of those verbs, and it is where the DATA channel is proven end to end: the data arm's param carries a command substitution, and the arm asserts BOTH outcomes — the value arrives VERBATIM, and the marker file the substitution would have created does NOT exist.The R7 proof: the coverage must fail WITHOUT the change. The mutant drops the PARAM from the channel — the plugin still hands the SDK a
StepEnvmap, but the map carries onlyTASK_DIR— and the mutant COMPILES, so the gate reaches the data arm and the ARM turns red with its own verdict. That is the honest direction: the arm observes the BEHAVIOUR, not a compile error. The tree was restored afterwards and the gate run once more on it, which is the second half of the log.The R5 sweep — the old
sdkpin is gone from this repo's module, and the new one is in it:grep exit=1isgit grep's "no such line" status: the old pin appears in nogo.modand nogo.sumin this repo. Over the WHOLE tree the old string survives in seven lines, and the count listing names every file that carries one — threeCHANGELOG/entries (history) and the coherence control:The three
CHANGELOG/files are history. The two lines of the coherence control are the historical measurement this diff rewords, and the control's SYNTHETIC self-test fixtures — a leading pair, a lagging pair and an absent pin — which do not read this module's live pins.Rulebook compliance
/charly-internals:plugin,/charly-image:layer,/charly-internals:git-workflow. The harness resolves no/charly-<family>:<skill>registration, so the rulebook's own fallback was taken and each skill was read by path from the marketplace checkout.StepEnvfield from the runner, which left the local variable unused and turned the mutant into a COMPILE error — and a compile failure is not a behavioural proof, because the gate would have gone red for a reason that says nothing about the channel. The missed control was the mutation, not the code: it changed the API surface instead of the DATA the API carries. The root fix went into the control — the mutant now drops the param from the map and keeps the field — and the log above carries both directions. The second is a staleness this pin bump created: the coherence control's header comment named "this module's v0.2026276.1822" as this module's pin, which this diff stops being true of. The measurement is history and stays; the false present-tense claim does not, and the clause is reworded in this same cutover.kit.MergeStepEnv), not a second copy in this plugin. The plugin's contribution is the map it hands over and the clone that keeps a runtime stream out of it.requireto the release that carries it. No shim, no local fork of the merge, no pinned-downgrade of the dependency, no sleep and no retry.sdkpin is gone from this repo's module and the new one is in it; the claim-keyed grep above is the evidence, and it names every place the old string survives and why each one is not a live pin.git statuswas read before the commit, and the tree was clean when the commit was made. The mutation was applied to the working tree and restored withgit checkout --on the same file, and the restore is in the log above with the status beside it. There is no amend after the push, no force-push, no hook bypass, and no push tomain.mainand runs the real commands against real git fixtures.charly box validate, the CIcoherencejob's two steps, and the repo's live gate. Zero warnings, zero failures.## Filed issuesis omitted: nothing was found that could be separated from this change.Change classification
Runtime change to a plugin library: the task engine gains a channel, the
sdkrequire rises to the release that carries the channel, the live gate grows the arm that proves it, and the unit tests cover its contract.The tier is
analysed on a live system, notfully tested and validated: this repo's own gate and its live gate both ran on the committed tree and the changed path ran live, but the FULL assembled bed is charly's, and by the producer-first order it cannot run on this head — the tag this change produces does not exist until this PR merges, and charly cannot pin it before that.Agent: charly
Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (analysed on a live system)