Releases: pzzld-org/shepherd
Release list
v6.5.6
v6.5.6 — 2026-08-23
Changed — Cargo is the crates.io publisher
The release path now delegates workspace packaging, dependency ordering,
package verification, upload, and registry polling to Cargo itself.
cargo-publish.yml retains only release-SHA/version custody, a dry-run and
explicit missing-package recovery surface, and bounded reconciliation of the
exact versions reported by crates.io metadata.
The publication-critical path no longer downloads each published archive.
A transient CDN or archive-read failure therefore cannot redefine an artifact
Cargo already uploaded and crates.io already accepted as a failed publication.
v6.5.5
v6.5.4
v6.5.4 — 2026-08-19
Changed — the npm adapters are named for the harness they adapt
@pzzld/pi-claude and @pzzld/pi-codex were wrong and read as nonsense: "Pi's
Claude adapter" describes nothing true, and neither package has anything to do
with Pi. The convention is <harness>-shepherd, which @pzzld/pi-shepherd
already followed by accident of Pi's own pi-* package convention.
| was | is |
|---|---|
@pzzld/pi-claude |
@pzzld/claude-shepherd |
@pzzld/pi-codex |
@pzzld/codex-shepherd |
@pzzld/pi-shepherd |
unchanged |
The rename immediately silenced two dependency rules, and they said nothing
about it. packages/scripts/check-deps.mjs identified an adapter by the name
prefix @pzzld/pi-, so under the correct names two of the three adapters simply
stopped being adapters — including for the reverse-edge rule that keeps the
shared component runtime from depending on the adapters above it. Adapter
identity is now an explicit, closed set.
Its self-test fixtures had the same defect from the other side: they used
synthetic @pzzld/pi-a, pi-b and pi-c names that were only ever adapters
because the check was a prefix match. They now use the real adapter names,
which is a more faithful violation regardless.
Not yet done, and it needs your npm credentials: the old names are still
published (through 6.5.3) and are not deprecated. Run
npm deprecate @pzzld/pi-claude "renamed to @pzzld/claude-shepherd"
npm deprecate @pzzld/pi-codex "renamed to @pzzld/codex-shepherd"Nothing depended on either package outside this repository, so no consumer
breaks; the deprecation is a signpost, not a migration.
v6.5.3
v6.5.3 — 2026-08-19
Fixed — the Pi adapter ran for the first time, and nothing about it worked
v6.5.2 made the extension loadable. This release makes it work. Loading
it was what finally executed the code, and executing it found three defects that
had been sitting in shipped bytes the whole time, unreachable and therefore
unfalsified.
The visible symptom was worse than the original bug: with the extension loading
and throwing, Pi could not initialize a session at all. Inert became blocking.
1. No dispatch request the transport built was ever accepted. Every request
struct in crates/core/src/dispatch/portable.rs declares pub schema: String
and is #[serde(deny_unknown_fields)]; crates/cli/src/dispatch_service.rs
validates it against one constant. The WIT records deliberately omit it — the
component owns the semantic payload, the transport owns the wire framing — and
nothing filled the gap. bind-root, start, resolve, stop, resume: all
rejected. planToNativeDispatch now stamps the envelope.
2. WIT says tool-use-id; the native struct says tool_call_id. With
deny_unknown_fields, every resolve was rejected, so the Pi guard denied
every write, edit and bash call it was consulted about — fail-closed, and
unusable. A repo-wide diff of all six shared records confirmed this is the
only naming divergence, so the reconciliation map is exhaustive rather than
a first instalment. It is reconciled in the transport, not the WIT, because the
WIT is a published contract and renaming a field there breaks every embedder.
3. A binding failure took the whole session down. session_start rethrew,
which Pi surfaces during bindExtensions and which prevents the session from
initializing. One unreadable run.json in a project made Pi unusable in that
directory. Shepherd's contract is that an unbound session may not mutate,
not that the host may not run — the Claude path has always returned
additionalContext on SessionStart and reserved deny for PreToolUse. The
failure is now recorded and the guard denies every mutating tool while it is
set, which preserves fail-closed without taking the session with it.
Fixed — two deadlocks where the repair tool was blocked by the thing it repairs
Both were reproduced against a real project (~/src/fl03/axiom), not constructed.
- A retired config key made every command fail, including the ones that fix
it.shepherd.codex.tomlstill carriedspawn.max_concurrent_children,
retired with the spawn-concurrency rework.deny_unknown_fieldsmade the
config unloadable, which aborteddoctor,migrateandinitalike — so
nothing capable of repairing it could run. The closed, typed retired-key
registry (strip_retired_layout_v5) already existed but was consulted only in
migration mode; it now applies in every mode. Typo protection is unchanged,
because the registry is closed and typed: a key shepherd itself once wrote is
recognized, type-checked and dropped, while a key that was never in the schema
still fails with the did-you-mean list. A retired key carrying the wrong
historical type still fails too, so tolerance never becomes silent discard. run migratecould not migrate the documents it exists for. It already
performed all three transforms an old run needs (run_id→run,lanes
dict→list,updated_at→epoch), but re-inserted the dict key verbatim as the
laneid, andRunStore::validate_id— a write-side lowercase rule —
rejected the historical upper-case ids on read. One case-fold repairs it.
A correction worth recording: the run.json failure was not a
deny_unknown_fields problem, though it looked like one. RunState has no such
attribute and already carries #[serde(flatten)] extra. It was a structural
change — lanes was a dict, the struct wants a list — which is why a migration,
not a relaxation, was the fix.
Added — /shepherd:start is perspective-bound
The flag now picks who owns fan-out:
- Root perspective (default). One
shepherd:engineerledgers the plan and
stops; it authors no execution. Root then runs the waves itself — discovery
and initialization, then execution — dispatching each implementer at
shepherd models resolve <role>under one bounded workflow per wave. No
conductor, no lane ledger. For work where splitting into lanes costs more than
it saves. - Lane perspective (
--lane <lane>). The previous behaviour, made
repeatable and explicit. Orientation is abbreviated on purpose because
lanes/<lane>/plan.mdalready carries the phases. The conductor drives rather
than authors:workerandcoderexecute, an adversarialauditorverifies
behind them, and a failed verification forces redo.
This is a pure skill-contract change — there is no shepherd start CLI command
and no existing level flag to reuse. It landed inside a 15-word budget: the
compiled Claude skill bundle is capped at 3,500 UAX-29 words and the first draft
came in at 3,515.
Added — the target-final oracle has a generator (#341)
conformance/content-target-final.json freezes what the compiler emits for all
three harnesses — a tree digest plus every file's byte length and hash — and had
no generator. Every content change meant hand-editing three trees of hashes,
which failed the way hand-editing generated data always does: once written as an
array when the schema is an object keyed by path, and once regenerated correctly
but pushed without the file, turning five CI checks red.
scripts/generate-content-oracle.py --write|--check reads the live compiler.
--write refuses to proceed if a file whose byte length did not change now
hashes differently, which is what separates "regenerate because content changed"
from "bless whatever the compiler now emits". --check is wired into the gate.
A third hand-maintained copy of the Codex digest lived in
crates/component/tests/component.rs, and its own comment admitted the copies
were allowed to disagree with the oracle. A digest permitted to disagree with
what it mirrors is not an assertion; it now reads the oracle.
Fixed — an error message that described the wrong defect
shepherd dispatch deserializes straight into the typed struct and mapped
any serde failure to request must be one valid RFC 8259 JSON value. The
JSON was well-formed; a required field was missing. That message sent debugging
after an encoding bug that did not exist. It now distinguishes the two cases and
names the field:
ERROR: request is valid JSON but does not match the dispatch schema:
missing field `schema` at line 1 column 100
Added — the boundary is now gated from both sides
scripts/check-wire-contract.pydiffs every WIT record against its native
struct. Divergences must be declared (WIRE_ONLYframing, or an explicit
RENAMESentry that the transport is verified to implement) — a new one
cannot appear silently. 4 self-test cases.scripts/tests/test-native-dispatch-wire.shfeeds a real request to the real
CLI and requires it to be accepted, then strips the envelope and requires
refusal. Its first draft passed vacuously because the scratch project was not
scaffolded, so the CLI refused before ever parsing stdin; it now scaffolds and
asserts it got past that check.
What existed and did not catch any of this:
packages/component-runtime/test/native-transport.test.mjs asserts only how the
CLI binary name is resolved. Two green tests, zero coverage of whether a
request was ever accepted.
v6.5.2
v6.5.2 — 2026-08-19
Fixed — the Pi adapter has never been loadable by Pi
@pzzld/pi-shepherd shipped with no pi key in package.json, for its entire
history. Pi discovers everything a package contributes from that one key:
"pi": { "extensions": [...], "skills": ["./skills"], "prompts": ["./prompts"] }With it absent, Pi loaded nothing — not the nine skills, not the nine role
prompts, and not src/extension.mjs, which means the lifecycle hooks, the guard,
and native dispatch never ran either. The package installed cleanly and was
completely inert. /shepherd:shepherd resolved to nothing in the Pi TUI while
every other installed package listed its skills normally. Confirmed absent in
every commit that has ever touched that manifest.
This was never a content problem. shepherd compile --target pi has always
emitted exactly the shape Pi wants — skills/<name>/SKILL.md and
prompts/<role>.md, 19 files. Nothing put that output inside the package, and
nothing declared it.
Measured before and after, by resource count in Pi's own resolver:
| Resources Pi registers | |
|---|---|
| before | 86 (shepherd contributes 0) |
| after | 105 (shepherd contributes 19) |
19 = 9 skills + 9 role prompts + 1 extension.
The fix is three parts:
packages/harness-pi/package.jsondeclarespi.extensions,pi.skills,
pi.prompts, thepi-packagekeyword, and afileslist that actually packs
the generated directories. Apikey naming./skillswhilefilesomits it
is inert in the same way and harder to see, so the gate checks both.scripts/stage-pi-carrier.shcompiles the carrier into the staged package
immediately beforenpm pack. It is not committed:
test-generated-carrier-authority.shfails ifpackages/harness-pi/skills
appears in the repository, and that gate is correct — a hand-copied generated
tree is a second, inevitably stale authority. The staging script states its
counts and fails on zero, and cross-checks them againstcontent/skills
(minusportability: claude-only) andcontent/rolesrather than hardcoding.scripts/tests/test-pi-package-surface.shasserts the declaration, wired into
gate.sh, falsifiable in three directions: a correct manifest passes, a
manifest with nopikey fails (the exact shipped defect), and a
declared-but-unpacked carrier fails.
Added — npm publication exists at all now
There has never been an npm publish anywhere in this repository's CI. The
release packed four tarballs, attached them to the GitHub release, and stopped.
@pzzld/pi-shepherd and @pzzld/component-runtime were published by hand once
at 6.4.5 and never again; @pzzld/pi-claude and @pzzld/pi-codex have never
been published at all. Seven releases (6.4.6 → 6.5.1) shipped crates to
crates.io and nothing to npm.
That is the second reason Pi was broken: even with the pi key fixed,
pi install npm:@pzzld/pi-shepherd resolves to 6.4.5, because 6.4.5 is the only
version npm has ever been given.
npm-publish.yml publishes the exact tarballs cargo-build.yml already
produced — never a rebuild, because a second construction path for bytes that
were already checksummed and attached to the release is how two sources of truth
begin disagreeing. Two asset sources and no third: the in-run artifact during a
release, the published release assets when recovering.
scripts/npm-publish.py is idempotent (an npm version is as un-reissuable as a
crates.io one, so an already-published version is skipped, never overwritten),
orders @pzzld/component-runtime first because every adapter pins it exactly,
and reads identity from the manifest inside each archive rather than the
filename. That last point was not theoretical: the first draft hardcoded the
directory names and resolved zero of the three adapters, because
packages/harness-claude publishes as @pzzld/pi-claude. A registry that
cannot be reached is a fail-closed error, not a traceback.
Both publishers are now reachable four ways and symmetric: workflow_call
from release.yml (held behind every asset job), workflow_run on
cargo-build completing on its own, workflow_dispatch for operator recovery
(defaulting to not publishing), and repository_dispatch. The release gate
asserts both declare needs on the build, and that NPM_TOKEN is forwarded
explicitly — workflow_call inherits no secrets.
Removed — dead code
scripts/publish.sh, 285 lines: a hand-rolled crate publisher with a
hardcoded, half commented-out crate list, executed by nothing and fully
superseded bycargo-publish.py(which covers all six crates against its
stale four). Dead code is not free — it reads as current and gets copied from.scripts/tests/fixtures/native-dispatch-ok.mjs, referenced nowhere.scripts/check-gate-wiring.pynow fails on any script underscripts/that
nothing runs and nothing documents, with a short, deliberate allowlist for
operator tools. Prose counts as reachability here, unlike for tests, because a
human is the runner.
Added — the cross-harness claim is now falsifiable
scripts/tests/test-harness-surface-parity.sh asserts all three harnesses at
once and derives every count from content/ rather than hardcoding one:
Claude ships all 10 authored skills and 9 role agents, Codex ships the 9
cross-harness skills, and Pi declares a loadable surface that release staging
materializes. Self-test proves the counts can fail, including the exact zero
case that shipped.
Each harness was previously checked in isolation. The comparison between
them — which is the product claim — was checked by nobody.
Why it went unnoticed for so long. No gate ever asked what Pi ships.
check-plugin.py derives its roots from the Claude and Codex shipping manifests
only, so "Claude 10 skills, Codex 9, Pi 0" was not an assertion anywhere. The
v6.5.1 sprint audited gates extensively and did not catch this, because it
checked whether existing gates worked rather than whether the cross-harness
claim was true. Verifying the three harnesses are actually at parity was the
sprint's headline goal and it was never tested end to end.
v6.5.1
v6.5.1 — 2026-08-19
Five remediation messages named a command that refuses to run. shepherd init is gated
behind --confirm because it mutates: it mints .shepherd/project.json, the registry, and
the projects row. Five user-facing messages nonetheless printed a bare shepherd init, so
an operator on a cold project copied the remediation, got exit 2, and landed exactly where
they started. shepherd doctor carried the correct wording the entire time, which makes this
drift between call sites rather than a missing decision.
Fixed
-
The remediation is runnable as printed.
cmd/dispatch.rs,cmd/wave_b1_mem.rs,
cmd/wave_b1_status_handoff.rs,cmd/wave_e_coordination.rs, and
cmd/wave_g_coordination.rsnow nameshepherd init --confirm.cmd/wave_c_bootstrap.rs
already did; that inconsistency is what made the defect legible. -
The gated-flag coupling cannot drift back.
hooks/scripts/remediation_flag_lint.py
derives the gated-subcommand map from the CLI's own refusal text (X is mutating; re-run with --FLAG) and rejects any message, skill, or agent line naming a gated subcommand
without its flag. A hard-coded list would stop covering a subcommand the day one is added,
so there is no list. Falsified three ways inhooks/tests/test_remediation_flags.sh:
a fixture reintroducing the exact v6.5.0 wording turns it red, a fixture with no refusal
text turns it red rather than passing on zero coverage, and it caught a live violation in
this change's ownSKILL.mddraft on first run. -
The regression tests assert the flag, not the prefix.
dispatch_cli.rsand the
cmd/dispatch.rsunit test previously acceptedrun \shepherd init`, which is precisely the broken string. Both now require--confirm`; reverting the source turns them red. -
Carrier drift was a CI-only failure. Editing
skills/shepherd/SKILL.mdsilently broke
plugins/shepherd/codex/skills/shepherd/SKILL.md, whichscripts/check-plugin.pyrequires
to be byte-identical. That script ran in.github/workflows/rust.ymland nowhere else, so
the local gate lane was green while the cross-harness projection was broken, and the author
learned about it from a red CI job twenty minutes later.hooks/tests/test_plugin_contract.sh
now runs it in the gate lane (0.45s, both the plain scan and--self-test), and falsifies
itself by drifting a scratch carrier and requiring a non-zero exit. -
The root role answered to two names and only one of them worked.
shepherd models resolve shepherdprintedunknown role: shepherdand exited 2 whileshepherd models resolve rootprintedopus[1m]and exited 0, because two crates each hardcoded their own
vocabulary:crates/cli/src/cmd/wave_a_models.rsconst ROLESsaysroot, and
crates/core/src/guard/engine.rsrole_tiersaysshepherdwith norootarm at all.
content/,agents/,skills/and theshepherd:shepherdsubagent type all spell the
roleshepherd.rootis canonical on themodelssurface andshepherdis a
documented INPUT alias resolving to it, in that direction and not the reverse:rootis
the literal[models]TOML key operators write (ModelsConfig::root,
crates/core/src/settings.rs:546), anddocs/configuration.md's default table is
cross-checked against that field name byscripts/check-workspace.sh's
rule_model_defaults_match_the_docs, so renaming would ripple intocrates/coreand
docs/, both outside this change's scope.ROLESstays 9 entries andmodels shownever
grows ashepherdrow; the alias is input-only. Theunknown roletext is now built from
ROLESandROLE_ALIASESinstead of being hand-typed a third time, and a unit test
iterates both consts against the USAGE string rather than checking today's nine literal
names, so adding a role or an alias without updating the usage text turns it red. -
shepherd seed verifyHARD-failed this project's own seeds.
shepherd seed verify .shepherd/runs/v646/seed.mdexited 1 onfootprint 393 lines > cap 200 (kind=patch-seed)and onfile_scope path does not resolve and is not marked (NEW): bin, wherebinis a directory v6.4.6's own decision D4 deleted after that seed was
written: the gate was validating a historical artifact against the live tree. Two written
rules now bound that. First, an unresolvedfile_scopepath degrades to a warning only
when the seed's run has closed, and "closed" requires both a siblingclose.mdand the
path shaperuns/<id>/seed.md; every other seed keeps today's HARD failure byte-identical.
Second, the declaredkindselects the smell threshold, not the ceiling: 400 lines is the
HARD ceiling for every seed whatever its label, so relabelling a seed down buys no slack,
and apatch-seedover 200 lines now gets a warning naming the mislabel. Neither
SPRINT_FOOTPRINT_CAPnorPATCH_FOOTPRINT_CAPchanged value; the v6.4.6 carry-forward
required that no number move. The path-shape half is the safety property:
hooks/scripts/seed_preflight_check.shruns the live SEED-GATE against a bare
mktemp -t shep-seed.XXXXXXfile in$TMPDIR, so a sibling-close.mdtest alone would
let any strayclose.mdin$TMPDIRsilently downgrade that gate for every seed a
planter writes; the temp copy is never namedseed.mdand never sits inside a
runs/<id>/directory, which makes the hook structurally immune. Rejected: a
frontmatter-date comparison (the verdict would change with the calendar) and resolving
paths against the commit the seed names (base: mainis a moving ref, and the hook's
temp-dir copy has no commit at all). v646 now exits 0 with 2 warnings and v651 exits 0
with 1 warning, while v645, historical but with noclose.mdon disk, keeps both of its
unresolved-path HARD failures byte-for-byte: "historical" is not a bypass, "closed" is a
fact on disk. A relaxation is only distinguishable from a disabled check by what still
fails, so forcing the path-shape predicate to returntrueturns exactly one test red
(close_md_beside_a_non_run_shaped_seed_path_does_not_relax_anything), a closed run's
seed carrying aTODO:marker still HARD-fails, and a 401-line seed is HARD over the
ceiling whether it is labelledsprint-seedorpatch-seed. -
80 shell assertions that could not say what they enforced, and 10 that could not fail.
A barerg -qprints nothing, soset -ekilled the script and named no requirement.
Worse, bash 3.2 does not honourset -efor a failing[[ ]]and macOS cannot ship
newer, so ten assertions were inert on the platform where development happens. That class
had already hidden a false count intest-release-workflow.shsince v6.4.6, invisible
untilgate.sh fastwas wired into Linux CI. All 80 now name their requirement, and
hooks/tests/lint_shell_assertions.shbans both forms.
Three sites inhooks/scripts/_lib.shwere deliberately left alone: they are the final
expression of predicate functions, where the exit status is the boolean result, and guarding
them would hard-exit on the negative branch —quiet_warningsdefaults to false, so every
hook would have died whenever an operator had not opted in. The lint carries that exclusion.
Every conversion is append-only: the original command byte-identical plus a guard, verified
mechanically at 80 conversions and 0 violations. -
A gate wired to nothing had been red for three refactors, and no one could tell.
scripts/tests/test_cli_authority_gate.shwas correct, falsifiable, and referenced by
no runner, no workflow, and no suite. Running it for the first time found three
independent failures it had accumulated undisturbed. Its legacy-bootstrap sweep named
$ROOT/bin, a directory D4 retired — ripgrep exits 2 on a missing path, and because
the call sat inside anif,set -ewas suppressed and rc=2 took the same branch as
rc=1, so the sweep reported clean by erroring out, every run since the launcher was
removed. Itshooks.jsonassertion demanded the native dispatch shape for every hook,
which stopped being true the moment the seven carrier hook scripts were restored. And
three lifecycle assertions still pointed atclaude_hook.rsafter the lifecycle moved to
the harness-neutralnative_hook.rs. All three repaired, and the manifest rule now states
what is actually true — every registration is either native dispatch or a carrier script
that resolves on disk — printing its count (11 checked: native=4, carrier-script=7,
unresolvable=0) so a manifest registering zero hooks cannot pass by vacuous truth. -
The unwired-gate class is now structurally unreachable. This was its fourth
occurrence;hooks/tests/run.shhad it once with a hand-maintained array covering 6 of
27 files, leaving 21 tests unrun.scripts/check-gate-wiring.pyasserts every test file
is reachable from a runner, computing reachability transitively to a fixed point and
treating glob discovery as first-class wiring — so the fix for the original defect is not
penalised by the checker that prevents its recurrence. Prose is excluded from the evidence
set: a CHANGELOG mention is a reference to a test, not an execution of one. The checker
laundered its own finding twice before it worked — its docstring names the file it was
written to catch (and.pyis not prose), and a.shepherd/lane worklist names it too.
A checker must never be its own evidence. Six self-test cases, both directions. -
An exact version pin on a third-party CLI, masking three defects behind it.
test-codex-marketplace.shasserted== "codex-cli 0.147.0", so Codex shipping 0.148.0
turned it red with nothing in this repository having regressed — and the reflex fix, bumping
the literal, teaches that the gate is noise. It is now a floor. Converting it let the
assertions *aft...
v6.5.0
v6.5.0 — unreleased
The release automation ran correctly all the way to git push and died there. The first
fully green release pipeline in the project's history published crates, cut the tag, and
uploaded 32 assets. Its post-publication handoff then computed the successor, cut the branch,
committed the bump, and was refused:
! [remote rejected] v6.5.0 -> v6.5.0 (refusing to allow a GitHub App to create or
update workflow `.github/workflows/rust-wasm.yml` without `workflows` permission)
Fixed
- A workflow file was a version authority, and no token can push that.
rust-wasm.yml
hard-coded the WIT export string, soversion-bump.pyrewrote it every release and the
push became a workflow update. There is no permission to grant:workflowsis not in the
GITHUB_TOKENpermission vocabulary at all, so adding it to thepermissions:block
would be a syntax error, not a fix. The step now derives the version fromCargo.toml, the
single source of truth, and the authority is retired — 53 authorities down to 52, and no
workflow file carries a version literal. - The coupling cannot come back.
check-github-actions.pygained a rule that rejects any
workflow line containing the workspace version, naming the dead end in the message.
Falsified two ways: reintroducing the literal turns the checker red, and deleting the rule
turns its own suite red.test-version-bump.pynow carries a derived workflow in its
fixture and asserts the bump leaves it byte-identical; pinning that fixture to the current
version turns the test red.
Fixed — Windows is a supported platform, not a shipped stub (#321)
392 tests run: 392 passed. The Windows suite is green on both feature sets. It went
94 failures, 35, 20, 12, 6, 3, 0 — every step a real cross-platform defect, none of them a
test that needed relaxing.
The Windows binary this repository builds, packages, and publishes could not create
.shepherd/, could not bind a session, and could not store a run. Five families of
#[cfg(not(unix))] twins were Err("... unavailable on this platform"), and the first Windows
test run in the project's history reported 384 tests run: 290 passed, 94 failed. Every one is
now implemented.
-
crate::safe_fsis the new shared primitive layer for non-unix targets, and its module
docs state exactly what it does and does not guarantee rather than implying parity with the
descriptor-anchored unix side. Opens passFILE_FLAG_OPEN_REPARSE_POINT, so a leaf that is
or becomes a link yields the link itself and never an attacker's target; every ancestor is
checked withsymlink_metadata; publication isCREATE_NEWplusCreateHardLinkW, the same
two refusals unix gets fromO_EXCLandlinkat. The residual gap — an ancestor swapped
between its check and its use — is written down in the module rather than discovered later. -
Implemented:
wave_c_bootstrap(all five, soinitworks), the wholedispatch_store
ledger (so the hooks work, for Claude Code, Codex, and Pi alike),dispatch_scope
containment,compile's generated-tree check and materialize,wave_d_planningartifacts,
wave_b1_status_handoffrun states and handoffs, andresume_context. Each returns the SAME
error variants and messages as its unix twin, because callers and fixtures branch on them. -
The directory fsync is now a paired platform decision.
run/atomic.rsopened the parent
directory tosync_all()it; on Windowsstd::fs::File::opendoes not set
FILE_FLAG_BACKUP_SEMANTICS, so every store failed withAccess is denied. (os error 5).
The non-unix arm does nothing and says why: NTFS journals the rename, so there is no
unflushed directory entry the way there is on POSIX. A stated difference, not a swallowed
error. -
windows-latestis now a permanent CI axis, not a dispatch option, so this cannot
regress unobserved..cargo/config.tomlandscripts/setup.shadd a local
x86_64-pc-windows-gnucross-check so the Windows half type-checks in seconds instead of a
six-minute round trip. -
safe_fsships its own suite: no-clobber publishes once and leaves no temporary,
replace_atomicoverwrites where no-clobber refuses, an over-limit read fails instead of
truncating, absence and wrong-type stay distinguishable,ensure_directoryreports only what
it created, children are sorted and split by kind, removal is idempotent, and a real symlink
is refused rather than followed. -
The defects Windows found that had nothing to do with the stubs.
reject_symlink_path
stat'ed the bare drive prefix, so every run command died with
ERROR: inspect \\?\C:: Incorrect function.The layout manifest rendered OS-native
separators into a durable, sorted, compared artifact, so the same migration produced a
different manifest on each platform — and once the sources were canonical, the deepest-first
removal ordering countedMAIN_SEPARATORand collapsed to zero, removing parents before
their children.normalize_relativerefused every absolute Windows path because a backslash
is a smuggling attempt on unix and the separator there. Path identity needed
canonical_identity, because Windows spells one directory three ways —
\\?\C:\Users\runneradmin,C:\Users\runneradmin, andC:\Users\RUNNER~1. -
Three tests held a live SQLite connection across their fixture removal. Windows cannot
delete a directory containing an open handle; unix unlinks an open file happily, which is
why no amount of retrying would ever have helped. The teardown helper names the surviving
files now, which is what turned that from a guess into a diagnosis.
Changed — the model tier map
The team leads no longer pin a tier. A sprint spawned at the reasoning tier gets leads at that
tier; a sprint spawned cheaply gets cheap leads.
| Roles | Hint | Claude / Codex / Pi |
|---|---|---|
| root, planter | reasoning-high |
opus[1m] / reasoning-high / opus |
| engineer, conductor | inherit-caller |
inherit / inherit-caller |
| critic, coder, auditor, worker | standard |
sonnet / standard |
| discovery | economy |
haiku / economy at low effort |
discovery is the widest fan-out role in the flock, so it reaches for the economy tier that
all three harness profiles already defined and nothing had ever used. All nine stay overridable
through [models].
This was not safe to change on its own. compiler.rs built the Codex [agent_types] table
by skipping any role whose model_hint was inherit-caller, a proxy for "not dispatchable"
that was wrong in both directions: planter is dispatchable: false and Codex had been
advertising the role that holds ask-operator as spawnable, while any lead adopting
inherit-caller would have silently vanished from the table. It keys on dispatchable now.
Changed — organization identity
Identity fields renamed to pzzld-org ahead of the repository transfer. Every remaining FL03
string resolves something — the binstall pkg-url, both installers' release bases, the README
curl one-liner, the marketplace add lines — and GitHub's permanent post-transfer redirect
makes FL03 correct in both states while pzzld-org 404s until the move lands. #326 flips them
afterwards.
Notes
- The fixture pins a synthetic
9.9.9, never the live release. A fixture pinned to the real
version would make the test file a version authority, which is the exact coupling the rule
under test exists to prevent.
v6.4.9
v6.4.9 — unreleased
A Windows checkout rewrote the LICENSE and the release found out last. Every asset built,
every crate published, and only then did the publication gate compare the LICENSE inside the
Windows zip against the repository copy and refuse to publish. The bytes differed by 201 CR
characters and nothing else. Because the published crates pin that exact commit, the release
could not be re-cut from a fix — the assets had to be lifted out of the failed run's artifacts
and attached to the tag by hand.
Fixed
-
Line endings are pinned at the repository, not hoped for. GitHub's Windows runners check
out withcore.autocrlf=true.stage-distribution-legal.shcopiesLICENSEverbatim into
all 16 assets andverify-release-distribution.shcompares every extracted copy against the
repository file, so a rewritten checkout guarantees a failure that only the last job can see.
.gitattributesnow pins* text=auto eol=lf(no tracked file carried a CR byte, so this
changes no content —git add --renormalize .is a no-op), with the existing
THIRD_PARTY_LICENSES/*.txt binaryoverride still winning for the hash-named upstream texts. -
The packaging runner refuses a rewritten checkout.
stage-distribution-legal.shfails on
aLICENSEcontaining CR bytes, before it copies anything. The job that would have produced
the divergence is the job that stops, instead of five build jobs and a crates.io publication
succeeding first. -
Both gates are falsified in the suite.
test-release-distribution-license.shasserts
git check-attr eol -- LICENSEreportslf, proves that assertion can observe the
unspecifiedstate by running the same query against a scratch repository with no
.gitattributes, drives the staging script with a CRLF fixture and requires the CR refusal,
and then drives it with an LF fixture and requires the staged copy to appear. -
The Windows test build was broken under
-D warnings, and nothing could see it.
rust.yml'stestjob is the only one whose runner is selectable, and three of its run
steps never declared a shell, so onwindows-latestGitHub ran them through PowerShell and
cargo nextest run ... \died at parse time before a single test executed
(ParserError: D:\a\_temp\<id>.ps1:3). The advertised escape hatch for proving
cross-platform behaviour could not prove anything. Withshell: bashon all three, the
suite ran on Windows for the first time and immediately failed on three real defects:
read_project_idandReadSubject/read_regular_nofollowimported unconditionally into
test modules whose only callers are#[cfg(unix)], and anexpect(dead_code)on
ReadSubject::open_labelgatednot(unix)when a non-unix lib test build does use it,
making the expectation unfulfilled -- which-D warningsrejects exactly as hard as the
dead code it was written to tolerate. The gate is nowall(not(unix), not(test)).
A fourth followed in the integration suite:invoke_with_pathin
wave_e_coordination.rs, whose only caller is a#[cfg(unix)]test that puts a stubkill
on PATH. All four are the same shape -- an item declared unconditionally whose every caller
is unix-gated -- and all four were invisible to every unix machine and to every release
build, which builds the lib rather than the test targets.
Found, not fixed
- The shipped Windows binary cannot initialize a project or durably store a run. With the
suite finally able to run onwindows-latest, it did — and reported
384 tests run: 290 passed, 94 failed. Two stubs account for all of it:
wave_c_bootstrap.rspairs every descriptor-safe mutation with a#[cfg(not(unix))]twin
that returnsdescriptor-safe bootstrap mutation is unavailable on this platform(five of
them), soshepherd initfails and every hook test cascades from it; and
crates/core/src/run/atomic.rs:135opens the parent directory tosync_all()it, which
Windows refuses withAccess is denied. (os error 5)becausestd::fs::File::opendoes not
setFILE_FLAG_BACKUP_SEMANTICS. Filed as #321 — it is a decision (implement the Windows
primitives, or stop shipping the Windows asset), not a patch.
Notes
cargo binstall shepherd-cliandscripts/install-shepherd.shwere both exercised against
the published v6.4.8 release onaarch64-apple-darwinand resolve, download, verify, and
installshepherd-cli 6.4.8.
v6.4.8
First release with binaries. cargo binstall shepherd-cli and the curl installer both resolve against this tag.
cargo binstall shepherd-cli
# or, no Rust toolchain required:
curl -fsSL https://raw.githubusercontent.com/FL03/shepherd/main/scripts/install-shepherd.sh | bashBoth verified end to end on aarch64-apple-darwin against these exact assets.
Assets
16 archives and 16 .sha256 sidecars. Every sidecar was re-verified against its asset bytes before upload.
| Native | aarch64-apple-darwin, x86_64-apple-darwin, aarch64-unknown-linux-gnu, x86_64-unknown-linux-gnu, x86_64-pc-windows-msvc |
| Component | wasm32-wasip2 |
| Harness adapters | pzzld-pi-claude, pzzld-pi-codex, pzzld-pi-shepherd, pzzld-component-runtime |
Each target ships twice: a version-pinned name for cargo binstall, and a version-less name so releases/latest/download/<asset> stays a stable URL.
Known deviation
The Windows zip's bundled LICENSE carries CRLF line endings — GitHub's Windows runners check out with core.autocrlf=true. The text is identical to every other archive's copy (differing by 201 CR bytes and nothing else) and is valid on the platform it ships to. The release job's byte-identity check correctly refused to publish over it; the assets were verified and attached separately. #313 pins * text=auto eol=lf so byte-identity holds from v6.4.9 forward.
What's Changed
- v6.4.7 by @FL03 in https://github.com/FL03/shepherd/pull/310
- v6.4.8 by @FL03 in https://github.com/FL03/shepherd/pull/311
Full Changelog: https://github.com/FL03/shepherd/compare/v6.4.6...v6.4.8
v6.4.7
Crates-only release. This tag carries no binary assets.
shepherd-core, shepherd-compiler, shepherd-registry, shepherd-render, shepherd-sdk, and shepherd-cli 6.4.7 are published on crates.io from commit f089c81. The GitHub release job did not complete for this version, and a published crate version cannot be reissued from a different commit, so no archives were ever produced for it.
Installing
Use v6.4.8 or later for binaries:
cargo binstall shepherd-cli
# or
curl -fsSL https://raw.githubusercontent.com/FL03/shepherd/main/scripts/install-shepherd.sh | bashcargo install shepherd-cli --version 6.4.7 still works — it builds from the published source.
Why the release did not complete
cargo-publish.yml triggered on push-to-main independently of release.yml, so crate publication raced the asset builds and won. Publication now happens inside release.yml, in a job that needs every asset job to succeed first. See the v6.4.8 changelog entry.
Tag created retroactively so every published crate version has git custody.