Files
pad/internal/cli/skillstamp.go
T
xarmian 3aff8e388f fix: pad keeps an edited /pad skill and never downgrades a newer one (BUG-3466) (#1857)
* test: pad init keeps an edited skill and does not downgrade a newer one (BUG-3466)

* fix: pad keeps an edited /pad skill and never downgrades a newer one; skill files carry a stamp, and --force replaces (BUG-3466)

* test: workspace init updates an older unedited skill, agent update reports a keep, --list names --force for a newer skill, and semver numbers have no size limit (BUG-3466, codex r1)

* fix: workspace init routes installed skills through the decision, agent update and --list report kept files with --force, and semver numbers compare at any size (BUG-3466, codex r1)

* test: --list flags an untracked edited skill, and agent update counts an untracked current one (BUG-3466, codex r2)

* fix: --list asks the decision for local skills, and every skill present after a write is recorded and counted (BUG-3466, codex r2)

* test: invalid SemVer never refuses as a downgrade; the frozen list pins a released text (BUG-3466, codex r3)

* fix: the frozen legacy list holds released output only, and SemVer is validated strictly before comparing (BUG-3466, codex r3)

* test: every unstamped release's skill output must be in the frozen list; the release workflow (which has the tags) fails loudly otherwise (BUG-3466, lead)

* test: whitespace after the stamp does not hide it; pinned real copies and per-tag formatter checks guard the frozen list independently (BUG-3466, codex r5)

* fix: whitespace after the stamp no longer hides it (BUG-3466, codex r5)

* test: a newer pad's identical text reads as current, not as a downgrade (BUG-3466, codex r6 declined with reason)

* chore: regenerate the legacy skill hashes with v0.18.0-rc.8 (BUG-3466)
2026-10-07 12:26:28 -04:00

342 lines
10 KiB
Go

package cli
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"os"
"regexp"
"strings"
)
// A skill file pad writes ends with one marker line (BUG-3466):
//
// <!-- pad:skill v=<pad version> sha256=<hash of everything above> -->
//
// The file then says by itself whether it was edited (its body no longer
// hashes to the stamp) and which pad wrote it, which is what lets pad keep an
// edited skill and refuse to downgrade a newer one. It has to live in the
// file: the installation registry is per machine, and the case that matters
// most is a teammate's copy committed to the repo. An HTML comment is
// invisible in every tool's Markdown, and at the end it is clear of Claude
// Code's frontmatter.
const skillStampPrefix = "<!-- pad:skill "
var skillStampLine = regexp.MustCompile(`^<!-- pad:skill v=(\S+) sha256=([0-9a-f]{64}) -->$`)
// normalizeSkill is what every hash is taken over: CRLF made LF and trailing
// newlines dropped, so a Windows checkout (core.autocrlf) or an editor that
// adds a final newline does not read as an edit forever.
func normalizeSkill(b []byte) []byte {
b = bytes.ReplaceAll(b, []byte("\r\n"), []byte("\n"))
return bytes.TrimRight(b, "\n")
}
func skillHash(b []byte) string {
sum := sha256.Sum256(normalizeSkill(b))
return hex.EncodeToString(sum[:])
}
// StampSkill returns content with the marker line appended for version.
func StampSkill(content []byte, version string) []byte {
body := normalizeSkill(content)
var out bytes.Buffer
out.Write(body)
out.WriteString("\n" + skillStampPrefix + "v=" + stampVersion(version) + " sha256=" + skillHash(body) + " -->\n")
return out.Bytes()
}
// stampVersion keeps the marker one token: a version with whitespace (never a
// real one) is written as "unknown".
func stampVersion(v string) string {
if v == "" || strings.ContainsAny(v, " \t\r\n") {
return "unknown"
}
return v
}
// parseSkillStamp splits a file into its body and its marker. ok is false when
// the last line with any non-whitespace on it is not a well-formed marker.
func parseSkillStamp(file []byte) (body []byte, ver, hash string, ok bool) {
// Whitespace after the stamp (an editor's trailing blank line, spaces on
// the stamp line) must not hide it (codex r5). Trimmed only to FIND the
// stamp; the body before it is hashed as it stands.
n := bytes.TrimRight(normalizeSkill(file), " \t\r\n")
i := bytes.LastIndexByte(n, '\n')
last := n[i+1:]
m := skillStampLine.FindSubmatch(last)
if m == nil {
return nil, "", "", false
}
if i < 0 {
return nil, string(m[1]), string(m[2]), true
}
return n[:i], string(m[1]), string(m[2]), true
}
// SkillAction is what a door does with an existing (or missing) skill file.
type SkillAction int
const (
// SkillInstall: nothing there; write it.
SkillInstall SkillAction = iota
// SkillUpdate: pad's own unedited text from this or an older pad (or an
// unstamped copy a past release wrote); replace it quietly.
SkillUpdate
// SkillUnchanged: already this text.
SkillUnchanged
// SkillKeepEdited: the file is not what any pad wrote; keep it.
SkillKeepEdited
// SkillKeepNewer: a newer pad wrote it; keep it rather than downgrade.
SkillKeepNewer
)
// SkillDecision is DecideSkillWrite's answer.
type SkillDecision struct {
Action SkillAction
// StampVersion is the version in the existing file's marker, when it has one.
StampVersion string
}
// DecideSkillWrite decides what to do with a tool's skill file. expected is
// FormatForTool's unstamped output for this pad; running is this pad's
// version. Every door that writes a skill file asks this first; only an
// explicit --force skips it.
func DecideSkillWrite(tool AgentTool, existing []byte, exists bool, expected []byte, running string) SkillDecision {
if !exists {
return SkillDecision{Action: SkillInstall}
}
want := normalizeSkill(expected)
body, ver, hash, stamped := parseSkillStamp(existing)
if stamped {
if skillHash(body) != hash {
return SkillDecision{Action: SkillKeepEdited, StampVersion: ver}
}
// Before the newer-version check, deliberately: a newer pad that wrote
// this same text leaves nothing to downgrade, so the file is current
// and is neither warned about nor re-stamped to an older version.
if bytes.Equal(body, want) {
return SkillDecision{Action: SkillUnchanged, StampVersion: ver}
}
if skillVersionNewer(ver, running) {
return SkillDecision{Action: SkillKeepNewer, StampVersion: ver}
}
return SkillDecision{Action: SkillUpdate, StampVersion: ver}
}
// Unstamped: written before stamps existed, or by hand.
if bytes.Equal(normalizeSkill(existing), want) || legacySkillHashKnown(tool, skillHash(existing)) {
return SkillDecision{Action: SkillUpdate}
}
return SkillDecision{Action: SkillKeepEdited}
}
// skillVersionNewer reports a stamp version strictly newer than running. Only
// when BOTH parse as semver: a dev build (or any unparseable version) never
// refuses as a downgrade, though the edit check above still applies.
func skillVersionNewer(stamp, running string) bool {
s, okS := parseSemver(stamp)
r, okR := parseSemver(running)
if !okS || !okR {
return false
}
return compareSemver(s, r) > 0
}
// semverParts is vMAJOR.MINOR.PATCH[-PRERELEASE], the shape pad's release
// versions take (v0.18.0, v0.18.0-rc.7). Build metadata is ignored. Kept
// here rather than importing golang.org/x/mod/semver so the module's vendor
// set (and the Nix vendorHash) does not move for one comparison.
type semverParts struct {
core [3]string // digit strings: SemVer numbers have no size limit
pre []string
}
func isDigits(s string) bool {
if s == "" {
return false
}
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return true
}
// compareDigits compares two digit strings as numbers of any size.
func compareDigits(a, b string) int {
a, b = strings.TrimLeft(a, "0"), strings.TrimLeft(b, "0")
switch {
case len(a) != len(b):
if len(a) < len(b) {
return -1
}
return 1
case a < b:
return -1
case a > b:
return 1
}
return 0
}
func parseSemver(v string) (semverParts, bool) {
v = strings.TrimPrefix(v, "v")
var p semverParts
if i := strings.IndexByte(v, '+'); i >= 0 {
// Build metadata is ignored for ordering, but must be well formed.
for _, id := range strings.Split(v[i+1:], ".") {
if !isIdentifier(id) {
return p, false
}
}
v = v[:i]
}
core := v
if i := strings.IndexByte(v, '-'); i >= 0 {
core, p.pre = v[:i], strings.Split(v[i+1:], ".")
for _, id := range p.pre {
if !isIdentifier(id) || (isDigits(id) && len(id) > 1 && id[0] == '0') {
return p, false
}
}
}
nums := strings.Split(core, ".")
if len(nums) != 3 {
return p, false
}
for i, n := range nums {
if !isDigits(n) || (len(n) > 1 && n[0] == '0') {
return p, false
}
p.core[i] = n
}
return p, true
}
// isIdentifier is a SemVer identifier: non-empty, [0-9A-Za-z-] only.
func isIdentifier(s string) bool {
if s == "" {
return false
}
for _, c := range s {
if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == '-') {
return false
}
}
return true
}
// compareSemver orders by core, then a release above any of its
// prereleases, then prerelease identifiers left to right: numeric ones
// numerically and below alphanumeric ones, which compare as text.
func compareSemver(a, b semverParts) int {
for i := 0; i < 3; i++ {
if c := compareDigits(a.core[i], b.core[i]); c != 0 {
return c
}
}
switch {
case len(a.pre) == 0 && len(b.pre) == 0:
return 0
case len(a.pre) == 0:
return 1
case len(b.pre) == 0:
return -1
}
for i := 0; i < len(a.pre) && i < len(b.pre); i++ {
x, y := a.pre[i], b.pre[i]
nx, ny := isDigits(x), isDigits(y)
switch {
case nx && ny:
if c := compareDigits(x, y); c != 0 {
return c
}
case nx:
return -1
case ny:
return 1
case x != y:
if x < y {
return -1
}
return 1
}
}
switch {
case len(a.pre) < len(b.pre):
return -1
case len(a.pre) > len(b.pre):
return 1
}
return 0
}
// legacySkillHashKnown reports whether hash is what some past pad release
// wrote for this tool, before files carried a stamp. The list is frozen
// (skill_legacy_hashes.go, generated from the release tags): every file
// written from the stamping release on carries a marker instead.
func legacySkillHashKnown(tool AgentTool, hash string) bool {
set, ok := legacySkillHashes[legacySkillFormat(tool)]
return ok && set[hash]
}
// legacySkillFormat names the output shape a tool got: claude, agents and
// copilot each had their own; every other tool got the stripped body.
func legacySkillFormat(tool AgentTool) string {
switch tool.Name {
case "claude", "agents", "copilot":
return tool.Name
}
return "body"
}
// SkillWriteResult is what WriteSkill did.
type SkillWriteResult struct {
SkillDecision
Path string
// Wrote is true when the file was written (installed, updated, or forced).
Wrote bool
}
// WriteSkill is the one door that writes a tool's skill file (BUG-3466). It
// decides with DecideSkillWrite and writes the stamped text only to install
// or update; an edited file or a newer pad's file is kept unless force.
func WriteSkill(tool AgentTool, embedded []byte, version string, force bool) (SkillWriteResult, error) {
path := ToolSkillPath(tool)
expected := FormatForTool(tool, embedded)
existing, err := os.ReadFile(path)
exists := err == nil
if err != nil && !os.IsNotExist(err) {
return SkillWriteResult{Path: path}, err
}
res := SkillWriteResult{SkillDecision: DecideSkillWrite(tool, existing, exists, expected, version), Path: path}
switch res.Action {
case SkillInstall, SkillUpdate:
case SkillUnchanged:
return res, nil
case SkillKeepEdited, SkillKeepNewer:
if !force {
return res, nil
}
}
written, err := InstallForTool(tool, StampSkill(expected, version))
if err != nil {
return res, err
}
res.Path, res.Wrote = written, true
return res, nil
}
// SkillState is DecideSkillWrite for the file currently at the tool's skill
// path, for status displays: exists is false when there is no file.
func SkillState(tool AgentTool, embedded []byte, version string) (action SkillAction, stampVersion string, exists bool) {
data, err := os.ReadFile(ToolSkillPath(tool))
if err != nil {
return SkillInstall, "", false
}
d := DecideSkillWrite(tool, data, true, FormatForTool(tool, embedded), version)
return d.Action, d.StampVersion, true
}