From 4c8f7c65512dc5d56bd183c0359e74f57553383e Mon Sep 17 00:00:00 2001 From: jon-devlapaz Date: Wed, 12 Aug 2026 15:00:18 -0500 Subject: [PATCH 1/6] fix: detect stale embedded manage-tink --- ACCEPTANCE.md | 1 + src/check.rs | 5 ++++- src/manage_tink.rs | 11 +++++++++++ tests/acceptance.rs | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/ACCEPTANCE.md b/ACCEPTANCE.md index 1a6924a..92c6497 100644 --- a/ACCEPTANCE.md +++ b/ACCEPTANCE.md @@ -182,6 +182,7 @@ Ids are stable. Tests must name or comment the id they prove. | C5 | `skill check` after an installed skill's frontmatter name is corrupted | Exit ≠ 0; reports the skill-name mismatch | | C6 | `skill check` after an installed `SKILL.md` loses its YAML frontmatter | Exit ≠ 0; reports that YAML frontmatter is required | | C7 | `skill check` after an installed `SKILL.md` has unclosed frontmatter | Exit ≠ 0; reports that the frontmatter is not closed | +| C8 | `skill check` or `skill lock` after embedded `manage-tink` differs from the active binary | Exit ≠ 0; reports the drift and the exact `tink skill refresh manage-tink` repair command; lockfiles are not written | ### Project manifest diff --git a/src/check.rs b/src/check.rs index 47dec6a..c505186 100644 --- a/src/check.rs +++ b/src/check.rs @@ -28,7 +28,10 @@ fn read_skill_entry(path: &Path) -> Result, Error> { } let skill = skills::read_skill(path, true)?; skills::validate_skill_tree(path)?; - provenance::read(&skill)?; + let provenance = provenance::read(&skill)?; + if skill.name == "manage-tink" && provenance.is_none() { + crate::manage_tink::require_current(&skill)?; + } Ok(Some(skill)) } diff --git a/src/manage_tink.rs b/src/manage_tink.rs index 480a402..7faa9b2 100644 --- a/src/manage_tink.rs +++ b/src/manage_tink.rs @@ -33,6 +33,17 @@ pub(crate) fn prepare_manage_tink() -> Result<(tempfile::TempDir, Skill), Error> Ok((staging, skill)) } +/// Require an installed embedded copy to match the payload in this binary. +pub(crate) fn require_current(installed: &Skill) -> Result<(), Error> { + let (_staging, embedded) = prepare_manage_tink()?; + if skills::skill_contents_equal(&installed.path, &embedded.path)? { + return Ok(()); + } + Err(Error::msg( + "manage-tink differs from this Tink binary; run `tink skill refresh manage-tink`", + )) +} + /// Stage the embedded skill and install it into the project via `add`. /// /// Uses the quiet add path so init can own the closing narrative. diff --git a/tests/acceptance.rs b/tests/acceptance.rs index 0bcf421..591ef8f 100644 --- a/tests/acceptance.rs +++ b/tests/acceptance.rs @@ -2103,6 +2103,38 @@ fn c7_check_rejects_unclosed_skill_frontmatter() { )); } +#[test] +fn c8_check_rejects_stale_embedded_manage_tink() { + let ws = Workspace::new(); + let project = ws.project("app"); + ws.cmd(&project).arg("init").assert().success(); + + fs::write( + Workspace::skill_path(&project, "manage-tink").join("references/commands.md"), + "stale embedded contents\n", + ) + .expect("replace embedded commands reference"); + + ws.cmd(&project) + .args(["skill", "check"]) + .assert() + .failure() + .stderr( + predicate::str::contains("manage-tink differs from this Tink binary") + .and(predicate::str::contains("tink skill refresh manage-tink")), + ); + + ws.cmd(&project) + .args(["skill", "lock"]) + .assert() + .failure() + .stderr(predicate::str::contains( + "manage-tink differs from this Tink binary", + )); + assert!(!project.join(".tink/skills.toml").exists()); + assert!(!project.join(".tink/skills.lock").exists()); +} + // --- M*: project manifest --- #[test] From 76fd1a5548b397ffc42d999b43f0d272ca221bb0 Mon Sep 17 00:00:00 2001 From: jon-devlapaz Date: Wed, 12 Aug 2026 15:04:09 -0500 Subject: [PATCH 2/6] feat: route embedded manage-tink refresh --- ACCEPTANCE.md | 2 ++ src/lib.rs | 9 +++++++ src/manage_tink.rs | 57 ++++++++++++++++++++++++++++++++++++++++++--- tests/acceptance.rs | 42 +++++++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 3 deletions(-) diff --git a/ACCEPTANCE.md b/ACCEPTANCE.md index 92c6497..2462402 100644 --- a/ACCEPTANCE.md +++ b/ACCEPTANCE.md @@ -260,6 +260,8 @@ Ids are stable. Tests must name or comment the id they prove. | P6 | `skill refresh` when upstream revision moves but skill tree bytes match | Exit 0; bumps project + library receipts | | P7 | `skill refresh` when project already at HEAD but library is stale | Exit 0; repairs library from project | | P8 | `skill refresh` for all when a later imported skill has local modifications | Exit ≠ 0; no project skill is updated | +| P9 | `skill refresh manage-tink` when the embedded copy is missing | Installs the active binary's copy; reconciles library and catalog; subsequent `skill check` passes | +| P10 | `skill refresh manage-tink` when the embedded copy already matches the active binary | Exit 0 with `Unchanged`; reconciles library and catalog; project tree remains identical | ### Remove diff --git a/src/lib.rs b/src/lib.rs index a4a1461..e38ee0d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -722,6 +722,15 @@ fn dispatch_skill_harvest(cwd: &Path) -> Result<(), Error> { fn dispatch_skill_refresh(cwd: &Path, name: Option<&str>) -> Result<(), Error> { let style = CliStyle::auto_stdout(); match name { + Some("manage-tink") => { + let outcome = manage_tink::refresh_manage_tink(cwd)?; + let action = match outcome { + manage_tink::RefreshOutcome::Installed => style.success("Installed"), + manage_tink::RefreshOutcome::Unchanged => style.muted("Unchanged"), + }; + println!("{} {}", action, style.skill("manage-tink")); + Ok(()) + } Some(name) => { let changed = refresh::refresh_skill(cwd, name)?; if changed { diff --git a/src/manage_tink.rs b/src/manage_tink.rs index 7faa9b2..1a4f2e8 100644 --- a/src/manage_tink.rs +++ b/src/manage_tink.rs @@ -3,14 +3,24 @@ use std::path::Path; use crate::add; +use crate::catalog; +use crate::check; use crate::error::Error; -use crate::paths::map_io; +use crate::library; +use crate::paths::{map_io, refuse_symlink}; +use crate::provenance; use crate::skills::{self, Skill}; const SKILL_MD: &str = include_str!("../skills/manage-tink/SKILL.md"); const OPENAI_YAML: &str = include_str!("../skills/manage-tink/agents/openai.yaml"); const COMMANDS_MD: &str = include_str!("../skills/manage-tink/references/commands.md"); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RefreshOutcome { + Installed, + Unchanged, +} + /// Materialize the embedded tree for read-only validation or later publication. /// The returned guard owns the bytes referenced by `Skill`. pub(crate) fn prepare_manage_tink() -> Result<(tempfile::TempDir, Skill), Error> { @@ -33,10 +43,14 @@ pub(crate) fn prepare_manage_tink() -> Result<(tempfile::TempDir, Skill), Error> Ok((staging, skill)) } +pub(crate) fn is_current(installed: &Skill) -> Result { + let (_staging, embedded) = prepare_manage_tink()?; + skills::skill_contents_equal(&installed.path, &embedded.path) +} + /// Require an installed embedded copy to match the payload in this binary. pub(crate) fn require_current(installed: &Skill) -> Result<(), Error> { - let (_staging, embedded) = prepare_manage_tink()?; - if skills::skill_contents_equal(&installed.path, &embedded.path)? { + if is_current(installed)? { return Ok(()); } Err(Error::msg( @@ -44,6 +58,43 @@ pub(crate) fn require_current(installed: &Skill) -> Result<(), Error> { )) } +pub(crate) fn refresh_manage_tink(project_root: &Path) -> Result { + check::check_zen_coupling(project_root)?; + let agents = crate::home::project_agents_path(project_root); + let skills_root = crate::home::project_skills_path(project_root); + let target = skills_root.join("manage-tink"); + refuse_symlink(&agents)?; + refuse_symlink(&skills_root)?; + refuse_symlink(&target)?; + + if !target.exists() { + install_manage_tink(project_root)?; + return Ok(RefreshOutcome::Installed); + } + if !target.is_dir() { + return Err(Error::msg("Installed manage-tink is not a directory")); + } + + let installed = skills::read_skill(&target, true)?; + skills::validate_skill_tree(&target)?; + if provenance::read(&installed)?.is_some() { + return Err(Error::msg( + "Refusing to replace manage-tink with remote provenance", + )); + } + if !is_current(&installed)? { + return Err(Error::msg( + "manage-tink differs from this Tink binary; replacement is not available", + )); + } + + library::preflight_deposit(&installed, None)?; + catalog::preflight_deposit_skill(project_root)?; + library::sync_from_installed(&installed)?; + catalog::deposit_skill(project_root, "manage-tink")?; + Ok(RefreshOutcome::Unchanged) +} + /// Stage the embedded skill and install it into the project via `add`. /// /// Uses the quiet add path so init can own the closing narrative. diff --git a/tests/acceptance.rs b/tests/acceptance.rs index 591ef8f..4edd131 100644 --- a/tests/acceptance.rs +++ b/tests/acceptance.rs @@ -3749,6 +3749,48 @@ fn p8_refresh_all_preflights_before_updating_any_skill() { ); } +#[test] +fn p9_refresh_manage_tink_installs_missing_embedded_copy() { + let ws = Workspace::new(); + let project = ws.project("app"); + ws.cmd(&project) + .args(["init", "--no-zen", "--no-tink-skills", "--no-manage-tink"]) + .assert() + .success(); + + ws.cmd(&project) + .args(["skill", "refresh", "manage-tink"]) + .assert() + .success() + .stdout(predicate::str::contains("Installed manage-tink")); + + ws.cmd(&project).args(["skill", "check"]).assert().success(); + ws.assert_cataloged("app", "manage-tink"); +} + +#[test] +fn p10_refresh_manage_tink_reports_current_copy_unchanged() { + let ws = Workspace::new(); + let project = ws.project("app"); + ws.cmd(&project).arg("init").assert().success(); + + let installed = Workspace::skill_path(&project, "manage-tink"); + let before = fs::read(installed.join("SKILL.md")).expect("installed SKILL.md"); + + ws.cmd(&project) + .args(["skill", "refresh", "manage-tink"]) + .assert() + .success() + .stdout(predicate::str::contains("Unchanged manage-tink")); + + assert_eq!( + fs::read(installed.join("SKILL.md")).expect("refreshed SKILL.md"), + before + ); + ws.cmd(&project).args(["skill", "check"]).assert().success(); + ws.assert_cataloged("app", "manage-tink"); +} + // --- D*: destroy --- #[test] From 1156d02ac41611961f87c31f03261f3e7840fa49 Mon Sep 17 00:00:00 2001 From: jon-devlapaz Date: Wed, 12 Aug 2026 15:06:12 -0500 Subject: [PATCH 3/6] feat: refresh embedded manage-tink safely --- ACCEPTANCE.md | 2 ++ src/lib.rs | 1 + src/manage_tink.rs | 16 +++++++++--- src/refresh.rs | 2 +- src/skills.rs | 6 +++-- tests/acceptance.rs | 63 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 83 insertions(+), 7 deletions(-) diff --git a/ACCEPTANCE.md b/ACCEPTANCE.md index 2462402..e1922de 100644 --- a/ACCEPTANCE.md +++ b/ACCEPTANCE.md @@ -262,6 +262,8 @@ Ids are stable. Tests must name or comment the id they prove. | P8 | `skill refresh` for all when a later imported skill has local modifications | Exit ≠ 0; no project skill is updated | | P9 | `skill refresh manage-tink` when the embedded copy is missing | Installs the active binary's copy; reconciles library and catalog; subsequent `skill check` passes | | P10 | `skill refresh manage-tink` when the embedded copy already matches the active binary | Exit 0 with `Unchanged`; reconciles library and catalog; project tree remains identical | +| P11 | `skill refresh manage-tink` when a receipt-free reserved copy differs from the active binary | Atomically replaces it with the active binary's copy; reconciles library and catalog; subsequent `skill check` passes | +| P12 | `skill refresh manage-tink` when the same-named skill has remote provenance | Exit ≠ 0; reports the provenance collision; user-owned tree remains byte-identical | ### Remove diff --git a/src/lib.rs b/src/lib.rs index e38ee0d..b8cdd61 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -727,6 +727,7 @@ fn dispatch_skill_refresh(cwd: &Path, name: Option<&str>) -> Result<(), Error> { let action = match outcome { manage_tink::RefreshOutcome::Installed => style.success("Installed"), manage_tink::RefreshOutcome::Unchanged => style.muted("Unchanged"), + manage_tink::RefreshOutcome::Refreshed => style.success("Refreshed"), }; println!("{} {}", action, style.skill("manage-tink")); Ok(()) diff --git a/src/manage_tink.rs b/src/manage_tink.rs index 1a4f2e8..152ad66 100644 --- a/src/manage_tink.rs +++ b/src/manage_tink.rs @@ -19,6 +19,7 @@ const COMMANDS_MD: &str = include_str!("../skills/manage-tink/references/command pub(crate) enum RefreshOutcome { Installed, Unchanged, + Refreshed, } /// Materialize the embedded tree for read-only validation or later publication. @@ -82,10 +83,17 @@ pub(crate) fn refresh_manage_tink(project_root: &Path) -> Result Result, Error> { .path .parent() .ok_or_else(|| Error::msg("skill has no parent"))?; - let _installed = skills::replace_verified(&new_skill, destination_root, &next)?; + let _installed = skills::replace_verified(&new_skill, destination_root, Some(&next))?; library::deposit_refresh(&new_skill, &next)?; Ok(Some(tree_changed)) } diff --git a/src/skills.rs b/src/skills.rs index 9a61931..05ca375 100644 --- a/src/skills.rs +++ b/src/skills.rs @@ -721,7 +721,7 @@ pub(crate) fn publish_staged_tree( pub fn replace_verified( skill: &Skill, destination_root: &Path, - provenance: &Provenance, + provenance: Option<&Provenance>, ) -> Result { require_safe_tree(&skill.path)?; let target = destination_root.join(&skill.name); @@ -744,7 +744,9 @@ pub fn replace_verified( output::display_path(&skill.path) ))); } - provenance::write_file(&staged.join(provenance::SIDECAR_FILE), provenance)?; + if let Some(provenance) = provenance { + provenance::write_file(&staged.join(provenance::SIDECAR_FILE), provenance)?; + } publish_staged_tree(staging, staged, &target) } diff --git a/tests/acceptance.rs b/tests/acceptance.rs index 4edd131..bb013fe 100644 --- a/tests/acceptance.rs +++ b/tests/acceptance.rs @@ -3791,6 +3791,69 @@ fn p10_refresh_manage_tink_reports_current_copy_unchanged() { ws.assert_cataloged("app", "manage-tink"); } +#[test] +fn p11_refresh_manage_tink_replaces_differing_reserved_copy() { + let ws = Workspace::new(); + let project = ws.project("app"); + ws.cmd(&project).arg("init").assert().success(); + + let installed = Workspace::skill_path(&project, "manage-tink"); + let commands = installed.join("references/commands.md"); + let embedded = fs::read(&commands).expect("embedded commands reference"); + fs::write(&commands, "local edit to reserved embedded contents\n") + .expect("modify embedded commands reference"); + + ws.cmd(&project) + .args(["skill", "refresh", "manage-tink"]) + .assert() + .success() + .stdout(predicate::str::contains("Refreshed manage-tink")); + + assert_eq!(fs::read(&commands).expect("refreshed commands"), embedded); + assert_eq!( + fs::read( + ws.library_skill("manage-tink") + .join("references/commands.md") + ) + .expect("library commands"), + embedded + ); + ws.cmd(&project).args(["skill", "check"]).assert().success(); + ws.assert_cataloged("app", "manage-tink"); +} + +#[test] +fn p12_refresh_manage_tink_refuses_remote_provenance_collision() { + let ws = Workspace::new(); + let project = ws.project("app"); + ws.cmd(&project) + .args(["init", "--no-zen", "--no-tink-skills", "--no-manage-tink"]) + .assert() + .success(); + + let remote = ws.root.join("remote-manage-tink"); + init_repo(&remote); + write_skill(&remote, "manage-tink", "remote user-owned contents"); + commit_all(&remote, "remote manage-tink"); + let public = "https://github.com/example/remote-manage-tink.git"; + let mut add = ws.cmd(&project); + add.args(["skill", "add", "example/remote-manage-tink"]); + add.envs(github_redirect(&remote, public)); + add.assert().success(); + + let installed = Workspace::skill_path(&project, "manage-tink"); + let before = fs::read(installed.join("SKILL.md")).expect("remote SKILL.md"); + ws.cmd(&project) + .args(["skill", "refresh", "manage-tink"]) + .assert() + .failure() + .stderr(predicate::str::contains("remote provenance")); + assert_eq!( + fs::read(installed.join("SKILL.md")).expect("preserved remote SKILL.md"), + before + ); +} + // --- D*: destroy --- #[test] From 11cabecb2e5b68cc5d4426b50198c3210853b13f Mon Sep 17 00:00:00 2001 From: jon-devlapaz Date: Wed, 12 Aug 2026 15:09:00 -0500 Subject: [PATCH 4/6] docs: align embedded refresh lifecycle --- ACCEPTANCE.md | 4 +-- README.md | 9 +++++-- skills/manage-tink/SKILL.md | 31 +++++++++++++---------- skills/manage-tink/references/commands.md | 2 +- src/lib.rs | 2 +- src/update.rs | 4 +++ tests/acceptance.rs | 15 ++++++++--- 7 files changed, 43 insertions(+), 24 deletions(-) diff --git a/ACCEPTANCE.md b/ACCEPTANCE.md index e1922de..5256842 100644 --- a/ACCEPTANCE.md +++ b/ACCEPTANCE.md @@ -273,7 +273,7 @@ Ids are stable. Tests must name or comment the id they prove. | X2 | `skill remove ` | Exit ≠ 0; mentions not found / missing; nothing deleted | | X3 | `skill remove` when `.agents` is a symlink | Exit ≠ 0; mentions symlink; tree unchanged | | X4 | Successful `skill remove ` | Does **not** delete `$TINK_HOME/skills//` | -| X5 | `init` installs `manage-tink` | Embedded skill covers standalone lifecycle, operation-specific proof and partial-state reporting, project-contained lock sources, session-versus-persistent completion authority, any-update re-embed warning, library/catalog effects, skillsets, update, and destroy | +| X5 | `init` installs `manage-tink` | Embedded skill covers standalone lifecycle, operation-specific proof and partial-state reporting, project-contained lock sources, session-versus-persistent completion authority, any-update refresh warning, library/catalog effects, skillsets, update, and destroy | | X6 | `skill remove ` when that project's catalog metadata is malformed | Exit ≠ 0; project and library skill trees remain intact | | X7 | `skill remove ` when `$TINK_HOME/catalog` is a symlink | Exit ≠ 0; mentions the symlink; project skill and external catalog target remain byte-identical | @@ -293,7 +293,7 @@ Ids are stable. Tests must name or comment the id they prove. |---|---|---| | U1 | `update` when releases API is unreachable | Exit ≠ 0; clear download/metadata failure; binary unchanged | | U2 | `update` when latest release version matches this binary | Exit 0; stdout notes up to date; binary unchanged | -| U3 | `update` when a newer release asset exists for this host | Exit 0; replaces the running binary; stdout notes updated version | +| U3 | `update` when a newer release asset exists for this host | Exit 0; replaces the running binary; stdout notes updated version and the explicit per-project `tink skill refresh manage-tink` next step; does not mutate a project | | U4 | `update` receives a valid-digest archive whose payload fails the exact version probe | Exit ≠ 0; running binary remains byte-identical; no success output | | U5 | `update` metadata names an older semantic version | Exit ≠ 0; refuses downgrade before publication; running binary remains unchanged | | U6 | `install.sh` receives an invalid or non-executable verified payload while a binary exists | Exit ≠ 0; existing binary remains byte-identical; no success output | diff --git a/README.md b/README.md index 07d60c1..9ab0bc5 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ tink skill list tink skill check tink skill refresh tink skill refresh skill-name +tink skill refresh manage-tink tink skill remove skill-name ``` @@ -151,7 +152,11 @@ tink skill remove skill-name by the error or by `tink inspect`. - GitHub tree URLs are inspection inputs, not `skill add` sources. Remote adds follow the repository's default branch and record the selected skill path. -- Refresh only clean GitHub imports; local edits are refused. +- Refresh only clean GitHub imports; local edits are refused. The explicit + `tink skill refresh manage-tink` path instead owns the reserved embedded + package: it installs a missing copy, leaves a current copy unchanged, or + atomically replaces differing receipt-free contents. Remote provenance is + refused. - tink does not overwrite a project skill that differs from what it would install. - tink never inits Git, stages, commits, or pushes. @@ -227,7 +232,7 @@ header, including for an empty catalog. Within fields, backslash, tab, carriage return, and newline are escaped as `\\\\`, `\\t`, `\\r`, and `\\n` so every skill remains one three-column row. -**Breaking in 0.3.0:** `skill list --home` → `--catalog`; `skill list --stash` → `--library`. On-disk layout is still `$TINK_HOME/skills/` and `catalog/by-project/`. After a major CLI upgrade, refresh the live project skill: `tink skill remove manage-tink && tink init --no-zen --no-tink-skills`. +**Breaking in 0.3.0:** `skill list --home` → `--catalog`; `skill list --stash` → `--library`. On-disk layout is still `$TINK_HOME/skills/` and `catalog/by-project/`. After updating the binary, refresh each project's embedded skill with `tink skill refresh manage-tink`. Project lockfiles now use digest format/version 2 so file boundaries and Unix executable modes are actually pinned. An older lock is deliberately refused; diff --git a/skills/manage-tink/SKILL.md b/skills/manage-tink/SKILL.md index d5c700b..1cb0727 100644 --- a/skills/manage-tink/SKILL.md +++ b/skills/manage-tink/SKILL.md @@ -1,6 +1,6 @@ --- name: manage-tink -description: "Tink CLI for repository-owned Agent Skills. Use when the user asks to initialize Tink; inspect GitHub skill sources; add, list, check, lock, verify, sync, refresh, or remove project skills; manage grouped skillsets; use the home library or catalog; harvest harness skills; configure shell completion; update Tink; re-embed manage-tink; or destroy project agent scaffolding." +description: "Tink CLI for repository-owned Agent Skills. Use when the user asks to initialize Tink; inspect GitHub skill sources; add, list, check, lock, verify, sync, refresh, or remove project skills; manage grouped skillsets; use the home library or catalog; harvest harness skills; configure shell completion; update Tink; refresh embedded manage-tink; or destroy project agent scaffolding." --- # Manage Tink @@ -76,7 +76,7 @@ harness roots, and only the matching `tink skillset add`, `refresh`, or known. **On failure:** Stop and report the failure. Do not repair Tink-managed state -by hand. Init, add, skillset refresh, and re-embedding can fail after an +by hand. Init, add, skillset refresh, and embedded-skill refresh can fail after an earlier project, library, catalog, or guidance write succeeded. Report each surface known or possibly changed; do not describe the failure as a no-op unless that was proved. @@ -118,20 +118,22 @@ mutation and requires authority for that exact file. **On failure:** Report the shell and command failure. Do not edit unrelated shell configuration. -### Step 6: Re-embed Manage Tink When Separately Authorized +### Step 6: Refresh Manage Tink When Separately Authorized After any binary update or observed contract mismatch, explain that the live skill may be stale. Do not replace it automatically. Before `destroy`, compare the active `tink destroy --help` boundary with this skill's ownership contract; if it is broader, stop and renew approval. If the user explicitly authorizes -re-embedding, run `tink skill remove manage-tink`, then -`tink init --no-zen --no-tink-skills`. +refreshing the embedded package, run `tink skill refresh manage-tink`. -**Expected:** Separate approval exists and the binary's embedded copy becomes -the live project skill. +**Expected:** Missing copies are installed, current copies report `Unchanged`, +and differing receipt-free copies are atomically replaced. The project, +library, and catalog are reconciled, and the binary's embedded copy becomes +the live project skill. A same-named skill with remote provenance is refused. -**On failure:** Stop after the failing command and report whether the old skill -was removed. Do not conceal a partially completed replacement. +**On failure:** Stop after the failing command and report which project, +library, and catalog states were proven. Do not conceal a partially completed +publication. ### Step 7: Prove the Post-state @@ -149,8 +151,8 @@ was removed. Do not conceal a partially completed replacement. - After `skillset remove`, verify project absence and library presence; its external definition should remain. - After update, resolve the active binary and probe its exact version. After - re-embedding, run project/catalog/library listings plus `tink skill check`; - structural check alone does not prove embedded payload identity. + refreshing embedded `manage-tink`, run project/catalog/library listings plus + `tink skill check`; check compares the live payload with the active binary. - After `destroy`, confirm `.agents/skills/` is gone, `.agents/` is gone only if it became empty, `ZEN.md` and `AGENTS.md` are preserved, unrelated `.agents/` siblings remain, and the project has no catalog rows; do not run `skill check`. @@ -202,12 +204,13 @@ from the mutation command alone. | Configure shell completion | Only the matching shell command | | Persist shell completion | Only the exact startup file the user authorizes | | Lock / verify / sync reproducible state | Only the matching `tink skill …` command; lock requires a project-contained source mapping for each local skill | -| Re-embed manage-tink | `tink skill remove manage-tink`, then `tink init --no-zen --no-tink-skills` | +| Refresh embedded manage-tink | `tink skill refresh manage-tink` | | Remove one project skill | `tink skill remove NAME` | -| Update the Tink binary | `tink update` only; re-embedding requires separate authority | +| Update the Tink binary | `tink update` only; refreshing embedded `manage-tink` requires separate authority | | Remove managed project skills / destroy Tink setup | `tink destroy` (TTY) or `tink destroy --yes` (scripts); guidance and unrelated `.agents/` siblings are preserved | -"Set up Tink" does **not** authorize ZEN, tink-skills, re-embedding, or destroy. +"Set up Tink" does **not** authorize ZEN, tink-skills, refreshing embedded +`manage-tink`, or destroy. ## Ownership (always) diff --git a/skills/manage-tink/references/commands.md b/skills/manage-tink/references/commands.md index 1c857cc..a6082b8 100644 --- a/skills/manage-tink/references/commands.md +++ b/skills/manage-tink/references/commands.md @@ -24,6 +24,7 @@ form for add, list, check, refresh, and remove. | Sync the exact pinned manifest set | `tink skill sync` (preflights expected project/library/catalog refusals, then publishes sequentially; rerun after an operational interruption) | | Refresh all clean imports | `tink skill refresh` | | Refresh one | `tink skill refresh NAME` | +| Refresh the active binary's embedded manage-tink | `tink skill refresh manage-tink` (explicitly replaces a differing receipt-free reserved copy; refuses remote provenance) | | Remove one project skill | `tink skill remove NAME` | | Add a pinned skillset | `tink skillset add NAME-skillset` | | List project skillsets | `tink skillset list` | @@ -31,7 +32,6 @@ form for add, list, check, refresh, and remove. | Refresh a clean pinned skillset | `tink skillset refresh NAME-skillset` | | Remove one project skillset | `tink skillset remove NAME-skillset` | | Update the tink CLI binary | `tink update` (newer host asset only; verifies release digest, archive shape, and exact candidate version before replacement) | -| Re-embed manage-tink after separate approval | `tink skill remove manage-tink`, then `tink init --no-zen --no-tink-skills` | | Destroy managed project skills | `tink destroy --yes` (non-TTY/scripts) or `tink destroy` (TTY, confirm `y`); preserves guidance and unrelated `.agents/` siblings | ## Layout facts diff --git a/src/lib.rs b/src/lib.rs index b8cdd61..cd41c59 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -153,7 +153,7 @@ pub enum SkillCommand { }, /// Install missing or pinned skills from the project manifest and lockfile Sync, - /// Refresh clean GitHub-imported skills; refuse local modifications + /// Refresh clean GitHub imports or the reserved embedded manage-tink Refresh { /// Optional skill name; default refreshes all imported skills name: Option, diff --git a/src/update.rs b/src/update.rs index 46bbefd..dc1a7aa 100644 --- a/src/update.rs +++ b/src/update.rs @@ -702,6 +702,10 @@ pub fn print_report(report: &UpdateReport) -> Result<(), Error> { "{}", style.muted(format!("Installed to {}", output::display_path(path))) ))?; + output::stdout_line(format_args!( + "{}", + style.muted("Next: run `tink skill refresh manage-tink` in each Tink project") + ))?; } } Ok(()) diff --git a/tests/acceptance.rs b/tests/acceptance.rs index bb013fe..71dc487 100644 --- a/tests/acceptance.rs +++ b/tests/acceptance.rs @@ -4151,6 +4151,7 @@ fn x5_manage_tink_documents_remove_and_catalog_sync() { "tink skill lock", "tink skill verify", "tink skill sync", + "tink skill refresh manage-tink", "tink skillset add NAME-skillset", "tink skillset list", "tink skillset list --library", @@ -4215,12 +4216,14 @@ fn x5_manage_tink_documents_remove_and_catalog_sync() { "manage-tink procedures must state expected and failure behavior: {skill_md}" ); assert!( - !skill_md.contains("tink skill remove manage-tink && tink init"), - "manage-tink must not chain re-embedding onto binary updates: {skill_md}" + !skill_md.contains("tink skill remove manage-tink") + && !commands.contains("tink skill remove manage-tink"), + "manage-tink must not teach the remove-then-init ceremony: {skill_md}\n{commands}" ); assert!( skill_md.contains("After any binary update or observed contract mismatch") - && skill_md.contains("tink destroy --help"), + && skill_md.contains("tink destroy --help") + && skill_md.contains("check compares the live payload with the active binary"), "manage-tink must treat every update as possible contract drift: {skill_md}" ); assert!( @@ -5386,7 +5389,11 @@ fn u3_update_replaces_binary_when_newer_release_exists() { .env("TINK_HOME", &ws.inventory) .assert() .success() - .stdout(predicate::str::contains("Updated").and(predicate::str::contains("v99.0.0"))); + .stdout( + predicate::str::contains("Updated") + .and(predicate::str::contains("v99.0.0")) + .and(predicate::str::contains("tink skill refresh manage-tink")), + ); assert!(installed.is_file()); assert_eq!( From e970a8d48bab2d0259458dcf0da9ff3a220a4974 Mon Sep 17 00:00:00 2001 From: jon-devlapaz Date: Wed, 12 Aug 2026 15:13:59 -0500 Subject: [PATCH 5/6] fix: preserve remote manage-tink library entries --- ACCEPTANCE.md | 1 + src/manage_tink.rs | 21 ++++++++++++++++++++- src/refresh.rs | 2 +- src/skills.rs | 20 ++++++++++++++++++-- tests/acceptance.rs | 44 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 4 deletions(-) diff --git a/ACCEPTANCE.md b/ACCEPTANCE.md index 5256842..595be5f 100644 --- a/ACCEPTANCE.md +++ b/ACCEPTANCE.md @@ -264,6 +264,7 @@ Ids are stable. Tests must name or comment the id they prove. | P10 | `skill refresh manage-tink` when the embedded copy already matches the active binary | Exit 0 with `Unchanged`; reconciles library and catalog; project tree remains identical | | P11 | `skill refresh manage-tink` when a receipt-free reserved copy differs from the active binary | Atomically replaces it with the active binary's copy; reconciles library and catalog; subsequent `skill check` passes | | P12 | `skill refresh manage-tink` when the same-named skill has remote provenance | Exit ≠ 0; reports the provenance collision; user-owned tree remains byte-identical | +| P13 | `skill refresh manage-tink` when the project copy is missing and the same-named library skill has remote provenance | Exit ≠ 0 before publication; project remains missing; library tree and receipt remain byte-identical | ### Remove diff --git a/src/manage_tink.rs b/src/manage_tink.rs index 152ad66..2ac7bfa 100644 --- a/src/manage_tink.rs +++ b/src/manage_tink.rs @@ -59,6 +59,24 @@ pub(crate) fn require_current(installed: &Skill) -> Result<(), Error> { )) } +fn refuse_remote_library_collision() -> Result<(), Error> { + let Some(home) = crate::home::existing_inventory_root(None)? else { + return Ok(()); + }; + let target = crate::home::skills_library_path(&home).join("manage-tink"); + refuse_symlink(&target)?; + if !target.is_dir() { + return Ok(()); + } + let library_skill = skills::read_skill(&target, true)?; + if provenance::read(&library_skill)?.is_some() { + return Err(Error::msg( + "Refusing to replace library manage-tink with remote provenance", + )); + } + Ok(()) +} + pub(crate) fn refresh_manage_tink(project_root: &Path) -> Result { check::check_zen_coupling(project_root)?; let agents = crate::home::project_agents_path(project_root); @@ -69,6 +87,7 @@ pub(crate) fn refresh_manage_tink(project_root: &Path) -> Result Result Result, Error> { .path .parent() .ok_or_else(|| Error::msg("skill has no parent"))?; - let _installed = skills::replace_verified(&new_skill, destination_root, Some(&next))?; + let _installed = skills::replace_verified(&new_skill, destination_root, &next)?; library::deposit_refresh(&new_skill, &next)?; Ok(Some(tree_changed)) } diff --git a/src/skills.rs b/src/skills.rs index 05ca375..da0bcae 100644 --- a/src/skills.rs +++ b/src/skills.rs @@ -717,8 +717,7 @@ pub(crate) fn publish_staged_tree( Ok(target.to_path_buf()) } -/// Replace an existing imported skill after dirty-tree preflight elsewhere. -pub fn replace_verified( +fn replace_verified_inner( skill: &Skill, destination_root: &Path, provenance: Option<&Provenance>, @@ -750,6 +749,23 @@ pub fn replace_verified( publish_staged_tree(staging, staged, &target) } +/// Replace an existing imported skill after dirty-tree preflight elsewhere. +pub fn replace_verified( + skill: &Skill, + destination_root: &Path, + provenance: &Provenance, +) -> Result { + replace_verified_inner(skill, destination_root, Some(provenance)) +} + +/// Replace the receipt-free embedded skill after its ownership preflight. +pub(crate) fn replace_embedded_verified( + skill: &Skill, + destination_root: &Path, +) -> Result { + replace_verified_inner(skill, destination_root, None) +} + #[cfg(test)] mod tests { use super::*; diff --git a/tests/acceptance.rs b/tests/acceptance.rs index 71dc487..e397fc6 100644 --- a/tests/acceptance.rs +++ b/tests/acceptance.rs @@ -3854,6 +3854,50 @@ fn p12_refresh_manage_tink_refuses_remote_provenance_collision() { ); } +#[test] +fn p13_refresh_manage_tink_refuses_remote_library_collision_when_project_is_missing() { + let ws = Workspace::new(); + let project = ws.project("app"); + ws.cmd(&project) + .args(["init", "--no-zen", "--no-tink-skills", "--no-manage-tink"]) + .assert() + .success(); + + let remote = ws.root.join("remote-library-manage-tink"); + init_repo(&remote); + write_skill(&remote, "manage-tink", "remote library contents"); + commit_all(&remote, "remote library manage-tink"); + let public = "https://github.com/example/remote-library-manage-tink.git"; + let mut add = ws.cmd(&project); + add.args(["skill", "add", "example/remote-library-manage-tink"]); + add.envs(github_redirect(&remote, public)); + add.assert().success(); + ws.cmd(&project) + .args(["skill", "remove", "manage-tink"]) + .assert() + .success(); + + let library = ws.library_skill("manage-tink"); + let skill_before = fs::read(library.join("SKILL.md")).expect("library SKILL.md"); + let receipt_before = fs::read(library.join(".tink-source.json")).expect("library provenance"); + + ws.cmd(&project) + .args(["skill", "refresh", "manage-tink"]) + .assert() + .failure() + .stderr(predicate::str::contains("remote provenance")); + + assert!(!Workspace::skill_path(&project, "manage-tink").exists()); + assert_eq!( + fs::read(library.join("SKILL.md")).expect("preserved library SKILL.md"), + skill_before + ); + assert_eq!( + fs::read(library.join(".tink-source.json")).expect("preserved library provenance"), + receipt_before + ); +} + // --- D*: destroy --- #[test] From 9678fe1a573550560b7288e34ae2c335c8340af6 Mon Sep 17 00:00:00 2001 From: jon-devlapaz Date: Wed, 12 Aug 2026 15:16:20 -0500 Subject: [PATCH 6/6] fix: preflight embedded library ownership --- ACCEPTANCE.md | 1 + src/manage_tink.rs | 2 +- tests/acceptance.rs | 59 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/ACCEPTANCE.md b/ACCEPTANCE.md index 595be5f..ff39cd5 100644 --- a/ACCEPTANCE.md +++ b/ACCEPTANCE.md @@ -265,6 +265,7 @@ Ids are stable. Tests must name or comment the id they prove. | P11 | `skill refresh manage-tink` when a receipt-free reserved copy differs from the active binary | Atomically replaces it with the active binary's copy; reconciles library and catalog; subsequent `skill check` passes | | P12 | `skill refresh manage-tink` when the same-named skill has remote provenance | Exit ≠ 0; reports the provenance collision; user-owned tree remains byte-identical | | P13 | `skill refresh manage-tink` when the project copy is missing and the same-named library skill has remote provenance | Exit ≠ 0 before publication; project remains missing; library tree and receipt remain byte-identical | +| P14 | `skill refresh manage-tink` when a current or stale receipt-free project copy exists and the same-named library skill has remote provenance | Exit ≠ 0 before publication; project trees plus library tree and receipt remain byte-identical | ### Remove diff --git a/src/manage_tink.rs b/src/manage_tink.rs index 2ac7bfa..c7e8454 100644 --- a/src/manage_tink.rs +++ b/src/manage_tink.rs @@ -85,9 +85,9 @@ pub(crate) fn refresh_manage_tink(project_root: &Path) -> Result