Repository navigation
Require the Claude Code link in the installer's ok state for a global skill - #1024
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 114d539f50
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
… skill tools/adoption/install_skills.py --check-only reported ok for typesafe-ai on 2026-10-11 while ~/.claude/skills had no entry for it: the global ok state stopped at the canonical folder and the lock entry. scripts/skills_status.py failed the skill (claude_link missing) and a fresh headless Claude Code session did not list it. The global ok state now also needs the link the Skills CLI makes for a non-universal agent (vercel-labs/skills@7407f389 src/installer.ts L395-426, createSymlink L227-290): a missing link is drift (install: --check-only prints missing-or-drifted and exits 1, --dry-run plans the pinned add, a real run repairs it); anything else at the link path (a real folder, a link to another folder, a dangling link) is local content that the CLI would replace (L255-263), refused as local-modified unless --force, as in project scope; the post-add verification re-checks the link, so an add that leaves none is rolled back. A Codex-only entry and a Claude-Code-only copy need no separate link. Seven new tests (five fail on the unmodified installer, two are controls) and nine mutants, all killed; the one hand-built "already installed" fixture of the idempotent-skip test gets the link a finished add leaves. One sentence of adoption/skills/lifecycle.md and the three registrations follow. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…te the CLI lines by function The review wave of 114d539 (the Codex review in GitHub, three threads; the command center's Claude read, PASS_WITH_P3; Astra's first read, CHANGES_REQUESTED with one P1, the same protection gap): P1, protection (Codex thread 1; the read's P3-1 and F1): the Claude entry check ran only for a locked entry, so a first install or an unlocked canonical folder at the pin could still make the CLI replace a folder or a foreign link at ~/.claude/skills/<name> (createSymlink, L255-263, a folder recursively) without --force. The check now comes before the lock: such an entry is local-modified for every global install, as in project scope. The resolve is no longer strict, so a dangling link that points at the canonical folder (the CLI's own, from a half-removed install) is not foreign. P2, copy fallback (Codex thread 2, ruled not taken by the command center): when the CLI cannot create the symlink it copies the skill into the Claude folder (L428-440). scripts/skills_status.py fails that layout (not_a_symlink), so the installer keeps requiring the link; the rollback now names the cause (no link, or the copy fallback) instead of a generic mismatch. P1, cited lines (Codex thread 3, false for the pinned blob): src/installer.ts at 7407f389 is git blob 71ebe2aa, 43,226 bytes, equal to the tree entry. The citations now also name the function and quote the statement, the files that decide Codex (universal, agents.ts L227, L910-912) and Claude Code (L158), and the dispatch that makes installSkillForAgent the function for every source of the manifest outside the vercel-labs ones (add.ts L1370-1392, L1978). P3-2 of the read: the --force help text names the global Claude entry it now overwrites. P3-3: a refusal names every local content it protects (the Claude entry and an unlocked SKILL.md that differs from the pin). Tests: 5 of the first 7 fail on 114d539; this commit adds a protection matrix (six states: nothing installed, unlocked canonical folder at the pin or edited, a lock of an older tree, a lock at the pin with an edited or no canonical folder; three kinds of entry: a real folder, a foreign link, a dangling link; 18 subtests, 15 fail on 114d539; each asserts no add, the whole local entry intact, and --force replacing it), the own-link and unresolvable-link guards, the copy-fallback cause test, the refusal-reasons test and a help assertion. Twenty mutants of the new code, none survives. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
classify_skill and unlocked_folder_differs (the wording of a refusal) both asked whether the installed SKILL.md is the pinned one, and the second copy added two reads to the push gate's monitoring baseline (tests.test_runtime_worker_openhands_push_gate; lines 315 and 317 of the earlier head). One helper, installed_skill_md_matches, now holds the check, so the installer keeps the baseline's five unclassified reads and neither the dated baseline nor #970's dated delta, which pins the baseline's bytes, is touched. Behavior-neutral: the same file and the same sha256 comparison, for project and global scope. The new test patches the helper and requires classify_skill and the refusal wording to follow it in both directions (it errors on the earlier commit, where the helper does not exist). Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
claude_link_problem took equal resolved paths of the Claude entry and the canonical path as proof that the entry was the CLI's own link. When the canonical path is itself a link onto a real Claude folder, the add removes that link first (skills@7407f389 src/installer.ts L389) and createSymlink then removes the Claude folder with the files in it (L263); the pinned CLI v1.7.0 deleted them in a scratch home. A canonical path that is a link is now local content: local-modified without --force whatever the lock says, and the refusal names it and the Claude entry. A link on a parent folder (L240-250), Codex-only entries and Claude-only copies are unchanged. Other files beside a pinned SKILL.md in the canonical folder stay unprotected and are named as such (the next change of this lane). The wording findings of the delta read fold in: the qualifier "an entry that does not resolve to the canonical folder" in the docstring and lifecycle.md, and the L428-440 anchor. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
The registry rows of tools/adoption/install_skills.py, tests/test_install_skills.py and adoption/skills/lifecycle.md, recomputed against main after the rebase. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
0d2bce2 to
0d5272d
Compare
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0d5272df71
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
… (F3 of #1024) Where an add is about to run over an existing folder whose SKILL.md is the pinned one (an unlocked folder, or one locked at the pin whose Claude link is missing), the folder must hold SKILL.md alone or be the pinned tree: the git tree SHA that scripts/skills_status.py already computes, with the regenerable runtime-artifact directories left out, equals the manifest's tree_sha. Anything else is local-modified, refused unless --force, for the global canonical folder, a Claude-Code-only copy and a project folder. Measured with the real pinned CLI: a file added to an unlocked gh-fix-ci folder was deleted at main 83cec12 and survives now. The canonical-link rule stays literal; an upgrade over an older lock and an ok install are not covered. Also the --force help text (P3-2), the Codex-only scope of the link rule in the lifecycle record (P3-3) and a missing space in a test (P3-5). Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
… parent aliases install_skills.py refuses a link at the canonical skill path and accepts a link on a parent folder (createSymlink L240-250); scripts/skills_status.py followed the first and failed the second. It now reports canonical_link for a link at the skill path (the reverse alias of #1024 included) and accepts a real folder that a parent link makes the canonical folder itself (kind parent_link), for a global entry only: the Claude-Code-only copy check is unchanged. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
… (F3 of #1024) Where an add is about to run over an existing folder whose SKILL.md is the pinned one (an unlocked folder, or one locked at the pin whose Claude link is missing), the folder must hold SKILL.md alone or be the pinned tree: the git tree SHA that scripts/skills_status.py already computes, with the regenerable runtime-artifact directories left out, equals the manifest's tree_sha. Anything else is local-modified, refused unless --force, for the global canonical folder, a Claude-Code-only copy and a project folder. Measured with the real pinned CLI: a file added to an unlocked gh-fix-ci folder was deleted at main 83cec12 and survives now. The canonical-link rule stays literal; an upgrade over an older lock and an ok install are not covered. Also the --force help text (P3-2), the Codex-only scope of the link rule in the lifecycle record (P3-3) and a missing space in a test (P3-5). Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
… parent aliases install_skills.py refuses a link at the canonical skill path and accepts a link on a parent folder (createSymlink L240-250); scripts/skills_status.py followed the first and failed the second. It now reports canonical_link for a link at the skill path (the reverse alias of #1024 included) and accepts a real folder that a parent link makes the canonical folder itself (kind parent_link), for a global entry only: the Claude-Code-only copy check is unchanged. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…(F3), and align the status check (#1035) * Do not let the skills add delete content the pinned CLI did not write (F3 of #1024) Where an add is about to run over an existing folder whose SKILL.md is the pinned one (an unlocked folder, or one locked at the pin whose Claude link is missing), the folder must hold SKILL.md alone or be the pinned tree: the git tree SHA that scripts/skills_status.py already computes, with the regenerable runtime-artifact directories left out, equals the manifest's tree_sha. Anything else is local-modified, refused unless --force, for the global canonical folder, a Claude-Code-only copy and a project folder. Measured with the real pinned CLI: a file added to an unlocked gh-fix-ci folder was deleted at main 83cec12 and survives now. The canonical-link rule stays literal; an upgrade over an older lock and an ok install are not covered. Also the --force help text (P3-2), the Codex-only scope of the link rule in the lifecycle record (P3-3) and a missing space in a test (P3-5). Co-Authored-By: Claude Sonnet 5.5 <[email protected]> * Make the status check agree with the installer on canonical links and parent aliases install_skills.py refuses a link at the canonical skill path and accepts a link on a parent folder (createSymlink L240-250); scripts/skills_status.py followed the first and failed the second. It now reports canonical_link for a link at the skill path (the reverse alias of #1024 included) and accepts a real folder that a parent link makes the canonical folder itself (kind parent_link), for a global entry only: the Claude-Code-only copy check is unchanged. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> * Register the F3 files in the evidence manifest Last commit of the change: the rows of every file it adds or changes, at their final bytes. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> * Refuse a regular file named like a runtime directory, and a repairing add over local work in a locked project; keep the push gate's read inventory unchanged The first reads of #1035 (Astra at 295ada5 and the four open Codex review threads) found two ways the guard still lost local work and one regression. One commit: - A regular file or a link named `.venv`, `node_modules`, `__pycache__` and the like was taken for a regenerable directory (the name was excluded before the entry's type was looked at), and the add deleted it. git_tree_sha now skips such a name only for a real directory and does not follow a link. The SKILL.md-alone shortcut, which had the same flaw and also followed a SKILL.md link, is removed: a lone SKILL.md is hashed like any other folder. - A project locked at the pin whose Claude target is missing returned `install` before the content check, so the repairing add deleted local files. The check now runs before that repair in a project as it does in the global scope. - The shortcut's os.listdir was a sixth unclassified read site of the push gate's monitoring baseline (342 -> 343; tests.test_runtime_worker_openhands_push_gate failed 1 of 125 and repeated). With the shortcut gone the installer has its five reads again: 125 of 125 pass at this head. - The status check rejected a canonical-leaf link for a Codex-only entry that the installer accepts; both now apply the rule to entries installed for Claude Code only. The moved link to budget_report in the 2026-10-10 record points at its current line. tests/test_install_skills.py::PinnedCliForeignContentTests repeats the two measurements offline with the pinned skills 1.7.0 CLI where it is installed (a local source folder, a scratch home and project, telemetry off): the classifier must say local-modified before any add, and the same add on a copy must delete the entry (the control). Where the CLI is not installed it is skipped; the fake-CLI tests cover the same states. The record and the lifecycle are updated; the evidence rows are re-registered at their final bytes. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> * Compare the whole folder before any add over a locked skill, fail closed on entries a git tree cannot hold, and judge runtime-named files the same way in the status check Fix wave 2 of #1035, on 9a77a96: the second GPT read (blockers 1 and 2, follow-up 3) and the open Codex thread (P2, "Fail closed on entries omitted from the tree hash"). - A lock at the requested pin plus an existing canonical folder is not an upgrade, and the add recreates the folder from nothing (vercel-labs/skills@7407f389 src/installer.ts L195, called at L389). classify_skill returned "install" when SKILL.md was not the pinned one, before the content check ran, so an edited SKILL.md plus a local file were deleted without --force. Now, when the lock names the pin (a project: source, ref, path and the pinned tree; a global entry: skillFolderHash), the folder is compared whatever made the add necessary (a missing Claude target, a missing link, an edited or missing SKILL.md); a folder that is gone is a clean repair; a lock that names another tree stays an upgrade over an older pin (the documented boundary). - git_tree_sha(strict=True) raises UnrepresentedEntry for an entry a git tree cannot hold (an empty directory, a FIFO, a socket, a device), which the add deletes all the same; the installer refuses it with a fixed text and never prints a tree for it. The status check stays a git-tree comparison. - check_folder_tree skipped runtime-artifact names by name at every level; it now skips only real directories (skip_dirs_only), as the installer does, so a regular file or a link named .venv is drift. - The two live line locators in the daily-record decision now point at budget_report (571) and claude_skills_dir (260). Fail-first: the final tests of tests.test_install_skills and tests.test_skills_status (194) give 23 failures and 6 errors against the implementation of 9a77a96 (9 tests: global and project edited-SKILL.md through the full installer path, the real pinned CLI in both, unrepresented entries, strict mode, the status check against the installer) and pass here; 20 of 20 installer mutants and 13 of 13 status mutants killed. The evidence rows of the changed files are updated. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> * Read a lock as naming an older pin only when it names another revision, and compare the folder in every other lock shape Fix wave 3 of #1035, on 5ace90c: Astra's delta read (~/.cache/ns2604-gpt-reads/fix2-pr1035-5ace90c12/VERDICT.md: one P1, one P2) and the command center's Claude full read (pr1035-claude-read-5ace90c1.md: PASS_WITH_P3, no P1; F3 here, F4 and F5 are claims of the PR body). - P1 (Astra): classify_skill returned "install" before the content check when a project lock entry lacked the optional `skillPath` (a lock written before the field existed), or its `computedHash` was not 64 hex digits, so the add recreated the folder from nothing (vercel-labs/skills@7407f389 src/installer.ts L195, called at L389) and deleted local work without --force. The pinned CLI makes these fields optional or does not need them whole: src/local-lock.ts L16-L37 (`ref?` and `skillPath?` are "optional for backward compatibility with lock files written before this field existed" and "omitted for non-repo sources"; `computedHash` is the CLI's own hash of the folder, not a revision), and readLocalLock and readSkillLock return an empty lock for a file that is too old or does not parse (src/local-lock.ts L83-L98; src/skill-lock.ts L89-L101). An entry that lacks them says nothing about which revision wrote the folder. The same early return took a project entry without `ref`, one that names another source and the entry the CLI writes for a local folder (`sourceType: local`, no `ref`, no `skillPath`; measured with the real CLI), and a global entry whose `skillFolderHash` is missing, empty or not text. The rule: only an entry that positively names another revision of the manifest's source (a project `ref` that is non-empty text and is not the pin's; a global `skillFolderHash` that is non-empty text and is not the pin's tree) is an upgrade over an older pin and runs as before. Every other entry is read like no lock: the folder must be the pinned tree, a folder that is gone is a clean repair, and `ok` needs the whole entry (source, type, `ref`, `skillPath`, a 64-digit `computedHash`; the global tree) with the pinned SKILL.md and the link. - F3 (command center): the module docstring (printed by --help) now gives the scope of the rule as it is: the edited SKILL.md, the lock shapes, the entries a git tree cannot hold. - P2 (Astra), the earlier review's locator premise: the previous read found the two live line locators of the daily-record decision "two lines short" (250 and 558 against 252 and 560). That was a false positive: lines are counted by LF (editors, grep -n, GitHub), and str.splitlines() also breaks at U+2028 and U+2029, one of each in skills_status.py, which shifted its count by two. The links of 5ace90c (260 and 571) are updates for this change's own line movement and stay. Fail-first: the tests of tests.test_install_skills and tests.test_skills_status (202) give FAILED (failures=34) in 5 tests on 5ace90c (the global lock shapes, the project lock shapes for both agents, and the real pinned CLI for a lock without skillPath, for the lock the CLI writes for a local folder and for a global lock without a tree) and pass here. Mutants: 15 of the new lock logic, 11 retained of the installer and 13 of the status check, all killed; nine earlier mutants of branches that this commit rewrites are replaced by the 15. Not taken, listed in the PR body: F1 (a project repair is refused, fail-closed, for a folder whose pinned tree holds a symlink or metadata.json, which the CLI's copy dereferences or leaves out), F2, F6. The evidence rows of the changed files are updated. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> * Read a lock from the upstream definition of what the pinned CLI writes: an entry names an older pin only as a git tree id or a commit id of the manifest's source Fix wave 4 of #1035, on 453ac68, by the command center's adjudication (adjudication-pr1035-453ac683-20261012.md: the lock-shape class recurred, so the fix starts from the upstream definition, enumerates every lock-entry form the pinned CLI writes or leaves, project and global, builds the test table from it and fails closed for anything outside it). Inputs: Astra's delta read (~/.cache/ns2604-gpt-reads/fix3-pr1035-453ac6834/VERDICT.md: one P1, one P2, seven P3) and the command center's Claude delta (pr1035-claude-micro-453ac683.md: B1 at P1, one P2, five P3). - P1, both files: classify_skill returned "install" before the content check for a global entry whose `skillFolderHash` is any non-empty text that is not the pin's tree (Astra: "not-a-tree", 39 hex digits, one space), for the CLI's own sha256 digest of the folder (the CC: what src/add.ts L2108-L2111 writes where the GitHub tree lookup returns nothing, and for every gitlab, git and local source), and for an entry of another source or type (src/add.ts L2114-L2122 records `source` and `sourceType` for every global install); the add then recreated the folder (src/installer.ts L195, called at L389) and deleted local work without --force. Reproduced with the real pinned CLI for a generic git source, global and project (the entry it writes holds the digest, a URL and no `ref`). - The reading is now one function, lock_reading, built from the table: an entry names another revision only through a field the CLI writes as one, in that form (a global `skillFolderHash` that is a git tree id, 40 hex digits matched case-insensitively as src/update.ts L638 does; a project `ref` that is a full commit id), and only for the manifest's GitHub source; a tree id equal to the pin's is a content address and reads "same" whatever source wrote it. Everything else is "unidentified" and the folder is compared like an unlocked one: a digest, a URL source, a branch, a tag or an abbreviated id as `ref` (src/local-lock.ts L19-L20: what the user typed), an empty, missing or malformed field. A lock file the CLI reads as empty (no numeric `version`, a version below 3 global or 1 project, no `skills` table; for a project any entry that is not an object: src/skill-lock.ts L89-L101, src/local-lock.ts L83-L98) holds no entry (lock_skills). `ok` needs the whole entry and the pinned SKILL.md and link. - The table: 53 entry forms (26 global, 27 project) and 20 lock-file forms, each with its upstream citation, in the "Lock entries" section of the decision record and in LOCK_ENTRY_TABLE and LOCK_FILE_TABLE of tests.test_install_skills, run for the global scope and for a project under both agents in three folder states (pristine, a local file beside the pinned ones, folder gone), through the classifier and through the full installer path with the fake CLI, plus the real pinned CLI for the generic git source. - The command center's P2 (body line 107, F3 listed as open), P3-1 (the test count), P3-2 (a ref that is a branch or a tag is compared: it can name the pin's own commit), P3-3 (the lock version rule), P3-4 (the fixtures write the numeric `version` and the source the CLI writes), P3-5 (the quotation belongs to `skillPath`; the pinned CLI writes no global lock for a local folder): fixed here or in the body. F1 (a project repair is refused for a folder whose pinned tree holds a symlink or metadata.json), F2, F6 and the P3s of both reads stay in the PR body's Follow-ups. Fail-first: tests.test_install_skills and tests.test_skills_status (208 tests) give FAILED (failures=41, errors=85) in 5 tests on 453ac68 (the lock-entry table, the lock-file table, lock_skills, the installer path and the real-CLI generic git source) and pass here. Mutants: 28 of the lock reading (the version rule and its minimums, the boolean and text versions, the null-entry rule, what counts as an id, its case and length, the pin comparison, the source and its type, the project and global field, every branch that sends an entry that identifies nothing to an install or to ok, the whole-entry test, the tree oracle, a gone folder), 11 retained of the installer and 13 of the status check: all killed (survivors: none); nine earlier installer mutants named branches of classify_skill that this commit rewrites, and the 28 replace them. The evidence rows of the changed files are updated. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> * Read a lock as the pinned reader reads it in every rejection it has: JSON constants and a local source that is not text Fix wave 5 of #1035 on 5f23fc3 (23e2dcb rebased onto main 2f59d74), released by the command center. Inputs: Astra's delta read of 23e2dcb (GPT-VERDICT-23e2dcbe9.md, 30,112 bytes, sha256 2ec782f8: three P1, one P2 carried under the scope lock, seven P3) and the command center's tests-only Claude check, which passed. - P1, global and project: load_lock took the tokens NaN, Infinity and -Infinity, which json.loads accepts and JSON.parse rejects (skills@7407f389 src/skill-lock.ts L85, src/local-lock.ts L80); both pinned readers return an empty lock when it throws (L99-L101, L97-L98), so the entry in such a lock excused nothing, but the installer read it as an older pin and let the add recreate the folder over local work. load_lock now passes parse_constant=reject_constant (CPython's hook for invalid JSON numbers, Doc/library/json.rst L324-L329 at python/[email protected]): a lock with a constant anywhere is unreadable and the folder is compared like an unlocked one. 1e999 is a valid number (both parse it as Infinity) and stays readable. - P1, project: lock_skills read every entry that is an object, but the pinned reader's loop throws, and its catch returns an empty lock, for a null entry and for a local entry whose source is missing or is not text (src/local-lock.ts L90-L98: isAbsolute(entry.source)); another entry of that kind excused the deletion for the managed entry at another commit. lock_skills now reads such a project lock as empty, for the managed entry or any other. The source description is corrected: the reader does not throw on an entry that is a string, a number, true or a list (measured by executing the pinned reader bodies on Node 24.21.0, reader-measure-20261012.txt); this installer still reads such a lock as empty, which is stricter than the reader and is now documented as such. - P1, privacy: the Git fixture of the native generic-source test gave author and committer an email-shaped literal; both are now the placeholder no-address, which is not an email (git takes it). - The fake CLI of the tests read a lock more leniently than the pinned readers, so a damaged lock survived its own add; its load_lock now mirrors them (constants, version, falsy skills, the project null and local-source rules), which lets the clean-install controls run on the complete installer path. Tests: LOCK_FILE_TABLE gained 19 forms (GL11-GL16, PL10-PL22; 39 in all) and LOCK_FILE_STRICTER_TABLE holds the four forms the reader keeps and this installer does not; every form runs through the classifier and through the whole installer path (refused with no add and the folder untouched where local work sits; installed where nothing is at risk), plus a unit test of load_lock on text, cases of lock_skills, and two controls with the real pinned CLI (the native add deletes the file the classifier protects). Fail-first: tests.test_install_skills and tests.test_skills_status (212 tests) give FAILED (failures=76, errors=1) in 6 tests on 5f23fc3 and pass here (136 + 76). 10 mutants of the new reader rules and 4 of the fake CLI's reader killed. Astra's P2 and the P3s go to the PR body's Follow-ups. The evidence rows of the changed files are updated. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Scope
okstate oftools/adoption/install_skills.pynow needs the Claude Code link (<claude skills folder>/<name>, the relative symlink the Skills CLI makes onto the canonical folder) as well as the canonical folder and the lock entry. Until now--check-onlysaidokfor a skill whose link was missing: on 2026-10-11typesafe-aihad its canonical folder and lock entry but no entry in~/.claude/skills, the installer reported itok,scripts/skills_status.pyfailed it asclaude_linkmissing, and a fresh headless Claude Code session did not list the skill (the link was restored by hand under the command center's release; this is the tool fix it asked for).install:--check-onlyprintsmissing-or-driftedand exits 1,--dry-runplans the pinnedskills add, a real run repairs it). An entry in its place that does not resolve to the canonical folder (a real folder, a link to another folder, a dangling link elsewhere) is local content that the CLI would replace, so it is refused aslocal-modifiedunless--forcewhatever the lock says (a first install and an unlocked canonical folder included), as the project-scope check does. So is a canonical path that is itself a link: the add removes it first (cleanAndCreateDirectory(canonicalDir), L389), and a real Claude folder that the link led to is then not the CLI's own link and is removed with its files (L263), so equal resolved paths are no proof of ownership there (Astra's reverse alias, below). A parent folder that is a link (~/.claude/skillsonto~/.agents/skills, the layoutcreateSymlinkaccepts at L240-250), a Codex-only entry (Codex loads the canonical folder) and a Claude-Code-only copy are unchanged. The post-add verification re-checks the link, so an add that leaves none is rolled back with the cause named (no link, or the CLI's copy fallback). Not covered, and the protection claim stops there: other files beside a pinnedSKILL.mdin the canonical folder (an extra file in the canonical folder) are still removed whenever the add runs, without--force; that is F3, pre-existing, the next pull request of this lane (Follow-ups). Nothing else in the installer changes.scripts/skills_status.pyalready checks this link and is untouched.114d539f5,2bcd7e3b0): the Codex review in GitHub raised three threads and the command center ruled them: the Claude-entry protection before the lock is taken (P1, valid), the CLI's copy fallback is not accepted because the manifest contract andskills_status.pyrequire the link (P2, design), and the cited lines hold for the pinned blob (P1, false), with the function named more precisely. Astra's first read was CHANGES_REQUESTED with one P1, the same gap across upgrades, unlocked installs and fresh installs (fixed; the protection matrix is the coverage she asked for). The command center's Claude read was PASS_WITH_P3: its P3-1 and F1 are the first thread, P3-2 (the--forcehelp text) and P3-3 (a refusal names every reason) are fixed, and P3-4 (the body countedlifecycle.mdas one sentence and linked the HTML page while hashing the Markdown source) is corrected in the SOTA sources below.0d2bce2a9): Astra's delta read is CHANGES_REQUESTED with one P1, the reverse canonical alias:<home>/.claude/skills/<name>a real folder holding the pinnedSKILL.mdand a file of the person's, and<home>/.agents/skills/<name>a symlink onto it.claude_link_problemtook the equal resolved paths as proof that the Claude entry was the CLI's own link, so with no lock or an older lock the ordinary run reached the add and the file was gone, and with a lock at the pin an upgrade would do the same. The command center's Claude delta read is PASS_WITH_P3 and named the same layout (W2, with F3). The command center ruled on 2026-10-12 that it blocks, to be folded into one push with the refusal of an inverted layout, her reproduction as a fail-before test, the sharedSKILL.mdcheck (the third commit below) and a protection claim that names what is not covered. Her follow-ups 2 to 4 and research-queue item 5 are in Follow-ups; the delta read's P3 wording findings (the qualifier "an entry that does not resolve to the canonical folder", theL428-440anchor, the labelled command block, the droppedsuperseded_candidate_snapshot_sha256) are fixed in this push.0d5272df7, five commits on main8b1ef0c37): the first two commits carry the patches of114d539f5and2bcd7e3b0unchanged (evidence.json aside; compared line by line, 0 differing lines each). The third shares oneSKILL.mdcheck betweenclassify_skilland the refusal wording (installed_skill_md_matches) in place of the second copy that CI'svalidate-shard-2caught as two new unclassified reads. With it the installer keeps its five unclassified reads and the push gate's monitoring snapshot is not refreshed: Measure daily native skill use and test the security-audit trigger cue #970 pinned that baseline's bytes in a dated delta (unclassified-gate-reads-20261011-skill-use-delta.json), so a refresh would break the pin;0d2bce2a9's snapshot commit is dropped, with the removal ofsuperseded_candidate_snapshot_sha256that came with it. The fourth refuses a canonical link and narrows the claim; the fifth registers the files. Main has since moved to421bf3f98(Add Anthropic key slots api-5 to api-10, the key practice and a no-token health check #969); the head merges with it without conflict and the gates below pass on that merge.8b1ef0c37e2f523aa84d2c75960540f45e74e814(main after Resolve the upstream-surface backlog to Claude Code 2.1.296 and Codex rust-v0.162.1 #950; the branch was rebased onto it from791eab026438656ece68579bfbe4b04f0451c319, main at the landing of Move the claude-native-practice skill row to the commit that holds #1001, #1005 and #986 #1018, 2026-10-11T23:36:29Z).lane:foundationtools/adoption/install_skills.py,tests/test_install_skills.py,adoption/skills/lifecycle.md(one paragraph of "Install and inspect"), and the three registrations inmanifests/evidence.json. The push gate's monitoring snapshot is not touched.SOTA sources
7407f3893ad4dceab546ac002c3ef806e4000c73(the manifest's pinned CLI; the tag peels to this commit and the mirror~/code/upstream/vercel-labs/skillsis at it).src/installer.tsis git blob71ebe2aaecc5(43,226 bytes), equal to the commit's tree entry; the lines below were read from the mirror's git objects.createSymlinkL227-293: returns when the two paths are already one folder, byrealpath(L240) or through a parent folder that is a link (L250); replaces an existing entry (lstatL255,await rm(linkPath)L261 for a link,await rm(linkPath, { recursive: true })L263 for a folder); writessymlink(relative(realLinkDir, target), linkPath)(L284-288).installSkillForAgentL295-441:cleanAndCreateDirectory(canonicalDir)L389 andcopyDirectoryL390;if (isGlobal && isUniversalAgent(agentType))returns without a link L395-402 (cleanAndCreateDirectoryL193-200 isrm(path, { recursive: true, force: true })thenmkdir: a link at the path is removed, not followed);createSymlink(canonicalDir, agentDir)L426;// Symlink failed, fall back to copyL428-440 (a copy at the agent folder,symlinkFailed: true).src/agents.ts:isUniversalAgentisskillsDir === '.agents/skills'(L910-912); Codex has.agents/skills(L227), Claude Code.claude/skills(L158).src/add.tsL1370-1392 sends only repositories of vercel, vercel-labs, heygen-com, remotion-dev andBLOB_ALLOWED_REPOSto the blob install; every other source of the adoption manifest goes toinstallSkillForAgent(L1978), and the blob variant has the same shape (installBlobSkillForAgent, installer.ts L1043-1088).skills1.7.0, a local-folder source,DISABLE_TELEMETRY=1, scratch homes, 2026-10-12T04:15Z): five layouts,skills add <folder> --skill <name> -g -y -a claude-code codex, each exit 0. (1) Astra's reverse alias: the file in the Claude folder is gone; the canonical path is a real folder and the Claude entry the CLI's relative link. (2) The CLI's own layout: unchanged. (3) The canonical path a link onto a folder elsewhere, no Claude entry: only the link is replaced; the folder it led to and its file survive. (4) A real canonical folder holding the pinnedSKILL.mdand an extra file, no Claude entry (F3): the extra file is gone. (5)~/.claude/skillsa link onto~/.agents/skillswith an extra file in the folder (F3 in the accepted layout): the extra file is gone. The receipt is a private host file; its result lines are the evidence-table row below.~/.claude/skills/<skill-name>/SKILL.md(line 160) and the entry "can be a symlink to a directory elsewhere on disk" that Claude Code reads (line 169); fetched 2026-10-11T23:58:28Z, 107,130 bytes, sha25635fd2c11f870fbe1497a53796de31b2a6b760645e724bda58894525356d4602a(the command center's read fetched it again at 2026-10-12T00:27:05Z with the same bytes).scripts/skills_status.pycheck_claude_link(a missing entry, a link that does not resolve to the canonical folder, or a copy fails), and the project-scope branch ofclassify_skill(a Claude target that is not the canonical link islocal-modified, citing the same L254-264 of the CLI's installer).Evidence-class table
okfor a global skill without its Claude link, and the fixed one does nottypesafe-aicanonical folder and lock entry, the real adoption manifest row and the pinned CLI (used for--versiononly), no link: unmodified--check-onlyexit 0ok; fixed exit 1missing-or-drifted; fixed--dry-runexit 0 plansskills add https://github.com/typesafe-ai/skills/tree/65a39f393687675ce170e6094757de20370365b9/skills/typesafe-ai --skill typesafe-ai -g -y -a claude-code codex; fixed with the link in place exit 0ok0 != 1 : {"ok": true, "skills": {"linked-skill": "ok"}}); the 2 controls pass before and after. Review-wave commit against114d539f5: 12 tests run, 18 failures (15 of the 18 subtests of the protection matrix, 2 of the refusal-reasons test, the copy-fallback cause test). This push: the 4 new tests of the reverse alias, run with this head's test module against the installer of0d2bce2a9and against this branch's third commit (6676c4346, the tree before the fix), fail identically: 4 tests, 7 failures (subtests), 2 tests pass. For no lock and for an older lock the ordinary run replaced the Claude folder:('link', '../../.agents/skills/linked-skill', ['SKILL.md']) != ('folder', {'SKILL.md': ..., 'local-work.txt': ...}); for a lock at the pin, for a canonical link onto a folder elsewhere and for a dangling one the run says0 != 1withokorinstalledwherelocal-modifiedis required. The two guards (a link on a parent folder staysok; a Codex-only entry is not refused) pass before and afterCLAUDE_CONFIG_DIR, post-add verification ignoring the link, a copy at the link path accepted, the rollback not naming its cause, a refusal naming only one reason, the--forcehelp text, three bypasses of the protection), 5 of the sharedSKILL.mdcheck, and 9 of the canonical-link rule (the check gone; looking at the Claude leaf instead of the canonical leaf; refusing any real folder at the Claude leaf, which breaks the parent-link layout; the check after the missing-link test;classify_skillleaving it to the add; the refusal not naming the link or not naming the Claude entry; the agent guard gone; a dangling canonical link not counted)--check-only --jsonread-only against this host's real~/.agentsand~/.claudewith the pinned CLI (--versiononly): 28 manifest skills, allokunder main's installer and under this head's, 0 states differ; 0 of the host's canonical entries are linkstests.test_runtime_worker_openhands_push_gate.RepositoryWorkflowTests.test_advisory_baseline_and_explicit_categories_stay_bounded_on_this_repositoryalone: OK (exit 0). The derivation of the committed tree (GitTree(ROOT, "HEAD")andderive_ci_protected) gives 342 unclassified reads against the baseline's 332 plus #970's dated delta (+10, all inscripts/skills_status.py); no script of this PR differs, the installer stays at its fivescripts/validate.pyandscripts/evidence_manifest.py --check(below)Local commands run
All of it on the committed head (
0d5272df7, clean tree) on main8b1ef0c37, except the first two lines, whose targets their comments name. The 13 modules other thantest_install_skills(the push gate's module is the last):test_skills_manifest,test_skills_status,test_new_wsl_client_config,test_bootstrap_full_profile,test_install_claude_profile,test_runtime_worker_skills,test_runtime_worker_openhands,test_runtime_worker_openhands_resolver,test_skill_usage,test_evidence_manifest,test_landscape_sweep_skills,test_final_stackandtest_runtime_worker_openhands_push_gate; withtest_install_skillsthey are the 14 modules of the merge run below. Changed test contract:test_matching_folder_and_lock_is_skipped_without_invoking_the_binarybuilt an "already installed" fixture without the link, which is the state this change calls drift; the fixture now holds the link a finished add leaves. The added lines hold no non-ASCII character. On the real merge with main421bf3f98(#969, which landed after the push; no conflict) the same gates give:scripts/validate.pyandscripts/evidence_manifest.py --check11,741 files,scripts/final_stack.py --checkexit 0, and the 14 modules Ran 1392 tests (91 + 1301), OK (skipped=10). The review-wave commit moves the refusal checks of the protection test into a helper that the first-install test shares, and the canonical-link tests of this push share one more; no assertion was dropped.Decision record
None new: the contract is in the installer's module docstring (condition (a), with its known limit) and one paragraph of
adoption/skills/lifecycle.md("Install and inspect"), both citing the pinned CLI source above. Disposition of the CLI's copy fallback (command center, 2026-10-12): not accepted; the manifest contract andskills_status.pyrequire the link and symlinks work on every platform we run (a junction on win32, L285). What would overturn this: a host without symlinks (thenskills_status.pyand the manifest change together, in their own pull request), a Skills CLI release that stops making the Claude link or changes where it goes (re-readsrc/installer.tsat the new pin), or a Claude Code release that lists personal skills without the entry.Host evidence
Not applicable: this PR adds or changes no file under
evidence/hosts/. The restoration of thetypesafe-ailink on this host and its before/after status are in a private host receipt of the same day, not part of this change.python3 scripts/host_receipts.py validatepasses for every new/changed receipt. (not applicable)scripts/host_receipts.py review) or explicitly requested in this PR. (not applicable)platform_statuschange is made from a host receipt alone; a platform status flip still needs the matrix rule indocs/component-evidence-matrix.md. (not applicable)Checklist
permissions: {}and grant each job only what it needs. (none changed)Follow-ups
scripts/skills_status.pyreportsnot_a_symlinkwhen~/.claude/skillsitself is a link onto~/.agents/skills(the layoutcreateSymlinktreats as already in place, L250), while the installer compares resolved paths and accepts it. This change does not touch the status check, and no such layout exists on this host.final_stack.py --check(exit 0).instructions-skills, P3; the fake-CLI fallback tests pass, an actual OS-level symlink failure was not run): whether to keep the explicit symlink requirement or to support the CLI's copy fallback with its own verification contract. The command center ruled the requirement stays for now; changing it is the research-queue item, not part of this change.pr1024-claude-micro-0d2bce2a.md): condition (b) ofclassify_skillcompares onlySKILL.md, so other content the installer did not write is removed by the add without--force: an extra file in a real canonical folder whoseSKILL.mdis the pinned one is removed whenever the add runs (no lock entry, an older lock, a missing Claude link), in the CLI's own layout and in the parent-link layout (the removal measured with the real CLI, layouts 4 and 5 above). The reverse alias, the worst form of it, is refused in this push. The fix for the rest is a design choice (compare the canonical folder's tree instead ofSKILL.md, or protect any file the CLI did not write), so under rule 8 the question goes to the research queue (layerinstructions-skills, trigger: this pull request landed); the change is the next pull request of this lane, with the property that no content the installer did not write is removed without--force, and a fail-before test.scripts/skills_status.pyon this one: the status check follows the link and saysok. A person with such a layout passes--forceonce or makes the link a real folder; the command center ruled (2026-10-12) to keep the literal rule, which is fail-safe, and to take the narrower rule (refuse only a link that leads to the Claude entry) and the status mismatch into the F3 pull request. On this host no canonical entry is a link and--check-onlyisokfor all 28 manifest skills under both installers.0d5272df7(PASS on content; its one blocker was the module count above, fixed by this edit with no push): P3-2, the--forcehelp text (install_skills.py:772-776) lists what--forceoverwrites but not the canonical-link refusal it now also overrides; P3-3,adoption/skills/lifecycle.md:128should say "for an entry installed for Claude Code" (the rule does not apply to a Codex-only entry, as the Codex-only guard test shows; the docstring's (a) is already scoped); P3-5, a missing space attests/test_install_skills.py:1095(forced =self.run_install(). All three go into the F3 pull request, not into this head, which stays under read.114d539fare all in the tests commit 2 adds). The landing uses a corrected squash body instead of the commit-message concatenation (sha256bb00abc2f76a48abcc9e5ea63daae1fc8d62c0e5f3a33850a79d39ad09c2a112).0d5272df7(ACK, 0 blockers, 4 P3s;GPT-VERDICT-1024-0d5272df7.md, sha256 prefixb65d0ca3327f92c2), recorded here with no head move: (1) the status check and the installer disagree in two layouts, both executed by her:~/.claude/skillslinked to the canonical parent (installerok,check_claude_link()not_a_symlink) and a canonical leaf linked to an external folder with the Claude link onto it (installerlocal-modified, statusok); the follow-up is a separate status-consistency change that makes the status helper tell a canonical-leaf link from an accepted parent-directory alias, with assertions for both state pairs, and keeps the canonical-leaf refusal the command center settled (it goes into the F3 pull request with the narrower-rule decision above); (2) the hostname hygiene above, unchanged (21 occurrences on 19 lines of the four touched files; the patch adds none); (3) research item 5, the copy fallback and the repair rollback (above); (4) F3, research-queue row 42 with trigger#1024 LANDED(above).typesafe-ailink is not established (Claude Code's own/skill-doctortable showed 15 uses of the skill, last used a day before 2026-10-11, so the link existed until recently); this change makes the installer report such drift instead ofok.okand run nothing. A repair-specific policy (keep the pinned content and only report the missing link) is a separate decision; it cannot be reproduced on a host that makes symlinks.🤖 Generated with Claude Code