diff --git a/.agents/skills/fleet-conformance-check/SKILL.md b/.agents/skills/fleet-conformance-check/SKILL.md index bbf68f07..923c288c 100644 --- a/.agents/skills/fleet-conformance-check/SKILL.md +++ b/.agents/skills/fleet-conformance-check/SKILL.md @@ -48,6 +48,14 @@ Nothing else. This skill never re-vendors a carried file, never deletes one, and setting or ruleset. Those are `resync-a-repo`'s job, driven from the hub with a named target, never a downstream repo acting on itself. +## Refresh cadence + +Re-run the installer when `--report` exits non-zero, and after any hub merge that touches +`.agents/skills/`. Session entry runs no automatic check, by design: the trigger is suspicion, +and the restated-rule symptom below is the loudest form of it. `docs/host-setup.md` +"Fleet Skills Install" in the hub states the same cadence for the host side, and an automated +refresh stays out of scope until the fleet has evidence the manual cadence fails. + ## What it escalates instead of touching - **A carried section that differs from the hub in a way that reads as a genuine local addition** diff --git a/.claude-plugin/fleet-skills/.source-digest b/.claude-plugin/fleet-skills/.source-digest index 91783939..e2c7a128 100644 --- a/.claude-plugin/fleet-skills/.source-digest +++ b/.claude-plugin/fleet-skills/.source-digest @@ -1 +1 @@ -d0932cf7b1575cb5 +c0b02ee0b6452295 diff --git a/.claude-plugin/fleet-skills/skills/fleet-conformance-check/SKILL.md b/.claude-plugin/fleet-skills/skills/fleet-conformance-check/SKILL.md index bbf68f07..923c288c 100644 --- a/.claude-plugin/fleet-skills/skills/fleet-conformance-check/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/fleet-conformance-check/SKILL.md @@ -48,6 +48,14 @@ Nothing else. This skill never re-vendors a carried file, never deletes one, and setting or ruleset. Those are `resync-a-repo`'s job, driven from the hub with a named target, never a downstream repo acting on itself. +## Refresh cadence + +Re-run the installer when `--report` exits non-zero, and after any hub merge that touches +`.agents/skills/`. Session entry runs no automatic check, by design: the trigger is suspicion, +and the restated-rule symptom below is the loudest form of it. `docs/host-setup.md` +"Fleet Skills Install" in the hub states the same cadence for the host side, and an automated +refresh stays out of scope until the fleet has evidence the manual cadence fails. + ## What it escalates instead of touching - **A carried section that differs from the hub in a way that reads as a genuine local addition** diff --git a/README.md b/README.md index 5d8ef271..0a4ccdbe 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Nothing here is installed as a dependency. The rules are read, the baseline is c - [How This Repo Operates](#how-this-repo-operates) - [Using This Repo](#using-this-repo) - [Deploy the Host Guardrails](#deploy-the-host-guardrails) + - [Install the Fleet Skills](#install-the-fleet-skills) - [Carry the Rules Into a Repository](#carry-the-rules-into-a-repository) - [Adopting Outside This Fleet](#adopting-outside-this-fleet) - [Diverging From a Rule](#diverging-from-a-rule) @@ -137,7 +138,7 @@ ProjectTemplate follows the same model it documents, and audits its own rules ag ## Using This Repo -Three things are deployed from here, and they land in different places. The host guardrails install once per machine, the baseline is carried once per repository, and the audit is run whenever a repository changes materially. Do them in that order on a new machine, because the guardrails bound every session that follows and retrofitting them means the sessions in between ran unguarded. +Four things are deployed from here, and they land in different places. The host guardrails install once per machine, the fleet skills install once per user on that machine, the baseline is carried once per repository, and the audit is run whenever a repository changes materially. Do them in that order on a new machine, because the guardrails bound every session that follows and retrofitting them means the sessions in between ran unguarded. ### Deploy the Host Guardrails @@ -153,6 +154,17 @@ host-setup/agent-safety/install.sh # Linux, WSL, macOS Restart Claude Code sessions on the machine afterward so the hook and the `CLAUDE.md` blocks load. The installer is idempotent, so re-running it is also how a machine picks up an upstream change to the guard. What it installs, how to verify it, and what it deliberately does not catch are in [`host-setup/agent-safety/README.md`][agent-safety], and the surrounding host prerequisites (git identity, SSH signing, `gh`, `docker`, `uv`) are in [`docs/host-setup.md`][host-setup]. +### Install the Fleet Skills + +The skills are the per-topic rules packaged so they surface in an agent session by trigger, instead of being re-read from the law docs each time. They install once per user per machine, from a hub checkout, and land beside the guardrails rather than in any repository: + +```shell +python3 scripts/skills_install.py # or the scripts/skills_install.sh / .ps1 wrapper +python3 scripts/skills_install.py --report # read-only: is this machine current? +``` + +A host stood up end to end by the [`host-setup/`][host-setup-dir] bootstrap gets this step at the end of its host mode, so a fresh machine finishes with the tools, the identity, and the skills together. [`docs/host-setup.md`][host-setup] "Fleet Skills Install" carries the details, including how the install degrades where the `claude` CLI is absent. + ### Carry the Rules Into a Repository A repository that does not exist yet is stood up with [`STANDUP.md`][standup], which is ordered rather than a menu: verify commit identity and signing before the first commit, hand the maintainer what only they can supply, classify the repo and write its [registry][repos] entry, carry the instruction set before authoring anything of your own, then carry the remaining baseline, the workflows, and the settings. The two steps with a closing window are first, because signing has to be live before the first commit and the rules have to be loaded before the first authored file. diff --git a/STANDUP.md b/STANDUP.md index bdcda489..1232e1f3 100644 --- a/STANDUP.md +++ b/STANDUP.md @@ -30,7 +30,7 @@ python3 scripts/host_gate.py # from a hub checkout, against the fleet **No `--repo` here, and that is the one place in these procedures where it is omitted deliberately.** The flag points the gate at a repo's own `host-tools.json` so its floors are layered over the fleet ones, and at this step there is no repo to point it at: the target does not exist yet, since this section runs before the `git init` in section 0B, and the file itself arrives with the baseline in section 2. So this run checks the fleet floors, which is all that is knowable now. -**Re-run it with `--repo` once section 2 has carried the file**, because a bare run does not read the target's declaration at all, so any floor that repo adds goes unapplied and the run cannot tell you it was skipped: +**Re-run it with `--repo` once section 2 has carried the file**, because a bare run reads no declaration but the one at its own working directory, so any floor the target adds goes unapplied. The gate warns when its working directory sits inside a repo whose overlay it did not read, and no warning can name a target that does not exist yet, so this re-run is the only thing that counts the target's floors: ```shell python3 scripts/host_gate.py --repo # after section 2, so the repo's own floors count diff --git a/docs/fleet-map.md b/docs/fleet-map.md index 0d1153b8..ab49888d 100644 --- a/docs/fleet-map.md +++ b/docs/fleet-map.md @@ -50,7 +50,6 @@ flowchart TD scripts["scripts/ hub-hosted tooling"] --> audit skills[".agents/skills/ source"] --> build[build_dist.py] build --> plugin[".claude-plugin/ generated"] - build -.->|"G8: no CI staleness gate"| plugin plugin --> install[skills_install.py] skills --> install install --> host["host: ~/.agents/skills + plugin + stamp"] @@ -76,13 +75,13 @@ flowchart TD fetch --> upgrade["upgrade host packages"] upgrade --> tools["install-tools: git gh jq node python uv dotnet"] tools --> github["setup-github: identity, SSH signing, gh auth"] - github --> safety["agent-safety install: write guard + CLAUDE.md blocks"] - safety -.->|"G1: step missing today"| skillsinstall["skills install"] - skillsinstall -.-> verify["verify: host_gate + skills_install --report"] - safety --> session["first agent session"] + github --> skillsinstall["install-skills: fleet skills for the user"] + skillsinstall --> safety["agent-safety install: write guard + CLAUDE.md blocks"] + safety --> verify["verify: host_gate + skills_install --report"] + verify --> session["first agent session"] ``` -Owned by [`host-setup/`][host-setup-readme] and [`docs/host-setup.md`][host-setup-doc]. Gaps on this path: G1 (the skills install has no home in the provisioning flow, so a fully bootstrapped host has every tool and no fleet skills). +Owned by [`host-setup/`][host-setup-readme] and [`docs/host-setup.md`][host-setup-doc]. No open gaps sit on this path: G1, the skills install with no home in the provisioning flow, is closed and its row records the resolution. ### A New Repository @@ -92,14 +91,13 @@ An agent is told to create or stand up a repo. The router of last resort is the flowchart TD ask["stand up a new repo"] --> route["AGENTS.md Fleet Bootstrap routes by repo state"] route --> s0["STANDUP section 0: identity and signing, host gate"] - s0 -.->|"G2: bare run skips repo floors silently"| s0 s0 --> s1["section 1: classify, registry entry, carry instruction set first"] s1 --> s2["sections 2-4: baseline, workflows, settings and rulesets"] s2 --> s5["section 5: run the audit"] s5 --> report["committed report under reports/"] ``` -Owned by [`STANDUP.md`][standup], packaged as the `standup-a-repo` skill. Gaps on this path: G2 (a bare `host_gate.py` run before the target's overlay exists reports nothing about the floors it skipped). +Owned by [`STANDUP.md`][standup], packaged as the `standup-a-repo` skill. No open gaps sit on this path: G2, the silent bare-run overlay skip, is closed and its row records the resolution. ### A Stale Repository @@ -113,11 +111,11 @@ flowchart TD apply -.->|"G4: deleted paths live on in prose"| apply apply -.->|"G5: intent-fidelity drift is invisible"| apply audit --> hostcheck["host gate on the way in"] - hostcheck -.->|"G3: a failed floor names no install command"| dead["operator improvises the install"] + hostcheck --> remedy["a failed floor prints its install command"] apply --> reaudit["re-audit"] --> report["committed report"] ``` -Owned by [`RESYNC.md`][resync] and [`AUDIT.md`][audit], packaged as the `resync-a-repo` skill with the `carried-instruction-file-guard` and `copilot-instructions-keeper` skills firing inside it. Gaps on this path: G3 (the audit-to-install bridge), G4 (deletion sweeps miss prose), G5 (intent-fidelity drift detection). +Owned by [`RESYNC.md`][resync] and [`AUDIT.md`][audit], packaged as the `resync-a-repo` skill with the `carried-instruction-file-guard` and `copilot-instructions-keeper` skills firing inside it. Gaps on this path: G4 (deletion sweeps miss prose), G5 (intent-fidelity drift detection). G3, the audit-to-install bridge, is closed and its row records the resolution. ### Daily Development in a Conformant Repository @@ -125,7 +123,7 @@ The steady state. An agent writes Python, C#, shell, or config in a repo that al ```mermaid flowchart TD - start["session start"] -.->|"G6: staleness check not wired in"| stale["skills_install --report"] + start["session start"] -->|"restated-rule symptom"| stale["skills_install --report, per the documented cadence"] start --> work["work: codestyle, commit, and doc skills fire by trigger"] work --> gates["pre-commit gates: prose lint, eol"] gates --> pr["pull request"] @@ -134,7 +132,7 @@ flowchart TD merge -.->|"G7: operational PR-only is prose-enforced"| merge ``` -Owned by the per-language sections of [`CODESTYLE.md`][codestyle] and the conduct skills. Gaps on this path: G6 (nothing at session entry checks whether this machine's skills are stale), G7 (the operational develop ruleset blocks no direct commit). +Owned by the per-language sections of [`CODESTYLE.md`][codestyle] and the conduct skills. Gaps on this path: G7 (the operational develop ruleset blocks no direct commit). G6, the unwired staleness check, is closed and its row records the resolution. ### Hub-Side Operations @@ -146,15 +144,14 @@ flowchart LR reg["registry iterate"] --> peraudit["per-repo audit"] --> issues["convergence issues"] end subgraph lifecycle["skill lifecycle"] - author["edit .agents/skills/"] --> gen["build_dist.py"] --> check["--check"] --> prq["PR"] --> merged["merge"] --> refresh["hosts re-run installer"] - author -.->|"G8: a missed regen ships silently"| prq + author["edit .agents/skills/"] --> gen["build_dist.py"] --> check["CI runs --check"] --> prq["PR"] --> merged["merge"] --> refresh["hosts re-run installer"] end subgraph rollout["carried-change rollout"] specedit["spec or law edit"] --> revendor["fleet re-vendor per RESYNC"] end ``` -Owned by [`AUDIT.md`][audit] section 10, [`GOVERNANCE.md` "Hub-Hosted Tooling"][governance-hub-hosted-tooling], and [`.agents/skills/README.md`][skills-readme]. Gaps on this path: G8 (no CI gate runs `build_dist.py --check`), G9 and G10 (topics and the skill lifecycle itself lack skills), G12 (conduct rules are scattered). +Owned by [`AUDIT.md`][audit] section 10, [`GOVERNANCE.md` "Hub-Hosted Tooling"][governance-hub-hosted-tooling], and [`.agents/skills/README.md`][skills-readme]. Gaps on this path: G9 and G10 (topics and the skill lifecycle itself lack skills), G12 (conduct rules are scattered). ## Skills Install Model @@ -162,25 +159,25 @@ Owned by [`AUDIT.md`][audit] section 10, [`GOVERNANCE.md` "Hub-Hosted Tooling"][ The lifecycle chain as built: a skill is hand-authored under [`.agents/skills/`][skills-readme], [`scripts/build_dist.py`][build-dist] generates the Claude Code plugin under [`.claude-plugin/`][marketplace], and [`scripts/skills_install.py`][skills-install] installs both forms per machine (an overlay copy into `~/.agents/skills/` for Codex and opencode, a user-scope plugin install for Claude Code), stamping the hub commit into `~/.agents/skills-install-stamp.json`. `skills_install.py --report` is the read-only staleness check and exits non-zero when the machine is behind the checkout. -Four wiring points close the model, each a register gap with its own deliverable: +Four wiring points close the model, and each is in place: -1. **Bootstrap** (G1): [`host-setup/bootstrap.sh`][bootstrap] and [`bootstrap.ps1`][bootstrap-ps1] gain a skills step after the agent-safety install, degrading gracefully when the `claude` CLI is absent (the overlay half still lands, and the stamp records the partial install). -2. **Host contract** (G1): [`docs/host-setup.md`][host-setup-doc] gains a section stating the install and the verify command, and the [`README.md`][readme] "Using This Repo" section names the skills install as the fourth deployed thing. -3. **Session entry** (G6): the tail of [`AGENTS.md`][agents] already says a rule that keeps needing restating signals a stale install, and the `fleet-conformance-check` skill already runs the report, so the gap closes by stating the cadence in both places rather than by new tooling. -4. **Refresh cadence** (G6): re-run the installer when `--report` exits non-zero, and after any hub merge that touches `.agents/skills/`. The maintainer runs it by hand today, and an automated refresh is deliberately out of scope until the fleet has evidence the manual cadence fails. +1. **Bootstrap** (G1, closed): [`host-setup/bootstrap.sh`][bootstrap] and [`bootstrap.ps1`][bootstrap-ps1] end their host mode with a skills step, driven by the `install-skills` pair in the platform directories, degrading gracefully when the `claude` CLI is absent (the overlay half still lands, and the stamp records the partial install). Each loader hands the commit it resolved to the installer, so a stamp written from the tarball tree stays checkable. +2. **Host contract** (G1, closed): [`docs/host-setup.md`][host-setup-doc] states the install and the verify command in its "Fleet Skills Install" section, and the [`README.md`][readme] "Using This Repo" section names the skills install among its four deployed things. +3. **Session entry** (G6, closed): the tail of [`AGENTS.md`][agents] says a rule that keeps needing restating signals a stale install, and the `fleet-conformance-check` skill runs the report and states the cadence, so the symptom routes to the check without new tooling. +4. **Refresh cadence** (G6, closed): [`docs/host-setup.md`][host-setup-doc] "Fleet Skills Install" states it: re-run the installer when `--report` exits non-zero, and after any hub merge that touches `.agents/skills/`. The maintainer runs it by hand, and an automated refresh is deliberately out of scope until the fleet has evidence the manual cadence fails. ## Gap Register | ID | Gap | Owner | Phase | | --- | --- | --- | --- | -| G1 | Skills install is absent from the cold-start flow | script + doc | P1 | -| G2 | Host-tools repo overlay is silently skippable | script + doc | P1 | -| G3 | A failed tool floor names no install remedy | spec + script | P1 | +| G1 | Skills install is absent from the cold-start flow | script + doc | closed | +| G2 | Host-tools repo overlay is silently skippable | script + doc | closed | +| G3 | A failed tool floor names no install remedy | spec + script | closed | | G4 | Deletion sweeps miss prose describing the deleted path | doc | P3 | | G5 | Intent-fidelity carried files have no drift detection | spec + decision | P3 | -| G6 | Session entry never checks skill staleness | doc + skill | P1 | +| G6 | Session entry never checks skill staleness | doc + skill | closed | | G7 | Operational develop PR-only rule is prose-enforced | decision | P3 | -| G8 | Generated plugin can ship stale with no CI gate | CI | P1 | +| G8 | Generated plugin can ship stale with no CI gate | CI | closed | | G9 | WORKFLOW.md and AUDIT.md have no skill coverage | skill | P2 | | G10 | The skill lifecycle itself has no skill | skill | P2 | | G11 | Peer messaging is live but undeclared | doc | P0 | @@ -188,38 +185,32 @@ Four wiring points close the model, each a register gap with its own deliverable Each gap's handoff below states who detects it, what closes it, and the test that proves it closed. The handoff sentence is the contract the closing pull request implements. -### G1: Skills Install Is Absent From the Cold Start +### G1: Skills Install Is Absent From the Cold Start (Closed) -- **Gap** - A host bootstrapped end to end via `host-setup/` has every tool and no fleet skills, because no provisioning step runs [`scripts/skills_install.py`][skills-install]. -- **Checked** - Zero matches for `skills_install` under `host-setup/`, and [`README.md`][readme] "Using This Repo" opens with "Three things are deployed from here". -- **Handoff** - When the bootstrap reaches the end of its host mode, it runs the skills installer from the fetched hub tree, and the verify step runs `skills_install.py --report` beside `host_gate.py`. -- **Closed when** - A fresh-host run per [`host-setup/README.md`][host-setup-readme] ends with the stamp present and `--report` exiting zero, and the README names four deployed things. -- **Target** - [`bootstrap.sh`][bootstrap] + [`bootstrap.ps1`][bootstrap-ps1] step, [`docs/host-setup.md`][host-setup-doc] section, [`README.md`][readme] edit. The closing PR decides how the step degrades when the `claude` CLI is absent, and whether that CLI joins [`spec/host-tools.json`][host-tools] as a cataloged tool. +- **Gap** - A host bootstrapped end to end via `host-setup/` had every tool and no fleet skills, because no provisioning step ran [`scripts/skills_install.py`][skills-install]. +- **Resolution** - The bootstrap host mode ends with a skills step: `install-skills.sh` and `install-skills.ps1` drive the installer from the fetched tree, a `--skills` action runs the step on its own, and the bootstrap report reads `--report` beside the other status lines. Each loader hands the commit it resolved to the installer via `SKILLS_SOURCE_COMMIT`, so the stamp written from a tarball tree stays checkable and `--report` exits zero on a fresh host, which is this row's closing test. [`docs/host-setup.md`][host-setup-doc] carries the "Fleet Skills Install" section with the verify line, and [`README.md`][readme] "Using This Repo" names four deployed things. +- **Decisions** - The `claude` CLI stays out of [`spec/host-tools.json`][host-tools]: a Codex-only machine is a complete machine, so the installer degrades where the CLI is absent, landing the overlay half and recording the partial install in the stamp. The skills step is the recorded exception to `host-setup/`'s no-Python and independent-fetchability rules, and it runs last in a stand-up so `install-tools` provides its interpreter first. +- **Cross-links** - [#671][issue-671] and [#673][issue-673] touch the same `host-setup/` scripts and stay open on their own tracks. -### G2: Host-Tools Repo Overlay Is Silently Skippable +### G2: Host-Tools Repo Overlay Is Silently Skippable (Closed) -- **Gap** - [`scripts/host_gate.py`][host-gate] run without `--repo` checks only the fleet floors, and reports nothing about the target-repo overlay floors it skipped. [`STANDUP.md`][standup] section 0 works around this in prose. -- **Checked** - STANDUP section 0 states a bare run "reports nothing about the omission". -- **Handoff** - When `host_gate.py` runs bare while the working directory is inside a repo carrying a `host-tools.json` overlay, it says so in its output, naming the `--repo` re-run that would count the overlay. -- **Closed when** - A bare run inside such a repo prints the warning, and the STANDUP prose can drop the workaround sentence. -- **Target** - `host_gate.py` warning, [`STANDUP.md`][standup] section 0 wording. +- **Gap** - [`scripts/host_gate.py`][host-gate] run without `--repo` read only the declaration at its own working directory, so a run started in a subdirectory of a repo carrying a `host-tools.json` overlay skipped that overlay without a word. +- **Resolution** - A bare run whose working directory sits inside such a repo prints a warning naming the overlay's directory and the `--repo` re-run that counts it, asserted by the `TestBareRunOverlayWarning` cases in `scripts/test_host_gate.py`. An explicit `--repo` and `--no-local` each stay silent, since both are a choice the caller made. [`STANDUP.md`][standup] section 0 states the residual case the warning cannot cover, a target repo that does not exist yet, instead of the workaround sentence. -### G3: A Failed Tool Floor Names No Install Remedy +### G3: A Failed Tool Floor Names No Install Remedy (Closed) -- **Gap** - The tool catalog detects a missing or stale tool, and the handoff back into `host-setup/` does not exist: a failed floor leaves the operator or agent to rediscover which installer provides the tool. -- **Checked** - `host_gate.py` output names the tool and the floor, and no output names an install command. Each [`spec/host-tools.json`][host-tools] entry carries a per-platform `source` already. -- **Handoff** - When `host_gate.py` reports a tool below its floor, its output names the command that installs or upgrades that tool on the current platform, derived from the catalog's `source` field, so the failure carries its own remedy. +- **Gap** - The tool catalog detected a stale tool, and the handoff back into `host-setup/` did not exist: a failed floor left the operator or agent to rediscover which installer provides the tool. +- **Resolution** - Each floored [`spec/host-tools.json`][host-tools] entry carries a per-platform `remedy` beside its `source`, and a below-floor failure prints it as a `REMEDY:` line, with a `host-setup/` path resolved against the checkout the gate runs from so the command is runnable as printed. The catalog's own note states the field's semantics. ```mermaid flowchart LR - fail["host_gate: tool below floor"] --> lookup["read source for the tool and platform"] - lookup --> remedy["output names the install command"] + fail["host_gate: tool below floor"] --> lookup["read remedy for the tool and platform"] + lookup --> remedy["output prints the install command"] remedy --> run["operator or agent runs it"] run --> recheck["re-run host_gate"] --> ok["proceed"] ``` -- **Closed when** - Every required tool's failure output names a runnable remedy, and [`scripts/test_bootstrap.py`][scripts-readme] asserts the mapping stays total. -- **Target** - [`spec/host-tools.json`][host-tools] remedy mapping (or a derivation from `source`), `host_gate.py` output, test coverage. +- **Closing test** - [`scripts/test_bootstrap.py`][test-bootstrap] asserts the mapping stays total per platform, with the one recorded not-applicable exception, and that a remedy handing back into an installer names a tool that installer manages. [`spec/validate.py`][validate] and the schema require a remedy on every hub floor. A repository overlay may still add a floor without one, in which case the failure degrades to the `INSTALL FROM:` source line. ### G4: Deletion Sweeps Miss Prose @@ -227,7 +218,7 @@ flowchart LR - **Checked** - [`RESYNC.md`][resync] section 4 documents the incident and prescribes the manual remedy. - **Handoff** - When a resync deletes a file, the session reads the files whose job is to describe what the repo holds (the layout and operations sections) before shipping, per the RESYNC section 4 step. - **Closed when** - Either the manual step is judged sufficient and this row closes as `accepted`, or a lint that flags a stale description ships and the row names it. -- **Target** - Decision first, optional [`scripts/prose_lint.py`][scripts-readme] check second. +- **Target** - Decision first, optional [`scripts/prose_lint.py`][prose-lint] check second. ### G5: Intent-Fidelity Drift Is Invisible @@ -237,13 +228,10 @@ flowchart LR - **Closed when** - Either the advisory surfaces in the audit report, or the row closes as `accepted` with the honest statement standing in [`AUDIT.md`][audit] and [`spec/fidelity-model.md`][fidelity-model]. - **Target** - Decision, then candidate advisory in `spec/audit.py`. -### G6: Session Entry Never Checks Skill Staleness +### G6: Session Entry Never Checks Skill Staleness (Closed) -- **Gap** - A machine with stale or missing skills behaves like a machine that never installed them, and nothing at session entry says so. The symptom is a rule that keeps needing to be restated. -- **Checked** - [`AGENTS.md`][agents] names the symptom in its closing paragraph, and the `fleet-conformance-check` skill runs the report, but only when invoked. -- **Handoff** - When a session starts work in a fleet repo, the documented cadence directs it to run `skills_install.py --report` from a hub checkout on suspicion, and the `fleet-conformance-check` skill is the trigger surface. -- **Closed when** - The cadence is stated in [`docs/host-setup.md`][host-setup-doc] and the skill's own text, and the restated-rule symptom routes to the check in both. -- **Target** - Doc wording, `fleet-conformance-check` skill text. +- **Gap** - A machine with stale or missing skills behaves like a machine that never installed them, and nothing at session entry said so. The symptom is a rule that keeps needing to be restated. +- **Resolution** - The cadence is stated in both places the row asked for. [`docs/host-setup.md`][host-setup-doc] "Fleet Skills Install" directs a re-run of the installer when `--report` exits non-zero and after any hub merge touching `.agents/skills/`, and the `fleet-conformance-check` skill carries the same cadence in its own "Refresh cadence" section, routing the restated-rule symptom to the report it already runs. No new tooling, by design: the trigger is suspicion, and session entry stays uninstrumented until the fleet has evidence the manual cadence fails. ### G7: Operational Develop PR-Only Is Prose-Enforced @@ -253,13 +241,11 @@ flowchart LR - **Closed when** - The maintainer records the disposition, mirroring the [`spec/divergences.json`][divergences] vocabulary. - **Target** - A disposition, not necessarily code. -### G8: Generated Plugin Can Ship Stale +### G8: Generated Plugin Can Ship Stale (Closed) -- **Gap** - `.claude-plugin/` is generated from `.agents/skills/`, and a merge that edits the source without re-running [`scripts/build_dist.py`][build-dist] ships a plugin that no longer matches it. `--check` exists and nothing in CI runs it. -- **Checked** - [`.github/workflows/validate-task.yml`][validate-task] runs markdownlint, cspell, JSON validation, and actionlint, and does not run `build_dist.py --check`. -- **Handoff** - When a pull request touches `.agents/skills/` or `.claude-plugin/`, CI runs `build_dist.py --check` and the aggregator gates on it. -- **Closed when** - A PR desyncing the two trees fails the required check. -- **Target** - A step in [`validate-task.yml`][validate-task]. +- **Gap** - `.claude-plugin/` is generated from `.agents/skills/`, and a merge that edits the source without re-running [`scripts/build_dist.py`][build-dist] would ship a plugin that no longer matches it. +- **Resolution** - [`.github/workflows/validate-task.yml`][validate-task] runs `build_dist.py --check` as its own step in the lint job, on every pull request, and the required aggregator check gates on that job. A PR desyncing the two trees therefore fails the required check, which is this row's closing test. +- **Provenance** - The step landed in [#676][pr-676], which predates this register's merge, so this row's original `Checked` claim was stale on arrival. Recording that here rather than silently deleting the row is the maintenance rule doing its job. ### G9: WORKFLOW.md and AUDIT.md Have No Skill @@ -331,7 +317,7 @@ Agent-to-agent messaging on one host is a working method with measured value, an ## Simplified Technical English Evaluation -Should agent-authored prose adopt ASD-STE100, a controlled language standard, or a lighter constrained house style? The criteria: does it improve agent instruction-following, does it compose with the enforcement that exists ([`scripts/prose_lint.py`][scripts-readme] and the character-set and semicolon rules), what does it cost to author, and does its vocabulary fit a technical fleet. +Should agent-authored prose adopt ASD-STE100, a controlled language standard, or a lighter constrained house style? The criteria: does it improve agent instruction-following, does it compose with the enforcement that exists ([`scripts/prose_lint.py`][prose-lint] and the character-set and semicolon rules), what does it cost to author, and does its vocabulary fit a technical fleet. | Criterion | Full ASD-STE100 | Constrained house style | | --- | --- | --- | @@ -354,11 +340,11 @@ Design-doc first: this doc merges, then each unchecked item becomes an issue lin ### P1: Close the Install Model -- [ ] G1 bootstrap skills step, host-setup section, README fourth deployed thing (cross-links the open host-tooling issues [#671][issue-671] and [#673][issue-673], which touch the same scripts) -- [ ] G2 `host_gate.py` bare-run warning -- [ ] G3 failed-floor remedy output -- [ ] G6 staleness cadence wording -- [ ] G8 `build_dist.py --check` in CI +- [x] G1 bootstrap skills step, host-setup section, README fourth deployed thing (cross-links the open host-tooling issues [#671][issue-671] and [#673][issue-673], which touch the same scripts) +- [x] G2 `host_gate.py` bare-run warning +- [x] G3 failed-floor remedy output +- [x] G6 staleness cadence wording +- [x] G8 `build_dist.py --check` in CI, found already in place via [#676][pr-676] and recorded closed ### P2: Close the Skill Coverage @@ -406,6 +392,7 @@ Design-doc first: this doc merges, then each unchecked item becomes an issue lin [host-tools]: ../spec/host-tools.json [marketplace]: ../.claude-plugin/marketplace.json [peer-messaging]: ./peer-messaging.md +[prose-lint]: ../scripts/prose_lint.py [readme]: ../README.md [repo-config-readme]: ../repo-config/README.md [repos]: ../registry/repos.json @@ -414,12 +401,15 @@ Design-doc first: this doc merges, then each unchecked item becomes an issue lin [skills-install]: ../scripts/skills_install.py [skills-readme]: ../.agents/skills/README.md [standup]: ../STANDUP.md +[test-bootstrap]: ../scripts/test_bootstrap.py [todo]: ../TODO.md +[validate]: ../spec/validate.py [validate-task]: ../.github/workflows/validate-task.yml [workflow]: ../WORKFLOW.md - + [issue-671]: https://github.com/ptr727/ProjectTemplate/issues/671 [issue-672]: https://github.com/ptr727/ProjectTemplate/issues/672 [issue-673]: https://github.com/ptr727/ProjectTemplate/issues/673 +[pr-676]: https://github.com/ptr727/ProjectTemplate/pull/676 diff --git a/docs/host-setup.md b/docs/host-setup.md index 3eb46d5c..b59a75c0 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -43,7 +43,7 @@ Presence is the weaker half of this contract. Both host defects this fleet has a Neither `node` nor `dotnet` is in the table above, deliberately: they serve the repositories that need them rather than the fleet contract, and a repository needing one declares it in a `host-tools.json` of its own, which [`scripts/host_gate.py`][host-gate] merges over this one. The merge tightens only, so a repository may raise a floor or add one and may not lower or remove one. -**A host being stood up needs no Python.** The tooling under [`host-setup/`][host-setup-dir] is shell and PowerShell, deliberately, because requiring an interpreter to upgrade a package or install a tool would make the first step of standing a host up depend on the thing that step exists to provide. The Python floor above is a development requirement, meaning [`scripts/`][scripts-dir] and [`spec/`][spec-dir], and a host that only runs services never has to meet it. `bootstrap.sh` needs `curl` and `tar`, both of which a base install carries or can install without a network tool of its own. `bootstrap.ps1` needs only `tar.exe`, which has shipped with Windows since 1803, and installs its one further dependency, PowerShell 7, itself through `winget`. +**A host being stood up needs no Python.** The tooling under [`host-setup/`][host-setup-dir] is shell and PowerShell, deliberately, because requiring an interpreter to upgrade a package or install a tool would make the first step of standing a host up depend on the thing that step exists to provide. The Python floor above is a development requirement, meaning [`scripts/`][scripts-dir] and [`spec/`][spec-dir], and a host that only runs services never has to meet it. `bootstrap.sh` needs `curl` and `tar`, both of which a base install carries or can install without a network tool of its own. `bootstrap.ps1` needs only `tar.exe`, which has shipped with Windows since 1803, and installs its one further dependency, PowerShell 7, itself through `winget`. The one exception is the skills step at the end of a stand-up, which drives the Python installer in [`scripts/`][scripts-dir], and it runs last for exactly that reason: `install-tools` has provided the interpreter by then, and run alone on a host without one it stops and names the tools step as its prerequisite. **Standing a host up.** [`host-setup/`][host-setup-dir] carries the tooling that makes a host satisfy this contract, and its README is the usage. A host with nothing runs [`host-setup/bootstrap.sh`][bootstrap], which fetches this repository and runs that tooling from the fetched tree. A native Windows host with nothing runs [`host-setup/bootstrap.ps1`][bootstrap-ps1] the same way, which finds or installs PowerShell 7 before it fetches anything, since every script under [`host-setup/windows/`][host-setup-windows] requires it. Neither is called by [`scripts/host_gate.py`][host-gate] and neither calls it: the gate measures a host against the floors above, and the tooling is a remedy a person chooses when the gate reports a gap. @@ -213,10 +213,28 @@ Run it bare, with no `VAR=value` prefix of its own, which would report a value t Withdraw a grant by deleting the `env` entry and restarting. Nothing expires it, so a grant left in place stays live for every later session in that checkout, which is the reason to remove it once the work that needed it is done. +## Fleet Skills Install + +The fleet's agent skills are hand-authored in the hub at `.agents/skills/` and installed per user by [`scripts/skills_install.py`][skills-install]: an overlay copy into `~/.agents/skills/` for Codex and opencode, and a user-scope Claude Code plugin install where the `claude` CLI is present. Every run stamps the hub commit it installed from into `~/.agents/skills-install-stamp.json`, and `--report` reads that stamp against the checkout and exits non-zero where the machine is behind it. + +Install from a hub checkout, once per machine: + +```shell +python3 scripts/skills_install.py # or the scripts/skills_install.sh / .ps1 wrapper +python3 scripts/skills_install.py --report # read-only: is this machine current? +``` + +A bootstrapped host does not run this by hand: the `--host` mode of [`host-setup/bootstrap.sh`][bootstrap] and [`bootstrap.ps1`][bootstrap-ps1] ends with the same installer, driven from the fetched tree by `install-skills.sh` or `install-skills.ps1`, and the `--skills` action runs that step on its own. + +The `claude` CLI is deliberately absent from the tool catalog in [`spec/host-tools.json`][host-tools]. A Codex-only machine is a complete machine, so the installer degrades where the CLI is missing, still landing the overlay half, saying so, and recording the partial install in the stamp, where cataloging the CLI would instead fail every host that never wanted it. + +**The refresh cadence**: re-run the installer when `--report` exits non-zero, and after any hub merge that touches `.agents/skills/`. Session entry runs no automatic check, by design: the trigger is suspicion, and a rule that keeps needing to be restated in a session is the loudest form of it, which is the symptom the `fleet-conformance-check` skill routes to this report. The maintainer runs the refresh by hand, and an automated one stays out of scope until the fleet has evidence the manual cadence fails. + ## Verify Host Setup ```shell python3 scripts/host_gate.py # presence and version floors, from spec/host-tools.json +python3 scripts/skills_install.py --report # the skills install stamp is current git config --global --list | grep -E "user\.|signing|gpg\." ssh-add -L # should list your public key git -c gpg.format=ssh commit -S --allow-empty -m "verify-signing" @@ -226,12 +244,13 @@ gh auth status If signing fails locally, the devcontainer will fail too, so fix here first. -The gate replaced a line that ran `--version` on each tool and read only whether it answered. That form reported a host carrying the broken `gh` as fully set up, which is the failure it exists to stop. It exits non-zero on a missing required tool or one below its floor, prints the defect behind the floor rather than the number alone, and names where to install from. +The gate replaced a line that ran `--version` on each tool and read only whether it answered. That form reported a host carrying the broken `gh` as fully set up, which is the failure it exists to stop. It exits non-zero on a missing required tool or one below its floor, and a below-floor finding prints the defect behind the floor rather than the number alone, names where to install from, and prints the command that installs or upgrades the tool on the current platform, so that failure carries its own fix. A missing tool prints the one-line fact, and [`host-setup/`][host-setup-dir] is its remedy. **This block is POSIX, and on native Windows two lines need translating.** Run the POSIX form from WSL2 or Git Bash per the shell note, or use the PowerShell form below. Git Bash inherits the Windows `PATH`, so `python3` reaches the same Store alias stub it does in PowerShell and reports a working interpreter as missing. ```powershell py -3 scripts/host_gate.py # presence and version floors, from spec/host-tools.json +py -3 scripts/skills_install.py --report # the skills install stamp is current git config --global --list | Select-String "user\.|signing|gpg\." ssh-add -L # should list your public key git -c gpg.format=ssh commit -S --allow-empty -m "verify-signing" @@ -252,6 +271,7 @@ Verified on Windows 11 Pro 10.0.26200 with PowerShell 7.6.4, where `py -3 script | Run the repo's own gates and tests | Python 3 covers `scripts/` and `spec/` with no packages to install | | Drive the PR and Copilot review loop | `gh` and an authenticated session | | Let an agent work with the `gh` credentials live | the write-safety kit is installed | +| Have the fleet skills surface in every agent session | the skills install stamp is current per `skills_install.py --report` | A host that fails any row is not ready for the procedure that row names, and the fix belongs on the host rather than in a repo. @@ -275,6 +295,7 @@ A host that fails any row is not ready for the procedure that row names, and the [issue-483]: https://github.com/ptr727/ProjectTemplate/issues/483 [operations]: ../OPERATIONS.md [scripts-dir]: ../scripts/ +[skills-install]: ../scripts/skills_install.py [spec-dir]: ../spec/ [ssh-signing]: ./ssh-signing.md [standup]: ../STANDUP.md diff --git a/host-setup/README.md b/host-setup/README.md index 9353ccc7..a6337505 100644 --- a/host-setup/README.md +++ b/host-setup/README.md @@ -6,8 +6,8 @@ What a machine needs before it can be worked in, and the tooling that puts it th - [`bootstrap.sh`][bootstrap] stands a Debian or Ubuntu host up from nothing. It is the one file fetched on its own, because a host with no git and no checkout is what it exists to fix. It fetches this repository and runs the tooling from that tree. - [`bootstrap.ps1`][bootstrap-ps1] does the same for native Windows. It runs under Windows PowerShell 5.1, the version every fresh Windows host guarantees, and hands off to PowerShell 7 once it has found or installed it, since every script it drives requires that version. -- [`linux/`][linux] holds the tooling itself, for Debian and Ubuntu based hosts, Proxmox and WSL included. `install-tools.sh` installs and upgrades the host tools, `upgrade-host.sh` upgrades the packages of the current release or moves to the next one, and `setup-github.sh` configures the SSH key, git, and commit signing. -- [`windows/`][windows] holds the tooling for native Windows, through `winget` and PowerShell 7. `install-tools.ps1` installs and upgrades the host tools, `upgrade-host.ps1` upgrades the winget packages and updates the WSL platform, `setup-github.ps1` configures the SSH key, git, and commit signing, and `setup-wsl.ps1` installs a WSL distribution and reports the Docker Desktop integration. +- [`linux/`][linux] holds the tooling itself, for Debian and Ubuntu based hosts, Proxmox and WSL included. `install-tools.sh` installs and upgrades the host tools, `upgrade-host.sh` upgrades the packages of the current release or moves to the next one, `setup-github.sh` configures the SSH key, git, and commit signing, and `install-skills.sh` drives the hub's skills installer from the same tree. +- [`windows/`][windows] holds the tooling for native Windows, through `winget` and PowerShell 7. `install-tools.ps1` installs and upgrades the host tools, `upgrade-host.ps1` upgrades the winget packages and updates the WSL platform, `setup-github.ps1` configures the SSH key, git, and commit signing, `setup-wsl.ps1` installs a WSL distribution and reports the Docker Desktop integration, and `install-skills.ps1` drives the hub's skills installer from the same tree. - [`agent-safety/`][agent-safety] holds the write-safety guards, deployed per machine and per account. ## Standing a Host Up @@ -77,9 +77,9 @@ host-setup\windows\setup-wsl.ps1 -Status **Group by whichever axis has one member.** `agent-safety/` is one concern across three platforms, so it is a concern directory holding `install.sh`, `install.ps1` and `install.py`. `linux/` is three concerns on one platform, so it is a platform directory. Windows host tooling therefore sits at `windows/` rather than beside the Linux scripts, because the `winget` equivalent of `install-tools.sh` is a different program rather than a translation of one. It carries one registry record per tool where the Linux script carries four functions, since every Windows source is `winget` and the per-tool variation those functions exist for does not arise. `windows/` also carries a fourth script with no Linux peer, because WSL is a Windows-side concern. The loader is the same shape as `agent-safety/`, not as `linux/`/`windows/`: one concern, two platforms, so `bootstrap.ps1` sits beside `bootstrap.sh` at the top level rather than inside `windows/`. -**Nothing here needs Python, and neither loader needs an interpreter to fetch what it drives.** [`docs/host-setup.md`][host-setup] carries that as part of the contract, with the reasoning. `bootstrap.sh` needs only `curl` and `tar`. `bootstrap.ps1` needs only `tar.exe`, which has shipped with Windows since 1803, and installs its one further dependency, `pwsh`, itself through `winget`. Neither runs a gate as a closing step: [`scripts/host_gate.py`][host-gate] measures a host against the floors and is not called from here, and nothing here is called from it. A host set up by hand years ago is an ordinary host, so the gate reports what it is missing and running this tooling is a remedy a person chooses. The two are joined at code time instead, by [`scripts/test_bootstrap.py`][test-bootstrap] asserting that every tool the spec requires is one this tooling can provide. +**Nothing here needs Python to stand a host up, and neither loader needs an interpreter to fetch what it drives.** [`docs/host-setup.md`][host-setup] carries that as part of the contract, with the reasoning. `bootstrap.sh` needs only `curl` and `tar`. `bootstrap.ps1` needs only `tar.exe`, which has shipped with Windows since 1803, and installs its one further dependency, `pwsh`, itself through `winget`. The one exception is the `install-skills` pair, which drives the Python installer at `scripts/skills_install.py` and runs last in a stand-up for exactly that reason: `install-tools` has provided the interpreter by then, and run alone on a host without one it stops and names the tools step as its prerequisite. Neither loader runs a gate as a closing step: [`scripts/host_gate.py`][host-gate] measures a host against the floors and is not called from here, and nothing here is called from it. A host set up by hand years ago is an ordinary host, so the gate reports what it is missing and running this tooling is a remedy a person chooses. The two are joined at code time instead, by [`scripts/test_bootstrap.py`][test-bootstrap] asserting that every tool the spec requires is one this tooling can provide. -**The scripts under `linux/` and `windows/` share no file, and the duplication is deliberate.** Each is independently fetchable and runnable on its own, which is the property that lets a host with no checkout use one without the others. A shared helper file would take that away: the moment one script sources a sibling, fetching it alone yields a script that dies on a missing file. What is duplicated is about thirty lines each of logging, the dry-run wrapper, the confirmation prompt, and a temporary directory, and those copies are identical rather than merely similar. Do not factor them out. `bootstrap.ps1` now exercises the same fetchability argument `bootstrap.sh` always has, rather than merely being written to allow for it. +**The scripts under `linux/` and `windows/` share no file, and the duplication is deliberate.** Each is independently fetchable and runnable on its own, which is the property that lets a host with no checkout use one without the others. A shared helper file would take that away: the moment one script sources a sibling, fetching it alone yields a script that dies on a missing file. What is duplicated is about thirty lines each of logging, the dry-run wrapper, the confirmation prompt, and a temporary directory, and those copies are identical rather than merely similar. Do not factor them out. `bootstrap.ps1` now exercises the same fetchability argument `bootstrap.sh` always has, rather than merely being written to allow for it. The `install-skills` pair is the one recorded exception to independent fetchability: it drives `scripts/skills_install.py` at the tree root, because the skills content lives in the tree, so a copy fetched alone has nothing to install and the property cannot apply to it. diff --git a/host-setup/bootstrap.ps1 b/host-setup/bootstrap.ps1 index 81bebc4d..0a119020 100644 --- a/host-setup/bootstrap.ps1 +++ b/host-setup/bootstrap.ps1 @@ -21,6 +21,7 @@ param( [switch]$Upgrade, [switch]$Tools, [switch]$Github, + [switch]$Skills, [Alias('w')][switch]$Wsl, [Alias('n')][switch]$DryRun, [Alias('y')][switch]$Yes, @@ -61,6 +62,7 @@ $ACTIONS = [ordered]@{ upgrade = [bool]$Upgrade tools = [bool]$Tools github = [bool]$Github + skills = [bool]$Skills wsl = [bool]$Wsl host = [bool]$StandUpHost dev = [bool]$Dev @@ -88,11 +90,12 @@ describe them come from one revision rather than from whatever a host happens to Actions, name one, default -Report: -r, -Report Report what each tool would do, change nothing - -Host Upgrade packages, install the tools, configure git and GitHub + -Host Upgrade packages, install the tools, configure git and GitHub, install the skills -Dev As -Host, and add the tools a development machine needs -Upgrade Upgrade the packages winget manages, only -Tools Install the host tools, only -Github Configure git, the SSH key, and commit signing, only + -Skills Install the fleet skills for the current user, only -w, -Wsl Report the WSL platform and the distributions installed, only -h, -Help Show this help @@ -262,7 +265,7 @@ function Invoke-Cleanup { # Written as one interpolated, forward-slashed string against the bare $TREE rather than $script:TREE or Join-Path, so this loader's one entry point into the tree reads as the literal pattern the Linux loader is checked by, and stays checkable by that same pattern. # A read resolves $TREE up to script scope on its own, and only a write needs the script: prefix, which is why the assignment in Get-Tree still carries it. function Invoke-Tool { - param([Parameter(Mandatory)][string]$Tool, [Parameter(ValueFromRemainingArguments)][string[]]$Arguments) + param([Parameter(Mandatory)][string]$Tool, [switch]$ToleratesFailure, [Parameter(ValueFromRemainingArguments)][string[]]$Arguments) $path = "$TREE/host-setup/windows/$Tool" if (-not (Test-Path $path)) { @@ -274,17 +277,21 @@ function Invoke-Tool { if ($script:DRY_RUN) { $flags += '-DryRun' } & $script:PWSH_PATH -NoProfile -ExecutionPolicy Bypass -File $path @Arguments @flags - if ($LASTEXITCODE -ne 0) { die "$Tool exited $LASTEXITCODE" } + if ($LASTEXITCODE -ne 0 -and -not $ToleratesFailure) { die "$Tool exited $LASTEXITCODE" } } function Show-Report { Invoke-Tool -Tool 'upgrade-host.ps1' -Arguments '-Status' Invoke-Tool -Tool 'install-tools.ps1' -Arguments '-Report' Invoke-Tool -Tool 'setup-github.ps1' -Arguments '-Status' + # Tolerated rather than fatal, since a missing install is a finding for a report to name and not a reason to stop naming the rest. + Invoke-Tool -Tool 'install-skills.ps1' -ToleratesFailure -Arguments '-Report' + if ($LASTEXITCODE -ne 0) { info 'The fleet skills install is missing or stale, and -Host or -Skills lands it' } } # The order is fixed rather than chosen. # Packages come first so install-tools.ps1 and setup-github.ps1 act on a host winget has just brought current, and GitHub comes last because it is the only step that waits on a person in a browser. +# The skills step runs after the tools, because install-tools.ps1 provides the interpreter it needs. function Invoke-StandUp { # Named Kind rather than Profile, which is PowerShell's own automatic variable for the current user's profile script. param([string]$Kind) @@ -296,6 +303,7 @@ function Invoke-StandUp { Invoke-Tool -Tool 'install-tools.ps1' -Arguments '-Install' } Invoke-Tool -Tool 'setup-github.ps1' -Arguments '-Configure' + Invoke-Tool -Tool 'install-skills.ps1' } # Names the host in the menu heading. @@ -322,9 +330,10 @@ function Show-Menu { log ' 2 Upgrade the packages winget manages' log ' 3 Install the host tools' log ' 4 Configure git and GitHub' - log ' 5 Report the WSL platform and the distributions installed' - log ' 6 All of the above but WSL, which is a host stood up' - log ' 7 All of the above but WSL, plus the development tools' + log ' 5 Install the fleet skills' + log ' 6 Report the WSL platform and the distributions installed' + log ' 7 All of the above but WSL, which is a host stood up' + log ' 8 All of the above but WSL, plus the development tools' log ' q Quit' log '' @@ -334,9 +343,10 @@ function Show-Menu { '2' { $script:MODE = 'upgrade' } '3' { $script:MODE = 'tools' } '4' { $script:MODE = 'github' } - '5' { $script:MODE = 'wsl' } - '6' { $script:MODE = 'host' } - '7' { $script:MODE = 'dev' } + '5' { $script:MODE = 'skills' } + '6' { $script:MODE = 'wsl' } + '7' { $script:MODE = 'host' } + '8' { $script:MODE = 'dev' } 'q' { exit 0 } 'Q' { exit 0 } default { die 'Not one of the choices' } @@ -401,12 +411,15 @@ function main { try { Resolve-Ref + # The commit the resolve produced is handed to the skills installer, since the tarball tree it runs from has no .git to answer for it. + $env:SKILLS_SOURCE_COMMIT = $script:RESOLVED Get-Tree switch ($script:MODE) { 'report' { Show-Report } 'upgrade' { Invoke-Tool -Tool 'upgrade-host.ps1' -Arguments '-Packages' } 'tools' { Invoke-Tool -Tool 'install-tools.ps1' -Arguments '-Install' } 'github' { Invoke-Tool -Tool 'setup-github.ps1' -Arguments '-Configure' } + 'skills' { Invoke-Tool -Tool 'install-skills.ps1' } # Only setup-wsl.ps1's -Status runs here: its -Install needs a distribution name, which no flag here collects, so choosing a default distro nobody asked for is exactly what -Wsl staying out of -Host and -Dev already exists to avoid. # Installing one by name is a checkout away, once this run has fetched it. 'wsl' { Invoke-Tool -Tool 'setup-wsl.ps1' -Arguments '-Status' } diff --git a/host-setup/bootstrap.sh b/host-setup/bootstrap.sh index 2f8f5de6..c7840016 100755 --- a/host-setup/bootstrap.sh +++ b/host-setup/bootstrap.sh @@ -42,11 +42,12 @@ describe them come from one revision rather than from whatever a host happens to Actions, the last one given wins, default --report on a terminal is the menu: -r, --report Report what each tool would do, change nothing - --host Upgrade packages, install the tools, configure git and GitHub + --host Upgrade packages, install the tools, configure git and GitHub, install the skills --dev As --host, and add the tools a development machine needs --upgrade Upgrade the packages of the current release only --tools Install the host tools only --github Configure git, the SSH key, and commit signing only + --skills Install the fleet skills for the current user only --release Upgrade to the next distribution release, on its own -h, --help Show this help @@ -173,10 +174,15 @@ report() { run_tool upgrade-host.sh --status run_tool install-tools.sh --report run_tool setup-github.sh --status + # Tolerated rather than fatal, since a missing install is a finding for a report to name and not a reason to stop naming the rest. + if ! SKILLS_SOURCE_COMMIT="$RESOLVED" run_tool install-skills.sh --report; then + info "The fleet skills install is missing or stale, and --host or --skills lands it" + fi } # The order is fixed rather than chosen. # Packages come first so a keyring or a repository is added against a current apt state, and GitHub comes last because it is the only step that waits on a person in a browser. +# The skills step runs after the tools, because install-tools.sh provides the interpreter it needs. stand_up() { local profile="$1" @@ -187,6 +193,7 @@ stand_up() { run_tool install-tools.sh --install fi run_tool setup-github.sh --configure + SKILLS_SOURCE_COMMIT="$RESOLVED" run_tool install-skills.sh } # Names the host in the menu heading. @@ -208,8 +215,9 @@ menu() { log " 2 Upgrade the packages of the current release" log " 3 Install the host tools" log " 4 Configure git and GitHub" - log " 5 All of the above, which is a host stood up" - log " 6 All of the above plus the development tools" + log " 5 Install the fleet skills" + log " 6 All of the above, which is a host stood up" + log " 7 All of the above plus the development tools" log " q Quit" log "" @@ -220,8 +228,9 @@ menu() { 2) MODE="upgrade" ;; 3) MODE="tools" ;; 4) MODE="github" ;; - 5) MODE="host" ;; - 6) MODE="dev" ;; + 5) MODE="skills" ;; + 6) MODE="host" ;; + 7) MODE="dev" ;; q | Q) exit 0 ;; *) die "Not one of the choices" ;; esac @@ -238,6 +247,7 @@ parse_args() { --upgrade) MODE="upgrade" ;; --tools) MODE="tools" ;; --github) MODE="github" ;; + --skills) MODE="skills" ;; --release) MODE="release" ;; -y | --yes) ASSUME_YES=true ;; -n | --dry-run) DRY_RUN=true ;; @@ -295,6 +305,7 @@ main() { upgrade) run_tool upgrade-host.sh --packages ;; tools) run_tool install-tools.sh --install ;; github) run_tool setup-github.sh --configure ;; + skills) SKILLS_SOURCE_COMMIT="$RESOLVED" run_tool install-skills.sh ;; release) run_tool upgrade-host.sh --release ;; host) stand_up host ;; dev) stand_up dev ;; diff --git a/host-setup/linux/install-skills.sh b/host-setup/linux/install-skills.sh new file mode 100755 index 00000000..138a7043 --- /dev/null +++ b/host-setup/linux/install-skills.sh @@ -0,0 +1,95 @@ +#!/bin/bash + +# Installs the fleet skills for the current user, by driving the hub-hosted installer in the tree this script sits in. +# This is the one script here that reaches outside host-setup/, deliberately: the skills content lives at the tree root, so a copy fetched without the tree has nothing to install, and the independent-fetchability rule the sibling scripts follow cannot apply to it. +# Python is its one dependency, which is why the bootstrap runs it last: install-tools.sh provides the interpreter before this needs one. +# +# Every step is idempotent, because the installer it drives is. +# SKILLS_SOURCE_COMMIT, where the caller sets it, names the commit of a tree git cannot answer for, which is what a tarball fetched by the bootstrap is. + +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +readonly ROOT + +MODE="install" +DRY_RUN=false + +log() { printf '%s\n' "$*"; } +info() { printf ' %s\n' "$*"; } +die() { + printf 'ERROR: %s\n' "$*" >&2 + exit 1 +} + +usage() { + cat <<'EOF' +Usage: install-skills.sh [options] + +Installs the fleet skills for the current user, by running scripts/skills_install.py from this +tree. The installer copies the skills to ~/.agents/skills/ for Codex and opencode, registers the +Claude Code plugin where the claude CLI is present and says so where it is not, and stamps what it +installed so a later report can answer whether this machine is current. + +Actions, name one, default install: + -r, --report Read-only: is this machine's skills install current against this tree? + -h, --help Show this help + +Options: + -n, --dry-run Print the command instead of running it + -y, --yes Accepted for symmetry with the sibling scripts, since the installer never prompts +EOF +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + -r | --report) MODE="report" ;; + -n | --dry-run) DRY_RUN=true ;; + -y | --yes) ;; + -h | --help) + usage + exit 0 + ;; + *) die "Unknown option \"$1\", --help lists the options" ;; + esac + shift + done +} + +# The first candidate that is a Python 3.7+, since the installer needs `from __future__ import annotations`. +find_python() { + local candidate + for candidate in python3 python; do + if command -v "$candidate" > /dev/null && + "$candidate" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 7) else 1)' 2> /dev/null; then + printf '%s' "$candidate" + return 0 + fi + done + return 1 +} + +main() { + parse_args "$@" + + local installer="$ROOT/scripts/skills_install.py" + [[ -f $installer ]] || die "This tree carries no scripts/skills_install.py, so there is nothing to drive" + + local py + py=$(find_python) || die "Python 3.7+ not found. install-tools.sh provides it, so run the tools step first and this one after." + + local -a args=() + [[ $MODE == "report" ]] && args+=(--report) + + if [[ $DRY_RUN == true ]]; then + info "[dry run] $py $installer ${args[*]}" + return 0 + fi + + exec "$py" "$installer" "${args[@]}" +} + +main "$@" diff --git a/host-setup/windows/install-skills.ps1 b/host-setup/windows/install-skills.ps1 new file mode 100644 index 00000000..8218bc45 --- /dev/null +++ b/host-setup/windows/install-skills.ps1 @@ -0,0 +1,92 @@ +# Installs the fleet skills for the current user, by driving the hub-hosted installer in the tree this script sits in. +# This is the one script here that reaches outside host-setup/, deliberately: the skills content lives at the tree root, so a copy fetched without the tree has nothing to install, and the independent-fetchability rule the sibling scripts follow cannot apply to it. +# Python is its one dependency, which is why the bootstrap runs it last: install-tools.ps1 provides the interpreter before this needs one. +# +# Every step is idempotent, because the installer it drives is. +# SKILLS_SOURCE_COMMIT, where the caller sets it, names the commit of a tree git cannot answer for, which is what a tarball fetched by the bootstrap is. + +[CmdletBinding()] +param( + [Alias('r')][switch]$Report, + [Alias('n')][switch]$DryRun, + [Alias('y')][switch]$Yes, + [Alias('h')][switch]$Help +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +# A non-zero exit from a probed interpreter is an answer here rather than a failure. +$PSNativeCommandUseErrorActionPreference = $false + +$WANT_REPORT = [bool]$Report +$DRY_RUN = [bool]$DryRun +# -Yes is accepted for symmetry with the sibling scripts, since the installer never prompts. +$null = $Yes +$WANT_HELP = [bool]$Help + +function info { param([string]$Message) Write-Host " $Message" } +function die { param([string]$Message) [Console]::Error.WriteLine("ERROR: $Message"); exit 1 } + +function usage { + # The closing marker of a here-string has to sit at column 0, so this block is deliberately unindented. + Write-Host @' +Usage: install-skills.ps1 [options] + +Installs the fleet skills for the current user, by running scripts/skills_install.py from this +tree. The installer copies the skills to ~/.agents/skills/ for Codex and opencode, registers the +Claude Code plugin where the claude CLI is present and says so where it is not, and stamps what it +installed so a later report can answer whether this machine is current. + +Actions, name one, default install: + -r, -Report Read-only: is this machine's skills install current against this tree? + -h, -Help Show this help + +Options: + -n, -DryRun Print the command instead of running it + -y, -Yes Accepted for symmetry with the sibling scripts, since the installer never prompts +'@ +} + +# The first candidate that is a Python 3.7+, since the installer needs `from __future__ import annotations`. +# Each available launcher is version-checked before being committed to, because none guarantees 3.7+ by construction. +function Find-Python { + $candidates = @( + @{ Exe = 'py'; Arguments = @('-3') }, + @{ Exe = 'python3'; Arguments = @() }, + @{ Exe = 'python'; Arguments = @() } + ) + foreach ($candidate in $candidates) { + if (-not (Get-Command $candidate.Exe -ErrorAction SilentlyContinue)) { continue } + & $candidate.Exe @($candidate.Arguments) -c 'import sys; sys.exit(0 if sys.version_info >= (3, 7) else 1)' 2>$null + if ($LASTEXITCODE -eq 0) { return $candidate } + } + return $null +} + +function main { + if ($WANT_HELP) { usage; exit 0 } + + $root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + $installer = Join-Path $root 'scripts' | Join-Path -ChildPath 'skills_install.py' + if (-not (Test-Path $installer)) { + die 'This tree carries no scripts/skills_install.py, so there is nothing to drive' + } + + $python = Find-Python + if (-not $python) { + die 'Python 3.7+ not found. install-tools.ps1 provides it, so run the tools step first and this one after.' + } + + $arguments = @() + if ($WANT_REPORT) { $arguments += '--report' } + + if ($DRY_RUN) { + info "[dry run] $($python.Exe) $($python.Arguments -join ' ') $installer $($arguments -join ' ')" + exit 0 + } + + & $python.Exe @($python.Arguments) $installer @arguments + exit $LASTEXITCODE +} + +main diff --git a/scripts/host_gate.py b/scripts/host_gate.py index cb0d5dde..64b4756e 100755 --- a/scripts/host_gate.py +++ b/scripts/host_gate.py @@ -27,6 +27,7 @@ import argparse import json import re +import shlex import subprocess import sys from pathlib import Path @@ -132,6 +133,8 @@ def field_problems(entry: dict) -> list[str]: problems.append(f'minimum must be dot-separated integers or null, not {entry["minimum"]!r}') if 'source' in entry and not isinstance(entry['source'], dict): problems.append('source must be an object') + if 'remedy' in entry and not isinstance(entry['remedy'], dict): + problems.append('remedy must be an object') if 'probes' in entry: probes = entry['probes'] if (not isinstance(probes, list) or not probes @@ -197,6 +200,18 @@ def contract_problems(tools: list[dict]) -> list[str]: for plat in sorted(set(src) & set(PLATFORM_KEYS)): if not isinstance(src[plat], str) or not src[plat]: problems.append(f'{name} source.{plat} is empty, so a host on that platform is told to upgrade and not where from') + # The remedy is shape-checked and not required, so a repository floor without one degrades to the source line rather than failing the merge. + # Requiring it fleet-wide is the hub declaration's contract, held by spec/validate.py and scripts/test_bootstrap.py rather than here. + rem = t.get('remedy') + if rem is not None and not isinstance(rem, dict): + problems.append(f'{name} remedy must be an object keyed by platform, so its command was not read') + elif isinstance(rem, dict): + stray = sorted(set(rem) - set(PLATFORM_KEYS)) + if stray: + problems.append(f'{name} remedy names {", ".join(stray)}, which no platform reads - use {", ".join(PLATFORM_KEYS)}') + for plat in sorted(set(rem) & set(PLATFORM_KEYS)): + if not isinstance(rem[plat], str) or not rem[plat]: + problems.append(f'{name} remedy.{plat} is empty, so a host on that platform is shown a command that is not one') return problems @@ -259,6 +274,65 @@ def merge(base: list[dict], local: list[dict]) -> tuple[list[dict], list[str]]: return [by_key[k] for k in sorted(by_key)], rejected +def platform_key() -> str: + """The PLATFORM_KEYS member the running host reads its source and remedy under.""" + if sys.platform.startswith('linux'): + return 'linux' + return 'macos' if sys.platform == 'darwin' else 'windows' + + +def platform_field(tool: dict, field: str) -> str | None: + """The current platform's value under `field`, or None where the entry does not usably carry one. + + The shape checks report a malformed declaration, and this read stays crash-free beside them, + so one bad field costs its own output line rather than the whole run and the findings already + collected. + """ + values = tool.get(field) + value = values.get(platform_key()) if isinstance(values, dict) else None + return value if isinstance(value, str) and value else None + + +def quote_argument(value: str) -> str: + """`value` as one shell word, for a printed command a reader pastes back into their shell.""" + if platform_key() == 'windows': + return f'"{value}"' if ' ' in value else value + return shlex.quote(value) + + +def resolve_remedy(command: str, root: Path | None = None) -> str: + """A remedy command with a repo-relative installer path made absolute against this checkout. + + The catalog stores host-setup/ paths repo-relative so the data stays portable, and the gate + runs from any working directory, so the path is resolved against the tree this script lives in + and quoted where the shell needs it, to keep the printed command runnable as printed. + """ + if not command.startswith('host-setup/'): + return command + script, _, rest = command.partition(' ') + resolved = str((root or Path(__file__).resolve().parent.parent) / script) + if platform_key() == 'windows': + # PowerShell runs a quoted path only through the call operator, and a single-quoted literal doubles its own quote character. + escaped = resolved.replace("'", "''") + quoted = f"& '{escaped}'" if resolved != escaped or ' ' in resolved else resolved + else: + quoted = shlex.quote(resolved) + return f'{quoted} {rest}' if rest else quoted + + +def overlay_above(start: Path) -> Path | None: + """The nearest ancestor of `start` carrying a host-tools.json, or None where none does. + + A bare run reads only the declaration at the working directory itself, so an overlay at the + root of the repo the run is inside goes unread without a word when the run starts in a + subdirectory. Naming that directory lets the run say what it skipped and which re-run counts it. + """ + for parent in start.resolve().parents: + if (parent / 'host-tools.json').is_file(): + return parent + return None + + def check(tools: list[dict]) -> list[str]: """Every declared tool against its floor, returning one line per failure.""" issues = [] @@ -292,12 +366,14 @@ def check(tools: list[dict]) -> list[str]: issues.append(f'{name} reported {version!r}, which is not dot-separated integers, so its floor was not applied') elif compare(found, floor) < 0: # The remedy rides on the finding rather than beside it, since a separate line would count as a second issue. - src = (tool.get('source') or {}).get('linux' if sys.platform.startswith('linux') else 'macos' if sys.platform == 'darwin' else 'windows') + src = platform_field(tool, 'source') + rem = platform_field(tool, 'remedy') # The head line stays the scannable fact and the rationale follows it, rather than being inlined into it. # An entry's why runs to a paragraph, so inlining made the one line a reader scans in CI output unreadable, and the longest entry is not the one that needs it least. issues.append(f'{name} {version} is below the {floor_text} floor' + f'\nWHY: {tool["why"]}' - + (f'\nINSTALL FROM: {src}' if src else '')) + + (f'\nINSTALL FROM: {src}' if src else '') + + (f'\nREMEDY: {resolve_remedy(rem)}' if rem else '')) else: NOTES.append(f'{name} {version} meets the {floor_text} floor') return issues @@ -306,7 +382,7 @@ def check(tools: list[dict]) -> list[str]: def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description='Check host tool versions against the hub declaration plus a repository\'s own.') ap.add_argument('--spec', default=str(SPEC), help='path to the hub declaration, for testing') - ap.add_argument('--repo', default='.', help='repository whose host-tools.json layers over the hub one') + ap.add_argument('--repo', default=None, help='repository whose host-tools.json layers over the hub one, default the working directory') ap.add_argument('--no-local', action='store_true', help='read the hub declaration alone, ignoring the repository') ap.add_argument('--quiet', action='store_true', help='print failures only, dropping the per-tool notes') a = ap.parse_args(argv) @@ -321,7 +397,7 @@ def main(argv: list[str] | None = None) -> int: NOTES.clear() rejected: list[str] = [] - local_path = Path(a.repo) / 'host-tools.json' + local_path = Path(a.repo or '.') / 'host-tools.json' # A repository layering onto the hub is the normal case, and carrying no local file is the common one, so its absence is silent. if not a.no_local and local_path.is_file(): local = read_declaration(local_path, 'repository host tool declaration') @@ -331,6 +407,12 @@ def main(argv: list[str] | None = None) -> int: tools, rejected = merge(tools, local) NOTES.append(f'{local_path} layered {len(local)} local entry(s) over the hub declaration') + # Only a bare run warns, since an explicit --repo and --no-local are each a choice the caller made. + # The default sits at None rather than '.' so the two are tellable apart. + skipped = None + if a.repo is None and not a.no_local and not local_path.is_file(): + skipped = overlay_above(Path.cwd()) + # The contract is read after layering, since neither file can see what the other adds to it. issues = rejected + contract_problems(tools) + check(tools) status = 'FAIL' if issues else 'ok' @@ -346,6 +428,9 @@ def main(argv: list[str] | None = None) -> int: # After the findings and outside the count, since a note is not one. for note in NOTES: print(f' note: {note}') + if skipped is not None: + # Outside --quiet, because a silently skipped overlay is the omission this line exists to name. + print(f' warning: {skipped} carries a host-tools.json overlay this bare run did not read - re-run with --repo {quote_argument(str(skipped))} so its floors count') return 1 if issues else 0 diff --git a/scripts/skills_install.py b/scripts/skills_install.py index b9dc6ee6..6528e0ca 100755 --- a/scripts/skills_install.py +++ b/scripts/skills_install.py @@ -64,6 +64,12 @@ def git(*args): sha = git("rev-parse", "HEAD") if not sha: + # A bootstrap runs this from a fetched tarball tree, which has no .git to answer for it. + # The loader resolved its ref to a commit before downloading and hands that in, keeping the stamp checkable instead of permanently stale. + # A tarball of a resolved commit is clean by construction, which is what dirty=False records. + handed = os.environ.get("SKILLS_SOURCE_COMMIT") + if handed: + return {"vcs": "archive", "commit": handed, "dirty": False} return {"vcs": "none"} ref = {"vcs": "git", "commit": sha} # Watches both paths this installer actually reads. diff --git a/scripts/test_bootstrap.py b/scripts/test_bootstrap.py index 3abdc97a..f59e3aa9 100644 --- a/scripts/test_bootstrap.py +++ b/scripts/test_bootstrap.py @@ -51,6 +51,15 @@ 'windows': {'python3': 'python'}, } +# A platform a floored tool's remedy deliberately omits, and the reason, so an omission is a decision rather than a hole in the mapping. +REMEDY_NOT_APPLICABLE = { + 'git-restore-mtime': {'windows': 'The tool serves a Linux deploy path, which its source states.'}, +} + +# The remedy commands that hand back into the host-setup installers, read so the tool each names can be checked against what that installer manages. +LINUX_INSTALLER_REMEDY = re.compile(r'^host-setup/linux/install-tools\.sh --upgrade (\S+)$') +WINDOWS_INSTALLER_REMEDY = re.compile(r'^host-setup/windows/install-tools\.ps1 -Upgrade (\S+)$') + # A spec tool an installer deliberately does not manage, and the reason, recorded so an omission is a decision somebody made rather than one nobody noticed. # The windows set is empty rather than absent, which is itself the assertion: Docker Desktop is one winget package there, where on Linux a hypervisor and a workstation want different answers, so an entry appearing here later is a decision to justify rather than a gap to fill. NOT_MANAGED = { @@ -200,6 +209,51 @@ def assert_coverage(platform: str, managed: set[str], installer: str) -> None: ) +def test_every_declared_floor_carries_a_total_remedy_mapping() -> None: + """Each floored tool names a runnable remedy on every platform, or carries a recorded exception. + + The gate prints the remedy under a below-floor failure, so a missing platform key is a failure + that tells the operator to upgrade and not how. A remedy that hands back into an installer here + is also checked to name a tool that installer manages, so the command it prints can actually run. + """ + linux_managed = declared_tools() + windows_managed = declared_windows_tools() + for tool in spec_tools(): + name = tool['name'] + if tool.get('minimum') is None: + continue + remedy = tool.get('remedy') + if not isinstance(remedy, dict) or not remedy: + failures.append(f'{name} declares a floor and no remedy, so its failure names no command') + continue + for platform in ('linux', 'macos', 'windows'): + if platform in REMEDY_NOT_APPLICABLE.get(name, {}): + check( + platform not in remedy, + f'{name} is recorded as not applicable on {platform} and carries a remedy there, so the record is stale', + ) + continue + command = remedy.get(platform) + check( + isinstance(command, str) and bool(command), + f'{name} declares a floor and no {platform} remedy, so a below-floor host there is told to upgrade and not how', + ) + installer_cases = ( + ('linux', LINUX_INSTALLER_REMEDY, linux_managed, 'install-tools.sh'), + ('windows', WINDOWS_INSTALLER_REMEDY, windows_managed, 'install-tools.ps1'), + ) + for platform, pattern, managed, installer in installer_cases: + command = remedy.get(platform) + if not isinstance(command, str): + continue + match = pattern.match(command) + if match and managed: + check( + match.group(1) in managed, + f'{name} remedy.{platform} names {match.group(1)}, which {installer} does not manage, so the printed command fails', + ) + + def test_every_required_linux_tool_is_installable() -> None: """A tool the spec requires on Linux is one the tooling can provide, or a recorded exception.""" assert_coverage('linux', declared_tools(), 'install-tools.sh') @@ -239,7 +293,7 @@ def indexed_modes() -> dict[str, str]: def test_every_managed_tool_is_executable() -> None: """Each script the loader hands control to is present and executable.""" modes = indexed_modes() - for name in ('install-tools.sh', 'upgrade-host.sh', 'setup-github.sh'): + for name in ('install-skills.sh', 'install-tools.sh', 'upgrade-host.sh', 'setup-github.sh'): path = LINUX / name check(path.is_file(), f'{name} is missing from host-setup/linux') if path.is_file() and modes: @@ -260,7 +314,7 @@ def test_every_windows_script_is_present() -> None: default with no pin of their own. A shebang added later would fail that gate from a file nobody would think to look at. """ - scripts = ('install-tools.ps1', 'upgrade-host.ps1', 'setup-github.ps1', 'setup-wsl.ps1') + scripts = ('install-skills.ps1', 'install-tools.ps1', 'upgrade-host.ps1', 'setup-github.ps1', 'setup-wsl.ps1') for name in scripts + ('README.md',): check((WINDOWS / name).is_file(), f'{name} is missing from host-setup/windows') @@ -298,6 +352,7 @@ def main() -> int: test_windows_loader_reads_one_path_into_the_tree, test_linux_loader_needs_no_python, test_windows_loader_needs_no_python, + test_every_declared_floor_carries_a_total_remedy_mapping, test_every_required_linux_tool_is_installable, test_every_required_windows_tool_is_installable, test_every_managed_tool_is_executable, diff --git a/scripts/test_host_gate.py b/scripts/test_host_gate.py index dd364787..f22f1cec 100755 --- a/scripts/test_host_gate.py +++ b/scripts/test_host_gate.py @@ -136,6 +136,51 @@ def test_a_version_below_the_floor_is_one_issue_not_two(self): self.assertIn('below the 2.47.0 floor', issues[0]) self.assertIn('INSTALL FROM', issues[0]) + def test_a_below_floor_failure_names_the_remedy_command(self): + """The finding carries its own fix, still as one issue.""" + remedy = dict.fromkeys(('linux', 'macos', 'windows'), 'pkg upgrade gh') + entry = tool('gh', minimum='2.47.0', probes=self.probe_for('v2.46.0'), + source=dict.fromkeys(('linux', 'macos', 'windows'), 'somewhere'), remedy=remedy) + issues = host_gate.check([entry]) + self.assertEqual(len(issues), 1) + self.assertIn('REMEDY: pkg upgrade gh', issues[0]) + + def test_a_host_setup_remedy_resolves_against_this_checkout(self): + """The data stays repo-relative and the printed command is runnable from any directory.""" + resolved = host_gate.resolve_remedy('host-setup/linux/install-tools.sh --upgrade gh') + self.assertTrue(resolved.startswith(str(host_gate.SPEC.parent.parent))) + self.assertTrue(resolved.endswith('install-tools.sh --upgrade gh')) + self.assertEqual(host_gate.resolve_remedy('brew upgrade gh'), 'brew upgrade gh') + + def test_a_checkout_path_needing_quoting_is_quoted(self): + """Runnable as printed holds for a checkout path carrying a space, on either shell.""" + resolved = host_gate.resolve_remedy('host-setup/linux/install-tools.sh --upgrade gh', + root=Path('/tmp/space dir')) + self.assertTrue(resolved.endswith("--upgrade gh")) + if host_gate.platform_key() == 'windows': + self.assertTrue(resolved.startswith("& '")) + else: + self.assertIn("'", resolved.partition(' --upgrade')[0]) + + def test_a_malformed_source_or_remedy_reports_rather_than_crashing(self): + """One bad field costs its own output line, not the run and the findings already collected. + + The hub declaration is shape-checked by spec/validate.py in CI and by nothing at gate + runtime, so the below-floor read has to survive a non-dict field and a non-string command. + """ + entry = tool('gh', minimum='2.47.0', probes=self.probe_for('v2.46.0'), + source='not a dict', remedy={'linux': 7, 'macos': 7, 'windows': 7}) + issues = host_gate.check([entry]) + self.assertEqual(len(issues), 1) + self.assertNotIn('INSTALL FROM', issues[0]) + self.assertNotIn('REMEDY', issues[0]) + + def test_a_non_dict_remedy_on_a_floored_tool_is_a_contract_problem(self): + problems = host_gate.contract_problems( + [tool('gh', minimum='2.47.0', source={'linux': 'somewhere'}, remedy='not a dict')]) + self.assertEqual(len(problems), 1) + self.assertIn('remedy must be an object', problems[0]) + def test_a_version_at_the_floor_passes(self): self.assertEqual(host_gate.check([tool('gh', minimum='2.47.0', probes=self.probe_for('v2.47.0'))]), []) @@ -302,6 +347,90 @@ def test_a_bad_pattern_reports_unreadable_rather_than_raising(self): self.assertEqual((status, version), ('unreadable', None)) +class TestBareRunOverlayWarning(unittest.TestCase): + """A bare run inside a repo whose root carries an overlay names what it skipped. + + The default --repo reads the working directory alone, so a run started in a subdirectory reads + nothing and used to say nothing, which is the silent skip the warning closes. An explicit + --repo and --no-local each stay silent, since both are a choice the caller made. + """ + + def spec_with_one_passing_tool(self, d): + spec = Path(d) / 'spec.json' + spec.write_text(json.dumps({'tools': [tool('ok')]}), encoding='utf-8') + return str(spec) + + def run_from(self, cwd, argv): + import contextlib + import io + import os + old = os.getcwd() + os.chdir(cwd) + try: + out = io.StringIO() + with contextlib.redirect_stdout(out): + host_gate.main(argv) + return out.getvalue() + finally: + os.chdir(old) + + def repo_with_overlay(self, d): + root = Path(d) + (root / 'host-tools.json').write_text('{"tools": []}', encoding='utf-8') + sub = root / 'scripts' + sub.mkdir() + return root, sub + + def test_a_bare_run_in_a_subdirectory_warns_and_names_the_re_run(self): + import tempfile + with tempfile.TemporaryDirectory() as d: + root, sub = self.repo_with_overlay(d) + out = self.run_from(sub, ['--spec', self.spec_with_one_passing_tool(d), '--quiet']) + self.assertIn('warning:', out) + self.assertIn(str(root.resolve()), out) + self.assertIn('--repo', out) + + def test_a_bare_run_at_the_root_layers_the_overlay_and_does_not_warn(self): + import tempfile + with tempfile.TemporaryDirectory() as d: + root, _ = self.repo_with_overlay(d) + out = self.run_from(root, ['--spec', self.spec_with_one_passing_tool(d)]) + self.assertIn('layered', out) + self.assertNotIn('warning:', out) + + def test_an_explicit_repo_does_not_warn(self): + import tempfile + with tempfile.TemporaryDirectory() as d: + _, sub = self.repo_with_overlay(d) + out = self.run_from(sub, ['--spec', self.spec_with_one_passing_tool(d), '--repo', str(sub), '--quiet']) + self.assertNotIn('warning:', out) + + def test_no_local_does_not_warn(self): + import tempfile + with tempfile.TemporaryDirectory() as d: + _, sub = self.repo_with_overlay(d) + out = self.run_from(sub, ['--spec', self.spec_with_one_passing_tool(d), '--no-local', '--quiet']) + self.assertNotIn('warning:', out) + + def test_a_spaced_overlay_path_is_quoted_in_the_re_run(self): + """The printed --repo re-run pastes back into a shell whole, path spaces included.""" + import tempfile + with tempfile.TemporaryDirectory(suffix=' with space') as d: + root, sub = self.repo_with_overlay(d) + out = self.run_from(sub, ['--spec', self.spec_with_one_passing_tool(d), '--quiet']) + self.assertIn(f'--repo {host_gate.quote_argument(str(root.resolve()))}', out) + self.assertNotIn(f'--repo {root.resolve()} ', out) + + def test_overlay_above_returns_the_nearest_carrier(self): + import tempfile + with tempfile.TemporaryDirectory() as d: + root = Path(d) + (root / 'host-tools.json').write_text('{"tools": []}', encoding='utf-8') + deep = root / 'a' / 'b' + deep.mkdir(parents=True) + self.assertEqual(host_gate.overlay_above(deep), root.resolve()) + + class TestMalformedLocalFile(unittest.TestCase): """A repository file is validated by nothing upstream, so the gate reports rather than crashes.""" diff --git a/scripts/test_skills_install.py b/scripts/test_skills_install.py index e52d2a9b..0247c86a 100755 --- a/scripts/test_skills_install.py +++ b/scripts/test_skills_install.py @@ -50,6 +50,34 @@ def fake_run(args, **kwargs): str(skills_install.CLAUDE_PLUGIN_DIR.relative_to(skills_install.ROOT).as_posix()), status_call ) + def test_a_handed_in_commit_stands_in_where_git_cannot_answer(self) -> None: + """A bootstrap runs the installer from a tarball tree with no .git, and hands in the commit + it resolved before downloading, so the stamp stays checkable rather than permanently stale.""" + def no_git(args, **kwargs): + result = mock.Mock() + result.returncode = 128 + result.stdout = "" + return result + + with mock.patch("subprocess.run", side_effect=no_git): + with mock.patch.dict("os.environ", {"SKILLS_SOURCE_COMMIT": "cafe1234"}): + self.assertEqual(skills_install.source_ref(), + {"vcs": "archive", "commit": "cafe1234", "dirty": False}) + with mock.patch.dict("os.environ", {}, clear=True): + self.assertEqual(skills_install.source_ref(), {"vcs": "none"}) + + def test_a_git_answer_outranks_a_handed_in_commit(self) -> None: + """In a real checkout the environment variable is stray state, and the checkout is the truth.""" + def fake_run(args, **kwargs): + result = mock.Mock() + result.returncode = 0 + result.stdout = "deadbeef\n" if "rev-parse" in args else "" + return result + + with mock.patch("subprocess.run", side_effect=fake_run): + with mock.patch.dict("os.environ", {"SKILLS_SOURCE_COMMIT": "cafe1234"}): + self.assertEqual(skills_install.source_ref()["commit"], "deadbeef") + class MaterializeCase(unittest.TestCase): def setUp(self) -> None: diff --git a/spec/host-tools-local.schema.json b/spec/host-tools-local.schema.json index c3107883..587ae0b2 100644 --- a/spec/host-tools-local.schema.json +++ b/spec/host-tools-local.schema.json @@ -39,6 +39,15 @@ "macos": { "type": "string", "minLength": 1 }, "windows": { "type": "string", "minLength": 1 } } + }, + "remedy": { + "type": "object", + "additionalProperties": false, + "properties": { + "linux": { "type": "string", "minLength": 1 }, + "macos": { "type": "string", "minLength": 1 }, + "windows": { "type": "string", "minLength": 1 } + } } } } diff --git a/spec/host-tools.json b/spec/host-tools.json index 22b5638d..d57ccd26 100644 --- a/spec/host-tools.json +++ b/spec/host-tools.json @@ -1,6 +1,6 @@ { "$schema": "./host-tools.schema.json", - "note": "The host contract in docs/host-setup.md, as data a gate can read. docs/host-setup.md states which tools a host needs and which repo procedure stops working without each one; this file adds the version floors, and each floor records the defect it encodes rather than a preference. A floor is one of two kinds and says which in its own why. A measured floor sits immediately above a version known to break a documented procedure, and is the kind most floors here are. A target floor declares the version the repo's own toolchain is configured for, where a lower interpreter is unverified rather than known broken, and it is honest about that rather than implying a defect nobody found. Everything else is presence-only, which is deliberate: a floor nobody can justify becomes a host failure nobody can act on. An entry is required unless it declares otherwise, and an optional tool that is absent is skipped rather than failed, since it is needed by one repository rather than by the fleet. Probes are tried in order and the first that runs is the answer, which is how a host whose interpreter is not called python3 still reports a version. The pattern is matched against the probe's combined output and its first capture group is the version, read as dot-separated integers, so a two-part YYYY.MM version compares correctly against a three-part semantic one.", + "note": "The host contract in docs/host-setup.md, as data a gate can read. docs/host-setup.md states which tools a host needs and which repo procedure stops working without each one; this file adds the version floors, and each floor records the defect it encodes rather than a preference. A floor is one of two kinds and says which in its own why. A measured floor sits immediately above a version known to break a documented procedure, and is the kind most floors here are. A target floor declares the version the repo's own toolchain is configured for, where a lower interpreter is unverified rather than known broken, and it is honest about that rather than implying a defect nobody found. Everything else is presence-only, which is deliberate: a floor nobody can justify becomes a host failure nobody can act on. An entry is required unless it declares otherwise, and an optional tool that is absent is skipped rather than failed, since it is needed by one repository rather than by the fleet. Probes are tried in order and the first that runs is the answer, which is how a host whose interpreter is not called python3 still reports a version. The pattern is matched against the probe's combined output and its first capture group is the version, read as dot-separated integers, so a two-part YYYY.MM version compares correctly against a three-part semantic one. A floor also carries a remedy beside its source: the source says where the tool must come from and why, and the remedy is the one command that installs or upgrades it on each platform, printed under a below-floor failure so the finding carries its own fix. A remedy is written for the below-floor state, which is why the host-setup installers run in their upgrade mode there. A macOS remedy runs brew install rather than brew upgrade where the entry's source is the platform rather than Homebrew: brew install upgrades a formula that is installed but outdated, per the Homebrew manpage's install entry, and brew upgrade refuses a tool Homebrew never installed, so install is the one form that fixes both below-floor states. A remedy path under host-setup/ is repo-relative and the gate resolves it against the checkout it runs from, so the printed command is runnable as printed. A platform where the tool does not apply carries no remedy key, and scripts/test_bootstrap.py is what keeps this mapping total and pointing at tools the installers actually manage.", "tools": [ { "name": "docker", @@ -21,6 +21,11 @@ "linux": "The official apt repository at cli.github.com, per https://github.com/cli/cli/blob/trunk/docs/install_linux.md, and never the distribution's own package.", "macos": "Homebrew, which tracks upstream releases.", "windows": "winget, which tracks upstream releases, so the hazard the Linux note describes does not arise." + }, + "remedy": { + "linux": "host-setup/linux/install-tools.sh --upgrade gh", + "macos": "brew upgrade gh", + "windows": "host-setup/windows/install-tools.ps1 -Upgrade gh" } }, { @@ -42,6 +47,10 @@ "linux": "The upstream release at https://github.com/MestreLion/git-tools, or in CI the action at https://github.com/chetan/git-restore-mtime-action which vendors it, and never the distribution's own package.", "macos": "Homebrew's git-tools, checked against the floor like any other source.", "windows": "Not applicable, since the tool is used by a Linux deploy path." + }, + "remedy": { + "linux": "host-setup/linux/install-tools.sh --upgrade git-restore-mtime", + "macos": "brew upgrade git-tools" } }, { @@ -55,6 +64,11 @@ "linux": "The distribution package where it meets the floor, which on current Debian stable it does at 1.7.1, so the floor is chosen to be satisfiable without a manual build. An older release sits below it, and there the upstream release at https://github.com/jqlang/jq is the source. Preferring the current upstream release everywhere keeps the fleet on one version in practice, which is what the other two platforms already do.", "macos": "Homebrew, which tracks upstream releases.", "windows": "winget, which tracks upstream releases, so the hazard the Linux note describes does not arise." + }, + "remedy": { + "linux": "host-setup/linux/install-tools.sh --upgrade jq", + "macos": "brew upgrade jq", + "windows": "host-setup/windows/install-tools.ps1 -Upgrade jq" } }, { @@ -68,6 +82,11 @@ "linux": "Whatever the platform provides at or above the floor, since the scripts need an interpreter and no packages, so no distribution or build is pinned here.", "macos": "Whatever the platform provides at or above the floor, on the same reasoning as Linux.", "windows": "The python.org installer, which registers py, python and python3.13 but not python3, so the gate reaches it through the py -3 probe rather than the python3 name that resolves to the Microsoft Store alias stub." + }, + "remedy": { + "linux": "host-setup/linux/install-tools.sh --upgrade python", + "macos": "brew install python", + "windows": "host-setup/windows/install-tools.ps1 -Upgrade python" } }, { diff --git a/spec/host-tools.schema.json b/spec/host-tools.schema.json index 3c9e4fbf..0a6aba39 100644 --- a/spec/host-tools.schema.json +++ b/spec/host-tools.schema.json @@ -22,15 +22,18 @@ "additionalProperties": false, "allOf": [ { - "title": "A declared floor carries a source", - "description": "A host below a floor is told where to install from rather than only to upgrade, which is the whole reason source exists. spec/validate.py enforces this, and encoding it here stops a declaration being schema-valid and validator-invalid at once. A null minimum declares no floor, so the requirement does not apply to it.", + "title": "A declared floor carries a source and a remedy", + "description": "A host below a floor is told where to install from and the command that does it, rather than only to upgrade, which is the whole reason source and remedy exist. spec/validate.py enforces this, and encoding it here stops a declaration being schema-valid and validator-invalid at once. A null minimum declares no floor, so the requirement does not apply to it.", "if": { "required": ["minimum"], "properties": { "minimum": { "type": "string" } } }, "then": { - "required": ["source"], - "properties": { "source": { "minProperties": 1 } } + "required": ["source", "remedy"], + "properties": { + "source": { "minProperties": 1 }, + "remedy": { "minProperties": 1 } + } } } ], @@ -79,6 +82,16 @@ "macos": { "type": "string", "minLength": 1 }, "windows": { "type": "string", "minLength": 1 } } + }, + "remedy": { + "type": "object", + "description": "The command that installs or upgrades the tool, per platform, printed under a below-floor failure so the finding carries its own fix. A host-setup/ path is repo-relative and the gate resolves it against the checkout it runs from. A platform where the tool does not apply carries no key.", + "additionalProperties": false, + "properties": { + "linux": { "type": "string", "minLength": 1 }, + "macos": { "type": "string", "minLength": 1 }, + "windows": { "type": "string", "minLength": 1 } + } } } } diff --git a/spec/validate.py b/spec/validate.py index c9623bc9..ea5ddf8e 100644 --- a/spec/validate.py +++ b/spec/validate.py @@ -190,6 +190,17 @@ def check_secret_set(label, entry, need_kind): for plat, where in t["source"].items(): if not isinstance(where, str) or not where: errors.append(f"host-tools.json: '{name}' source.{plat} must be a non-empty string") + # The remedy is held to the same shape as the source it sits beside, and required on the same trigger. + # A floor whose failure prints no runnable command leaves the operator to rediscover the installer, which is the gap the field closes. + if not isinstance(t.get("remedy"), dict) or not t["remedy"]: + errors.append(f"host-tools.json: '{name}' declares a floor and no 'remedy', so a below-floor failure names no command that fixes it") + else: + stray = sorted(set(t["remedy"]) - platforms) + if stray: + errors.append(f"host-tools.json: '{name}' remedy names {', '.join(stray)}, which no platform reads - use {', '.join(sorted(platforms))}") + for plat, command in t["remedy"].items(): + if not isinstance(command, str) or not command: + errors.append(f"host-tools.json: '{name}' remedy.{plat} must be a non-empty string") ht_names = [t["name"] for t in host_tools["tools"] if isinstance(t, dict) and isinstance(t.get("name"), str)] if ht_names != sorted(ht_names, key=str.lower): errors.append("host-tools.json: 'tools' is not sorted by name, which is how a reader finds an entry")