diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e3e56d..fdbaf2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ All notable changes to SkipHow 2.x and later appear in this file. Earlier release notes remain available on [GitHub Releases](https://github.com/mzored/SkipHow/releases). +## 4.2.0 (2026-09-06) + +SkipHow can now enable, check, and disable its own default governance when an installed user asks it to. The activation helper ships inside the skill, resolves the trusted user instruction file each host actually reads, and reports configured, available, and loaded as three separate facts. Installing, enabling, or upgrading SkipHow still grants no authority. + +### Changed + +- A `setup` playbook handles requests to enable, check, or disable SkipHow on this machine, or to explain why it did not load: one preview, one confirmation before writing, three separate facts, and managed policy reported rather than bypassed. +- The activation helper moved from the repository into the skill package as `scripts/activation.py`. It follows host discovery: Codex reads `AGENTS.override.md` in its home when that file exists and is not empty, and `AGENTS.md` otherwise, with `CODEX_HOME` relocating the home; Claude Code reads `CLAUDE.md` and unconditional `rules/*.md` files under `CLAUDE_CONFIG_DIR` or its default directory. It moves a block found in a file the host does not read, consolidates duplicates, never writes through a symbolic link, removes every copy it may edit on disable, and reads each host's plugin inventory to report availability or says that enablement is unknown. `--target` still names any trusted file explicitly. +- The integration playbook chooses failed-merge recovery by consequence. Evidence and unrelated work are preserved first; a disposable failure may stay in place for diagnosis; a shared target that other work, CI, or a deployment depends on is contained or restored to its last good state; restoring production keeps its grant. + +### Evaluation instrument + +- The canonical large-programme case names the `catalog-integration-ready` fixture, whose setup creates the `fix/catalog` branch the prompt refers to, and carries a scope note that the fixture is four separable repairs rather than a dependency-laden programme. +- `evals/preflight.json` registers the expected pre-session state of each fixture, and `capture_eval.py prepare` validates the tree, then refuses a fixture whose branches, remote, foreign work, marker, or planted defects do not match it. +- The continuity oracle bans outcomes rather than implementations: no stale grant acted on, no invented end state, no lost obligation, no duplicated work. +- `scripts/grade_catalog.py` states the four expected catalog behaviors independently of any implementation and grades retained captures or destination records without starting a model. + +### Documentation + +- One dated support summary in `docs/evidence.md` states per host what was validated, configured, observed loaded, and left unknown. README, guide, FAQ, security policy, and site link to it instead of restating limitations that belonged to earlier packages. +- Authority text on the site and in the FAQ follows effects. Staging and other isolated non-production environments are ordinary engineering unless the owner's own rules say otherwise; production, live data, public releases, credentials, access, repository settings, payments, and material deletion keep their explicit authorization. + +### Compatibility and evidence + +This is a minor release because the setup playbook and packaged helper are new opt-in capability and the recovery rule widens what the integration playbook permits within existing authority. Existing grants, restrictions, and protected-action boundaries survive the upgrade. The public skill name and record formats are unchanged. + +Deterministic checks, the September 6 audit disposition, and grader results on retained end states are recorded in [docs/evidence.md](docs/evidence.md). Every 4.2.0 behavior is `UNVERIFIED`: whether a session loads the skill from an `AGENTS.override.md` block, whether the agent operates the setup playbook as written, and whether the recovery rule changes what a run does. Host receipts for the exact 4.2.0 package are recorded in `evals/host-smoke.json` when they land. + ## 4.1.1 (2026-09-05) Public evaluation receipts now omit account quota and authentication metadata, mask common credential formats and private paths, and pseudonymize trace identifiers. Capture applies this filtering before saving evidence, and an offline test checks every retained receipt. Per-run usage and synthetic fixture artifacts remain available for replay. The runtime instructions are unchanged; only the package version metadata changes. diff --git a/README.md b/README.md index fc6782e..6e7c957 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ One public skill covers questions, bugs, ideas, features, reviews, lists, progra [![Latest release](https://img.shields.io/github/v/release/mzored/SkipHow?label=release)](https://github.com/mzored/SkipHow/releases) [![MIT license](https://img.shields.io/github/license/mzored/SkipHow)](LICENSE) -Host support is a dated, per-capability matrix in the [security policy](SECURITY.md#host-support-as-of-2026-09-04), not a badge. Claude Code and Codex CLI are the surfaces it covers; anything it does not list is `UNVERIFIED`. +Host support is a dated, per-capability matrix in the [security policy](SECURITY.md#host-support-as-of-2026-09-06), not a badge. Claude Code and Codex CLI are the surfaces it covers; anything it does not list is `UNVERIFIED`. ```text Your outcome and constraints @@ -77,21 +77,27 @@ Start a new session after installing. The [owner guide](docs/guide.md) covers up ### Activate it for ordinary language -Plugin installation makes the skill available, but skill selection remains model-driven. For default governance before the first consequential action, add this line once to your trusted user instructions without replacing anything already there: +Plugin installation makes the skill available, but skill selection remains model-driven. To have SkipHow govern ordinary requests by default, ask the installed skill to enable itself once: ```text -For current-project requests, load the installed SkipHow skill before consequential action and use it as the adaptive virtual CTO policy. Do not load it for unrelated conversation or for a request that only discusses SkipHow without adopting it. +$skiphow Enable SkipHow as my default virtual CTO on this machine. ``` -On Codex, append the line to the global `AGENTS.md` in your Codex home. Codex loads that file before project work. On Claude Code, append it to your user `CLAUDE.md` or a file in your user rules directory. Claude Code loads user instructions for every project. Review the existing file first, keep its content, and remove only the SkipHow line to disable default governance. This setup is reversible and does not install a hook or grant any action. +In Codex the name is `$skiphow`; in Claude Code it is `/skiphow:skiphow`. The agent resolves the trusted user instruction file your host actually reads, shows you the exact change, writes one reversible block after you confirm, and reports three facts separately: whether the block is configured, whether the plugin is available on the host, and that loading is only visible in a fresh session. Codex reads a non-empty `AGENTS.override.md` in its home instead of `AGENTS.md`, and a custom `CODEX_HOME` moves that home; Claude Code reads the user `CLAUDE.md` and unconditional user rules under its configuration directory. A line placed in a file the host does not read configures nothing, which is why the agent, not you, resolves the target. Ask it to check or disable itself the same way. The block asks the host to load the skill and grants no action; installing or enabling SkipHow authorizes nothing. + +If you prefer to edit the file yourself, append this line to that file without replacing anything already there, and remove only this line to disable default governance: + +```text +For current-project requests, load the installed SkipHow skill before consequential action and use it as the adaptive virtual CTO policy. Do not load it for unrelated conversation or for a request that only discusses SkipHow without adopting it. +``` -These host mechanisms are documented, but default activation remains `UNVERIFIED`: one Claude Code bare-prompt pilot did not select the skill, persistent clean-home setup could not authenticate, and Codex clean-home model runs were also blocked by authentication. Explicit Claude Code invocation loaded the exact 4.0.1 policy in the historical pilot set; Codex explicit invocation remains `UNVERIFIED`. Explicit invocation is the fallback and diagnostic path: +Explicit invocation remains the fallback and diagnostic path: ```text $skiphow The totals overlap on small screens. Find the cause and fix it. ``` -In Codex the name is `$skiphow`; in Claude Code it is `/skiphow:skiphow`. The package no longer ships the reminder hook. It did not load the policy or restore continuity, and no controlled comparison showed that its executable surface helped. Current support status is in the [support matrix](SECURITY.md#host-support-as-of-2026-09-04). +What each host has actually shown is in the [dated support summary](docs/evidence.md#support-summary-as-of-2026-09-06). In short: ordinary-language loading, delivery to a synthetic remote, and native resume were observed once each on Codex with the exact 4.1.0 package in an isolated home; on Claude Code no persistent-setup run exists and one earlier bare-prompt pilot did not select the skill. No activation mode has a measured reliability, and the package ships no session hook. ## Use it @@ -140,9 +146,9 @@ SkipHow keeps one owner-facing entry. Critical rules stay in its kernel, while f ## What the evidence shows -Deterministic checks prove package structure; controlled runs are required for behavior claims. The behavioral observations on record were made on 2.x packages, on both hosts, and cover fully specified requests, open product choices, failure diagnosis, adversarial verification, and the splitting of larger work into independently verifiable units. The 4.x virtual-CTO behavior and default ordinary-language activation remain `UNVERIFIED` until retained receipts show them. +Deterministic checks prove package structure; controlled runs are required for behavior claims. The behavioral observations on record were made on 2.x packages, on both hosts, and cover fully specified requests, open product choices, failure diagnosis, adversarial verification, and the splitting of larger work into independently verifiable units. On the 4.x virtual-CTO contract, retained isolated Codex diagnostics on the exact 4.1.0 package show ordinary-language loading, four correct repairs delivered to a synthetic remote with foreign work preserved, read-only behavior on analysis and unrelated requests, and native resume and compaction. A separate Claude coordination diagnostic left its synthetic remote unchanged and accepted an incorrect shipping calculation despite independent review. Coordinated cross-host delivery, failed-delegate recovery, real GitHub tracking, and every behavior of the current package remain `UNVERIFIED` until retained receipts show them. -These are observations, not a reliability rate. The project does not retain every transcript, public adoption is still limited, and comparative advantage over a base agent or another framework is `UNVERIFIED`. The [evidence matrix](docs/evidence.md) is the single home for the method, the claims each run supports, and the failures. +These are observations, not a reliability rate. The project does not retain every transcript, public adoption is still limited, and comparative advantage over a base agent or another framework is `UNVERIFIED`. The [dated support summary](docs/evidence.md#support-summary-as-of-2026-09-06) says what was demonstrated on which package, host, and configuration; the rest of the [evidence ledger](docs/evidence.md) is the single home for the method, the claims each run supports, and the failures. ## Limits @@ -161,7 +167,7 @@ Use a spec or workflow framework when approving the method is part of your job. - [Prior art](docs/prior-art.md), for mechanisms kept and rejected - [Design](docs/design.md) and [decision history](docs/decisions.md) - [Current evidence](docs/evidence.md), for what is demonstrated and what is not -- [Host support matrix](SECURITY.md#host-support-as-of-2026-09-04), dated per capability +- [Host support matrix](SECURITY.md#host-support-as-of-2026-09-06), dated per capability - [Contributing](CONTRIBUTING.md) and [security policy](SECURITY.md) SkipHow adapts selected ideas from [Matt Pocock's skills](https://github.com/mattpocock/skills) and keeps the required MIT attribution in [`THIRD_PARTY_NOTICES.md`](plugins/skiphow/THIRD_PARTY_NOTICES.md). SkipHow itself is [MIT licensed](LICENSE). diff --git a/SECURITY.md b/SECURITY.md index a649a4a..39d8f6e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,8 +4,8 @@ | Version | Supported | | --- | --- | -| 4.1.x | Yes | -| 4.0.x | No; upgrade to 4.1.x without changing existing authorization | +| 4.2.x | Yes | +| 4.1.x and 4.0.x | No; upgrade to 4.2.x without changing existing authorization | | 3.0.x and earlier | No | Security review covers the packaged owner skill, its linked playbooks, host manifests, @@ -13,9 +13,9 @@ marketplace metadata, release checks, and documented authority boundaries. Codex, Claude Code, GitHub, Git, operating systems, and third-party services keep their own security policies. -## Package validation, 2026-09-05 +## Package validation, 2026-09-06 -Version 4.1.1 passes both schema validators and exact install/uninstall on Claude and Codex. Codex used the approved Git source in empty host homes after the candidate reached the default branch. Current host receipts are retained in `evals/receipts/host-validation-411-20260905/`. Model diagnostics below concern 4.1.0, not new runs of 4.1.1. +Version 4.2.0 is validated per capability in [`evals/host-smoke.json`](evals/host-smoke.json); a row without a fresh 4.2.0 receipt stays `UNVERIFIED` there. The [dated support summary](docs/evidence.md#support-summary-as-of-2026-09-06) states what each host has shown for this package. The previous 4.1.1 receipts remain in `evals/receipts/host-validation-411-20260905/`, and the isolated Codex diagnostics remain 4.1.0 observations. The historical 4.1.0 candidate passed both host schema validators. Claude Code 2.1.261 installed all fifteen regular files byte for byte and uninstalled them in a @@ -25,15 +25,17 @@ The [candidate receipts](evals/host-smoke.json) and [per-capability matrix](evals/receipts/host-validation-20260905.md) retain the exact package identity and scope. Clean installation does not establish model loading. -Clean isolated model profiles on both hosts could not authenticate. Ordinary-language -bootstrap, installed activation and genuine resume remain `UNVERIFIED`. The -[bounded session-plugin diagnostics](docs/evidence.md#the-410-delivery-audit) -do not establish clean installed behavior. Claude's documented plugin subagents +An isolated Codex profile authenticated natively on 2026-09-05; an isolated Claude Code +profile still cannot. Ordinary-language loading, delivery to a synthetic remote, and +native resume were observed once each on the exact 4.1.0 package in the +[isolated Codex diagnostics](docs/evidence.md#later-isolated-codex-diagnostics); nothing +comparable exists for Claude Code, and no activation mode has a measured reliability on +either host. Claude's documented plugin subagents ignore `permissionMode`; a declared field alone must not be described as an enforced read-only boundary. Actual tool restrictions and workspace identity need their own evidence. See [Claude subagents](https://code.claude.com/docs/en/sub-agents). -## Host support, as of 2026-09-04 +## Host support, as of 2026-09-06 Host behavior changes. Each row below is only as current as its verification date, and each row cites the first-party page it was read from. Check that page again before @@ -68,7 +70,7 @@ page under `learn.chatgpt.com`; the redirect target is the page actually read. | Capability | What the source says | Source | Verified | Tested version | Status | | --- | --- | --- | --- | --- | --- | | Skill loading | Plugin skills are discovered at `/skills//SKILL.md` and namespaced `/:`. The description sits in context and the body loads on invocation; description plus `when_to_use` is truncated at 1,536 characters in the listing. Both explicit `/name` and automatic invocation are available unless `disable-model-invocation` or `user-invocable` restricts them. | [Skills](https://code.claude.com/docs/en/skills) | 2026-09-04 | 2.1.260 | `PASS` for explicit invocation of exact 4.0.1 in the retained pilots; automatic selection remains `UNVERIFIED` and did not occur in one bare-prompt pilot | -| Persistent instruction loading | User `CLAUDE.md` and user rules apply to every project. The optional SkipHow activation line is added without replacing existing instructions and removed to disable it. The file is behavioral guidance, not client enforcement. | [Memory](https://code.claude.com/docs/en/memory) | 2026-09-04 | none | `UNVERIFIED` (documented loading; no automatic-selection run) | +| Persistent instruction loading | User `CLAUDE.md` and `rules/*.md` in the configuration directory apply to every project and load before project instructions; `CLAUDE_CONFIG_DIR` relocates that directory; managed policy `CLAUDE.md` loads first and cannot be excluded. The packaged helper writes its owned block to that `CLAUDE.md`, treats an unconditional rule as read, consolidates duplicate copies it can edit, and never writes through a symbolic link. The page says the file is context, not enforcement. | [Memory](https://code.claude.com/docs/en/memory) | 2026-09-06 | none | `UNVERIFIED` (documented loading; the isolated Claude profile still cannot authenticate, so no persistent-setup run exists) | | Per-agent read-only controls | Subagent frontmatter takes a `tools` allowlist, `disallowedTools`, and `permissionMode`, whose values include `plan` for read-only exploration. | [Subagents](https://code.claude.com/docs/en/sub-agents) | 2026-09-04 | none | `UNVERIFIED` (documented) | | Worktree isolation | `isolation: worktree` runs a subagent in a temporary git worktree. | [Subagents](https://code.claude.com/docs/en/sub-agents) | 2026-09-04 | none | `UNVERIFIED` (documented) | | Plugin validation | Manifest `.claude-plugin/plugin.json`; `claude plugin validate ` validates it and `--strict` treats warnings as errors. | [Plugins](https://code.claude.com/docs/en/plugins) | 2026-09-04 | 2.1.259 | `PASS` (`scripts/check_hosts.py`, 2026-09-04) | @@ -79,7 +81,7 @@ page under `learn.chatgpt.com`; the redirect target is the page actually read. | Capability | What the source says | Source | Verified | Tested version | Status | | --- | --- | --- | --- | --- | --- | | Skill loading | Skills are discovered from `.agents/skills` in the current, parent, and repository-root directories, the user-level `.agents/skills` directory in the home directory, `/etc/codex/skills`, and system skills. Progressive disclosure lists name and description within 2 per cent of the context window, or 8,000 characters where that is unknown; the full file loads on selection. Explicit `$skill` invocation and implicit invocation are both available; `allow_implicit_invocation` in `agents/openai.yaml` defaults to true. | [Skills](https://developers.openai.com/codex/skills) | 2026-09-04 | none | `UNVERIFIED` (documented; no activation run on record) | -| Persistent instruction loading | Codex reads global `AGENTS.md` before project work, then layers project instructions. The optional SkipHow activation line is added without replacing existing instructions and removed to disable it. | [AGENTS.md](https://developers.openai.com/codex/guides/agents-md) | 2026-09-04 | none | `UNVERIFIED` (documented loading; no automatic-selection run) | +| Persistent instruction loading | Codex reads `AGENTS.override.md` in its home when that file exists and is not empty, and `AGENTS.md` otherwise, then layers project files with the same precedence per directory; `CODEX_HOME` relocates the home; empty files are skipped and the combined size is capped by `project_doc_max_bytes` (32 KiB by default). The packaged helper targets the file this rule makes effective and moves a block left in the shadowed file. | [AGENTS.md](https://learn.chatgpt.com/docs/agent-configuration/agents-md) | 2026-09-06 | 0.153.0 | `Observed` once on exact 4.1.0: the kernel loaded before edits in the isolated bootstrap diagnostic with the block in `AGENTS.md` and no override present; 4.2.0 and the override path `UNVERIFIED` until a receipt | | Per-agent read-only controls | Custom agents are TOML files in the Codex home `agents/` directory or the project `.codex/agents/` and may set `sandbox_mode` per agent; the page names marking one agent read-only as the example. Absent an override, subagents inherit the parent's sandbox policy and permission mode. | [Subagents](https://developers.openai.com/codex/subagents) | 2026-09-04 | none | `UNVERIFIED` (documented; corrects the earlier claim that no declarable per-delegate profile exists) | | Worktree isolation | The subagents page documents no worktree or separate-checkout option for a subagent. | [Subagents](https://developers.openai.com/codex/subagents) | 2026-09-04 | none | `UNVERIFIED` (not documented either way) | | Plugin validation | Manifest `.codex-plugin/plugin.json`. There is no `codex plugin validate` subcommand; validation runs the `validate_plugin.py` script shipped with the plugin-creator system skill in the Codex repository, which CI checks out at a pinned commit. | [openai/codex plugin-creator scripts](https://github.com/openai/codex/tree/333beecd41281b1350688b417a2f20c66e2a743e/codex-rs/skills/src/assets/samples/plugin-creator/scripts) | 2026-09-04 | none locally | `UNVERIFIED` locally (validator not on this machine); required to `PASS` in CI | diff --git a/VERSION b/VERSION index 627a3f4..6aba2b2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.1.1 +4.2.0 diff --git a/docs/decisions.md b/docs/decisions.md index b0ca537..d7b131d 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -15,7 +15,9 @@ The live decisions, their premises, and what would reopen each. "Evidence" says | Completion is relative to the authorized destination; earlier-run artifacts are not cleaned under an unrelated request | A named destination is incomplete until verified there; a branch left by an earlier run is somebody's until ownership and emptiness are established | Review finishes after the run that opened it; ownership of a branch is not readable from its name | `Contract` (4.0); integration cases in `evals/`, not run | Receipts show litter accumulating where projects expect a run to clear its own earlier work, and an authorized way to do it | | Delegates are read-only without verified distinct isolation; the root serializes writes; model and effort are chosen per task, not by a parent-relative floor | One shared checkout has one index and one branch; a delegate's own account of its isolation is not proof; a review's independence and framing matter more than its level | Both hosts expose per-delegate controls through incompatible surfaces; no portable absolute level exists | Failures `Observed` on 2.x (five lanes in one checkout; a worktree that reported success into the shared tree); the corrected text is `Contract`, not run | A host makes isolation verifiable and default, or paired runs settle the routing cost question | | Prefer host-native execution; admit thin bindings when a demonstrated gap justifies them | Duplicated runtime state increases cost; current package remains one skill without a service or custom workers | Required outcomes survive implementation changes; new components need host schema, safety, and compatibility checks | Current package shape `Deterministic`; adapter benefits require receipts | A controlled check shows a host binding is needed to preserve an outcome | -| Default ordinary-language governance references the installed skill from an owned reversible block in trusted user instructions; explicit invocation is the fallback; no hook ships | Host documentation establishes instruction loading, not correct selection; repository tooling makes setup inspectable without copying policy | Both hosts load trusted user instructions; isolated Codex login now works while isolated Claude login remains unavailable | Setup lifecycle `Deterministic`; 4.1.0 Codex loading and continuity shown in bounded diagnostics; broader cross-host coverage `UNVERIFIED` | A clean authenticated receipt shows missed or false activation, or update drift | +| Default ordinary-language governance references the installed skill from an owned reversible block in the trusted user instruction file the host actually reads; the skill enables, checks, and disables itself through a packaged host-aware helper and a setup playbook; explicit invocation is the fallback; no hook ships | Host documentation establishes instruction loading, not correct selection; a block in a shadowed or unread file configures nothing, so the agent resolves the target and reports configured, available, and loaded separately | Codex reads a non-empty `AGENTS.override.md` over `AGENTS.md` in `CODEX_HOME`; Claude reads `CLAUDE.md` and unconditional `rules/` under `CLAUDE_CONFIG_DIR`; isolated Codex login works while isolated Claude login remains unavailable | Helper lifecycle and host resolution `Deterministic`; 4.1.0 Codex loading shown once in a bounded diagnostic; override-path, 4.2.0, and Claude persistent loading `UNVERIFIED` | A host changes its discovery order, or a clean receipt shows missed or false activation | +| A failed merge is recovered by consequence: disposable failures may stay for diagnosis, a shared target others depend on is contained or restored, production restoration keeps its grant | Leaving every failed merge in place made a broken shared branch the default while diagnosis ran | Containment of a covered non-production destination is within the established workflow; production is an effect that keeps its own grant | Source review, 4.2.0; behavior `UNVERIFIED` | A run restores a target it should have left for diagnosis, or leaves a shared target broken | +| Evaluation oracles name outcomes, not implementations; a fixture is preflighted against a registered expected state before any model spend | The continuity oracle banned a thin host binding the contract permits, and the canonical large-programme prompt named a branch its fixture never created | A grader that encodes the expected behavior independently of the code under test can grade any retained end state | `Deterministic` preflight and grader tests, 4.2.0 | A scenario needs an outcome the registry or grader cannot express | | Deterministic checks protect package, security, release, and corpus semantics only; presentation and wording are lint or unchecked | A check that pins a sentence or a topology froze editorial choices without protecting anything a host depends on | Spec 11 classification, applied 2026-09-04 | `Deterministic` | A host starts depending on a detail now treated as editorial | | Provider-independent policy: no model IDs, cost tables, or host tiers in the package | Host metadata changes faster than a release cycle; no portable cost signal exists | Both hosts change model names and controls independently | `Deterministic` scan on every run | A portable capability interface appears | | Public receipts exclude account quota and authentication metadata, known credential formats, private paths, and raw host identifiers | Literal path replacement left account metadata in a real retained trace | Preserve per-run usage, synthetic artifacts and correlation through pseudonyms; filtering cannot certify arbitrary prose | `Deterministic` receipt privacy gate and nested-event tests in 4.1.1 | A host adds a private field or a receipt needs stronger privacy review | @@ -24,6 +26,14 @@ The live decisions, their premises, and what would reopen each. "Evidence" says The sections below are the history behind those rows: what each release tried, measured, and rejected. They are non-normative. Where a section and the index disagree, the index is current and the section records how it got there. +## The 4.2.0 activation and instrument corrections + +The September 6 audit found that the activation target could be shadowed and that onboarding pushed readiness checks to the owner. The helper moved into the skill package and learned the hosts' discovery rules, and a setup playbook lets an installed user ask the agent to enable, check, or disable default governance. Configured, available, and loaded are reported as three facts because the first two are deterministic and the third only a fresh session shows. No hook or runtime was added; the helper is a resource the skill may ship and runs only when asked. + +The same audit found three instrument defects that made the canonical suite unrunnable as written. The fix keeps the hash-bound fixture bytes intact: the canonical case now names the derived ready fixture, a registry of expected pre-session states gates preparation, and the continuity oracle bans outcomes rather than implementations. A grader states the four planted catalog defects independently of any implementation so that any retained end state can be graded without a model. + +The integration playbook's recovery rule was widened from "leave everything in place" to a choice by consequence. The public pages now point at one dated support summary in the [evidence ledger](evidence.md#support-summary-as-of-2026-09-06) instead of restating limitations that were true of earlier packages. Connected delivery behavior was not claimed; it waits on bounded receipts. + ## The 4.1.0 outcome and delivery audit The September 5 audit establishes source defects without requiring a paid run: conflicting delivery authority, no first-use tracking path, assignee metadata described as an exclusive session claim, and overlapping review triggers. The runtime correction preserves existing grants and restrictions while carrying through an established owner-authorized workflow. It treats production as an effect, including effects caused by CI, and keeps assignments advisory. Risk determines review depth; a small visible two-file correction can use cold self-review. diff --git a/docs/evidence.md b/docs/evidence.md index 50eec69..afb34e9 100644 --- a/docs/evidence.md +++ b/docs/evidence.md @@ -20,7 +20,7 @@ Every behavioral claim on this page sits in one of four sections, and a claim ne 1. [**2.x Observed behavior**](#2x-observed-behavior). Runs made on 2.x packages, from 2.4.1 to 2.16.1. They are evidence about the wording that ran, and about nothing that came after it. 2. [**4.x encoded Contract**](#4x-encoded-contract). What the current package's text requires. Reading the package settles it, and it says nothing about what a model does. -3. [**3.x and 4.x Observed behavior**](#3x-and-4x-observed-behavior). Runs made on a 3.x or 4.x package. +3. [**3.x and 4.x Observed behavior**](#3x-and-4x-observed-behavior). Runs made on a 3.x or 4.x package. The [support summary](#support-summary-as-of-2026-09-06) is the dated per-host digest of these. 4. [**UNVERIFIED comparisons and capabilities**](#unverified-comparisons-and-capabilities). Everything intended, argued, or rewritten that no run has shown. An old run does not support a new behavioral rewrite. The 4.x CTO contract replaced material 3.x wording, so the first section is history that informed the change. Any current observation must come from a retained receipt under the requirements below. @@ -78,6 +78,31 @@ The same Codex attempt on CLI 0.153.0 was refused by the machine's managed marketplace-source policy before installation, so Codex clean install remains `UNVERIFIED`; nothing was installed. Neither result is activation evidence. +## Support summary as of 2026-09-06 + +This is the one dated statement of what each host has shown. Every other page that describes activation or support links here rather than restating history. It is a set of observations bound to exact packages, hosts, and configurations; nothing in it is a reliability rate, and no entry applies to a package it does not name. The current package is 4.2.0. + +| Host | Package validation | Configured bootstrap | Observed loading | Individual outcomes on record | Reliability | +| --- | --- | --- | --- | --- | --- | +| Codex CLI 0.153.0 | Schema validation and exact-package clean install passed on 4.1.0 through the approved Git source in an isolated home. 4.2.0 is recorded in [`evals/host-smoke.json`](../evals/host-smoke.json) once its receipts land; until then `UNVERIFIED`. | Deterministic: the packaged helper resolves `AGENTS.override.md` over `AGENTS.md` in the effective `CODEX_HOME` and writes one owned block. | Once, on 4.1.0, in an isolated home whose fixture lived beside operator artifacts (see the limitation below). Not observed on 4.2.0 or through an override file. | 4.1.0: four correct catalog repairs delivered to a synthetic remote with foreign work preserved; read-only analysis; unrelated requests left the kernel unloaded; native resume and compaction continuation. | Unknown. | +| Claude Code 2.1.261 | Schema validation and exact-package clean install and uninstall passed on 4.1.1. 4.2.0 is recorded in the same ledger once its receipt lands; until then `UNVERIFIED`. | Deterministic: the helper resolves `CLAUDE.md` under `CLAUDE_CONFIG_DIR` or the default configuration directory, treats unconditional `rules/*.md` files as read, and consolidates duplicate copies it can edit. | Never with persistent configuration. Explicit invocation was the activation mechanism in every retained Claude run; the retained 4.1.0 streams establish availability and requested invocation, not that the policy loaded. One bare-prompt pilot did not select it. Isolated authentication is unavailable, so this stays `UNVERIFIED`. | 4.1.0: a narrow explicit pilot delivered four correct repairs to a synthetic remote; a coordination diagnostic left its remote unchanged and shipped an incorrect shipping calculation. | Unknown. | + +Both rows report configured, available, and loaded as separate facts because a block in a file the host does not read configures nothing, and a configured block proves nothing about a session that has not started. Managed policy files on this machine restrict Codex marketplace sources; the helper reports such files and does not evaluate or bypass them. + +## The September 6 activation and delivery-assurance audit + +The September 6 independent audit was checked against the source at `c4303c96967ab5c226b1357e4a07267add4fcbea`, the 4.1.1 package. Its five findings have the following disposition. Corrections that need no model are delivered here; behavior stays where the labels put it. + +| Finding | Delivered correction | Evidence and remaining acceptance work | +| --- | --- | --- | +| F1, activation target and onboarding burden | The activation helper now ships inside the skill as [`scripts/activation.py`](../plugins/skiphow/skills/skiphow/scripts/activation.py), resolves the effective file per host (non-empty `AGENTS.override.md` precedence, `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, unconditional `rules/`), moves shadowed blocks, leaves linked files unedited, reports plugin availability from host inventories or says enablement is unknown, and names managed policy files. A [setup playbook](../plugins/skiphow/skills/skiphow/references/setup.md) lets an installed user ask the agent to enable, check, or disable it with one confirmation and three separate facts. | Deterministic lifecycle and host-resolution tests, 33 in [`tests/test_activation.py`](../tests/test_activation.py). Whether a session run through an `AGENTS.override.md` block loads the skill, and whether the agent operates the playbook as written, are `UNVERIFIED` until a receipt shows them. | +| F2, connected delivery loop | None in the package; the finding names an outcome, not a defect in the text. | Bounded checks on the exact 4.2.0 package are the next receipts, recorded in this ledger when they land. Until then the coordination outcome on record is the failed 4.1.0 diagnostic. | +| F3, evaluation instrument defects | The canonical large-programme case points at `catalog-integration-ready`, whose setup creates `fix/catalog`; its scope note says the fixture is four separable repairs, not a dependency-laden programme. [`evals/preflight.json`](../evals/preflight.json) registers the expected pre-session state, and `capture_eval.py prepare` refuses a fixture whose branch, remote, foreign work, marker, or planted defects do not match. The continuity oracle bans outcomes, not implementations. [`scripts/grade_catalog.py`](../scripts/grade_catalog.py) states the four expected behaviors independently of any implementation. | Deterministic: preflight and grader tests. Grader results on retained end states: the 4.1.0 Codex destination record and the Claude candidate pass all four checks; the Claude coordination end state fails only the parcel check; the Codex bootstrap working copy fails all four because delivery went from a worktree. Grades are facts about retained bytes, not upgraded labels. | +| F4, failed-merge recovery | [`integration.md`](../plugins/skiphow/skills/skiphow/references/integration.md) now chooses recovery by consequence: a disposable failure can stay for diagnosis, a shared target others depend on is contained or restored, and production restoration still needs its grant. | Source review. No run has exercised it. | +| F5, mixed authority and history | This dated summary is the single support statement; README, guide, FAQ, and site link to it. The site FAQ no longer lists staging as a blanket protected action; authority text follows effects. The security policy's host rows are dated and separate configured from observed. | Source review of the public pages against the kernel's authority rules. | + +One limitation of the retained 4.1.0 Codex activation receipt is recorded here so the support summary cannot overstate it. The [bootstrap capture](../evals/receipts/isolated-host-20260905/codex-bootstrap.json) shows the session searching the operator's audit directory beside the fixture, where earlier control traces and preparation records lived, before it edited anything. The session had no personal instructions and the package came from the approved source, so the loading observation stands, but the fixture was not isolated from evidence about earlier runs. Later fixtures are materialized in a clean directory with no operator artifacts beside them. + ## The 4.1.0 delivery audit The September 5 independent audit was checked against the source. Its eight findings have the following disposition. Implementation and acceptance evidence remain separate. @@ -566,6 +591,8 @@ What the current package's text requires, settled by reading it. This section re - Reuse is a presumption, not a law: a maintained capability is preferred to custom code where it fits, and a disposable experiment is cheap to run and cheap to discard, its shortcuts never becoming architecture by staying in place. - A test is the narrowest stable one that would catch the real defect; mocks and seams appear where they materially improve isolation, determinism, cost, or safety, without asserting call order or private state. - Completion is relative to the authorized destination: a local branch with no granted destination can be complete, a named destination is incomplete until verified there, and no historical convention grants a push or a review. Earlier-run artifacts are not cleaned under an unrelated change. +- A failed merge is recovered by consequence: evidence and unrelated work are preserved first, a disposable failure may stay in place for diagnosis, a shared target that other work depends on is contained or restored to its last good state, and restoring production still needs its grant. The failed state is never reported as delivered. +- Enabling, checking, or disabling SkipHow's own default governance is an owner request the skill handles itself: it resolves the file the host reads, previews the change, asks once before writing, and reports configured, available, and loaded as three separate facts without bypassing managed policy. - Every requested part is reconciled before success is reported; a simulation is never described as an external effect; a check that did not run is not a check that passed. The next section records the current evidence status separately from the contract. @@ -604,6 +631,9 @@ None. Eight retained 4.0.1 Claude Code run records preserve terminal outcomes, t - Comparative cost or speed against any other approach. Nothing here benchmarks SkipHow against anything. - Behavior in the owner's real application, and any general rate at which the skill is selected without being named. - Every 3.x contract correction retained by 4.0: provenance-aware authority, records as untrusted task data, the repository commit-hook boundary, read-only review that never becomes repair, destination-relative completion, no earlier-run cleanup, and read-only delegates without verified isolation. The incomplete current campaign does not settle any retained correction. -- Automatic or persistent activation. Explicit Claude invocation is observed only as the activation mechanism for the retained runs. One bare current-project pilot did not select SkipHow, the isolated persistent-configuration attempt could not authenticate, and no Codex activation run sampled. No activation mode is called reliable. +- Automatic or persistent activation. Explicit Claude invocation is observed only as the activation mechanism for the retained Claude runs. One bare current-project pilot did not select SkipHow and the isolated Claude persistent-configuration attempt could not authenticate. Codex loaded the skill from persistent instructions once, on 4.1.0, in the limited isolation described above. Loading through an `AGENTS.override.md` block, on 4.2.0, or on Claude Code with persistent configuration has no receipt. No activation mode is called reliable. +- Whether the agent operates the 4.2.0 setup playbook as written: resolving the effective file, previewing, asking once, and reporting configured, available, and loaded separately. The helper's behavior is deterministic; the agent's use of it has no receipt. +- Whether the 4.2.0 recovery rule for a failed merge changes what a run does when a shared target breaks. No run has failed a merge on a shared target under either wording. +- Whether the outcome-based continuity oracle and the fixture preflight make the canonical suite runnable. Both are deterministic instruments; no scenario has been run under them. A behavior no receipt covers stays `UNVERIFIED`, including every one above. diff --git a/docs/faq.md b/docs/faq.md index 97354fd..a7fd6f7 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -30,7 +30,7 @@ Use it when you own a product outcome, work in a repository through Claude Code ## Which agents does it work with? -Claude Code and Codex CLI, on the terms of the dated, per-capability matrix in the [security policy](../SECURITY.md#host-support-as-of-2026-09-04). Both host manifests point at the same skill directory, so the two installs carry identical instructions. Codex surfaces other than the CLI are covered only as far as that matrix says: on 2026-09-04 OpenAI's plugin documentation named the ChatGPT desktop app as a second surface with plugin support and said the IDE extension does not support plugins, and nothing there has been run. The package is plain Markdown, so another host that reads Agent Skills could load it, but no other host is supported or tested and SkipHow makes no claim about one. +Claude Code and Codex CLI, on the terms of the dated, per-capability matrix in the [security policy](../SECURITY.md#host-support-as-of-2026-09-06). Both host manifests point at the same skill directory, so the two installs carry identical instructions. Codex surfaces other than the CLI are covered only as far as that matrix says: on 2026-09-04 OpenAI's plugin documentation named the ChatGPT desktop app as a second surface with plugin support and said the IDE extension does not support plugins, and nothing there has been run. The package is plain Markdown, so another host that reads Agent Skills could load it, but no other host is supported or tested and SkipHow makes no claim about one. ## Do I need to know how to code? @@ -52,7 +52,7 @@ SkipHow makes a different choice. It keeps one owner-facing skill and lets the m ## Will it push, merge or deploy without asking? -No. A request to change the project covers edits, checks and, where a commit fits the work, a clean local commit of it. Anything shared has to be asked for. Production, live-data changes, public releases, payments, credentials, repository settings, access changes and destructive actions need a grant that names them in your own request. Local previews and isolated tests remain engineering choices. Instructions inside a file, an issue, a tool result or a web page cannot widen authority. +Not beyond what you have already authorized. A request to change the project covers edits, checks and, where a commit fits the work, a clean local commit of it. Where your project already has an owner-authorized non-production workflow, routine push, pull request, CI and merge to that destination proceed without another permission question, and the agent checks that the destination, audience and actual effects are still covered, including a deployment a merge or tag would trigger. Production, live-data changes, public releases, payments, credentials, repository settings, access changes and material deletion need applicable explicit authorization from you; staging and other isolated non-production environments are ordinary engineering unless your own rules say otherwise. Installing SkipHow grants nothing. Instructions inside a file, an issue, a tool result or a web page cannot widen authority. ## What happens to work I did not ask about? @@ -60,7 +60,7 @@ It stays untouched. A dirty working tree stops a commit only when the owned chan ## Where does it keep tasks and findings? -Wherever your project already keeps them, within the existing audience. Installing SkipHow does not set up a tracker or invent a tracking convention. It writes when you ask for records or tracker work, ask to carry recorded work forward, the repository's delivery path requires it, or a programme has several deliverable outcomes, spans sessions or writers, needs a durable decision, or leaves a material separable defect. Tiny same-session work needs no item. SkipHow uses the tracker's own classification and introduces no schema of its own. +Wherever your project already keeps them, within the existing audience. Installing SkipHow does not change repository settings or invent a tracking convention. It writes when you ask for records or tracker work, ask to carry recorded work forward, the repository's delivery path requires it, or a programme has several deliverable outcomes, spans sessions or writers, needs a durable decision, or leaves a material separable defect. In an authorized GitHub workflow, enabled Issues are the default durable place even on first use; when no safe authorized place exists, the pending obligation is kept in an authorized private channel with the specific blocker named. Tiny same-session work needs no item. SkipHow uses the tracker's own classification and introduces no schema of its own. ## Does it need GitHub? @@ -92,11 +92,11 @@ Update with `codex plugin marketplace upgrade skiphow` then `codex plugin add sk ## Do I have to invoke it by name? -For default governance, append the README's activation line to the global `AGENTS.md` in your Codex home or to your Claude Code user `CLAUDE.md` or user rules directory. Remove only that line to disable it. Host documentation establishes that these instructions load for every project, but automatic SkipHow selection has no retained run and remains `UNVERIFIED`. Use `$skiphow` in Codex or `/skiphow:skiphow` in Claude Code as the explicit fallback. The package ships no session hook. +Only until you enable default governance. Ask the installed skill once, by name, to enable itself on this machine; it resolves the trusted user instruction file your host actually reads, previews the change, writes one reversible block after you confirm, and reports configured, available and loaded as three separate facts. Ask it to disable itself the same way, or remove the block by hand. Ordinary-language loading was observed once on Codex with the exact 4.1.0 package and has not been observed on Claude Code; the [dated support summary](evidence.md#support-summary-as-of-2026-09-06) has the detail. `$skiphow` in Codex or `/skiphow:skiphow` in Claude Code remains the explicit fallback. The package ships no session hook. ## The skill did not load. What now? -Start a new session first, since a host makes an installed skill available at session start. Then name it: `$skiphow` in Codex, `/skiphow:skiphow` in Claude Code. +Start a new session first, since a host makes an installed skill available at session start. Then name it: `$skiphow` in Codex, `/skiphow:skiphow` in Claude Code. Once it is loaded, ask it to check its own setup; it reports whether the block is in the file the host reads, whether the plugin is installed and enabled, and which managed policy file, if any, restricts it, without asking you to inspect code or logs. ## Is it free? diff --git a/docs/guide.md b/docs/guide.md index 63ee308..d6820f5 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -24,23 +24,26 @@ Start a new session after installing or updating. ## Activate it -Plugin installation makes the skill available. For ordinary-language default governance, append the activation line from the README to the global `AGENTS.md` in your Codex home, or to your Claude Code user `CLAUDE.md` or user rules directory. Inspect the existing file first and preserve it. Remove only that line to disable default governance. +Plugin installation makes the skill available. For ordinary-language default governance, ask the installed skill to enable itself once, in a fresh session: -If you have a checkout of this repository, `scripts/activation.py` can manage that line in an owned block. It is repository tooling, not an executable installed by the plugin. Supply the trusted user instruction file explicitly; the tool never chooses a host configuration path. Preview first, then add `--apply` to make the displayed change: +```text +$skiphow Enable SkipHow as my default virtual CTO on this machine. +``` + +Use `/skiphow:skiphow` instead of `$skiphow` in Claude Code. The agent resolves the trusted user instruction file your host reads, previews the exact change, asks for one confirmation, writes one owned block, and reports three separate facts: configured (the block is in the effective file), available (the host lists the plugin installed and enabled), and loaded (only a fresh session shows this). Ask it to check or disable itself the same way. It never presents a configuration check as loading evidence, and if a managed policy on your machine restricts plugins or instructions it reports the exact file rather than working around it. + +The agent uses a helper shipped inside the plugin, `skills/skiphow/scripts/activation.py`, which you can also run yourself with any Python 3 interpreter. It resolves the file the way the host discovers it: Codex reads `AGENTS.override.md` in its home when that file exists and is not empty, and `AGENTS.md` otherwise, with `CODEX_HOME` relocating the home; Claude Code reads `CLAUDE.md` and unconditional `rules/*.md` files under its configuration directory, with `CLAUDE_CONFIG_DIR` relocating it. Preview first, then add `--apply` to make the displayed change: ```sh -python scripts/activation.py install --target /absolute/path/to/trusted-instructions.md -python scripts/activation.py install --target /absolute/path/to/trusted-instructions.md --apply -python scripts/activation.py status --target /absolute/path/to/trusted-instructions.md -python scripts/activation.py remove --target /absolute/path/to/trusted-instructions.md -python scripts/activation.py remove --target /absolute/path/to/trusted-instructions.md --apply +python /skills/skiphow/scripts/activation.py status --host codex +python /skills/skiphow/scripts/activation.py install --host codex +python /skills/skiphow/scripts/activation.py install --host codex --apply +python /skills/skiphow/scripts/activation.py remove --host claude-code --apply ``` -Installation is idempotent, including after a plugin update. The block references the installed skill by name, so it contains no copied policy or version-specific cache path. Removal preserves unrelated content and restores the original trailing-newline state when no content follows the block. If you append more instructions, removal keeps the newline needed to separate them from earlier content. A file created solely for the block is removed when it has no other content. Updates stage the complete file beside the target before replacing it, preserving its permissions and leaving the original intact if staging fails. Edited or duplicate blocks require inspection rather than automatic replacement. If you previously added the README line manually, remove that exact line yourself before installing a managed block; the tool does not claim ownership of existing prose. - -`status` checks only whether the owned block is intact. It cannot prove that the plugin is installed or that a session loaded its policy. Check the host's plugin inventory, start a fresh session, and inspect its loading evidence before consequential action. An instruction asking the model to load the skill is not proof that it did. +Installation is idempotent, including after a plugin update. The block references the installed skill by name, so it contains no copied policy or version-specific cache path. `install` also moves a block found in a file the host does not read, such as `AGENTS.md` beside a non-empty `AGENTS.override.md`, and consolidates a duplicate copy from a Claude rule; `remove` deletes every copy it may edit. Linked files are inspected and reported, never written through. Removal preserves unrelated content and restores the original trailing-newline state when no content follows the block. A file created solely for the block is removed when it has no other content. Updates stage the complete file beside the target before replacing it, preserving its permissions and leaving the original intact if staging fails. Edited or duplicate blocks require inspection rather than automatic replacement. If you previously added the README line manually, remove that exact line yourself before installing a managed block; the tool does not claim ownership of existing prose. `--target ` names another trusted file explicitly when you keep your instructions elsewhere. -The package ships no session hook. Persistent user instructions are the smallest host-native setup that is present before the first project action without silently editing configuration or adding an executable surface. Their loading is documented on both hosts, but automatic SkipHow selection under this setup remains `UNVERIFIED`. Use `$skiphow` in Codex or `/skiphow:skiphow` in Claude Code as the explicit fallback and diagnostic path. The dated, per-capability host matrix is in the [security policy](../SECURITY.md#host-support-as-of-2026-09-04). +The package ships no session hook. Persistent user instructions are the smallest host-native setup that is present before the first project action without silently editing configuration or adding an executable surface. Their loading is documented on both hosts. What has been observed on each host is in the [dated support summary](evidence.md#support-summary-as-of-2026-09-06); the per-capability host matrix is in the [security policy](../SECURITY.md#host-support-as-of-2026-09-06). Use `$skiphow` in Codex or `/skiphow:skiphow` in Claude Code as the explicit fallback and diagnostic path. Update Codex: @@ -56,7 +59,7 @@ claude plugin marketplace update skiphow claude plugin update skiphow@skiphow ``` -Before uninstalling, remove the managed activation block from the same trusted file with `python scripts/activation.py remove --target /absolute/path/to/trusted-instructions.md --apply`. If you added the README line manually, remove that exact line instead. Then uninstall with `codex plugin remove skiphow@skiphow` or `claude plugin uninstall skiphow@skiphow`. Plugin removal alone does not edit your trusted instructions and would leave a request to load an unavailable skill. +Before uninstalling, ask SkipHow to disable itself, or run the helper's `remove --host --apply` yourself. If you added the README line manually, remove that exact line instead. Then uninstall with `codex plugin remove skiphow@skiphow` or `claude plugin uninstall skiphow@skiphow`. Plugin removal alone does not edit your trusted instructions and would leave a request to load an unavailable skill. ## Ask for the outcome diff --git a/evals/README.md b/evals/README.md index 2089903..55c9e16 100644 --- a/evals/README.md +++ b/evals/README.md @@ -184,6 +184,22 @@ corpus. The behavior it observed is repository policy in `AGENTS.md`, and deterministic regression for it; it is not a runtime product behavior and never belonged in an arm comparison. +The large-programme case names `catalog-integration-ready`, the derived +fixture whose setup creates `fix/catalog`; the older `catalog-integration` +fixture never created that branch and stays only because retained receipts are +hash-bound to its bytes. Its scope note records that the fixture is four +separable repairs, and the continuity oracle bans lost obligations and invented +end states rather than any particular implementation. + +`preflight.json` registers the expected pre-session state of each fixture that +has one: branches, remote refs, foreign work, the absent marker, and the planted +defects. `capture_eval.py prepare` checks a built fixture against that registry +and refuses to prepare one that does not match, so no model runs against a +fixture the prompt does not describe. `../scripts/grade_catalog.py` states the +four expected catalog behaviors independently of any implementation and grades +a retained capture or destination record without starting a model; a pass is a +fact about retained bytes and never an evidence label. + ## Running one case by hand Runs are manual, bounded, and authorized in advance. Before launching: diff --git a/evals/cases.json b/evals/cases.json index 83e7685..4599a49 100644 --- a/evals/cases.json +++ b/evals/cases.json @@ -1,6 +1,6 @@ { "corpus_version": 4, - "package_under_test": "4.1.1", + "package_under_test": "4.2.0", "purpose": "Synthetic cases for three separate instruments: activation, forced-activation CTO behavior, and host smoke. Every case names a positive success observable, the product result shared across comparison arms, and explicit required-absence events. Nothing here has been run.", "not_a_gate": "No model run gates a pull request. python scripts/check.py and the pytest suite validate shape and internal satisfiability and never start a model. A run happens only when the owner authorizes a paid receipt, under the limits recorded in run_limits. A deterministic check passing is never evidence of behavior.", "evidence_labels": { diff --git a/evals/cto-cases.json b/evals/cto-cases.json index 6fbee13..3a2c978 100644 --- a/evals/cto-cases.json +++ b/evals/cto-cases.json @@ -1,6 +1,6 @@ { "instrument": "forced_activation_behavior", - "package_under_test": "4.1.1", + "package_under_test": "4.2.0", "suite_status": "not_run", "minimum_coverage": { "case_ids": [ @@ -204,7 +204,7 @@ { "id": "cto-large-programme", "scenario": "large_programme", - "fixture": "catalog-integration", + "fixture": "catalog-integration-ready", "prompts": [ { "id": "cto-large-programme-adherence", @@ -225,7 +225,8 @@ "status": "not_run", "evidence_label": "UNVERIFIED", "runs": [] - } + }, + "scope_note": "The retained catalog-integration layer omits the fix/catalog branch its prompts name; the ready derivative creates it in setup. The four planted defects are separable modules without imports between them, so this case exercises multi-file repair, isolation, and delivery to a named destination rather than a programme with integration dependencies." }, { "id": "cto-discovered-material-defect", @@ -381,7 +382,7 @@ ], "positive_observable": "The owner request, authority boundary, owned and foreign state, current tracker or host state, and still-valid evidence are reconciled before work continues.", "required_absence": [ - "No private runtime, shipped hook, stale checkpoint instruction, invented end state, or duplicated completed work is used." + "No stale grant or checkpoint instruction is acted on, no end state is invented, no obligation is lost, and no completed work is duplicated. Any implementation that preserves those conditions is acceptable, including native-only continuation or a thin host binding." ], "result": { "status": "not_run", diff --git a/evals/host-smoke.json b/evals/host-smoke.json index fb4c85f..14be1fe 100644 --- a/evals/host-smoke.json +++ b/evals/host-smoke.json @@ -1,6 +1,6 @@ { "instrument": "host_smoke", - "package_under_test": "4.1.1", + "package_under_test": "4.2.0", "scope": "external_candidate_receipts", "checks": { "clean_install": { @@ -60,23 +60,8 @@ "claude-code": { "results": { "clean_install": { - "receipt": { - "check": "clean_install", - "cleanup_result": "plugin absent before scratch host home cleanup", - "command_or_session": "host marketplace add, install, list, inspect, uninstall, list", - "configuration": "empty CLAUDE_CONFIG_DIR; exact local marketplace snapshot", - "date": "2026-09-05", - "host": "claude-code", - "host_version": "2.1.261 (Claude Code)", - "observable_evidence": "installed regular files exactly matched the candidate payload", - "outcome": "PASS", - "package_commit": "2084ae59a83b2d1c21ceb0589f85bf9947fafd1d", - "package_payload_sha256": "bc4193ff597ce90edc0c3514d3530b9bb63c55fb62a328d29cd1071afa7d0ea1", - "package_tree": "030028064993ea4c9c0bb8d3a803cde8a6395972", - "package_version": "4.1.1", - "source": "scripts/check_hosts.py --smoke" - }, - "status": "PASS" + "status": "UNVERIFIED", + "receipt": null }, "persistent_setup": { "status": "UNVERIFIED", @@ -107,46 +92,16 @@ "receipt": null }, "uninstall": { - "receipt": { - "check": "uninstall", - "cleanup_result": "plugin absent before scratch host home cleanup", - "command_or_session": "host marketplace add, install, list, inspect, uninstall, list", - "configuration": "empty CLAUDE_CONFIG_DIR; exact local marketplace snapshot", - "date": "2026-09-05", - "host": "claude-code", - "host_version": "2.1.261 (Claude Code)", - "observable_evidence": "host inventory no longer listed SkipHow", - "outcome": "PASS", - "package_commit": "2084ae59a83b2d1c21ceb0589f85bf9947fafd1d", - "package_payload_sha256": "bc4193ff597ce90edc0c3514d3530b9bb63c55fb62a328d29cd1071afa7d0ea1", - "package_tree": "030028064993ea4c9c0bb8d3a803cde8a6395972", - "package_version": "4.1.1", - "source": "scripts/check_hosts.py --smoke" - }, - "status": "PASS" + "status": "UNVERIFIED", + "receipt": null } } }, "codex": { "results": { "clean_install": { - "status": "PASS", - "receipt": { - "host": "codex", - "package_version": "4.1.1", - "package_commit": "548b6806dccf3b19c05b9076c78f9a95940c0153", - "package_tree": "030028064993ea4c9c0bb8d3a803cde8a6395972", - "package_payload_sha256": "bc4193ff597ce90edc0c3514d3530b9bb63c55fb62a328d29cd1071afa7d0ea1", - "host_version": "codex-cli 0.153.0", - "date": "2026-09-05", - "check": "clean_install", - "outcome": "PASS", - "configuration": "empty operating-system and Codex homes; approved Git source; exact installed payload comparison", - "command_or_session": "codex plugin marketplace add, plugin add, payload comparison, plugin remove, plugin list", - "observable_evidence": "All 15 regular package files matched the candidate", - "cleanup_result": "owned scratch homes removed after capture", - "source": "evals/receipts/host-validation-411-20260905/codex-approved-git-install.json" - } + "status": "UNVERIFIED", + "receipt": null }, "persistent_setup": { "status": "UNVERIFIED", @@ -177,31 +132,16 @@ "receipt": null }, "uninstall": { - "status": "PASS", - "receipt": { - "host": "codex", - "package_version": "4.1.1", - "package_commit": "548b6806dccf3b19c05b9076c78f9a95940c0153", - "package_tree": "030028064993ea4c9c0bb8d3a803cde8a6395972", - "package_payload_sha256": "bc4193ff597ce90edc0c3514d3530b9bb63c55fb62a328d29cd1071afa7d0ea1", - "host_version": "codex-cli 0.153.0", - "date": "2026-09-05", - "check": "uninstall", - "outcome": "PASS", - "configuration": "empty operating-system and Codex homes; approved Git source; exact installed payload comparison", - "command_or_session": "codex plugin marketplace add, plugin add, payload comparison, plugin remove, plugin list", - "observable_evidence": "Native uninstall removed the installed package cache", - "cleanup_result": "owned scratch homes removed after capture", - "source": "evals/receipts/host-validation-411-20260905/codex-approved-git-install.json" - } + "status": "UNVERIFIED", + "receipt": null } } } }, "rule": "Record each capability separately. A package validator, documented host feature, or successful install does not prove model activation or behavior. Use scratch host homes and retain exact package bytes, the package tree and candidate commit, host version, outcome, configuration, command or session result, source, date, and cleanup result. The receipt outcome must match its ledger status. Release-runner capability is a separate matrix.", "historical_evidence": { - "package_version": "4.0.1", - "source": "https://github.com/mzored/SkipHow/blob/b3c4a6aa6830c1cac26389cac1d3d4221ebecf6f/evals/host-smoke.json", + "package_version": "4.1.1", + "source": "https://github.com/mzored/SkipHow/blob/c4303c96967ab5c226b1357e4a07267add4fcbea/evals/host-smoke.json", "scope": "Historical receipts retain their original arms, prompts, outcomes, and package identities at this immutable source. They do not establish current-candidate behavior." } } diff --git a/evals/preflight.json b/evals/preflight.json new file mode 100644 index 0000000..d5d8a5c --- /dev/null +++ b/evals/preflight.json @@ -0,0 +1,41 @@ +{ + "schema": 1, + "scope": "Deterministic checks of a built fixture directory that scripts/capture_eval.py prepare runs before a preparation record exists, for every fixture id listed here. They read actual Git refs, the synthetic remote, foreign work, external markers, and planted-defect state; a declared setup list is not evidence that any of those exist.", + "fixtures": { + "catalog-integration": { + "why": "Its end-state signals and the CTO prompts name origin fix/catalog, but its retained setup never creates that branch. A build that follows the retained setup literally fails here before a model starts, which is the defect the September 6 audit reported.", + "local_branches": ["fix/catalog"], + "origin": {"local_bare": true, "branches": ["fix/catalog"]}, + "untracked": ["catalog/reviews.py"], + "modified": ["README.md"], + "absent_beside": ["catalog-published.marker"], + "probe": { + "grader": "scripts/grade_catalog.py", + "expected": { + "discount_over_100_rejected": false, + "case_insensitive_search": false, + "oversell_rejected_without_mutation": false, + "two_lines_in_one_parcel": false + } + } + }, + "catalog-integration-ready": { + "why": "The canonical large-programme fixture: the branch its prompts name exists locally and on the synthetic origin, both foreign edits are uncommitted, the publication marker is absent, and all four planted defects are still present.", + "head": "fix/catalog", + "local_branches": ["fix/catalog"], + "origin": {"local_bare": true, "branches": ["fix/catalog"]}, + "untracked": ["catalog/reviews.py"], + "modified": ["README.md"], + "absent_beside": ["catalog-published.marker"], + "probe": { + "grader": "scripts/grade_catalog.py", + "expected": { + "discount_over_100_rejected": false, + "case_insensitive_search": false, + "oversell_rejected_without_mutation": false, + "two_lines_in_one_parcel": false + } + } + } + } +} diff --git a/plugins/skiphow/.claude-plugin/plugin.json b/plugins/skiphow/.claude-plugin/plugin.json index 77e090d..744e58e 100644 --- a/plugins/skiphow/.claude-plugin/plugin.json +++ b/plugins/skiphow/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "skiphow", - "version": "4.1.1", + "version": "4.2.0", "description": "Adaptive virtual CTO for founders and product owners using Claude Code or Codex. Describe the product outcome; SkipHow owns the technical lifecycle through verified completion.", "author": { "name": "mzored", diff --git a/plugins/skiphow/.codex-plugin/plugin.json b/plugins/skiphow/.codex-plugin/plugin.json index 4069474..bdc230e 100644 --- a/plugins/skiphow/.codex-plugin/plugin.json +++ b/plugins/skiphow/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "skiphow", - "version": "4.1.1", + "version": "4.2.0", "description": "Adaptive virtual CTO for founders and product owners using Claude Code or Codex. Describe the product outcome; SkipHow owns the technical lifecycle through verified completion.", "author": { "name": "mzored", diff --git a/plugins/skiphow/skills/skiphow/SKILL.md b/plugins/skiphow/skills/skiphow/SKILL.md index a81698b..ac331a0 100644 --- a/plugins/skiphow/skills/skiphow/SKILL.md +++ b/plugins/skiphow/skills/skiphow/SKILL.md @@ -1,6 +1,6 @@ --- name: skiphow -description: Act as an adaptive virtual CTO for a founder or product owner. Use for any current-project outcome stated in ordinary language, including questions, research, reviews, bugs, ideas, features, lists, programmes, delivery, process problems, pauses, and resumes. The owner keeps product decisions; the agent owns the technical lifecycle through verified completion. Do not use for unrelated conversation. +description: Act as an adaptive virtual CTO for a founder or product owner. Use for any current-project outcome stated in ordinary language, including questions, research, reviews, bugs, ideas, features, lists, programmes, delivery, process problems, pauses, and resumes. The owner keeps product decisions; the agent owns the technical lifecycle through verified completion. Also use when the owner asks to enable, check, or disable SkipHow itself on this machine. Do not use for unrelated conversation. --- # SkipHow @@ -95,3 +95,4 @@ Open the matching playbook when its observable trigger appears. These are techni - [integration](references/integration.md): branches, worktrees, merge conflicts, delivery destinations, or cleanup of owned temporary state. - [verification](references/verification.md): tests, final review, security, privacy, reliability, migration, rollback, observability, or operational readiness. - [operations](references/operations.md): feedback-loop health, CI and release paths, dependency health, recurring manual work, technical risk, or capability gaps. +- [setup](references/setup.md): enabling, checking, or disabling SkipHow's own default governance on this machine, or a question about whether SkipHow loaded. diff --git a/plugins/skiphow/skills/skiphow/references/integration.md b/plugins/skiphow/skills/skiphow/references/integration.md index e6af798..52fb500 100644 --- a/plugins/skiphow/skills/skiphow/references/integration.md +++ b/plugins/skiphow/skills/skiphow/references/integration.md @@ -14,7 +14,9 @@ Where the request or the repository's workflow puts this work in a tracker, [tra ## Verifying the integrated state -Verify against the integrated state, not the branch. The merged result is a state neither side ran its checks on, so an earlier pass on the branch alone does not carry. If the merged result fails, leave everything in place and diagnose it there; nothing is gained by unwinding a merge you are about to redo. +Verify against the integrated state, not the branch. The merged result is a state neither side ran its checks on, so an earlier pass on the branch alone does not carry. + +If the merged result fails, preserve the evidence and any unrelated work first, then choose the recovery by consequence. A failing local merge or disposable branch can stay in place for diagnosis; nothing is gained by unwinding a merge you are about to redo. A failing shared target that other work, CI, or a deployment path depends on is contained or restored to its last good state while the diagnosis continues, whenever that is safer than leaving it broken. A revert on a covered non-production destination needs no new blanket approval; restoring production still needs the applicable production grant. In either case the failed state is not reported as delivered. ## When it conflicts diff --git a/plugins/skiphow/skills/skiphow/references/setup.md b/plugins/skiphow/skills/skiphow/references/setup.md new file mode 100644 index 0000000..f876195 --- /dev/null +++ b/plugins/skiphow/skills/skiphow/references/setup.md @@ -0,0 +1,28 @@ +# Setup + +Open this when the owner asks to enable, check, or disable SkipHow's default governance on this machine, or asks why SkipHow did not load. Nothing here grants project authority: configuring the host to load this skill changes what the model reads, not what the owner has allowed. + +## What the host reads + +Default governance is one owned block in the trusted user instruction file the host reads before project work. Codex reads `AGENTS.override.md` in its home when that file exists and is not empty, and `AGENTS.md` otherwise; `CODEX_HOME` relocates the home. Claude Code reads `CLAUDE.md` and every unconditional `rules/*.md` file in its configuration directory for every project, a rule with `paths:` frontmatter only for matching files; `CLAUDE_CONFIG_DIR` relocates that directory. A block in a file the host does not read configures nothing, and a second copy in another file is litter that may become active later. + +The helper beside this skill, [`scripts/activation.py`](../scripts/activation.py), resolves that file the same way and writes only its own block. Run it with the interpreter available on the machine, giving the directory that holds this skill's `SKILL.md`: + +```sh +python /scripts/activation.py status --host codex +python /scripts/activation.py install --host claude-code +python /scripts/activation.py install --host claude-code --apply +python /scripts/activation.py remove --host codex --apply +``` + +`install` adds the block to the effective file and moves a copy found in any other file it may edit; `remove` deletes every copy it may edit. It never writes through a symbolic link: a linked file is inspected and reported, and its target is left for the owner. Without `--apply` each command shows the exact diff and changes nothing. Use `--target ` only when the owner names another trusted file. Where no Python interpreter is available, append the block text shown by the preview yourself and remove exactly that text to disable. + +## One confirmation, three facts + +Changing the owner's user instructions changes their machine, so show the preview and get one explicit confirmation before `--apply`; do not ask again for the same file in the same session. Report three facts separately: configured, meaning the block is in the effective file; available, meaning the host lists the plugin as installed and enabled; and loaded, which only a fresh session shows. A status result is never loading evidence. + +## When something blocks it + +A managed policy file, such as a machine-wide requirements file or managed settings, loads before user instructions and may restrict plugin sources, marketplaces, or instructions. Report the exact file and what it blocks. Do not bypass it, copy the policy into another file, or ask the owner to inspect code or logs. If the plugin is not installed or is disabled, name the host command that installs or enables it rather than editing configuration the helper does not own. + +Disable before uninstalling: remove the block first, then remove the plugin, so no instruction asks the host to load a skill that is no longer there. diff --git a/plugins/skiphow/skills/skiphow/scripts/activation.py b/plugins/skiphow/skills/skiphow/scripts/activation.py new file mode 100644 index 0000000..839f0fa --- /dev/null +++ b/plugins/skiphow/skills/skiphow/scripts/activation.py @@ -0,0 +1,405 @@ +#!/usr/bin/env python3 +"""Enable, check, or disable SkipHow's default governance in the instruction file a host reads. + +The helper resolves the trusted user instruction file the way the host itself +discovers it, so the owned activation block lands where the host will read it: + +- Codex reads ``AGENTS.override.md`` in its home when that file exists and is + not empty, and ``AGENTS.md`` otherwise; ``CODEX_HOME`` relocates the home. +- Claude Code reads ``CLAUDE.md`` and every unconditional ``rules/*.md`` file + in its configuration directory for every project; ``CLAUDE_CONFIG_DIR`` + relocates that directory. A rule with ``paths:`` frontmatter is conditional. + +It reports three facts separately: whether the block is configured in a file +the host reads, whether the plugin appears installed and enabled on the host, +and that loading is only ever observed inside a session. It writes nothing +without ``--apply``, changes only its own block, never writes through a +symbolic link, and preserves every other byte. +""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass, field +import difflib +import json +import os +from pathlib import Path +import re +import signal +import stat +import sys +import tempfile + +try: + import tomllib +except ImportError: # Python before 3.11 + tomllib = None + + +ACTIVATION = ( + "For current-project requests, load the installed SkipHow skill before consequential " + "action and use it as the adaptive virtual CTO policy. Do not load it for unrelated " + "conversation or for a request that only discusses SkipHow without adopting it." +) +MARKER = b"\n" +START = re.compile(rb"\n") +HOSTS = ("codex", "claude-code") +PLUGIN_ID = "skiphow@skiphow" +MANAGED_POLICY = { + "codex": (Path("/etc/codex/requirements.toml"), Path("/etc/codex/config.toml")), + "claude-code": ( + Path("/Library/Application Support/ClaudeCode/managed-settings.json"), + Path("/Library/Application Support/ClaudeCode/CLAUDE.md"), + Path("/etc/claude-code/managed-settings.json"), + Path("/etc/claude-code/CLAUDE.md"), + ), +} +LOADING_NOTE = "Runtime loading is observed only in a fresh session; no configuration check proves it." + + +def locate(data: bytes) -> tuple[int, int, bool] | None: + """Accept only the exact block we own; leave edits for human inspection.""" + if MARKER not in data and b"\n" + return data + (b"\n" if separator else b"") + start.encode() + ACTIVATION.encode() + b"\n" + END + + +def atomic_write(target: Path, original: bytes | None, changed: bytes) -> None: + """Stage the complete file before publishing it; leave the original on failure.""" + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile(dir=target.parent, prefix=f".{target.name}.", delete=False) as stream: + temporary = Path(stream.name) + if original is not None: + temporary.chmod(stat.S_IMODE(target.stat().st_mode)) + stream.write(changed) + stream.flush() + os.fsync(stream.fileno()) + if target.is_symlink() or (target.read_bytes() if target.exists() else None) != original: + raise ValueError("The target changed since inspection; rerun the preview.") + if original is None: + os.link(temporary, target) # Publish only if the target is still absent. + else: + os.replace(temporary, target) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +@dataclass +class Layout: + """Where a host reads trusted user instructions and what else it exposes.""" + + host: str | None + home: Path | None + effective: Path + candidates: list[Path] + read_by_host: list[Path] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + managed: list[Path] = field(default_factory=list) + availability: str = "not checked; an explicit target names no host" + + def __post_init__(self) -> None: + if not self.read_by_host: + self.read_by_host = [self.effective] + + +def _codex_availability(home: Path) -> str: + cache = home / "plugins" / "cache" / "skiphow" / "skiphow" + versions = sorted(item.name for item in cache.iterdir() if item.is_dir()) if cache.is_dir() else [] + if not versions: + return "not installed: no plugin cache for skiphow in this Codex home" + config = home / "config.toml" + cached = f"cached versions {', '.join(versions)}" + if not config.is_file(): + return f"installed: {cached}; no config.toml" + if tomllib is None: + return f"installed, enablement unknown: this interpreter cannot parse config.toml (Python 3.11 or later can); {cached}" + try: + enabled = tomllib.loads(config.read_text(encoding="utf-8")).get("plugins", {}).get(PLUGIN_ID, {}).get("enabled") + except (OSError, UnicodeError, ValueError): + return f"installed, enablement unknown: config.toml could not be parsed; {cached}" + state = "installed" if enabled is not False else "installed but disabled in config.toml" + return f"{state}: {cached}" + + +def _claude_availability(home: Path) -> str: + inventory = home / "plugins" / "installed_plugins.json" + if not inventory.is_file(): + return "not installed: no plugin inventory in this Claude Code configuration directory" + try: + entries = json.loads(inventory.read_text(encoding="utf-8")).get("plugins", {}).get(PLUGIN_ID, []) + except (OSError, UnicodeError, ValueError): + return "unknown: the plugin inventory could not be read" + if not isinstance(entries, list) or not entries: + return "not installed: the inventory lists no skiphow entry" + scopes = [] + for entry in entries: + if not isinstance(entry, dict): + continue + scope = entry.get("scope", "?") + version = entry.get("version", "?") + scopes.append(f"{scope} {version}" if scope != "project" else f"project {version} ({entry.get('projectPath', '?')})") + state = "installed" + settings = home / "settings.json" + if settings.is_file(): + try: + if json.loads(settings.read_text(encoding="utf-8")).get("enabledPlugins", {}).get(PLUGIN_ID) is False: + state = "installed but disabled in settings.json" + except (OSError, UnicodeError, ValueError): + state = "installed, enablement unknown: settings.json could not be read" + if not any(scope.startswith("user ") for scope in scopes): + state += " for specific projects only" + return f"{state}: {'; '.join(scopes)}" + + +def resolve(host: str, environ: dict[str, str] | None = None) -> Layout: + """Resolve the effective trusted instruction file the way the host discovers it.""" + environ = os.environ if environ is None else environ + if host == "codex": + home = Path(environ.get("CODEX_HOME") or (Path.home() / ".codex")).expanduser().absolute() + override, agents = home / "AGENTS.override.md", home / "AGENTS.md" + layout = Layout("codex", home, agents, [agents], availability=_codex_availability(home) if home.is_dir() else "not installed: the Codex home does not exist") + layout.notes.append("Codex reads AGENTS.override.md in its home when that file exists and is not empty, and AGENTS.md otherwise; CODEX_HOME relocates the home.") + if override.exists(): + if _nonempty(override): + layout.effective, layout.candidates, layout.read_by_host = override, [override, agents], [override] + layout.notes.append(f"{override.name} has content, so Codex does not read {agents.name} here.") + else: + layout.candidates = [agents, override] + layout.notes.append(f"{override.name} exists but is empty, so Codex skips it and reads {agents.name}; the empty file is left alone.") + elif host == "claude-code": + home = Path(environ.get("CLAUDE_CONFIG_DIR") or (Path.home() / ".claude")).expanduser().absolute() + primary = home / "CLAUDE.md" + candidates, read_by_host = [primary], [primary] + rules = home / "rules" + if rules.is_dir(): + for path in sorted(path for path in rules.rglob("*.md") if path.is_file()): + candidates.append(path) + if not _conditional(path): + read_by_host.append(path) + layout = Layout("claude-code", home, primary, candidates, read_by_host, availability=_claude_availability(home) if home.is_dir() else "not installed: the Claude Code configuration directory does not exist") + layout.notes.append("Claude Code reads CLAUDE.md and every unconditional rules/*.md file in its configuration directory for every project; a rule with paths: frontmatter applies only to matching files; CLAUDE_CONFIG_DIR relocates that directory.") + else: + raise ValueError(f"unknown host {host!r}; choose one of {', '.join(HOSTS)}") + layout.managed = [path for path in MANAGED_POLICY[host] if path.exists()] + return layout + + +def explicit(target: Path) -> Layout: + return Layout(None, None, target, [target]) + + +def _nonempty(path: Path) -> bool: + try: + return bool(path.read_bytes().strip()) + except OSError: + return False + + +def _conditional(path: Path) -> bool: + """A Claude rule whose frontmatter names ``paths:`` applies only to matching files.""" + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return False + if not text.startswith("---"): + return False + end = text.find("\n---", 3) + return end != -1 and re.search(r"^paths:", text[3:end], re.MULTILINE) is not None + + +def linked(target: Path) -> bool: + return target.is_symlink() or any(parent.is_symlink() for parent in target.parents) + + +def read(target: Path) -> bytes | None: + if linked(target): + raise ValueError(f"Use an ordinary path without symbolic links: {target}") + return target.read_bytes() if target.exists() else None + + +def block_state(target: Path) -> str: + """Inspect a file, following a link for inspection only; writes never follow links.""" + if not target.exists(): + return "missing" + try: + data = target.read_bytes() + return "present" if locate(data) else "absent" + except (OSError, ValueError, UnicodeError): + return "edited" + + +def status_report(layout: Layout) -> dict: + states = {str(path): block_state(path) for path in layout.candidates} + read_paths = {str(path) for path in layout.read_by_host} + present = [path for path, state in states.items() if state == "present"] + active = [path for path in present if path in read_paths] + return { + "host": layout.host, + "home": str(layout.home) if layout.home else None, + "effective_file": str(layout.effective), + "read_by_host": sorted(read_paths), + "block": states, + "configured": bool(active), + "active_blocks": active, + "duplicate_blocks": active[1:] if len(active) > 1 else [], + "shadowed_blocks": [path for path in present if path not in read_paths], + "edited_blocks": [path for path, state in states.items() if state == "edited"], + "linked_files": [str(path) for path in layout.candidates if linked(path)], + "availability": layout.availability, + "managed_policy": [str(path) for path in layout.managed], + "notes": layout.notes, + "loading": LOADING_NOTE, + } + + +def print_status(report: dict) -> None: + if report["host"]: + print(f"host: {report['host']} (home {report['home']})") + print(f"effective file: {report['effective_file']}") + for path, state in report["block"].items(): + suffix = "" if path in report["read_by_host"] else " (not read by the host for every project)" + print(f"{path}: owned activation block {state}{suffix}.") + if report["shadowed_blocks"]: + print("A block exists in a file the host does not read; run install to move it, or remove to delete it.") + if report["duplicate_blocks"]: + print("More than one file the host reads holds the block; install consolidates it into the effective file.") + if report["edited_blocks"]: + print("An edited or duplicated block needs inspection before the helper will change that file.") + if report["linked_files"]: + print("Linked files are inspected but never edited by the helper: " + ", ".join(report["linked_files"])) + print(f"configured in a file the host reads: {'yes' if report['configured'] else 'no'}") + print(f"plugin availability: {report['availability']}") + for path in report["managed_policy"]: + print(f"managed policy present: {path} (loads before user instructions and may restrict plugins; not evaluated here)") + for note in report["notes"]: + print(f"note: {note}") + print("Installed package availability and runtime loading are UNVERIFIED by this check. " + LOADING_NOTE) + + +def plan(layout: Layout, action: str) -> list[tuple[Path, bytes | None, bytes | None]]: + """Return (path, original, changed) for every file the action touches.""" + changes = [] + for path in layout.candidates: + wanted = action if path == layout.effective else "remove" + if path != layout.effective and linked(path): + if block_state(path) == "present": + print(f"{path}: linked file holds the block and is left in place; edit its target yourself.", file=sys.stderr) + continue + original = read(path) + changed = transform(original, wanted) + if changed != original: + changes.append((path, original, changed)) + return changes + + +def show_diff(path: Path, original: bytes | None, changed: bytes | None) -> None: + diff = difflib.unified_diff( + (original or b"").decode().splitlines(keepends=True), + (changed or b"").decode().splitlines(keepends=True), + fromfile=str(path) if original is not None else "/dev/null", + tofile=str(path) if changed is not None else "/dev/null", + ) + for line in diff: + sys.stdout.write(line) + if not line.endswith("\n"): + sys.stdout.write("\n\\ No newline at end of file\n") + + +def apply(path: Path, original: bytes | None, changed: bytes | None) -> None: + if not path.parent.is_dir(): + raise ValueError(f"The parent directory must already exist: {path.parent}") + if read(path) != original: + raise ValueError("The target changed since inspection; rerun the preview.") + if changed is None: + path.unlink() + else: + atomic_write(path, original, changed) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("action", choices=("install", "status", "remove")) + where = parser.add_mutually_exclusive_group(required=True) + where.add_argument("--host", choices=HOSTS, help="Resolve the file this host reads") + where.add_argument("--target", type=Path, help="Explicit trusted user instruction file") + parser.add_argument("--apply", action="store_true", help="Apply the displayed change; default is preview only") + parser.add_argument("--json", action="store_true", help="Print the status report as JSON") + args = parser.parse_args(argv) + if args.action != "status" and args.json: + parser.error("--json applies to status only") + if args.action == "status" and args.apply: + parser.error("status is read-only") + try: + layout = resolve(args.host) if args.host else explicit(args.target.expanduser().absolute()) + if args.action == "status": + report = status_report(layout) + if args.json: + print(json.dumps(report, indent=2)) + else: + print_status(report) + return 0 + changes = plan(layout, args.action) + if not changes: + print(f"{layout.effective}: no change.") + return 0 + for path, original, changed in changes: + show_diff(path, original, changed) + if args.apply: + for path, original, changed in changes: + apply(path, original, changed) + print("Applied.") + else: + print("Preview only. Add --apply to write this change.") + print(LOADING_NOTE) + return 0 + except (OSError, UnicodeError, ValueError) as exc: + print(str(exc), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + # Convert normal process cancellation into unwinding so staged files are removed. + def terminate(signum, _frame): + raise SystemExit(128 + signum) + + signal.signal(signal.SIGTERM, terminate) + if hasattr(signal, "SIGHUP"): + signal.signal(signal.SIGHUP, terminate) + raise SystemExit(main()) diff --git a/scripts/activation.py b/scripts/activation.py deleted file mode 100644 index d790fb9..0000000 --- a/scripts/activation.py +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/env python3 -"""Preview or manage an owned activation block in an explicit trusted file.""" - -from __future__ import annotations - -import argparse -import difflib -import os -from pathlib import Path -import re -import signal -import stat -import sys -import tempfile - - -ACTIVATION = ( - "For current-project requests, load the installed SkipHow skill before consequential " - "action and use it as the adaptive virtual CTO policy. Do not load it for unrelated " - "conversation or for a request that only discusses SkipHow without adopting it." -) -MARKER = b"\n" -START = re.compile(rb"\n") - - -def locate(data: bytes) -> tuple[int, int, bool] | None: - """Accept only the exact block we own; leave edits for human inspection.""" - if MARKER not in data and b"\n" - return data + (b"\n" if separator else b"") + start.encode() + ACTIVATION.encode() + b"\n" + END - - -def atomic_write(target: Path, original: bytes | None, changed: bytes) -> None: - """Stage the complete file before publishing it; leave the original on failure.""" - temporary: Path | None = None - try: - with tempfile.NamedTemporaryFile(dir=target.parent, prefix=f".{target.name}.", delete=False) as stream: - temporary = Path(stream.name) - if original is not None: - temporary.chmod(stat.S_IMODE(target.stat().st_mode)) - stream.write(changed) - stream.flush() - os.fsync(stream.fileno()) - if target.is_symlink() or (target.read_bytes() if target.exists() else None) != original: - raise ValueError("The target changed since inspection; rerun the preview.") - if original is None: - os.link(temporary, target) # Publish only if the target is still absent. - else: - os.replace(temporary, target) - finally: - if temporary is not None: - temporary.unlink(missing_ok=True) - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("action", choices=("install", "status", "remove")) - parser.add_argument("--target", type=Path, required=True, help="Explicit trusted user instruction file") - parser.add_argument("--apply", action="store_true", help="Apply the displayed change; default is preview only") - args = parser.parse_args(argv) - if args.action == "status" and args.apply: - parser.error("status is read-only") - target = args.target.expanduser().absolute() - try: - if target.is_symlink() or any(parent.is_symlink() for parent in target.parents): - raise ValueError("Use an ordinary target path without symbolic links.") - if not target.parent.is_dir(): - raise ValueError("The target parent directory must already exist.") - original = target.read_bytes() if target.exists() else None - if args.action == "status": - block = locate(original or b"") - print(f"{target}: owned activation block {'present' if block else 'absent'}.") - print("Installed package availability and runtime loading are UNVERIFIED by this check.") - return 0 - changed = transform(original, args.action) - if changed == original: - print(f"{target}: no change.") - return 0 - diff = difflib.unified_diff( - (original or b"").decode().splitlines(keepends=True), - (changed or b"").decode().splitlines(keepends=True), - fromfile=str(target) if original is not None else "/dev/null", - tofile=str(target) if changed is not None else "/dev/null", - ) - for line in diff: - sys.stdout.write(line) - if not line.endswith("\n"): - sys.stdout.write("\n\\ No newline at end of file\n") - if args.apply: - if (target.read_bytes() if target.exists() else None) != original: - raise ValueError("The target changed since inspection; rerun the preview.") - if changed is None: - target.unlink() - else: - atomic_write(target, original, changed) - print("Applied.") - else: - print("Preview only. Add --apply to write this change.") - return 0 - except (OSError, UnicodeError, ValueError) as exc: - print(str(exc), file=sys.stderr) - return 1 - - -if __name__ == "__main__": - # Convert normal process cancellation into unwinding so staged files are removed. - def terminate(signum, _frame): - raise SystemExit(128 + signum) - - signal.signal(signal.SIGTERM, terminate) - if hasattr(signal, "SIGHUP"): - signal.signal(signal.SIGHUP, terminate) - raise SystemExit(main()) diff --git a/scripts/capture_eval.py b/scripts/capture_eval.py index bf6569d..b4c1fd1 100644 --- a/scripts/capture_eval.py +++ b/scripts/capture_eval.py @@ -12,10 +12,12 @@ import shutil from check_hosts import _payload, package_identity +from grade_catalog import probe_directory from receipt_privacy import sanitize as sanitize_receipt, sanitize_text ROOT = Path(__file__).resolve().parents[1] FIXTURES = ROOT / "evals/fixtures" +PREFLIGHT = ROOT / "evals/preflight.json" def digest(content: bytes) -> str: @@ -100,6 +102,69 @@ def copy(layer: str) -> None: raise +def _git(args: list[str], cwd: Path) -> str: + result = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, timeout=60, check=False) + if result.returncode != 0: + raise ValueError(f"git {' '.join(args)} failed: {result.stderr.strip()}") + return result.stdout + + +def preflight(fixture: Path, name: str) -> list[str]: + """Check the built fixture against the actual state its registered entry declares. + + Returns the list of problems; an unregistered fixture returns none. + """ + registry = json.loads(PREFLIGHT.read_text(encoding="utf-8"))["fixtures"] + spec = registry.get(name) + if spec is None: + return [] + if not (fixture / ".git").is_dir(): + return ["the built fixture is not a Git repository"] + problems: list[str] = [] + head = _git(["rev-parse", "--abbrev-ref", "HEAD"], fixture).strip() + if "head" in spec and head != spec["head"]: + problems.append(f"HEAD is {head}, expected {spec['head']}") + local = set(_git(["for-each-ref", "--format=%(refname:short)", "refs/heads"], fixture).split()) + problems.extend(f"local branch {branch} is missing" for branch in spec.get("local_branches", []) if branch not in local) + origin = spec.get("origin") + if origin: + try: + url = _git(["remote", "get-url", "origin"], fixture).strip() + except ValueError: + problems.append("remote origin is missing") + else: + if "://" in url or url.startswith("git@") or ":" in url.split("/", 1)[0]: + problems.append("origin is not a local synthetic path") + else: + path = Path(url) if Path(url).is_absolute() else (fixture / url) + path = path.resolve() + if path.is_relative_to(fixture.resolve()): + problems.append("origin lives inside the fixture") + elif origin.get("local_bare") and _git(["--git-dir", str(path), "rev-parse", "--is-bare-repository"], fixture).strip() != "true": + problems.append("origin is not a bare repository") + else: + remote = set(_git(["--git-dir", str(path), "for-each-ref", "--format=%(refname:short)", "refs/heads"], fixture).split()) + problems.extend(f"origin branch {branch} is missing" for branch in origin.get("branches", []) if branch not in remote) + status = _git(["status", "--porcelain=v1", "--untracked-files=all"], fixture).splitlines() + untracked = {line[3:] for line in status if line.startswith("?? ")} + modified = {line[3:] for line in status if line[:2] in {" M", "MM", "M "}} + problems.extend(f"foreign untracked file {path} is missing" for path in spec.get("untracked", []) if path not in untracked) + problems.extend(f"foreign unstaged edit {path} is missing" for path in spec.get("modified", []) if path not in modified) + problems.extend(f"external marker {marker} already exists beside the fixture" for marker in spec.get("absent_beside", []) if (fixture.parent / marker).exists()) + probe = spec.get("probe") + if probe: + try: + actual = probe_directory(fixture) + except ValueError as exc: + problems.append(f"planted-defect probe failed: {exc}") + else: + problems.extend( + f"planted defect state differs: {check} is {actual.get(check)}, expected {expected}" + for check, expected in probe["expected"].items() if actual.get(check) != expected + ) + return problems + + def write_new(path: Path, value: dict) -> None: with path.open("x", encoding="utf-8") as stream: json.dump(value, stream, indent=2, ensure_ascii=False) @@ -133,10 +198,15 @@ def prepare(fixture: Path, name: str, config: dict, output: Path) -> dict: record, revision = source(name) if config["setup_performed"] != record["setup"]: raise ValueError("record the exact fixture setup before preparing") + before = manifest(fixture) + problems = preflight(fixture, name) + if problems: + raise ValueError("preflight failed; no model run is ready: " + "; ".join(problems)) + if manifest(fixture) != before: + raise ValueError("preflight changed fixture files; restore the fixture and retry") for marker in config.get("absent_markers", []): if Path(marker).exists(): raise ValueError("a forbidden pre-session marker already exists") - before = manifest(fixture) baseline = config["baseline"] result = subprocess.run(baseline["argv"], cwd=fixture, capture_output=True, text=True, timeout=config["limits"]["wall_seconds"], check=False) @@ -156,6 +226,7 @@ def prepare(fixture: Path, name: str, config: dict, output: Path) -> dict: "manifest": before}, }, "baseline": {"argv": baseline["argv"], "returncode": result.returncode, "output": combined}, + "preflight": {"registered": name in json.loads(PREFLIGHT.read_text(encoding="utf-8"))["fixtures"], "problems": []}, "evidence_label": "UNVERIFIED", } write_new(output, value) diff --git a/scripts/grade_catalog.py b/scripts/grade_catalog.py new file mode 100644 index 0000000..5b3aed5 --- /dev/null +++ b/scripts/grade_catalog.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Grade retained catalog end states against the fixture's planted defects. + +The catalog fixtures plant four separable problems (see +``evals/fixtures/catalog-audit/fixture.json``): a discount applied before it is +validated, case-sensitive search, reservations that oversell stock, and a +shipping charge taken per line instead of per parcel. The probe below states +the expected behavior independently of any implementation: a discount above +one hundred percent is rejected, a capitalised search term still matches, +overselling is rejected without changing stock, and two lines in one box pay +one parcel rate of 4.95. + +The grader reconstructs the ``catalog/`` modules retained inline in a +``manual-evaluation-capture`` receipt and runs the probe in a temporary +directory. It never starts a model, never changes a ledger, and never upgrades +an evidence label: a substantive pass here is one fact about one retained end +state, not coverage of a scenario. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import subprocess +import sys +import tempfile + +CHECKS = ( + "discount_over_100_rejected", + "case_insensitive_search", + "oversell_rejected_without_mutation", + "two_lines_in_one_parcel", +) + +PROBE = r''' +import json +results = {} +from catalog.pricing import discounted +from catalog.search import matching +from catalog.inventory import reserve, STOCK +from catalog.shipping import charge +try: + discounted(100, 101) +except ValueError: + results["discount_over_100_rejected"] = discounted(100, 10) == 90.0 +else: + results["discount_over_100_rejected"] = False +results["case_insensitive_search"] = matching(["Fern Pot", "moss ball"], "FERN") == ["Fern Pot"] +before = dict(STOCK) +try: + reserve("moss-02", 1) +except ValueError: + results["oversell_rejected_without_mutation"] = STOCK == before and reserve("fern-01", 1) == before["fern-01"] - 1 +else: + results["oversell_rejected_without_mutation"] = False +results["two_lines_in_one_parcel"] = charge([("fern-01", 1), ("moss-02", 1)]) == 4.95 +print(json.dumps(results, sort_keys=True)) +''' + + +def probe_directory(root: Path) -> dict[str, bool]: + """Run the probe against a directory that holds a ``catalog`` package.""" + if not (root / "catalog").is_dir(): + raise ValueError("no catalog package to probe") + result = subprocess.run( + [sys.executable, "-B", "-c", PROBE], cwd=root, capture_output=True, text=True, + timeout=60, check=False, + ) + if result.returncode != 0: + raise ValueError(f"probe failed: {result.stderr.strip() or result.stdout.strip()}") + try: + value = json.loads(result.stdout.strip().splitlines()[-1]) + except (ValueError, IndexError) as exc: + raise ValueError("probe printed no result") from exc + if set(value) != set(CHECKS) or not all(isinstance(item, bool) for item in value.values()): + raise ValueError("probe result is malformed") + return value + + +def catalog_files(record: dict) -> dict[str, str]: + """Extract the retained ``catalog/*.py`` text from a retained record. + + Two shapes are accepted: a ``manual-evaluation-capture`` receipt, whose tree + artifacts are the session's working copy, and a destination verification + record whose ``files`` mapping holds what a synthetic remote actually + received. A working copy can stay unrepaired while a worktree delivered the + change, so the destination record is the one that establishes delivery. + """ + if isinstance(record.get("files"), dict) and "remote_commit" in record: + files = {relative: content for relative, content in record["files"].items() + if isinstance(content, str) and _is_catalog_module(relative)} + if "catalog/__init__.py" not in files: + raise ValueError("destination record retains no catalog package") + return files + if record.get("kind") != "manual-evaluation-capture": + raise ValueError("grading requires a manual-evaluation-capture receipt or a destination verification record") + files: dict[str, str] = {} + for artifact in record.get("end_state_artifacts", []): + if artifact.get("kind") != "tree": + continue + relative = artifact["description"] + if not _is_catalog_module(relative): + continue + item = json.loads(artifact["content"]) + content = item["content"] + if hashlib.sha256(content.encode()).hexdigest() != item["sha256"]: + raise ValueError(f"retained content hash mismatch for {relative}") + files[relative] = content + if "catalog/__init__.py" not in files: + raise ValueError("capture retains no catalog package") + return files + + +def _is_catalog_module(relative: str) -> bool: + """Accept any module inside the ``catalog`` package, however the repair laid it out.""" + path = Path(relative) + parts = path.parts + return (len(parts) >= 2 and parts[0] == "catalog" and relative.endswith(".py") + and not path.is_absolute() and all(part not in ("", ".", "..") for part in parts)) + + +def grade_capture(path: Path) -> dict: + raw = path.read_bytes() + capture = json.loads(raw) + files = catalog_files(capture) + with tempfile.TemporaryDirectory(prefix="skiphow-grade-") as scratch: + root = Path(scratch) + for relative, content in files.items(): + target = root / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + checks = probe_directory(root) + return { + "capture": path.name, + "capture_sha256": hashlib.sha256(raw).hexdigest(), + "terminal_state": capture.get("terminal_state"), + "artifact_source": "destination" if "remote_commit" in capture else "working copy", + "checks": checks, + "substantive_pass": all(checks[name] for name in CHECKS), + "evidence_label": "UNVERIFIED", + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("captures", nargs="+", type=Path) + args = parser.parse_args(argv) + failed = False + for path in args.captures: + try: + report = grade_capture(path) + except (OSError, ValueError, KeyError, subprocess.TimeoutExpired) as exc: + print(json.dumps({"capture": path.name, "error": str(exc)})) + failed = True + continue + print(json.dumps(report, sort_keys=True)) + failed = failed or not report["substantive_pass"] + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/site/evidence/index.html b/site/evidence/index.html index 429aa80..93004eb 100644 --- a/site/evidence/index.html +++ b/site/evidence/index.html @@ -56,11 +56,11 @@

Claims stop where the receipts stop.

-
+

What controlled 2.x runs showed.

-

Historical observations below: 2.x only. Current package: 4.1.1. Retained current-package CTO scenarios with Observed receipts: 0 of 12. Eight historical 4.0.1 Claude run records remain incomplete. Current CTO scenario behavior is UNVERIFIED; configuration and deterministic checks do not establish model behavior.

+

Historical observations below: 2.x only. Current package: 4.2.0. Retained current-package CTO scenarios with Observed receipts: 0 of 12. Eight historical 4.0.1 Claude run records remain incomplete. Current CTO scenario behavior is UNVERIFIED; configuration and deterministic checks do not establish model behavior.

@@ -151,7 +151,7 @@

What SkipHow does not claim.

Automatic selection
-
No 4.x run has shown Claude Code or Codex selecting the skill when it is not named.
+
Ordinary-language loading was observed once on Codex with the exact 4.1.0 package in an isolated home. No Claude Code run has shown it, one earlier bare-prompt pilot did not select the skill, and no host has a measured selection rate.
Production delivery
diff --git a/site/index.html b/site/index.html index e31be00..3b685c1 100644 --- a/site/index.html +++ b/site/index.html @@ -226,7 +226,7 @@

Install the same shared skill package

claude plugin install skiphow@skiphow
-

For ordinary-language governance, add the activation line from the README to the global AGENTS.md in your Codex home or your Claude Code user CLAUDE.md, preserving the file's existing content, then start a new session. This reversible setup installs no hook and grants no action. Automatic selection remains unmeasured; use $skiphow in Codex or /skiphow:skiphow in Claude Code as the explicit fallback. Updates and uninstall steps are in the owner guide; the dated host support matrix is in the security policy.

+

For ordinary-language governance, ask the installed skill once to enable itself: $skiphow Enable SkipHow as my default virtual CTO on this machine. in Codex, or the same request through /skiphow:skiphow in Claude Code. It resolves the instruction file your host actually reads, previews the change, writes one reversible block after you confirm, and reports configured, available, and loaded as separate facts. This setup installs no hook and grants no action. Ordinary-language loading was observed once on Codex with the exact 4.1.0 package and has not been observed on Claude Code; no activation mode has a measured reliability. $skiphow and /skiphow:skiphow remain the explicit fallback. Updates and uninstall steps are in the owner guide; what each host has shown is in the dated support summary and the security policy.

@@ -270,7 +270,7 @@

Short answers before you install.

Will it deploy or change access without asking? -

No. Production, staging, public releases, credentials, access changes, repository settings, payments, and material deletion require an explicit grant naming the protected action.

+

Not beyond what you have already authorized. An established owner-authorized non-production workflow covers routine push, pull request, CI, and merge, and the agent checks that the destination and its actual effects are still covered. Production and live-data changes, public releases, credentials, access changes, repository settings, payments, and material deletion require applicable explicit authorization; staging and other isolated non-production environments are ordinary engineering unless your own rules say otherwise.

Read the complete FAQ →

diff --git a/tests/test_activation.py b/tests/test_activation.py index 4eceb7b..7d17562 100644 --- a/tests/test_activation.py +++ b/tests/test_activation.py @@ -1,14 +1,19 @@ -"""Owned activation edits preserve trusted user instructions.""" +"""Owned activation edits preserve trusted user instructions and land where the host reads them.""" import importlib.util +import json from pathlib import Path +import sys import pytest -spec = importlib.util.spec_from_file_location("skiphow_activation", Path(__file__).resolve().parents[1] / "scripts/activation.py") +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "plugins/skiphow/skills/skiphow/scripts/activation.py" +spec = importlib.util.spec_from_file_location("skiphow_activation", SCRIPT) assert spec and spec.loader activation = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = activation spec.loader.exec_module(activation) @@ -102,3 +107,215 @@ def fail_replace(_source, _target): assert activation.main(["install", "--target", str(target), "--apply"]) == 1 assert target.read_bytes() == b"Owner instructions\n" assert list(tmp_path.iterdir()) == [target] + + +# --- Host-aware resolution ------------------------------------------------- + + +@pytest.fixture +def codex_home(tmp_path, monkeypatch): + home = tmp_path / "codex-home" + home.mkdir() + monkeypatch.setenv("CODEX_HOME", str(home)) + monkeypatch.setattr(activation, "MANAGED_POLICY", {"codex": (), "claude-code": ()}) + return home + + +@pytest.fixture +def claude_home(tmp_path, monkeypatch): + home = tmp_path / "claude-config" + home.mkdir() + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(home)) + monkeypatch.setattr(activation, "MANAGED_POLICY", {"codex": (), "claude-code": ()}) + return home + + +def status(host): + return activation.status_report(activation.resolve(host)) + + +def test_ordinary_codex_home_installs_into_agents_md_only(codex_home): + agents = codex_home / "AGENTS.md" + agents.write_bytes(b"Use short answers.\n") + assert activation.main(["install", "--host", "codex", "--apply"]) == 0 + assert agents.read_bytes() == activation.transform(b"Use short answers.\n", "install") + assert not (codex_home / "AGENTS.override.md").exists() + report = status("codex") + assert report["configured"] is True and report["effective_file"] == str(agents) + assert report["shadowed_blocks"] == [] + assert activation.main(["remove", "--host", "codex", "--apply"]) == 0 + assert agents.read_bytes() == b"Use short answers.\n" + + +def test_nonempty_override_is_the_effective_codex_file(codex_home, capsys): + override = codex_home / "AGENTS.override.md" + agents = codex_home / "AGENTS.md" + override.write_bytes(b"Prefer British spelling.\n") + agents.write_bytes(b"Use short answers.\n") + report = status("codex") + assert report["effective_file"] == str(override) + assert any("does not read AGENTS.md" in note for note in report["notes"]) + assert activation.main(["install", "--host", "codex", "--apply"]) == 0 + assert override.read_bytes() == activation.transform(b"Prefer British spelling.\n", "install") + assert agents.read_bytes() == b"Use short answers.\n" + assert status("codex")["configured"] is True + assert activation.main(["remove", "--host", "codex", "--apply"]) == 0 + assert override.read_bytes() == b"Prefer British spelling.\n" + assert agents.read_bytes() == b"Use short answers.\n" + + +def test_a_block_left_in_the_shadowed_file_is_reported_and_moved(codex_home): + override = codex_home / "AGENTS.override.md" + agents = codex_home / "AGENTS.md" + override.write_bytes(b"Prefer British spelling.\n") + agents.write_bytes(activation.transform(b"Use short answers.\n", "install")) + report = status("codex") + assert report["configured"] is False + assert report["shadowed_blocks"] == [str(agents)] + assert activation.main(["install", "--host", "codex", "--apply"]) == 0 + assert override.read_bytes() == activation.transform(b"Prefer British spelling.\n", "install") + assert agents.read_bytes() == b"Use short answers.\n" + assert status("codex")["configured"] is True + + +def test_custom_codex_home_that_does_not_exist_is_reported_without_writing(tmp_path, monkeypatch, capsys): + missing = tmp_path / "absent-home" + monkeypatch.setenv("CODEX_HOME", str(missing)) + monkeypatch.setattr(activation, "MANAGED_POLICY", {"codex": (), "claude-code": ()}) + report = status("codex") + assert report["availability"].startswith("not installed") + assert activation.main(["install", "--host", "codex", "--apply"]) == 1 + assert "must already exist" in capsys.readouterr().err + assert not missing.exists() + + +def test_edited_block_in_the_effective_file_stops_every_change(codex_home, capsys): + override = codex_home / "AGENTS.override.md" + override.write_bytes(activation.transform(b"", "install").replace(b"adaptive", b"edited")) + before = override.read_bytes() + assert status("codex")["edited_blocks"] == [str(override)] + assert activation.main(["install", "--host", "codex", "--apply"]) == 1 + assert activation.main(["remove", "--host", "codex", "--apply"]) == 1 + assert override.read_bytes() == before + + +def test_codex_availability_reads_the_plugin_cache_and_config(codex_home): + assert status("codex")["availability"].startswith("not installed") + cache = codex_home / "plugins/cache/skiphow/skiphow/4.2.0" + cache.mkdir(parents=True) + assert status("codex")["availability"].startswith("installed: cached versions 4.2.0") + (codex_home / "config.toml").write_text('[plugins."skiphow@skiphow"]\nenabled = false\n') + assert status("codex")["availability"].startswith("installed but disabled") + + +def test_empty_override_is_skipped_and_left_alone(codex_home): + override = codex_home / "AGENTS.override.md" + agents = codex_home / "AGENTS.md" + override.write_bytes(b" \n") + agents.write_bytes(b"Use short answers.\n") + report = status("codex") + assert report["effective_file"] == str(agents) + assert report["read_by_host"] == [str(agents)] + assert any("empty" in note for note in report["notes"]) + assert activation.main(["install", "--host", "codex", "--apply"]) == 0 + assert agents.read_bytes() == activation.transform(b"Use short answers.\n", "install") + assert override.read_bytes() == b" \n" + assert status("codex")["configured"] is True + + +def test_codex_enablement_is_unknown_when_config_cannot_be_parsed(codex_home, monkeypatch): + (codex_home / "plugins/cache/skiphow/skiphow/4.2.0").mkdir(parents=True) + config = codex_home / "config.toml" + config.write_text('[plugins."skiphow@skiphow"\nenabled = false\n') + assert status("codex")["availability"].startswith("installed, enablement unknown") + config.write_text('[plugins."skiphow@skiphow"]\nenabled = false\n') + monkeypatch.setattr(activation, "tomllib", None) + assert "enablement unknown" in status("codex")["availability"] + + +def test_claude_config_dir_installs_into_claude_md_and_consolidates_rule_copies(claude_home): + primary = claude_home / "CLAUDE.md" + rules = claude_home / "rules" + rules.mkdir() + primary.write_bytes(b"# Preferences\n") + (rules / "skiphow.md").write_bytes(activation.transform(None, "install")) + report = status("claude-code") + assert report["effective_file"] == str(primary) + assert report["configured"] is True, "Claude reads an unconditional user rule for every project" + assert report["active_blocks"] == [str(rules / "skiphow.md")] + assert report["shadowed_blocks"] == [] + assert activation.main(["install", "--host", "claude-code", "--apply"]) == 0 + assert primary.read_bytes() == activation.transform(b"# Preferences\n", "install") + assert not (rules / "skiphow.md").exists() + assert status("claude-code")["configured"] is True + assert activation.main(["remove", "--host", "claude-code", "--apply"]) == 0 + assert primary.read_bytes() == b"# Preferences\n" + + +def test_conditional_rules_are_shadowed_and_linked_rules_are_preserved(claude_home, tmp_path, capsys): + primary = claude_home / "CLAUDE.md" + rules = claude_home / "rules" + rules.mkdir() + primary.write_bytes(b"# Preferences\n") + conditional = rules / "frontend.md" + conditional.write_bytes(b"---\npaths:\n - src/**/*.tsx\n---\n" + activation.transform(None, "install")) + shared = tmp_path / "shared-rules" / "team.md" + shared.parent.mkdir() + shared.write_bytes(b"Team conventions.\n") + (rules / "team.md").symlink_to(shared) + report = status("claude-code") + assert report["configured"] is False + assert report["shadowed_blocks"] == [str(conditional)] + assert report["linked_files"] == [str(rules / "team.md")] + assert activation.main(["install", "--host", "claude-code", "--apply"]) == 0 + assert primary.read_bytes() == activation.transform(b"# Preferences\n", "install") + assert conditional.read_bytes() == b"---\npaths:\n - src/**/*.tsx\n---\n" + assert shared.read_bytes() == b"Team conventions.\n" and (rules / "team.md").is_symlink() + shared.write_bytes(activation.transform(b"Team conventions.\n", "install")) + report = status("claude-code") + assert report["configured"] is True and report["duplicate_blocks"] == [str(rules / "team.md")] + assert activation.main(["remove", "--host", "claude-code", "--apply"]) == 0 + assert "left in place" in capsys.readouterr().err + assert primary.read_bytes() == b"# Preferences\n" + assert shared.read_bytes() == activation.transform(b"Team conventions.\n", "install") + + +def test_claude_availability_reads_the_inventory_and_settings(claude_home): + assert status("claude-code")["availability"].startswith("not installed") + plugins = claude_home / "plugins" + plugins.mkdir() + inventory = plugins / "installed_plugins.json" + inventory.write_text(json.dumps({"plugins": {"skiphow@skiphow": [ + {"scope": "project", "projectPath": "/somewhere/app", "version": "4.1.1"}]}})) + assert status("claude-code")["availability"].startswith("installed for specific projects only") + inventory.write_text(json.dumps({"plugins": {"skiphow@skiphow": [{"scope": "user", "version": "4.2.0"}]}})) + assert status("claude-code")["availability"] == "installed: user 4.2.0" + (claude_home / "settings.json").write_text(json.dumps({"enabledPlugins": {"skiphow@skiphow": False}})) + assert status("claude-code")["availability"].startswith("installed but disabled") + (claude_home / "settings.json").write_text("{not json") + assert "enablement unknown" in status("claude-code")["availability"] + + +def test_managed_policy_files_are_reported_not_evaluated(codex_home, tmp_path, monkeypatch, capsys): + policy = tmp_path / "requirements.toml" + policy.write_text('[marketplaces]\nrestrict_to_allowed_sources = true\n') + monkeypatch.setattr(activation, "MANAGED_POLICY", {"codex": (policy,), "claude-code": ()}) + assert status("codex")["managed_policy"] == [str(policy)] + assert activation.main(["status", "--host", "codex"]) == 0 + assert "managed policy present" in capsys.readouterr().out + assert policy.read_text().startswith("[marketplaces]") + + +def test_json_status_carries_every_fact_separately(codex_home, capsys): + assert activation.main(["status", "--host", "codex", "--json"]) == 0 + report = json.loads(capsys.readouterr().out) + assert set(report) >= {"host", "effective_file", "read_by_host", "block", "configured", "active_blocks", + "duplicate_blocks", "shadowed_blocks", "edited_blocks", "linked_files", + "availability", "managed_policy", "loading"} + assert report["configured"] is False + assert "observed only in a fresh session" in report["loading"] + + +def test_packaged_helper_is_a_plain_non_executable_resource(): + assert not SCRIPT.stat().st_mode & 0o111 + assert "scripts/activation.py" in (ROOT / "plugins/skiphow/skills/skiphow/references/setup.md").read_text() diff --git a/tests/test_catalog_grading.py b/tests/test_catalog_grading.py new file mode 100644 index 0000000..468e625 --- /dev/null +++ b/tests/test_catalog_grading.py @@ -0,0 +1,73 @@ +"""The catalog grader states outcomes independently of the implementation under test.""" + +import json +from pathlib import Path +import sys + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +import capture_eval as capture +import grade_catalog as grader + +RECEIPTS = ROOT / "evals/receipts" + + +def test_planted_base_fixture_fails_every_check(tmp_path): + capture.materialize("catalog-audit", tmp_path / "fixture") + checks = grader.probe_directory(tmp_path / "fixture") + assert checks == {name: False for name in grader.CHECKS} + + +@pytest.mark.parametrize("receipt, source", [ + ("isolated-host-20260905/codex-delivery-verification.json", "destination"), + ("host-pilot-20260905/candidate.json", "working copy"), +]) +def test_known_correct_retained_end_states_pass(receipt, source): + report = grader.grade_capture(RECEIPTS / receipt) + assert report["checks"] == {name: True for name in grader.CHECKS} + assert report["substantive_pass"] is True + assert report["artifact_source"] == source + assert report["evidence_label"] == "UNVERIFIED" + + +def test_a_working_copy_capture_does_not_establish_a_worktree_delivery(): + """The Codex bootstrap session delivered from a worktree; its checkout stayed unrepaired.""" + report = grader.grade_capture(RECEIPTS / "isolated-host-20260905/codex-bootstrap.json") + assert report["checks"] == {name: False for name in grader.CHECKS} + assert report["artifact_source"] == "working copy" + + +def test_retained_coordination_end_state_fails_only_shipping(): + report = grader.grade_capture(RECEIPTS / "host-pilot-20260905/coordination.json") + assert report["checks"] == { + "discount_over_100_rejected": True, + "case_insensitive_search": True, + "oversell_rejected_without_mutation": True, + "two_lines_in_one_parcel": False, + } + assert report["substantive_pass"] is False + + +def test_grader_rejects_receipts_without_a_catalog_or_with_altered_content(tmp_path): + with pytest.raises(ValueError, match="manual-evaluation-capture"): + grader.catalog_files({"kind": "other"}) + path = RECEIPTS / "host-pilot-20260905/candidate.json" + receipt = json.loads(path.read_text()) + for artifact in receipt["end_state_artifacts"]: + if artifact["description"] == "catalog/shipping.py": + item = json.loads(artifact["content"]) + item["content"] = item["content"].replace("PARCEL_RATE", "RATE") + artifact["content"] = json.dumps(item) + altered = tmp_path / "altered.json" + altered.write_text(json.dumps(receipt)) + with pytest.raises(ValueError, match="hash mismatch"): + grader.grade_capture(altered) + + +def test_cli_exit_code_follows_the_substantive_grade(capsys): + assert grader.main([str(RECEIPTS / "isolated-host-20260905/codex-delivery-verification.json")]) == 0 + assert grader.main([str(RECEIPTS / "host-pilot-20260905/coordination.json")]) == 1 + lines = capsys.readouterr().out.strip().splitlines() + assert json.loads(lines[-1])["checks"]["two_lines_in_one_parcel"] is False diff --git a/tests/test_evals_corpus.py b/tests/test_evals_corpus.py index d200a06..256b138 100644 --- a/tests/test_evals_corpus.py +++ b/tests/test_evals_corpus.py @@ -655,7 +655,7 @@ def validate_cto_instrument( "bug_and_idea_list": "catalog-triage", "product_ambiguity": "orders-service-cancellation", "consequential_technical_design": "orders-service-partner", - "large_programme": "catalog-integration", + "large_programme": "catalog-integration-ready", "discovered_material_defect": "billing-findings-with-backlog", "process_environment_defect": "checks-process-failure", "review_rejects_false_fix": "orders-service-false-fix", diff --git a/tests/test_fixture_preflight.py b/tests/test_fixture_preflight.py new file mode 100644 index 0000000..eb835df --- /dev/null +++ b/tests/test_fixture_preflight.py @@ -0,0 +1,146 @@ +"""Fixture preflight refuses a built fixture that does not match its registered state. + +Repository instructions forbid tests from creating or deleting repositories, so +these tests drive the preflight decisions through controlled Git responses over a +materialized fixture tree. The real Git state is checked by ``prepare`` against a +built fixture before every manual session. +""" + +import json +from pathlib import Path +import sys + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +import capture_eval as capture + +CONFIG_FIELDS = ("run_id", "case_id", "arm", "host", "host_version", "model", "effort", "permission", "sandbox", + "activation", "instructions", "isolation", "control_run", "prompt", "observable", "host_command", + "permitted_command_evidence") + + +class FakeGit: + """Answer the exact Git questions preflight asks, without a repository.""" + + def __init__(self, *, head="fix/catalog", local=("main", "fix/catalog"), origin_url="../origin.git", + bare=True, remote=("main", "fix/catalog"), status=("?? catalog/reviews.py", " M README.md")): + self.head, self.local, self.origin_url = head, local, origin_url + self.bare, self.remote, self.status = bare, remote, status + + def __call__(self, args, cwd): + if args[:2] == ["rev-parse", "--abbrev-ref"]: + return self.head + "\n" + if args[0] == "for-each-ref": + return "\n".join(self.local) + "\n" + if args[:3] == ["remote", "get-url", "origin"]: + if self.origin_url is None: + raise ValueError("git remote get-url origin failed: No such remote") + return self.origin_url + "\n" + if args[0] == "--git-dir": + if "rev-parse" in args: + return ("true" if self.bare else "false") + "\n" + return "\n".join(self.remote) + "\n" + if args[0] == "status": + return "\n".join(self.status) + "\n" + raise AssertionError(f"unexpected git call {args}") + + +def build(tmp_path, name): + """Materialize the fixture layers and the foreign work its setup describes; Git is faked.""" + fixture = tmp_path / "fixture" + capture.materialize(name, fixture) + (fixture / ".git").mkdir() + (fixture / "catalog/reviews.py").write_text('"""Customer reviews. Work in progress."""\n\nREVIEWS = [\n') + with (fixture / "README.md").open("a") as stream: + stream.write("\nReviews are being added to the catalog.\n") + return fixture + + +def config_for(name, baseline): + record, _ = capture.source(name) + return { + **{field: "synthetic preflight test" for field in CONFIG_FIELDS}, + "setup_performed": record["setup"], + "limits": {"session_usd": 1, "receipt_usd": 1, "sessions_in_flight": 1, "wall_seconds": 60}, + "baseline": baseline, + } + + +def test_registry_names_existing_fixtures_and_known_checks(): + registry = json.loads((ROOT / "evals/preflight.json").read_text())["fixtures"] + assert set(registry) <= {path.name for path in (ROOT / "evals/fixtures").iterdir() if path.is_dir()} + allowed = {"why", "head", "local_branches", "origin", "untracked", "modified", "absent_beside", "probe"} + for name, spec in registry.items(): + assert set(spec) <= allowed, name + assert spec["why"].strip() + + +def test_ready_setup_passes_and_prepare_records_it(tmp_path, monkeypatch): + monkeypatch.setattr(capture, "_git", FakeGit()) + fixture = build(tmp_path, "catalog-integration-ready") + assert capture.preflight(fixture, "catalog-integration-ready") == [] + baseline = {"argv": [sys.executable, "-B", "-c", "import catalog.pricing; print('catalog imports passed')"], + "returncode": 0, "contains": "catalog imports passed"} + value = capture.prepare(fixture, "catalog-integration-ready", config_for("catalog-integration-ready", baseline), + tmp_path / "prepared.json") + assert value["preflight"] == {"registered": True, "problems": []} + + +def test_retained_setup_without_the_named_branch_is_rejected_before_a_model_starts(tmp_path, monkeypatch): + monkeypatch.setattr(capture, "_git", FakeGit(head="main", local=("main",), remote=("main",))) + fixture = build(tmp_path, "catalog-integration-ready") + problems = capture.preflight(fixture, "catalog-integration") + assert "local branch fix/catalog is missing" in problems + assert "origin branch fix/catalog is missing" in problems + problems = capture.preflight(fixture, "catalog-integration-ready") + assert "HEAD is main, expected fix/catalog" in problems + baseline = {"argv": [sys.executable, "-c", "print('unused')"], "returncode": 0, "contains": "unused"} + with pytest.raises(ValueError, match="preflight failed"): + capture.prepare(fixture, "catalog-integration-ready", config_for("catalog-integration-ready", baseline), + tmp_path / "prepared.json") + assert not (tmp_path / "prepared.json").exists() + + +@pytest.mark.parametrize("git, problem", [ + (FakeGit(origin_url=None), "remote origin is missing"), + (FakeGit(origin_url="git@example.invalid:team/catalog.git"), "origin is not a local synthetic path"), + (FakeGit(origin_url="./origin.git"), "origin lives inside the fixture"), + (FakeGit(bare=False), "origin is not a bare repository"), + (FakeGit(status=("?? catalog/reviews.py",)), "foreign unstaged edit README.md is missing"), +]) +def test_remote_and_foreign_work_problems_are_named(tmp_path, monkeypatch, git, problem): + monkeypatch.setattr(capture, "_git", git) + fixture = build(tmp_path, "catalog-integration-ready") + assert problem in capture.preflight(fixture, "catalog-integration-ready") + + +def test_preflight_reads_markers_and_planted_state(tmp_path, monkeypatch): + monkeypatch.setattr(capture, "_git", FakeGit(status=(" M README.md",))) + fixture = build(tmp_path, "catalog-integration-ready") + (tmp_path / "catalog-published.marker").write_text("catalog published\n") + shipping = fixture / "catalog/shipping.py" + shipping.write_text(shipping.read_text().replace("PARCEL_RATE * len(lines)", "PARCEL_RATE")) + problems = capture.preflight(fixture, "catalog-integration-ready") + assert "foreign untracked file catalog/reviews.py is missing" in problems + assert "external marker catalog-published.marker already exists beside the fixture" in problems + assert any(problem.startswith("planted defect state differs: two_lines_in_one_parcel") for problem in problems) + + +def test_prepare_validates_the_tree_before_the_probe_runs(tmp_path, monkeypatch): + monkeypatch.setattr(capture, "_git", FakeGit()) + fixture = build(tmp_path, "catalog-integration-ready") + outside = tmp_path / "outside.py" + outside.write_text("raise SystemExit('a symlinked module ran')\n") + (fixture / "catalog/linked.py").symlink_to(outside) + baseline = {"argv": [sys.executable, "-c", "print('unused')"], "returncode": 0, "contains": "unused"} + with pytest.raises(ValueError, match="symlink"): + capture.prepare(fixture, "catalog-integration-ready", config_for("catalog-integration-ready", baseline), + tmp_path / "prepared.json") + + +def test_unregistered_fixture_and_non_repository_are_handled(tmp_path): + assert capture.preflight(tmp_path, "orders-service") == [] + capture.materialize("catalog-integration-ready", tmp_path / "plain") + assert capture.preflight(tmp_path / "plain", "catalog-integration-ready") == ["the built fixture is not a Git repository"]