Skip to content

Releases: pzzld-org/shepherd

v6.5.6

Choose a tag to compare

@FL03 FL03 released this 23 Aug 08:59
d7520c1

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

Choose a tag to compare

@FL03 FL03 released this 21 Aug 10:48
bf86d89

What's Changed

  • v6.5.5 by @github-actions[bot] in #347

Full Changelog: v6.5.4...v6.5.5

v6.5.4

Choose a tag to compare

@github-actions github-actions released this 19 Aug 23:46
fa21ea2

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

Choose a tag to compare

@github-actions github-actions released this 19 Aug 23:26
97ce5d0

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.toml still carried spawn.max_concurrent_children,
    retired with the spawn-concurrency rework. deny_unknown_fields made the
    config unloadable, which aborted doctor, migrate and init alike — 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 migrate could not migrate the documents it exists for. It already
    performed all three transforms an old run needs (run_idrun, lanes
    dict→list, updated_at→epoch), but re-inserted the dict key verbatim as the
    lane id, and RunStore::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:engineer ledgers 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.md already carries the phases. The conductor drives rather
    than authors: worker and coder execute, an adversarial auditor verifies
    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.py diffs every WIT record against its native
    struct. Divergences must be declared (WIRE_ONLY framing, or an explicit
    RENAMES entry that the transport is verified to implement) — a new one
    cannot appear silently. 4 self-test cases.
  • scripts/tests/test-native-dispatch-wire.sh feeds 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

Choose a tag to compare

@github-actions github-actions released this 19 Aug 22:01
d706195

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.json declares pi.extensions, pi.skills,
    pi.prompts, the pi-package keyword, and a files list that actually packs
    the generated directories. A pi key naming ./skills while files omits it
    is inert in the same way and harder to see, so the gate checks both.
  • scripts/stage-pi-carrier.sh compiles the carrier into the staged package
    immediately before npm pack. It is not committed:
    test-generated-carrier-authority.sh fails if packages/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 against content/skills
    (minus portability: claude-only) and content/roles rather than hardcoding.
  • scripts/tests/test-pi-package-surface.sh asserts the declaration, wired into
    gate.sh, falsifiable in three directions: a correct manifest passes, a
    manifest with no pi key 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 by cargo-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.py now fails on any script under scripts/ 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

Choose a tag to compare

@github-actions github-actions released this 19 Aug 20:22
e52f264

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.rs now name shepherd 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 in hooks/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 own SKILL.md draft on first run.

  • The regression tests assert the flag, not the prefix. dispatch_cli.rs and the
    cmd/dispatch.rs unit test previously accepted run \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.md silently broke
    plugins/shepherd/codex/skills/shepherd/SKILL.md, which scripts/check-plugin.py requires
    to be byte-identical. That script ran in .github/workflows/rust.yml and 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 shepherd printed unknown role: shepherd and exited 2 while shepherd models resolve root printed opus[1m] and exited 0, because two crates each hardcoded their own
    vocabulary: crates/cli/src/cmd/wave_a_models.rs const ROLES says root, and
    crates/core/src/guard/engine.rs role_tier says shepherd with no root arm at all.
    content/, agents/, skills/ and the shepherd:shepherd subagent type all spell the
    role shepherd. root is canonical on the models surface and shepherd is a
    documented INPUT alias resolving to it, in that direction and not the reverse: root is
    the literal [models] TOML key operators write (ModelsConfig::root,
    crates/core/src/settings.rs:546), and docs/configuration.md's default table is
    cross-checked against that field name by scripts/check-workspace.sh's
    rule_model_defaults_match_the_docs, so renaming would ripple into crates/core and
    docs/, both outside this change's scope. ROLES stays 9 entries and models show never
    grows a shepherd row; the alias is input-only. The unknown role text is now built from
    ROLES and ROLE_ALIASES instead 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 verify HARD-failed this project's own seeds.
    shepherd seed verify .shepherd/runs/v646/seed.md exited 1 on footprint 393 lines > cap 200 (kind=patch-seed) and on file_scope path does not resolve and is not marked (NEW): bin, where bin is 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 unresolved file_scope path degrades to a warning only
    when the seed's run has closed, and "closed" requires both a sibling close.md and the
    path shape runs/<id>/seed.md; every other seed keeps today's HARD failure byte-identical.
    Second, the declared kind selects 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 a patch-seed over 200 lines now gets a warning naming the mislabel. Neither
    SPRINT_FOOTPRINT_CAP nor PATCH_FOOTPRINT_CAP changed 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.sh runs the live SEED-GATE against a bare
    mktemp -t shep-seed.XXXXXX file in $TMPDIR, so a sibling-close.md test alone would
    let any stray close.md in $TMPDIR silently downgrade that gate for every seed a
    planter writes; the temp copy is never named seed.md and 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: main is 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 no close.md on 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 return true turns exactly one test red
    (close_md_beside_a_non_run_shaped_seed_path_does_not_relax_anything), a closed run's
    seed carrying a TODO: marker still HARD-fails, and a 401-line seed is HARD over the
    ceiling whether it is labelled sprint-seed or patch-seed.

  • 80 shell assertions that could not say what they enforced, and 10 that could not fail.
    A bare rg -q prints nothing, so set -e killed the script and named no requirement.
    Worse, bash 3.2 does not honour set -e for 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 in test-release-workflow.sh since v6.4.6, invisible
    until gate.sh fast was wired into Linux CI. All 80 now name their requirement, and
    hooks/tests/lint_shell_assertions.sh bans both forms.
    Three sites in hooks/scripts/_lib.sh were 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_warnings defaults 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.sh was 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 an if, set -e was 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. Its hooks.json assertion 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 at claude_hook.rs after the lifecycle moved to
    the harness-neutral native_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.sh had it once with a hand-maintained array covering 6 of
    27 files, leaving 21 tests unrun. scripts/check-gate-wiring.py asserts 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 .py is 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.sh asserted == "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...

Read more

v6.5.0

Choose a tag to compare

@github-actions github-actions released this 18 Aug 21:04
4a02a88

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, so version-bump.py rewrote it every release and the
    push became a workflow update. There is no permission to grant: workflows is not in the
    GITHUB_TOKEN permission vocabulary at all
    , so adding it to the permissions: block
    would be a syntax error, not a fix. The step now derives the version from Cargo.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.py gained 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.py now 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_fs is 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 pass FILE_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 with symlink_metadata; publication is CREATE_NEW plus CreateHardLinkW, the same
    two refusals unix gets from O_EXCL and linkat. 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, so init works), the whole dispatch_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_planning artifacts,
    wave_b1_status_handoff run states and handoffs, and resume_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.rs opened the parent
    directory to sync_all() it; on Windows std::fs::File::open does not set
    FILE_FLAG_BACKUP_SEMANTICS, so every store failed with Access 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-latest is now a permanent CI axis, not a dispatch option, so this cannot
    regress unobserved. .cargo/config.toml and scripts/setup.sh add a local
    x86_64-pc-windows-gnu cross-check so the Windows half type-checks in seconds instead of a
    six-minute round trip.

  • safe_fs ships its own suite: no-clobber publishes once and leaves no temporary,
    replace_atomic overwrites where no-clobber refuses, an over-limit read fails instead of
    truncating, absence and wrong-type stay distinguishable, ensure_directory reports 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 counted MAIN_SEPARATOR and collapsed to zero, removing parents before
    their children. normalize_relative refused 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, and C:\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

Choose a tag to compare

@github-actions github-actions released this 18 Aug 13:15
75d2323

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 with core.autocrlf=true. stage-distribution-legal.sh copies LICENSE verbatim into
    all 16 assets and verify-release-distribution.sh compares every extracted copy against the
    repository file, so a rewritten checkout guarantees a failure that only the last job can see.
    .gitattributes now 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 binary override still winning for the hash-named upstream texts.

  • The packaging runner refuses a rewritten checkout. stage-distribution-legal.sh fails on
    a LICENSE containing 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.sh asserts
    git check-attr eol -- LICENSE reports lf, proves that assertion can observe the
    unspecified state 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's test job is the only one whose runner is selectable, and three of its run
    steps never declared a shell, so on windows-latest GitHub 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. With shell: bash on all three, the
    suite ran on Windows for the first time and immediately failed on three real defects:
    read_project_id and ReadSubject/read_regular_nofollow imported unconditionally into
    test modules whose only callers are #[cfg(unix)], and an expect(dead_code) on
    ReadSubject::open_label gated not(unix) when a non-unix lib test build does use it,
    making the expectation unfulfilled -- which -D warnings rejects exactly as hard as the
    dead code it was written to tolerate. The gate is now all(not(unix), not(test)).
    A fourth followed in the integration suite: invoke_with_path in
    wave_e_coordination.rs, whose only caller is a #[cfg(unix)] test that puts a stub kill
    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 on windows-latest, it did — and reported
    384 tests run: 290 passed, 94 failed. Two stubs account for all of it:
    wave_c_bootstrap.rs pairs every descriptor-safe mutation with a #[cfg(not(unix))] twin
    that returns descriptor-safe bootstrap mutation is unavailable on this platform (five of
    them), so shepherd init fails and every hook test cascades from it; and
    crates/core/src/run/atomic.rs:135 opens the parent directory to sync_all() it, which
    Windows refuses with Access is denied. (os error 5) because std::fs::File::open does not
    set FILE_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-cli and scripts/install-shepherd.sh were both exercised against
    the published v6.4.8 release on aarch64-apple-darwin and resolve, download, verify, and
    install shepherd-cli 6.4.8.

v6.4.8

Choose a tag to compare

@FL03 FL03 released this 18 Aug 11:39
dc5f047

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 | bash

Both 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

Full Changelog: https://github.com/FL03/shepherd/compare/v6.4.6...v6.4.8

v6.4.7

Choose a tag to compare

@FL03 FL03 released this 18 Aug 11:55
f089c81

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 | bash

cargo 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.