diff --git a/AGENTS.md b/AGENTS.md index c115a9b71..1e45b8add 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,16 +14,28 @@ Session handoff files live ONLY in `docs/handoff/` — exactly one untracked fil cli/src/ ├── main.rs CLI entry; routes to commands/ ├── commands/ add, remove, list, check, update, update_pi, verify, refresh, init -├── pi_extension.rs Pi extension discovery, install/remove, settings.json merge +├── commands/add/source.rs Source resolution for `add` — local dir, cached remote, or the checkout; SourceFetch states whether a source is fetched now or served from a fresh cache +├── commands/check/ render.rs — the drift report's text and its credential/control/length scrubbing +├── pi_extension.rs Pi extension discovery, install/remove +├── pi_extension/settings.rs Pi settings.json `packages` array — registration write, removal, and matching ├── config.rs Lock file (JSON), project root detection, staleness/mtime helpers +├── config/remote_cache.rs Remote source cache fetch stamps and recorded refresh outcomes +├── config/remote_cache/fetch.rs The CacheLease a reader holds, bounded git fetch/reset, refresh drivers +├── config/remote_cache/fetch/guard.rs The two fetch-guard implementations (flock, and the portable lock file) +├── config/remote_cache/fetch/guard/liveness.rs A portable lock's owner record, its held-for-life heartbeat, and stale-holder takeover +├── refresh_sources.rs Source resolution; RemoteSource (display/URL/cache key), hardened git commands, cache-entry ownership +├── refresh_sources/records.rs A lock's sources as records — resolution, refusals, and the lease each cached root is read under ├── scope.rs Scope enum (project | global | all); uniform `--scope`/`-g` parsing ├── catalog.rs Source catalog discovery from default dirs or vstack.toml `[catalog]` +├── catalog/roots.rs Configured-path expansion and the entry-type rule a root must satisfy to be readable ├── mapping.rs Source vstack.toml — MappingConfig (catalog, agent-skills, role-skills, hook-events) ├── project_config.rs Project vstack.toml — ProjectConfig, ensure/write/update ├── resolve.rs Shared helpers — skill-pair resolution, hook source attribution/matching, read_existing_extras, is_vstack_source +├── json_config.rs Harness JSON configs vstack shares (claude settings.json, codex hooks.json, opencode.json/.jsonc, Pi settings.json) — the syntax and declared schema every reader and writer validates against +├── json_config/ deviation.rs — walking a document against its schema and naming the value that deviates, since that message is the user's whole remedy ├── installer.rs Symlink/copy logic, install/remove orchestration -├── installer/hooks.rs Hook install/remove orchestration and shared Claude/Codex/Cursor helpers -├── installer/hooks/ Hook submodules — OpenCode cleanup/install and focused hook tests +├── installer/hooks.rs Hook install/remove orchestration and shared Claude helpers +├── installer/hooks/ contract.rs (event × harness matrix), enforcement.rs (per-install resolution of it), command.rs (reading a registered command the way a shell would), per-harness submodules — codex.rs (native registration, config.toml feature, prose fallback), cursor.rs (safety rule + its alwaysApply switch), opencode.rs — and focused hook tests ├── harness/ (canonical → per-harness translation) │ ├── claude.rs → .claude/agents/*.md (disallowedTools, effort/background/isolation/memory, skills, hooks frontmatter) │ ├── cursor.rs → .cursor/rules/*.mdc (description + alwaysApply + skills) @@ -53,7 +65,11 @@ Seam map and sequenced split plan for `project_config.rs` and `installer.rs`: [d - **Report ownership is identity-based.** Installed lock entries stamp the source GitHub `owner/repo` as `source_repo` when resolvable. `vstack report` uses skill/agent frontmatter first, then `source_repo` or a live source Git origin; a vstack-shaped local directory alone is never proof of upstream ownership. - **Upstream reports carry a routing label.** vstack-targeted issues get one flat label — `cli`, `skills`, `harness` (team VST subsystems) or `ci-infra`, `docs`, `chore` (existing workspace labels, reused) — derived from the asset selector or set explicitly with `--area`. The GitHub→Linear sync carries it across, where a Linear triage rule files the synced issue into the matching project. Labels are bare words with no prefix or parent group, matching memsira/hyprtrade; `agent:*` is the workspace's only grouped-and-prefixed set. Project-local reports never get one: a `gh` call naming a label the target repo lacks fails outright. See [docs/issue-label-taxonomy.md](docs/issue-label-taxonomy.md). - **One hook execution contract.** `cli/src/installer/hooks/contract.rs` holds the event × harness matrix — enforcement mechanism, advisory, or unsupported — and every install path, CLI label, and the published table derive from it. Advisory artifacts carry `advisory — this harness cannot execute hooks`. Registered commands anchor on something that holds from any working directory in git and non-git projects: `$CLAUDE_PROJECT_DIR` for project-scope Claude, the install-time absolute path everywhere else. -- **Pi extensions are npm-shaped.** vstack copies them to `/packages/`, runs a prod-only, lockfile-free `npm install` there when the package declares dependencies, and registers the path in Pi's `settings.json` `packages` array. +- **An enforcement level is only as good as the artifact behind it.** `cli/src/installer/hooks/enforcement.rs` downgrades a contract cell to `unsupported` unless everything the harness needs to invoke the hook is present — script AND registration, in the hook's own event and matcher, with Codex's `[features] hooks` on and Pi's carrier package deployed and registered. One reader per harness answers that question for `list`, `check` and `verify` alike, and it is the same predicate the installer replaces entries with and `remove` deletes them by, so no two of them can disagree about which registration is ours. +- **vstack never overwrites a value it could not understand.** Every harness config vstack shares — claude `settings.json`, codex `hooks.json` and `config.toml`, OpenCode's `opencode.json`/`opencode.jsonc`, Pi `settings.json` — is read through one declared schema in `cli/src/json_config.rs`. A deviation anywhere on the path vstack reads makes the whole document UNREADABLE: presence reports it unverifiable naming the file and the value, and every writer refuses rather than replacing what it could not parse. Add a key to the schema, not a probe at the call site that noticed — and add EVERY field the code goes on to interpret, with its real type. A read field left as `Any` is a field whose malformed value the reader silently rereads as its own fallback: a `"matcher"` that is not a string read as "no matcher" answered `Registered` for a hook the harness cannot deserialize and would never run. `Any` is a claim that vstack only ever preserves that value and draws no conclusion from it, so each one carries the per-field reason it is correct. +- **A presence check never returns on the first fault it finds.** Two faults co-occur constantly — a missing artifact and the config the reinstall for it would have to write — and a report that stops at the missing artifact prescribes a command the other fault refuses. Every per-harness reader in `cli/src/commands/verify/hooks.rs` asks all of its questions and collects the answers into three lists; the classification is `Unverifiable` → `Missing` → `Disabled`, which is the order the remedies have to happen in, and every note rides along so a repair is never followed by a second surprise. The one thing a fault may suppress is a fault the SAME command would fix anyway (a claude registration beside a missing script; codex's `hooks.json` and `[features] hooks` beside a missing script) — never one it would refuse. Same rule outside hooks: `install_gap` reads Pi settings whatever the package directory says and names an unusable `.agents` ahead of the project skill it blocks, and `cli/src/config/remote_cache/problems.rs` puts an unwritable cache ahead of the fetch failure its frozen stamp still records. +- **A config's syntax is the harness's parser, never its file extension.** `Syntax` sits beside the schema in `cli/src/json_config.rs`: OpenCode hands every spelling of its config to one JSONC parser, so comments and trailing commas are content in `opencode.json` as much as in `opencode.jsonc`, while claude, codex and Pi parse strictly and a comment there really is a broken file. Reading with the wrong parser breaks both ways — too strict calls a working install unverifiable and blocks `add`/`remove` on it, too loose accepts a file the harness silently drops and then rewrites it. A JSONC config is edited through `jsonc-parser`'s CST, the way codex's `config.toml` is edited through `toml_edit`, so an install or removal changes only its own value and every comment, blank line and key order survives. +- **Pi extensions are npm-shaped.** vstack copies them to `/packages/`, runs a prod-only, lockfile-free `npm install` there when the package declares dependencies, and registers the path in Pi's `settings.json` `packages` array. Both artifacts define presence: `check`/`verify` call a package installed only when the copy exists AND a `packages` entry resolves to it, since Pi loads only what that array points at. Entries are compared as paths, so any spelling resolving to the same directory counts. - **Skill/hook attribution is config-driven.** Source `vstack.toml` `[agent-skills]` is authoritative — explicit entries skip prefix matching; `[role-skills]` adds skills to all agents of a role. Project `vstack.toml` gets `[agent-skills]` populated at install; users edit and refresh. Markdown harnesses get `skills:` frontmatter; Codex agents get a "Required Skills" instruction section. - **Reconciliation is automatic.** After every `vstack add`, all installed agents are regenerated with the current full set of installed skills and hooks. - **Project root walks up from CWD.** `config::project_root()` finds `.vstack-lock.json` or a harness dir (`.claude/`, `.cursor/`, `.codex/`, `.opencode/`, `.pi/`, `.agents/`) by walking parents. `$HOME` with only user-level harness dirs and no lock file is rejected, so project-scope writes never route into user state. @@ -198,6 +214,7 @@ Each canonical agent declares its own `effort:` (`low` | `medium` | `high` | `xh - **`vstack add` scope is destructive — read the printed summary.** Every non-interactive run prints `Scope: PROJECT (...)` vs `GLOBAL (...)`, method, and every item written. Confirm both before claiming success. - **Never `--global` without an item filter.** CLI refuses `--global -y` unless `--all` or an item filter is set. Item filters are exclusive, except `--agent` auto-includes dependent skills from `[agent-skills]` + `[role-skills]` (opt out with `--no-auto-skills`); auto-included skills appear in the scope summary. - **Scope flag is uniform.** `list`, `check`, `refresh`, `remove` accept `--scope project|global|all`; `-g` = `--scope global`. Default: `all` for read-only, `project` for `remove`. Bare `vstack refresh` reinstalls items at every scope they're locked at. +- **`vstack check` is the drift contract.** Exit `0` clean, `1` drift, `2` the check failed; available-but-not-installed items are suggestions, never drift, while a source cache that has been failing to refresh for more than two TTL windows (or cannot be written at all) is. A source whose cache another vstack process is fetching while the check runs is reported unchecked — its entries are classified as nothing at all, and it is neither drift nor counted clean; readers probe that lock and never wait on it. A source's malformed asset is drift only for the kinds that scope installs from it, and an install whose evidence is itself unreadable is reported as that, never as a phantom — reinstalling repairs nothing there. An install that is complete and SWITCHED OFF (Claude `disableAllHooks`, Codex `[features] hooks`, a Cursor rule whose `alwaysApply` is not `true`) is its own report naming the setting and the file — also drift, also not a phantom. OpenCode has no such switch; Pi's live in vstack's own extension-manager UI and are deliberately never reported. Drift is computed from disk alone — a cache older than the six-hour TTL is refreshed by a detached `vstack cache-refresh` nobody waits on, and its outcome is reported at the next session. The only network call left is the human report's CLI-version lookup, which `--quiet` (the hook path) and `--offline` skip. A background refresh that cannot even be started is reported on its own line — informational, never drift. `--quiet` prints nothing when clean, `--json` goes to stdout, `--offline` skips the CLI version lookup and the background refresh while still reporting recorded cache failures. The `session-drift-check` hook and the Pi `pi-hooks` `sessionDriftCheck` setting are thin adapters over `check --quiet` — change the report in `cli/src/commands/check/render.rs`, not in the adapters. - **Verify after refresh.** `vstack refresh -v` prints per-item `old→new` hash; `vstack verify [-g] [name…]` confirms source matches lock and byte-matches Pi package installs. Use both before claiming a change is live. - **Docs and instruction payloads ship with the code change.** Any change to a hook, skill, agent, or Pi extension updates — in the same commit — affected READMEs, AGENTS.md, `vstack.toml`, settings examples, `package.json`, and agent instruction payloads. A behavior change without its docs update is incomplete. - **Every Pi extension keeps a `CHANGELOG.md` in its package folder** (`pi-extensions//CHANGELOG.md`), led by a `## Consumer-impacting changes` section. This is the authoritative channel for critical developer information to consumers and repos that vendor an extension: record behavior deltas, new/renamed/removed exports, settings and config changes, and protocol/audit-shape changes under the version that ships them, in the same commit as the change (with the `package.json` bump). Internal-only changes may be omitted; a consumer-impacting change without its changelog entry is incomplete. `package-policy.test.mjs` enforces the file's presence and shape. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a6cf1237..2dbc54b2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,168 @@ ## Unreleased +- cli: A shared config is read with the parser its OWN harness uses, never the + one its file extension suggests. OpenCode hands `opencode.json`, + `opencode.jsonc`, its global config and whatever `$OPENCODE_CONFIG` names to + one JSONC parser, so comments and trailing commas are a working OpenCode + config; vstack was reading all of them strictly, which reported a perfectly + live hook install as unverifiable and made `add` and `remove` refuse to touch + the file. Claude's `settings.json`, Codex's `hooks.json` and Pi's + `settings.json` stay strict, because their harnesses are — a comment in one + of those really is a file the harness drops. The relaxation is exactly what + OpenCode's parser takes and no more: a single-quoted string, an unquoted key + or a hex number is still a config OpenCode ignores, so vstack still refuses + it rather than rewriting a file the harness is not loading. + Writes preserve what they did not author. A JSONC config is edited through a + syntax tree, the way Codex's `config.toml` is edited through `toml_edit`, so + installing or removing a hook changes only its own entries and every comment, + blank line, indent and key order comes back byte-for-byte. Serializing the + parsed value back over the file would have deleted every comment in it on the + first `vstack add`. OpenCode's GLOBAL config is now resolved by the spelling + that is actually on disk too, so a user who keeps `opencode.jsonc` gets the + registration written into the file they use instead of a second one beside + it. + +- cli: `vstack check` is a process contract a session can branch on — exit `0` + clean, `1` drift, `2` the check itself failed, `--quiet` silent when clean, + `--json` on stdout, `--offline` skipping every network call. Items a source + ships but the scope never installed are suggestions and never drift. The + verdict is computed from disk alone: a remote source cache older than six + hours is refreshed by a detached `vstack cache-refresh` nobody waits on, and + its outcome is reported at the next session, so a session start never blocks + on the network — a cache that has been failing to refresh for more than two + refresh windows, or that vstack cannot write to at all, is drift, and each + report names the cause rather than a generic staleness. A source vstack + REFUSED is reported as refused by `check`, `verify` and `refresh` alike, + with the refusal's own remedy instead of a `vstack add` that would refuse + again. A new `session-drift-check` hook (Claude Code and Codex) and the Pi + `pi-hooks` `sessionDriftCheck` setting relay the quiet report at session + start; both are thin adapters over `check --quiet`, whose output is bounded + by construction — every section is capped, every displayed name is rendered + through the bounded renderer, AND the quiet report as a whole has both a + line budget and a byte budget (item name length is unrestricted, so counting + lines alone bounded nothing), spent on drift before suggestions and closing + with one line naming what it left out; a copy-paste command argument stays + complete, since an elided argument is a command that cannot work. A + config file vstack shares with a harness — a Claude `settings.json`, a Codex + `hooks.json` or `config.toml`, an OpenCode `opencode.json`, Pi's + `settings.json` — that EXISTS and cannot be read is reported as unverifiable + naming the file and what was wrong with it, never as a missing hook or an + unregistered package whose printed remedy is `vstack add`; and every writer + refuses such a file instead of parsing it as a default and rewriting it, so + no vstack command can discard the settings and registrations it holds. + "Cannot be read" is now the WHOLE shape vstack depends on, declared once and + validated at the reader: invalid JSON, but also an event value that is not + an array, an entry, handler list, handler or command of another shape, a Pi + `packages` that is not an array, an `opencode.json` `instructions` or + `permission` of another type. Each of those used to read as "nothing + registered here" while the matching writer replaced the offending value with + an empty default, crashed on it, or refused it — leaving the user with a + destroyed setting or a drift the printed remedy could never clear. A Codex + agent file the prose fallback could not read is reported the same way rather + than as a missing safety block. A command that installs from a cached source + (`add`, `refresh`, the wizard) now waits for an in-flight refresh of that + cache and then refuses, instead of discovering, hashing and copying out of a + tree another process is running `reset --hard` on; only the detached + background refresh treats a busy cache as a no-op. A READ-ONLY reader — + `check`, `verify`, hook attribution, source-identity recovery — neither + waits nor takes that lock: it probes it, and a source whose cache is being + rewritten is reported as not checked this run instead of measured against a + half-written tree. That is neither drift nor clean, it costs a session + start nothing, and the next run reports the source normally; before it, + `check` could call a live entry REMOVED and print `vstack remove` beside it. + The initial clone — the one cache write no lock can cover, since the lock + lives inside a `.git` that does not exist yet — is published into its entry + by rename, so a clone that did not finish is never visible under the entry's + own name. Where the platform has no `flock` to release the lock for it, a + holder records its liveness for as long as the lock is HELD rather than only + while its fetch runs, so a lease kept across discovery, hashing, copying or + an interactive selection is no longer read as a crashed process's leftover + and taken over mid-read; a holder that really is gone stops recording, and + its lock is still taken over once it goes stale, so no cache wedges. + Codex's safety-prose + fallback is located by one predicate scoped to the agent's + `developer_instructions`, so marker text in a comment or another field can + no longer make the install skip the block and the presence read call it + installed — and the block counts only while it still carries the hook's + action line, so a heading whose body was deleted is reported rather than + reported installed, and a reinstall rewrites the section instead of skipping + it. An install that is COMPLETE and switched off is a third report with a + third remedy: Claude's `disableAllHooks` (read through the declared schema, + over claude's own settings precedence, and never from a + `~/.claude/settings.local.json` claude does not load), Codex's + `[features] hooks`, and a Cursor safety rule whose `alwaysApply` is no longer + `true` each leave every artifact in place while the harness runs none of it — + now named with the setting and the file holding it, instead of reported as a + missing install whose printed remedy is a reinstall that changes nothing. + OpenCode exposes no such switch; Pi's live in vstack's own extension-manager + UI and stay out of the report (VST-258). +- cli: every structured file vstack reads is now read by a parser rather than + matched as text, so the answers no longer depend on how a value was spelled. + A Cursor rule's `alwaysApply` is a YAML boolean, so `alwaysApply: true # keep + enabled` is the same "on" to vstack that it is to Cursor, and a rule whose + frontmatter does not parse — or whose `alwaysApply` is a value Cursor itself + would not honor — is unverifiable naming the file rather than silently off. A + Codex agent's `developer_instructions` is located by parsing the TOML, so the + assignment text quoted inside another field, or a `developer_instructions` + belonging to a different table, is no longer spliced into or cut out of; an + agent file that is not TOML vstack can read is refused by name and never + rewritten, by install, removal or the presence read. A registered hook + command is split into the words a shell would run, so a `bash '/path with + spaces/hook.sh'` — the command vstack itself writes for any install path + containing a space — reads back as registered instead of as permanent drift + no reinstall could clear; a command whose words cannot be settled still reads + as unregistered. The `session-drift-check` hook reads the session's start + reason from the payload's top-level `source` via `jq` where it is available, + so a nested key or a matching string elsewhere in the payload no longer + decides whether the report is printed. Source picker rows and the scope + summary now label a GitHub remote by the repository it names, so every + spelling of one repository is one row. An installed agent's declared + skills are read as parsed YAML — a block sequence and a value carrying a + trailing comment both count, where before either read as declaring none + and every skill the agent named went unchecked. Removing a hook from + `opencode.json` deletes the entry that RESOLVES to vstack's own instruction + file, through the same predicate the registration read accepts it with; it + used to split the hook's name on `-` and drop any entry whose text held + every fragment, so removing one hook deleted the user's own unrelated + instructions, and a `vstack-hook-` substring anywhere in a path kept the + bash restriction alive after the last vstack hook was gone. Whether any + vstack hook still needs the shared bash rule is decided by that same + predicate; a file-name glob over the entry text answered it separately, so a + hook registered under an equivalent spelling counted as installed for + `check` and as nothing at all for removal — removing a sibling took the rule + out from under it and left a partial uninstall no command reported + (VST-258). +- cli: every command vstack PRINTS for you to paste is built from one helper, + which POSIX-quotes each argument, so a source, an item name or a package + spelled with shell syntax is passed literally instead of executed — a + recorded source of the shape `https://host/team/$(id).git` produced a + restoration command that ran the substitution. The same helper owns the + credential redaction, the terminal-escape scrub and the length bound every + displayed string gets, and the two places that quote for EXECUTION rather + than display — a harness's `settings.json` hook command and + `GIT_SSH_COMMAND` — stay separate so they carry a path byte for byte. + Diagnostics are no longer scrubbed as if each were a single source URL: a + message's `?` is a question mark, not a query string, so a refusal is no + longer cut off mid-sentence and given a `` naming nothing. Neither + is a local source path: `?` and `#` are a URL's query and fragment but + ordinary characters in a directory name, so a local source is now shown as + itself — still terminal-escaped and still quoted inside a command — and only + a remote-shaped source goes through the credential and query redaction, as + classified by the resolver itself. A local source directory spelled with + either character used to render as `/path/source?`, and the + restore and add-item commands built from it named a directory that does not + exist. A + subprocess's output and a lock file's names are displayed text and get a + displayed string's treatment. A hook locked for Pi is only installed when + the `@vanillagreen/pi-hooks` carrier is deployed AND registered in a scope + Pi loads — its absence is drift naming the carrier and the remedy, an + unregistered copy is drift naming the registration, and an unreadable Pi + `settings.json` is unverifiable naming the file; `check`, `verify` and the + enforcement level `list` prints all read one probe, so they cannot disagree. + An owning checkout's lock file that exists and cannot be parsed no longer + reads as absent: unknown ownership is not permission to clear another + checkout's recovery marker (VST-258). - pi-agents-tmux: Monitor tree task rows show elapsed/total run-time instead of a jumpy local `HH:MM` clock (`updatedAt` is no longer a time source); detail-pane timestamps render local human time instead of UTC ISO, and the @@ -120,6 +282,16 @@ - hooks: `vstack add` checks every selected hook's event against the contract before its first write, so a refused event leaves no lock, agent, settings or config behind (VST-283). +- hooks: one predicate per harness decides which registered command is + vstack's, and install, removal and every presence report ask it. A Claude + Code or Codex command you reshaped by hand around vstack's script — an + `env`/`timeout` prefix, extra flags, a different quoting of the same path — + was already counted as installed, but `vstack remove` matched the literal + string only and left it registered, so the harness kept running a hook the + lock no longer knew about. The enforcement level `list` and `check` print + now comes from the same reader `verify` reports the gap from, so a hook + cannot read `enforced` on one command and drifted on another (VST-258, + VST-283). - second-opinion settings example: the `SECOND_OPINION_CURRENT_MODEL` block announced "three cases, and only the third makes a project file usable at diff --git a/README.md b/README.md index 0fc65cf24..3806cac38 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ nix run github:vanillagreencom/vstack -- add vanillagreencom/vstack That opens an interactive installer where you pick which agents, skills, hooks, and Pi extensions to bring in, and which tools to install them into. +A source you name on the command line is fetched before anything is read from it, interactive or not — naming it is asking for that repo as it is now. Only the installer's own source browsing serves a cached copy while it is fresh, so switching repos in the picker never waits on an unreachable remote; `vstack check` reports a cache that has fallen behind. + ## How It Works A source repo is a package registry. vstack discovers what's there, asks which pieces you want, then writes the right files for each tool. @@ -81,7 +83,9 @@ pi_extensions = ["pkgs/plugins/pi-*", "pkgs/plugins/a-specific-extension"] extras = ["theme-packs"] ``` -Each path is relative to the source repo. A path may point at a container directory; skills, Pi extensions, and extras may name one specific item directory, while agents and hooks may also name one specific `.md` or `.sh` file. `*` is supported on the final path segment only. Omitted keys keep the default directory for that item kind. +Each path is relative to the source repo. A path may point at a container directory; skills, Pi extensions, and extras may name one specific item directory, while agents and hooks may also name one specific `.md` or `.sh` file. `*` is supported on the final path segment only. Omitted keys keep the default directory for that item kind, and an empty list (`skills = []`) declares that the source ships no items of that kind. + +`vstack check` only calls an installed item removed upstream when every configured root for its kind is there, is the right kind of thing, and every item under it was readable. A configured root that has gone missing is reported as a source layout problem to investigate, never as a `vstack remove` to run — and so is one that exists but is the wrong sort of entry, named with what was found there. Every root is judged by that rule, whether the path was written out, matched by a `*`, or defaulted: a regular file where a container belongs, a globbed parent that is not a directory, and a glob match of the wrong entry type are one answer. ### Customizing With `vstack.toml` @@ -154,6 +158,20 @@ Key rules: > **v3 migration:** legacy shared `[agent-frontmatter]` and `tools` allowlists are no longer read. Move overrides into `[agent-frontmatter.]` and switch allowlists to `deny-tools`. +### Checking For Drift + +`vstack check` compares every installed scope against its source and reports outdated items, items removed upstream, skills on disk but missing from the lock, lock entries whose install is incomplete — the files are missing, or the harness never registered them and so would never run or load them (agents, skills, hooks and Pi packages; extras record no single install path) — agents referencing uninstalled skills, and sources it cannot resolve, inventory, or fully read. An install whose evidence is itself unreadable — a Pi `settings.json`, a Claude `settings.json`, a Codex `hooks.json`/`config.toml`, an OpenCode `opencode.json`/`opencode.jsonc`, or a Codex agent file that will not parse, or that holds a value where vstack reads one of another shape — gets its own section naming the file and what was wrong with it, never a reinstall for an item that may be fine. `vstack add` and `vstack remove` refuse those files rather than rewriting them, so no vstack command can quietly discard the other settings and registrations they hold; fix the file by hand and rerun. An install that is complete and switched off gets a third section, because its remedy is neither: Claude's `disableAllHooks`, Codex's `[features] hooks`, or a Cursor safety rule whose `alwaysApply` is no longer `true` leave every artifact in place while the harness runs none of it, so the report names the setting and the file holding it instead of prescribing a reinstall that would change nothing. Pi's hook toggles are deliberately not reported: they live in vstack's own extension-manager UI, which already shows their state. A source's malformed asset is reported only for the kinds that scope installs from it — the same limit the suggestions below already apply — so a broken Pi package in a source a project draws only skills from is not that project's drift. A source whose cache another vstack process is refreshing while the check runs is listed as not checked this run: its items are measured against nothing rather than against a tree being rewritten, so none of them is reported outdated or removed, and the next check reports them normally — this is not drift. It also lists items a source ships that the scope never installed (only kinds the scope already uses are offered) — a suggestion, not drift. Its exit code is the contract: `0` clean, `1` drift found, `2` the check itself could not run. Suggestions alone exit `0`. Every remediation command it prints is scoped to the section it sits under — a global finding prints `vstack remove -g ` and `vstack add -g …`, since `add` and `remove` default to project scope — so a printed command always acts on the install it was printed for. `vstack refresh` is the exception and stays unflagged: it reinstalls at every scope an item is locked at. + +```bash +vstack check # human report; also looks up the latest CLI version +vstack check --quiet # prints nothing when clean — what the session-drift-check hook runs +vstack check --json # machine-readable report on stdout +vstack check --offline # no network at all +vstack check --no-available # skip the available-but-not-installed suggestions +``` + +`check` never touches the project's git state and never blocks on the network: the verdict comes from the lock, the source trees, and each cache's recorded refresh outcome. The human report additionally looks up the latest CLI version, which `--quiet` and `--offline` skip — so the session-start path (`--quiet`) is fully local and works offline. Remote source caches under `~/.vstack/cache/` are vstack's own clones: one older than six hours is refreshed in the background (never with `--offline`), so cache news lands at the next session rather than costing this one. A single failed refresh is a footnote — working offline stays quiet — but a cache that has been failing for more than two refresh windows, or one vstack cannot write to at all, counts as drift so a permanently broken remote cannot read as clean forever; the report names the cause and points at `vstack refresh`. No vstack git invocation ever stops to ask a human anything — terminal prompts are disabled and ssh runs in batch mode — so a private source needs a configured git credential helper or ssh key rather than a typed password. `vstack refresh` applies updates; `check` itself never installs or removes anything. A command that installs from a cached source waits for any refresh already running against that cache and then refuses rather than installing from a tree being rewritten — rerun it once the refresh finishes. `--quiet` is bounded by construction: each section lists at most ten items and closes with `… and M more (run `vstack check` for the full report)`, and the report as a whole has a line budget AND a byte budget — item names are unrestricted in length, so counting lines alone bounded nothing — spent on drift before suggestions and closing with one line naming what it left out. Section headers keep the true counts — the full listing is always one `vstack check` away. + ### Runtime Settings Portable skill scripts load runtime settings in this order: @@ -247,7 +265,8 @@ Windows: CLI runs natively; symlink mode falls back to copy. | `block-repo-copy` | `PreToolUse` | Refuses a recursive copy (`cp -r`/`-R`/`-a`, recursive or archive `rsync`, local `git clone`, `tar` create-to-extract pipe) when the source carries repository history or a build tree AND the destination resolves under a temp/scratch root. Temp roots are commonly RAM-backed tmpfs, where such a copy fills the filesystem and every process writing there fails with ENOSPC. | | `pre-commit-check` | `PreToolUse` | Validates formatting and lint before commits. Rust Clippy lane is scoped to staged packages and configurable via `VSTACK_PRE_COMMIT_RUST_CLIPPY` (custom command or `off`). | | `post-edit-lint` | `PostToolUse` | Runs lint checks after source edits. | -| `task-completed-check` | `TaskCompleted` | Runs final lint checks before marking work complete. Claude-Code-only — codex has no clean equivalent event. | +| `task-completed-check` | `TaskCompleted` | Runs final lint checks before marking work complete. Scoped to Claude Code with `harnesses:` — it is the one harness that runs the event natively. | +| `session-drift-check` | `SessionStart` | On a fresh session start (not resume or compact) runs `vstack check --quiet` and hands the agent the drift report — outdated items (`vstack refresh`), items removed upstream (`vstack remove `, `-g` in a global section), unreachable sources — plus, alongside drift, items available but not installed (`vstack add -- `, pending your approval). Prints nothing when the install is current; one line when `vstack` is not on `PATH`, the project directory is unreadable, or the check fails unexpectedly. Never waits on the network: a stale source cache is refreshed in the background and reported at the next session. Never installs or removes anything and never touches the project's git; vstack's own source caches under `~/.vstack/cache` may be fetched at most once per TTL. `VSTACK_DRIFT_HOOK=off` disables it, `VSTACK_DRIFT_HOOK_AVAILABLE=off` hides the available-item suggestions. Claude Code and Codex only (native `SessionStart`); Pi gets the same report from `pi-hooks`. | #### Hook execution contract @@ -276,20 +295,45 @@ every harness it is locked at, and each advisory artifact carries refused at install: no harness column could be filled in for it. A level is a claim about what vstack installed and what the harness does with -it, downgraded to `unsupported` when the artifact behind it is gone, the -`harnesses:` allowlist excludes the harness, or Pi's carrier package is not -installed. It is not a probe of harness runtime state — whether Codex has been +it, downgraded to `unsupported` when any artifact behind it is gone, the +`harnesses:` allowlist excludes the harness, Pi's carrier package is not +installed, or the harness is configured not to run it — `disableAllHooks`, +`[features] hooks`, a rule's `alwaysApply`. The level names the same fault in +the same words `check` and `verify` report, off the same readers, so the three +commands cannot disagree about one install. It is not a probe of harness +runtime state — whether Codex has been told to trust the project's `.codex/` layer, or which hooks are toggled on in pi-extension-manager, is the harness's to answer. `vstack verify` re-checks -every installed artifact against its source. - -Where the artifacts land: - -- **Claude Code** — script under `/.claude/hooks/`, registered in `settings.json` plus the owning agent's frontmatter. Project scope anchors on `$CLAUDE_PROJECT_DIR`; global scope on the installed absolute path. -- **Codex** — script under `/.codex/hooks/`, entry merged into `/.codex/hooks.json`, and `[features] hooks = true` ensured in `config.toml`. Codex sets no project-root variable and runs the command from the session cwd, so the registered command carries the install-time absolute path and resolves in projects that are not git repositories. -- **Cursor** — advisory `.mdc` under `/.cursor/rules/`. -- **OpenCode** — permission rule + advisory instruction file referenced from `opencode.json`. -- **Pi** — no per-hook artifact. The behaviors ship as `@vanillagreen/pi-hooks`, which listens on Pi's `tool_call`/`tool_result`/`turn_end` events and uses `{block: true, reason}` to short-circuit unsafe tool calls; each is independently toggleable from the pi-extension-manager settings panel. Without that package installed, Pi enforces nothing, and `vstack list` says so. +every installed artifact against its source and names the exact gap. + +Where the artifacts land, and what `check`/`verify` require of each: + +- **Claude Code** — script under `/.claude/hooks/`, registered in `settings.json` plus the owning agent's frontmatter. Project scope anchors on `$CLAUDE_PROJECT_DIR`; global scope on the installed absolute path. Both artifacts are required — a script whose registration was deleted, or one registered under a different event or matcher, is drift, because Claude Code would never run it at the time the hook declares. A registration you keep in `settings.local.json` instead counts: Claude Code merges it, so it runs. `disableAllHooks` is reported on its own: every artifact is there and Claude Code runs none of them, so the remedy is that setting, not a reinstall. +- **Codex** — script under `/.codex/hooks/`, entry merged into `/.codex/hooks.json`, and `[features] hooks = true` ensured in `config.toml`. Codex sets no project-root variable and runs the command from the session cwd, so the registered command carries the install-time absolute path and resolves in projects that are not git repositories. All three are required — a script whose registration was deleted, and a scope with the `hooks` feature switched off, are each reported with their own remedy. +- **Cursor** — advisory `.mdc` under `/.cursor/rules/`. The rule's own `alwaysApply: true` is what makes Cursor attach it to every request; a rule edited down to description-matching is reported as switched off, because "the model may judge it relevant" is not the same as attached. +- **OpenCode** — permission rule + advisory instruction file referenced from `opencode.json` (or `opencode.jsonc`, whichever your project keeps; the global config and `$OPENCODE_CONFIG` are resolved the same way). Both are required — an instruction file no `instructions` entry names is prose OpenCode never loads. Any spelling of the path that still resolves to the same file counts, so a hand-edited entry keeps working. OpenCode reads its config as JSONC, and so does vstack: comments and trailing commas are a working config, and an install or removal edits only its own entries — your comments, blank lines and key order come back exactly as you wrote them. +- **Pi** — no per-hook artifact. The behaviors ship as `@vanillagreen/pi-hooks`, which listens on Pi's `session_start`/`tool_call`/`tool_result`/`turn_end` events and uses `{block: true, reason}` to short-circuit unsafe tool calls; each is independently toggleable from the pi-extension-manager settings panel. That package IS the artifact a Pi hook runs from, so `check` and `verify` require it exactly as they require a Codex registration: a hook locked for Pi with the package missing, or deployed and not registered in Pi's `settings.json`, is drift naming which of the two to fix. Pi loads packages from both scopes, so a global install backs a project-locked hook. A `settings.json` that cannot be read is reported as unverifiable naming the file — never as a missing package. + +A Claude Code or Codex registration counts only when the recorded command would +actually RUN the script: the command itself, or the operand of a shell or an +`env`/`timeout`-style prefix that execs it — so you can wrap the command by hand +(`env FOO=1 bash