Skip to content

feat(preflight): add an agent-CLI tier to check-prereqs + stale-PATH detection - #2761

Closed
POWERFULMOVES wants to merge 4 commits into
mainfrom
chore/cli-prereq-preflight
Closed

POWERFULMOVES wants to merge 4 commits into
mainfrom
chore/cli-prereq-preflight

Conversation

@POWERFULMOVES

Copy link
Copy Markdown
Owner

Why

The CLI contract an agent session assumes was never written down or verified. BOOTSTRAP.md names tools inside Known Roads (make / docker / tailscale / gh / uv), its MCP entrypoint table adds uvx, pmoves/scripts/*.sh shell out to nats, the minted NATS trust hierarchy needs nsc, and the Pinokio skills need pterm. Nothing enumerated any of it. check-prereqs covered only the 5 bringup binaries.

This surfaced on Z890: claude-pmoves and crush both reported "not recognized". Both were installed and both were on the persisted PATH — the shell simply predated the install, because Windows never refreshes a running process's environment. That failure is indistinguishable from "never installed" and sends you chasing a reinstall that cannot help.

What

Extends the existing script rather than adding a parallel one.

  • bringup tier (default) — unchanged. Same 5 binaries, same fatal exit. The no-arg invocation is contract-compatible, so venv-bringup and any CI caller are unaffected.
  • agent tier (--agent) — the 14-tool CLI contract. Advisory by default (exit 0), because a node may legitimately lack some; --strict makes gaps fatal.
  • stale-session PATH detection (Windows) — compares the live PATH against the persisted registry PATH via cygpath and names the missing directories, so a false MISSING is explained rather than chased.
  • VIRTUAL_ENV disclosure — an active venv silently changes which python you get.

Every agent-tier entry is grounded in a real caller, not a guess.

New targets follow the existing -strict convention (cf. supa-env-doctor-strict, worktree-sitrep-strict):

make -C pmoves check-prereqs-agent
make -C pmoves check-prereqs-agent-strict
make -C pmoves check-prereqs-all

Testing

Check Result
bash -n scripts/check_prereqs.sh clean
default tier, missing binary exit 1 (contract preserved)
--agent with gaps exit 0 (advisory)
--agent --strict with gaps exit 1
unknown argument exit 2
--help renders header block
make -n on all 4 targets correct recipes
make help all 4 targets listed with descriptions

Run on Z890 during a genuinely stale session, the new output did the job it was written for: it reported make as MISSING, then named WinGet\Links — where make.exe actually lives — as one of 16 stale PATH entries. All 8 agent-tier "gaps" in that run were tools verified present, every one sitting in a stale directory.

Notes for review

  • .claude/BOOTSTRAP.md was already 5669 chars against its own stated ≤5k budget before this change. The addition is a single table row (now 5853). The file needs a trim, but that is independent of this work and deliberately not bundled here.
  • check-tools (Makefile:373) overlaps slightly — docker/supabase/python with version-currency logic. Left alone: different purpose, and merging would widen this PR past its point.
  • Windows-only code paths (path_staleness_report) no-op on Linux/macOS via a uname -s guard and command -v checks on powershell.exe / cygpath.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Jxd5gryAFPXwhjaCMw9Qrx

@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f14ba4b7-b3a4-433a-82a4-49605dfab031


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

POWERFULMOVES and others added 2 commits August 28, 2026 15:24
…detection

The tooling contract an agent session assumes was undocumented. BOOTSTRAP.md
names tools inside Known Roads (make/docker/tailscale/gh/uv/uvx), scripts shell
out to `nats`, the minted trust hierarchy needs `nsc`, and the Pinokio skills
need `pterm` — but nothing enumerated or verified any of it. check-prereqs
covered only the 5 bringup binaries.

Consequence in practice: a stale Windows session PATH makes installed tools read
as "not found", which is indistinguishable from "never installed" and sends you
chasing reinstalls that cannot help.

Extends the existing script rather than adding a parallel one:

- bringup tier (default) unchanged — same 5 binaries, same fatal exit. The
  no-arg invocation is contract-compatible, so `venv-bringup` is unaffected.
- agent tier (--agent) — the 14-tool CLI contract, advisory by default because
  a node may legitimately lack some; --strict makes gaps fatal.
- stale-session PATH detection (Windows) — compares the live PATH against the
  persisted registry PATH via cygpath and names the missing directories, so a
  false "MISSING" is explained instead of chased.
- VIRTUAL_ENV disclosure — an active venv changes which `python` you get.

Every agent-tier entry is grounded in a real caller, not a guess: Known Roads in
BOOTSTRAP.md, its MCP entrypoint table, the NATS publishers under
pmoves/scripts/*.sh, and the Pinokio skills.

Targets follow the existing -strict convention (cf. supa-env-doctor-strict,
worktree-sitrep-strict):

  make -C pmoves check-prereqs-agent
  make -C pmoves check-prereqs-agent-strict
  make -C pmoves check-prereqs-all

Verified: bash -n clean; all four exit paths confirmed (bringup-missing 1,
agent advisory 0, --strict 1, bad arg 2); all four targets dry-run and appear
in `make help`.

Note: .claude/BOOTSTRAP.md was already 5669 chars against its own stated <=5k
budget before this change; the addition is a single table row (now 5853). The
file needs a trim independent of this work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jxd5gryAFPXwhjaCMw9Qrx
The stale-PATH scan and the per-binary table were computed independently, so
they never spoke to each other. On a stale Windows session the output read:

    ❌ claude  MISSING — https://claude.com/claude-code
    ...
    ⚠️  Stale session PATH — 17 persisted entr(y|ies) missing from this shell
    ⚠️  8 agent-CLI gap(s) — advisory, not blocking.

Every one of those 8 was installed. The reader had to intersect two lists by
hand to work that out, and the summary line still asserted 8 gaps. The failure
mode the previous commit set out to prevent — chasing reinstalls that cannot
help — survived, just one indirection further away.

Binaries now resolve to three states rather than two. A binary that is absent
from the live PATH but present under a stale persisted PATH entry is SHADOWED,
and reports where it actually lives:

    ⚠️  claude  SHADOWED — installed at C:\Users\...\.local\bin\claude.exe,
                           not on this shell's PATH

Counts are tracked separately through to the summary, because the remedies are
opposite: install vs. open a new terminal. Shadowed is fatal wherever missing
is fatal (required tier always, agent tier under --strict) — this shell
genuinely cannot invoke the binary; only the advice differs.

Implementation notes:

- The index reads directory entries rather than stat-ing candidate names. This
  is correctness, not just speed: MSYS's stat() transparently appends .exe, so
  `-f <dir>/claude` succeeds for claude.exe and the first cut of this reported
  a path that does not exist on disk.
- Built lazily and at most once, and deliberately fork-free (glob + ${v,,}
  rather than ls + tr). Dropping the same forks from the scan loop makes the
  whole script faster than before the change: 12.0s -> 6.9s on the bringup
  tier, which is on the venv-bringup path.
- --help no longer hardcodes a line range; it prints to the first non-comment
  line, so the header can grow without silently truncating.
- Fixed an unrelated pipefail interaction: `$(cmd --version | head -1 || echo
  <unknown>)` printed *both* for a tool that emits its banner then exits
  non-zero on an unrecognised flag, which ffmpeg does (it wants -version).

Verified on z890 (17 stale entries, 8 shadowed agent CLIs, make shadowed in the
bringup tier): all eight exit paths unchanged from before — default 1, --agent
0, --agent --strict 1, --all 1, --all --strict 1, --quiet 0, bad arg 2, --help
0. Non-Windows path re-tested with a stubbed uname: scan no-ops, arrays stay
empty under set -u without unbound errors, every binary falls back to
MISSING/present exactly as before, so CI behaviour is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BEJJxcNjG1gC8Dxmr13np7
@POWERFULMOVES
POWERFULMOVES force-pushed the chore/cli-prereq-preflight branch from e389d85 to 406cfb4 Compare August 28, 2026 19:24
@POWERFULMOVES
POWERFULMOVES marked this pull request as ready for review August 28, 2026 19:24

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 406cfb4099

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread pmoves/scripts/check_prereqs.sh Outdated
# Known Roads (make / docker / tailscale / gh / uv), its MCP entrypoint table
# (uvx), the NATS publishers in pmoves/scripts/*.sh (nats), the minted NATS
# trust hierarchy (nsc), and the Pinokio skills (pterm).
AGENT=(claude crush uv uvx gh docker node npm tailscale nats nsc pterm rg ffmpeg)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check the fleet launcher commands themselves

On Windows nodes where the underlying claude and crush binaries remain visible but the newly installed claude-pmoves.cmd or crush-pmoves.cmd shim is only on the persisted PATH, this contract reports both agents present and never classifies the actual fleet entry points as SHADOWED. Those wrappers are installed as user-facing commands by deploy/provision/install-claude-pmoves-command.ps1 and its Crush counterpart, so even --agent --strict can exit 0 and print “All checked prereqs present” while the command the operator needs is unavailable; include the wrapper names or explicitly check both each base CLI and its PMOVES launcher.

Useful? React with 👍 / 👎.

[[ -n "$version" ]] || version="<version-unknown>"
printf " ✅ %-10s %s\n" "$bin" "$version"
fi
elif found_at=$(find_in_stale "$bin"); then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Build the stale directory index in the parent shell

In the Windows stale-PATH path, found_at=$(find_in_stale "$bin") executes the function in a command-substitution subshell, so its updates to STALE_INDEXED and STALE_BINS are discarded after every lookup. Consequently, when several of the 14 agent tools are unresolved—the primary scenario this feature handles—the script re-enumerates every file in every stale PATH directory once per missing tool instead of once overall, which can make preflight noticeably slow on large Windows PATH directories; build the index before the loop or return the match without command substitution.

Useful? React with 👍 / 👎.

POWERFULMOVES and others added 2 commits August 28, 2026 16:34
… half the contract

The CLI tiers check what resolves on PATH. That would never have caught the
failure that prompted this: `make sign-trail` could not import yaml, so it
signed a provenance record with a FALLBACK identity -- explicitly "NOT the
agent's registered identity" -- while the provisioned environment sat alongside
with yaml installed. Nothing on PATH was wrong.

Adds `--env` (and `make -C pmoves check-prereqs-env`), which reports:

  * whether the bringup environment is provisioned, probing BOTH layouts
    (Scripts/python.exe on Windows, bin/python on POSIX)
  * its Python version
  * whether the modules the tools actually import are importable BY THAT
    interpreter

Two correctness details that took measurement rather than reasoning:

1. The module probe runs `python -I` (isolated: cwd off sys.path). Without it
   the probe is cwd-sensitive and lies. `pmoves/` contains a `nats/` directory
   that shadows the real nats-py package, so a plain `import nats` PASSES when
   run from pmoves/ and FAILS from the repo root -- same interpreter, opposite
   answers. The Make target runs from pmoves/, so the false PASS is the one an
   operator would have seen. An instrument that reports fine without having
   measured anything is the exact failure this tier exists to catch.

2. The root is derived from BASH_SOURCE, not the cwd. The Make target runs
   `bash scripts/check_prereqs.sh` from inside pmoves/ while an operator runs
   `bash pmoves/scripts/check_prereqs.sh` from the repo root; a cwd-relative
   path is correct for exactly one of those. Both invocations now agree.

The probed set is `yaml httpx nats jsonschema` -- chosen by counting real
imports across pmoves/tools/*.py rather than by reading a manifest. That
distinction matters: `yaml` is the single most-imported module (52 occurrences)
and is declared in NO requirements file, so a declaration-driven check reports
healthy while sign-trail is broken.

First real run found a genuine gap: nats-py is declared in
tools/requirements.txt but is NOT installed in the provisioned environment.

Also fixes a summary bug introduced alongside: the all-clear line ignored
env-tier gaps, so the script could print a warning and "All checked prereqs
present" in the same breath.

Advisory (exit 0) like the agent tier -- a node may legitimately not have run
the bringup yet, and this is diagnosis, not a gate.

Verified on Z890 (win32):
  exit matrix   default=0  --agent=0  --env=0  --all=0  --all --strict=0  --bogus=2
  both invocation cwds agree
  default tier output unchanged (backward compatible)
  all five check-prereqs targets listed in `make help`

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jxd5gryAFPXwhjaCMw9Qrx
…o the contract

"MISSING" tells you to install something. It does not tell you what you
currently cannot do, which is the part that matters -- a missing CLI here
rarely breaks loudly. It removes a capability somewhere else, in a skill or
Make target that then degrades or skips silently:

  nats missing    -> no error; the GEOMETRY BUS / CHIT event surface is dark
                     and the publishers in pmoves/scripts/*.sh skip quietly
  glances missing -> no error; the node probe stops writing
                     pmoves/config/profiles/<node>.yaml
  pterm missing   -> no error; the pinokio:* skills simply cannot run

Each gap now prints an `unlocks:` line, and a SHADOWED entry prints
`dark while shadowed:` -- same capability, different remedy.

Every claim is grounded in a file in this repo rather than inferred, and the
comment above the table says to keep it that way: an unlocks line that
overstates is worse than none, because it will be trusted. The rg entry, for
instance, notes that plain grep treats AGNOTE4482PHI.t1.md as binary, so rg is
not a like-for-like substitute there.

Also adds `glances` to the agent tier. It was missing from the contract despite
driving deploy/provision/glances-autodetect.{sh,ps1} and the node-*-probe
skills -- and it is genuinely absent on this node, so the omission was hiding a
real gap. With it counted, `--all --strict` now correctly exits 1 here.

Header updated: the script documents three tiers now, not two.

Verified on Z890 (win32):
  exit matrix  default=0  --agent=0  --env=0  --all=0  --all --strict=1  --bogus=2
  --help renders the new header
  default tier output unchanged (backward compatible)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jxd5gryAFPXwhjaCMw9Qrx
@POWERFULMOVES

Copy link
Copy Markdown
Owner Author

Closing in favour of a narrow replacement.

The tiers and the unlocks: reporting are worth keeping. The SHADOWED index — where most of the defects cluster — is not, at least not in this form. Verified findings:

  1. The SHADOWED path drops the filename. ${STALE_DIRS[i]%\}\${f} is parsed by bash as a single parameter expansion whose pattern swallows ${f}. Reproduced: with D='C:\Program Files\Docker\resources\bin' and f='docker.exe', the result is C:\Program Files\Docker\resources\bin — no binary name, and every file in a directory collapses to the same value. This discards exactly what the header comment says the glob exists to preserve.
  2. --strict never reaches the env tier. check-prereqs-agent-strict advertises "non-zero exit on any gap" and returns 0 with an entirely unprovisioned environment. A CI job wired to it passes green.
  3. The env-gap epilogue overstates. It always blames sign-trail/yaml/FALLBACK regardless of which module is missing — it printed that for a missing nats, which has nothing to do with import yaml. That violates the rule written 200 lines above it in the same file: "an unlocks line that overstates is worse than none, because it will be trusted."
  4. Performance on the bringup path. 5.638s vs 0.053s for the equivalent command -v loop (~188 forks); venv-bringup depends on check-prereqs, so every bringup pays it, and on a healthy node the scan finds nothing and prints nothing. The file's own comment justifies avoiding forks here.
  5. Memoization lands in a subshell. find_in_stale is only called inside $( ), so STALE_INDEXED/STALE_BINS writes are discarded and the index rebuilds per binary (measured 13.6x).
  6. --agent --env silently drops a tier (last-wins, no conflict detection).
  7. The index filters on -f rather than executability, so a non-executable file sharing a tool's name reports as SHADOWED with an actively wrong remedy.
  8. .claude/BOOTSTRAP.md grew to 5853 chars against its own stated ≤5k budget (it was already 5669 before this change).

Root cause on my side: after the operator's VS Code restart every tool resolved, so the SHADOWED path never executed in any of my testing — and I still wrote "verified" in the PR body. Same failure mode I filed against #2809, #2804 and #2789 this week.

Replacement will ship the tiers plus unlocks: without the SHADOWED index, with --strict covering ENV_MISSING, an epilogue that names the actual missing module, the PATH scan deferred behind the tier that consumes it, and conflicting tier flags treated as an error.

Found by multi-angle review; findings independently reproduced on this node before accepting.

POWERFULMOVES added a commit that referenced this pull request Aug 28, 2026
… the index

Replaces #2761, closed because most of its defects clustered in one component.

The tooling contract an agent session assumes was never written down or
verified. BOOTSTRAP.md names tools inside Known Roads, scripts shell out to
nats, the trust hierarchy needs nsc, the Pinokio skills need pterm, the node
probes need glances -- and check-prereqs covered five bringup binaries.

What ships:

* agent tier (--agent) -- the 15-tool CLI contract, each entry grounded in a
  real caller rather than inferred. Advisory.
* env tier (--env) -- the bringup interpreter and whether the modules the tools
  actually import are importable BY IT. A binary on PATH is half the contract.
* a gap reports what it UNLOCKS. A missing CLI here rarely breaks loudly; it
  removes a capability elsewhere that then degrades silently.
* stale-PATH banner on Windows, the single most common reason an installed tool
  reads as MISSING.

What was DROPPED from #2761, deliberately: the per-binary SHADOWED index.
Seven of that PR's fifteen findings lived in it, including a parameter
expansion that silently discarded the filename it existed to report.
Attributing a specific missing binary to a specific stale directory needs a
directory walk whose cost and correctness problems outweigh its value. The
banner is what actually explained the operator symptom; the attribution was
decoration.

Defects from the #2761 review, fixed here:

* --strict now covers the env tier. Previously check-prereqs-agent-strict
  advertised "non-zero exit on any gap" and returned 0 with an entirely
  unprovisioned environment -- a CI gate that passed green.
* the epilogue names the ACTUAL missing module. It used to blame
  sign-trail/yaml unconditionally, including when the gap was nats, which has
  nothing to do with `import yaml`. Both branches verified.
* conflicting tier flags are an error, not a silent drop. `--agent --env` used
  to run only the second and exit 0.
* --version probes run with stdin closed, so a tool that prompts cannot hang.
* the module probe runs `python -I`, keeping cwd off sys.path: pmoves/ contains
  a `nats/` directory that shadows the real package, so a plain `import nats`
  passes from pmoves/ and fails from the repo root for the same interpreter.
* root derived from BASH_SOURCE, not cwd -- the Make target and an operator
  invoke this from different directories.

Performance. #2761 measured 5.638s on the default tier, which venv-bringup
depends on. The cause was three forks per PATH entry across 94 entries. All
per-entry work is now bash builtins with a single cygpath for the whole PATH,
and the banner only runs when something actually failed to resolve.

  default tier   5638ms -> 57ms    (99x)
  --agent        7014ms -> 431ms   (16x)
  --all          7648ms -> 963ms   (8x)

I caught that by measuring my own claim: the first draft of this file carried a
comment asserting "two forks total" while doing 282. The comment was written
before the measurement, which is the same mistake #2761 shipped.

Verified on Z890 (win32):

  default                 exit 0, output unchanged (backward compatible)
  --agent / --env / --all exit 0   (advisory)
  --all --strict          exit 1   (glances absent, nats not importable)
  --agent --env           exit 2   (conflict, with a message)
  --bogus                 exit 2
  epilogue                names "nats" when nats is the gap; names the
                          interpreter and the sign-trail consequence when the
                          interpreter itself is absent -- both branches run
  banner                  dropping WinGet/Links from PATH is detected

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jxd5gryAFPXwhjaCMw9Qrx
POWERFULMOVES added a commit that referenced this pull request Aug 28, 2026
Five targets: check-prereqs (unchanged, fatal), -agent, -env, -all (advisory),
and -strict for a CI gate.

check-prereqs-agent-strict from #2761 is replaced by check-prereqs-strict.
The old name promised 'non-zero exit on any gap' while --strict never reached
the env tier, so it returned 0 with an entirely unprovisioned environment. The
new name covers all tiers because --strict now actually does.

Note on exit codes: the script returns 1 on gaps; make reports its own 2 for
any failing recipe. Both are non-zero, so either is a usable CI signal.

No .claude/BOOTSTRAP.md row this time. #2761 added one and pushed that file to
5853 chars against the <=5k budget its own CLAUDE.md states, which it was
already over at 5669. The Known Road belongs there, but the file needs a trim
first and that is a separate concern.

Verified: all five appear in `make help`, all five dry-run to the right
recipe, check-prereqs exits 0 and check-prereqs-strict exits non-zero on this
node (glances absent, nats not importable).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jxd5gryAFPXwhjaCMw9Qrx
POWERFULMOVES added a commit that referenced this pull request Aug 29, 2026
… the index (#2821)

* feat(preflight): agent + interpreter tiers for check-prereqs, without the index

Replaces #2761, closed because most of its defects clustered in one component.

The tooling contract an agent session assumes was never written down or
verified. BOOTSTRAP.md names tools inside Known Roads, scripts shell out to
nats, the trust hierarchy needs nsc, the Pinokio skills need pterm, the node
probes need glances -- and check-prereqs covered five bringup binaries.

What ships:

* agent tier (--agent) -- the 15-tool CLI contract, each entry grounded in a
  real caller rather than inferred. Advisory.
* env tier (--env) -- the bringup interpreter and whether the modules the tools
  actually import are importable BY IT. A binary on PATH is half the contract.
* a gap reports what it UNLOCKS. A missing CLI here rarely breaks loudly; it
  removes a capability elsewhere that then degrades silently.
* stale-PATH banner on Windows, the single most common reason an installed tool
  reads as MISSING.

What was DROPPED from #2761, deliberately: the per-binary SHADOWED index.
Seven of that PR's fifteen findings lived in it, including a parameter
expansion that silently discarded the filename it existed to report.
Attributing a specific missing binary to a specific stale directory needs a
directory walk whose cost and correctness problems outweigh its value. The
banner is what actually explained the operator symptom; the attribution was
decoration.

Defects from the #2761 review, fixed here:

* --strict now covers the env tier. Previously check-prereqs-agent-strict
  advertised "non-zero exit on any gap" and returned 0 with an entirely
  unprovisioned environment -- a CI gate that passed green.
* the epilogue names the ACTUAL missing module. It used to blame
  sign-trail/yaml unconditionally, including when the gap was nats, which has
  nothing to do with `import yaml`. Both branches verified.
* conflicting tier flags are an error, not a silent drop. `--agent --env` used
  to run only the second and exit 0.
* --version probes run with stdin closed, so a tool that prompts cannot hang.
* the module probe runs `python -I`, keeping cwd off sys.path: pmoves/ contains
  a `nats/` directory that shadows the real package, so a plain `import nats`
  passes from pmoves/ and fails from the repo root for the same interpreter.
* root derived from BASH_SOURCE, not cwd -- the Make target and an operator
  invoke this from different directories.

Performance. #2761 measured 5.638s on the default tier, which venv-bringup
depends on. The cause was three forks per PATH entry across 94 entries. All
per-entry work is now bash builtins with a single cygpath for the whole PATH,
and the banner only runs when something actually failed to resolve.

  default tier   5638ms -> 57ms    (99x)
  --agent        7014ms -> 431ms   (16x)
  --all          7648ms -> 963ms   (8x)

I caught that by measuring my own claim: the first draft of this file carried a
comment asserting "two forks total" while doing 282. The comment was written
before the measurement, which is the same mistake #2761 shipped.

Verified on Z890 (win32):

  default                 exit 0, output unchanged (backward compatible)
  --agent / --env / --all exit 0   (advisory)
  --all --strict          exit 1   (glances absent, nats not importable)
  --agent --env           exit 2   (conflict, with a message)
  --bogus                 exit 2
  epilogue                names "nats" when nats is the gap; names the
                          interpreter and the sign-trail consequence when the
                          interpreter itself is absent -- both branches run
  banner                  dropping WinGet/Links from PATH is detected

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jxd5gryAFPXwhjaCMw9Qrx

* feat(preflight): Known Roads for the new tiers

Five targets: check-prereqs (unchanged, fatal), -agent, -env, -all (advisory),
and -strict for a CI gate.

check-prereqs-agent-strict from #2761 is replaced by check-prereqs-strict.
The old name promised 'non-zero exit on any gap' while --strict never reached
the env tier, so it returned 0 with an entirely unprovisioned environment. The
new name covers all tiers because --strict now actually does.

Note on exit codes: the script returns 1 on gaps; make reports its own 2 for
any failing recipe. Both are non-zero, so either is a usable CI signal.

No .claude/BOOTSTRAP.md row this time. #2761 added one and pushed that file to
5853 chars against the <=5k budget its own CLAUDE.md states, which it was
already over at 5669. The Known Road belongs there, but the file needs a trim
first and that is a separate concern.

Verified: all five appear in `make help`, all five dry-run to the right
recipe, check-prereqs exits 0 and check-prereqs-strict exits non-zero on this
node (glances absent, nats not importable).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jxd5gryAFPXwhjaCMw9Qrx

* fix(preflight): address the three Codex review findings

P1 -- bash 3.2. Stock macOS ships bash 3.2 as /bin/bash and pmoves/AGENTS.md
documents a macOS bootstrap, so this script would die on a parse error naming
none of the constructs at fault. Note the requirement is PRE-EXISTING: `declare
-A HINTS` is on origin/main already. This adds an explicit guard that re-execs
under a modern bash when one exists (Homebrew installs alongside /bin/bash
rather than replacing it), and otherwise fails with a per-platform remedy:

  Darwin  brew install bash, then run via $(brew --prefix)/bin/bash
  Linux   distros ship bash 5; this means a minimal image (Alpine/BusyBox
          provide ash) or invocation through sh -- apt-get/apk, run as bash
  MSYS    Git Bash ships 4.4+, so it points at a stripped-down sh on PATH
  other   generic

All four branches exercised, not just macOS. Every branch also references
pmoves/docs/operations/LOCAL_TOOLING_REFERENCE.md (the canonical Windows/WSL/
Linux environment doc) and `make -C pmoves venv-bringup`, so the message ends
somewhere useful instead of at a version number.

The re-exec carries a one-shot guard. Testing surfaced that without it, any
case where the re-exec target still fails the version test spawns shells
forever -- my own forced-failure test hit exactly that and had to be killed.

P2 -- probe the interpreter Make selects. The tier probed .venv-pmoves only,
but pmoves/AGENTS.md documents Conda 3.11+ as the preferred Python, and
PYTHON=/custom is supported. So the tier could fail a healthy Conda setup, and
worse, could PASS while the interpreter Make actually invokes lacks the
modules -- an instrument reporting on something other than what runs, which is
the failure this tier exists to catch. The Make targets now export
PMOVES_PREREQ_PY="$(PRECHECK_PY)" and the script probes that when set, falling
back to discovery. Verified: with it set the report reads "as selected by make
(PRECHECK_PY)".

P2 -- glances. `venv-bringup` installs glances INTO .venv-pmoves, where
`command -v` cannot see it from a non-activated shell, so following the printed
remedy left check-prereqs-strict still failing and still telling the operator
to install what they had just installed. The agent tier now looks in the
bringup environment too and distinguishes the two states, because the remedies
are opposite:

  absent everywhere       -> MISSING + install hint
  in the env, not on PATH -> "activate the env or call it by path --
                             reinstalling will not help"

Verified on Z890 (win32): both glances states produce the right line; the four
bash-guard branches print correctly; exit matrix unchanged (0/0/0/0 advisory,
1 under --strict, 2 on conflicting tier flags); default tier still 91ms; all
five Make targets parse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jxd5gryAFPXwhjaCMw9Qrx

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
POWERFULMOVES added a commit that referenced this pull request Aug 29, 2026
…2761) (#2814)

* docs(agnote): retroactive CLAIM+RELEASE for the CLI preflight lane (#2761)

Files the register row that PR #2761 should have opened with. The work is done
and the PR is up, so this is a CLAIM+RELEASE rather than a CLAIM -- and the row
says plainly that it is retroactive rather than dressing it as process that was
followed. That gap is the same one #2811 exists to measure; recording it late
is the honest repair, not evidence of compliance.

Covers the delivered lane (agent-CLI tier + SHADOWED/MISSING PATH detection)
and the Windows validation sweep across the open queue: #2809, #2807, #2804,
#2789, #2811/#2812.

Three disclosures carried in the row rather than left for a reviewer to find:

1. `make -C pmoves sign-trail` warns `identity not resolved: pyyaml
   unavailable` and signs with a FALLBACK glyph/color under the precheck
   interpreter. Re-running the tool with pyyaml present resolves the registered
   identity, so the recorded signature is the resolved one. The degradation is a
   live defect in the signing path and is left unclaimed.

2. The register's NUL byte at line 1433 was examined and deliberately LEFT
   ALONE. `test_the_register_needs_a_tolerant_reader` pins its existence on
   purpose; 21 other control characters remain, so `read_register()`'s tolerant
   read is required regardless; and GNU grep was measured printing matches
   normally with and without it (317 vs 318). An earlier attempt to clean it was
   reverted -- the justification did not survive measurement.

3. The append is byte-preserving: 4 insertions, 0 deletions, the pre-existing
   NUL intact, written through bytes rather than a lossy errors='replace'
   round-trip that would have rewritten it.

Gates run locally before commit:
  test_identity_lineage.py     31 passed
  test_claim_collision_hook.py 38 passed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jxd5gryAFPXwhjaCMw9Qrx

* docs(agnote): the row claimed grep prints every match; it prints 140 of 318

Codex P2, and correct. The disclosure argued the register's NUL was
harmless because "GNU grep was measured to print matches normally either
way (317 vs 318)". That figure is a COUNT, and a count does not
demonstrate printing.

Measured on this branch's copy:

    grep -c CLAIM   ->  318      the count is complete
    grep CLAIM      ->  140 lines printed, plus "Binary file ... matches"
    grep -ac CLAIM  ->  318

So 178 rows are invisible to a plain grep. The row asserted the opposite of
what its own repository already knows: pmoves/tests/test_identity_lineage.py
preserves that NUL specifically to verify that grep-based audits ARE
truncated. Recording "prints normally" would send a future reviewer looking
for the newest claims into output that silently stops before them — the
exact failure the test exists to pin.

The DECISION is unchanged and the NUL stays: the test pins it deliberately,
21 other control characters mean the tolerant reader is needed regardless,
and removing it would churn a test for no gain. Only the reasoning was
wrong, and the row now states the real limitation and the workaround
(`grep -a`).

test_identity_lineage.py: 31 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X9mpK1ZFjexjthZbvcb94f

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
POWERFULMOVES added a commit that referenced this pull request Sep 3, 2026
…a refuted cause

Register hygiene done through the sanctioned `make register-release` path.
Open claims 24 -> 21; expired-and-never-released 5 -> 2.

  fix/pm-pick-python-empty-array      #2809 MERGED
  fix/register-write-path-failclosed  #2879 MERGED
  chore/cli-prereq-preflight          #2761 CLOSED UNMERGED - gap still open
  fix/nats-bus-auth-outage            delivered; CLAIM hypothesis refuted

The nats row matters beyond bookkeeping. Its CLAIM asserted that
rotate_secret replaces the first occurrence while readers take the last,
making every rotation a silent no-op. That is false: NATS_PASSWORD occurs
exactly once, and rotate_secret has dropped later duplicates since #1854.
The real cause was an exported shell variable shadowing --env-file. Left
unamended, an append-only register would have preserved a fleet-wide
credential-rotation scare as its own last word.

Two lanes remain expired pending a disposition trace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
POWERFULMOVES added a commit that referenced this pull request Sep 4, 2026
…not a gate (#2894)

* feat(register): a refusal that names only write paths is a dead end, not a gate

#2879 closed the shell write-hole on the claim register correctly, and it should
not be loosened: six interpreter shapes were reaching an append-only ledger at
exit 0, and a command line cannot be trusted to declare its own intent, so the
allowlist has to enumerate what it positively recognises as a read.

The cost was that it left the question with no answer. `open_claims_in()` is
what the register names as the authority on what is open, it is only reachable
from an interpreter, and every interpreter naming the register is refused. The
three sanctioned paths the refusal offered -- register-claim, register-release,
register-amend -- are all writes. An agent could file a claim and could not ask
whether the lane was free.

Adds `make -C pmoves register-status`: open lanes and who holds them,
`BRANCH=` for whether one lane is free, and which open claims carry a TTL and
which of those have expired. It is not a second parser -- open_claims_in(),
canonical_owner() and evaluate_claims() come from the hook, build_row() and
_ttl_delta() from register_append, ROW_RE/parse_ts from register_postdate_check.
Asserted as SET EQUALITY against the gate's own open_claims_in(), not matching
totals: two counts can agree while the rows underneath disagree, and the row is
what a claimant acts on.

BRANCH= renders the row register-claim would file and puts it through
evaluate_claims(), so the answer is the gate's own three-way verdict rather than
a branch-string comparison that would drift from it. The probe is never
appended; the register is asserted byte-identical across every mode.

Two things the measurement surfaced and the target now reports: 3 open claims
are expired and never released, and 15 of 21 carry no TTL at all, so they cannot
expire and nothing will ever prompt a release.

The tool refuses to answer (exit 3) when PyYAML is missing. Without it the gate
compares owner IDs exactly; for a WRITE that fails closed -- it blocks more --
but for a READ the same degradation inverts and fails OPEN, reporting a lane
held under one spelling as free to someone asking under another. Hence
REGISTER_PYTHON on the target rather than $(PYTHON).

Hook changes are the minimum that makes the road usable, and they were load
-bearing: measured against origin/main's hook, BOTH new roads were refused at
exit 2 -- the target existed and the gate it exists to satisfy would not let it
run. `register-status` joins the make allowlist, `register_status.py` joins the
sanctioned-tool names (still refused if `-c` rides along), and the opaque
refusal now names the read path beside the write path.

The write surface is unchanged: the full #2879 matrix re-driven, 60/60, with
every interpreter write, deletion, replacement and output-flag shape still
refused and every legitimate read still allowed. 195 tests pass across the hook
and the three register tools.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz

* docs(register): close four B850 lanes, and correct one that recorded a refuted cause

Register hygiene done through the sanctioned `make register-release` path.
Open claims 24 -> 21; expired-and-never-released 5 -> 2.

  fix/pm-pick-python-empty-array      #2809 MERGED
  fix/register-write-path-failclosed  #2879 MERGED
  chore/cli-prereq-preflight          #2761 CLOSED UNMERGED - gap still open
  fix/nats-bus-auth-outage            delivered; CLAIM hypothesis refuted

The nats row matters beyond bookkeeping. Its CLAIM asserted that
rotate_secret replaces the first occurrence while readers take the last,
making every rotation a silent no-op. That is false: NATS_PASSWORD occurs
exactly once, and rotate_secret has dropped later duplicates since #1854.
The real cause was an exported shell variable shadowing --env-file. Left
unamended, an append-only register would have preserved a fleet-wide
credential-rotation scare as its own last word.

Two lanes remain expired pending a disposition trace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(register): close the last two expired B850 lanes with their real disposition

Zero expired-and-never-released rows remain; open claims 24 -> 18.

  fix/branch-audit-protected-divergence  NEVER-DELIVERED
  docs/hardened-branch-topology          PARTIAL, and overrun

Neither closes quietly. The first promised divergence+age on PROTECTED
rows in branch_cleanup.py; `git log origin/main` on that file since the
claim is empty and the blind spot is unchanged, so the RELEASE says so
and hands the gap back rather than implying it was handled.

The second is the sharper one. Its CLAIM said, verbatim, "characterisation
only - no merge, rebase, retire, or push." The question was then answered
and acted on by two differently-named branches in the same session, one of
which (#2818) performed exactly the re-baseline this lane promised not to
do. The register named one thing under one name while something larger
happened under two others.

That determination - hardened = old main + drift, 129/200 sampled files
byte-identical to March-era main - survives only in a PR description. No
file under pmoves/docs carries it, so a fresh clone does not have it.
Logged as an open, unowned gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(register): the gate trusted a FILENAME, and branch mode printed EXPIRED while returning 0

Two defects, both filed by review on #2894, both correct.

1. THE SANCTIONED-TOOL ALLOWANCE WAS A PASSWORD, NOT A CHECK.

Adding `register_status.py` to the hook's suffix tuple made the filename a
bypass token. `_segment_verdict()` approved the ENTIRE command when any
argument anywhere in argv ended with that name -- before anything asked which
program would run. Measured on the parent revision:

    python3 /tmp/register_status.py pmoves/docs/AGENTS/<register>   -> exit 0

An arbitrary script, chosen by the caller, handed the append-only ledger as
argv[1] with the gate's blessing. It can truncate it. #2879 made this path fail
closed precisely because a shell hole let agents write the register unchecked;
#2894 re-opened one in the shape of a trusted filename. `cp <tool> <register>`
and a byte-identical copy outside the repo passed the same way.

The allowance is now keyed on the RESOLVED FILE and the invocation SHAPE: the
script operand must resolve -- the way the shell will resolve it, against the
command's own cwd -- to the same real path as this repository's copy, reached
either directly or as the script operand of a python/uv interpreter. The repo
is located from the hook file itself, the one path in the command's environment
the caller does not choose. A symlink to the real tool passes because it IS the
real tool; a copy does not, because "same content today" is not "runs the gate's
own code". Resolving cwd-relative and NOT falling back to the repo root is
load-bearing: an agent standing in its own tree runs ITS
`pmoves/tools/register_status.py`, and a hook that resolved that token against
the repo would certify a program that is not the one about to run.

`make` carried the identical defect one layer along -- the TARGET NAME alone was
the test, so `make -C /tmp/evil register-status ARGS=<register>` ran an
arbitrary Makefile under a sanctioned name. It now requires an explicit `-C`
resolving to this repository's `pmoves`, and refuses `-f`.

2. AN EXPIRED SHARED LANE PRINTED THE FINDING AND RETURNED CLEAN.

In branch mode the expiry check sat behind `not verdict.shared`, so a
reciprocated lane whose open claim was already past its TTL rendered
`TTL 24h EXPIRED 3d3h ago` and exited 0. The whole-file report exits 1 on that
identical row. A report that PRINTS the finding and RETURNS a pass is the
fail-open this tool exists to prevent -- automation reading the exit code would
accept a stale co-held lane as clean -- and it contradicts the documented
contract that an expired unreleased claim is a finding.

Matching expired rows are now checked before success, the report says why in
words, and the JSON `branch` object names them so a consumer need not re-derive
from `open_claims` the one fact that moved the exit code. Shared-lane REPORTING
is untouched: co-holding is deliberate and the register must keep naming
everyone who worked a lane. What is not deliberate is a broken
promise-to-release, reported in full, scored as a pass.

EVIDENCE. Nine tests added, and proven non-vacuous by reverting each fix:
against the parent behaviour all five P1 negative controls return exit 0 (the
gate approving) and the shared+expired lane returns 0 while printing EXPIRED;
restored, all pass. Register/hook suite 204/204. Full suite failure set is
byte-identical to HEAD's -- 254 FAILED/ERROR lines both ways, zero regressions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci(register): the 27 tests proving the filename-bypass is closed ran on no CI machine

The gate fix in 6a7a9f3 shipped with `test_register_status.py` as its whole
proof and nothing anywhere collected the file.

Not "the workflow did not fire" -- it fires. This PR touches
`.claude/hooks/governance/claim-collision-pre.py`, which IS in the `paths:`
trigger. The workflow runs, executes the two OLDER test files, and reports
GREEN. That is the worst available failure mode: the proof is dark and the
dashboard says the proof passed.

Measured on the PR head, addopts neutralised so `-v` cannot mask `-q`:

    pytest -o addopts= --collect-only -q <workflow's OLD file list> \
      | grep -c 'test_register_status.py::'   ->  0
    pytest -o addopts= --collect-only -q      ->  0   (default testpaths)

Both zero, from two directions. `pmoves/pyproject.toml` `testpaths` covers
`pmoves/tests*` only, so `pmoves/tools/tests` is reachable ONLY by explicit
enumeration -- and the new file was enumerated nowhere.

Non-vacuity, because a wiring change that cannot fail is this same defect one
level up. With one assertion in `test_register_status.py` deliberately broken:

    old file list -> 177 passed, exit 0    <- green, break undetected
    new file list ->   1 failed, exit 1    <- the step fails

Restored byte-clean (`git diff` empty); new list then 204 passed, exit 0.

`register_status.py` itself was also absent from `paths:`, so editing the tool
under test triggered nothing. Added alongside its test, matching how
`register_postdate_check.py` and `register_append.py` are each paired here.

testpaths deliberately NOT widened. Adding `pmoves/tools/tests` would collect
the whole directory into the default suite and turn it red on any node without
optional ML deps -- measured, 8 failed / 439 passed / 3 skipped: 6 in
test_cymatic.py (no `librosa`), 2 in test_beats_features.py (no `sklearn`).
Trading a silent failure for a noisy one is not a fix, so the wiring is
explicit and the comment says why the enumeration is load-bearing.

This is a class, not an incident. Of 29 test files under `pmoves/tools/tests`,
8 are now reachable and 21 are collected by nothing -- 268 tests that run on
no CI machine. Named as follow-up, deliberately not fixed here to keep this
change reviewable.

A defense whose proof runs only on its author's laptop is not a defense on any
other node.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(anchors): drop 24 suppressions whose defects were fixed — a stale entry is a blind spot

The anchor ratchet failed on this PR with ZERO new findings: 474 total, 474
baselined, 0 new. It failed only because 24 baseline entries name defects that
no longer occur. That refusal is the point. A suppression is a promise that a
specific defect is known and tolerated; once the defect is gone the entry stops
describing reality and starts holding a door open — the same bad anchor can be
reintroduced later and the gate will say nothing, because the entry still
matches. The ratchet will not let a suppression outlive what it suppresses.

All 24 were fixed by #2913 (merged 2026-09-03), by two mechanisms, verified
one by one rather than sampled:
  - target now defined (10): health-agent-zero, a0-mcp-smoke, a0-mcp-exec-smoke
    were .PHONY-declared and documented but never defined; #2913 defined them
    (health-agent-zero now at pmoves/Makefile:3892).
  - doc reference removed (14): the e2b / wger / firefly-iii / jellyfin READMEs
    were reconciled against reality and stopped naming targets that never existed.
Zero unexplained: no entry went stale because a file was deleted or fell out of
the scan.

INHERITED, NOT INTRODUCED BY THIS PR. #2894 touches six files — the claim hook,
the postdate workflow, pmoves/Makefile, the register, register_status.py and its
test — and none of the 24 doc anchors. Its only Makefile change adds the
register-status target. This PR merely surfaced the staleness because
pmoves/Makefile is a trigger path for the anchor workflow.

Why the baseline was allowed to go stale is worth recording, because it is not
that the signal was missing. #2913's own ratchet run printed the identical
"474 total, 474 baselined, 0 new" and "STALE BASELINE - 24 entries no longer
occur", concluded FAILURE, and the PR merged 2.5 hours later anyway — the
anchor ratchet is not in the required-check set, so a red run does not block.
The signal was delivered and passed over, and it then landed on the next PR to
touch a trigger path. (The workflow also has no push trigger, so nothing
re-checks main between PRs; that is why the debt sat rather than why it formed.)

Direction of change verified before commit, since re-baselining is exactly the
operation that can silently loosen a gate: the diff is 24 deletions and 0
insertions, and the removed set is an exact match for the 24 reported stale
entries. Nothing new is suppressed. Post-change the ratchet still reports
474 total / 474 baselined / 0 new and now exits 0 — the gate is strictly
tighter, and those 24 anchors are live checks again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant