Skip to content

Fix #29, #30, #31: codev update stops reporting things it never checked - #48

Merged
pseudoseed merged 2 commits into
mainfrom
fix/update-truthfulness
Aug 23, 2026
Merged

Fix #29, #30, #31: codev update stops reporting things it never checked#48
pseudoseed merged 2 commits into
mainfrom
fix/update-truthfulness

Conversation

@pseudoseed

Copy link
Copy Markdown
Owner

Closes #29, closes #30, closes #31.

Three issues filed separately; one defect wearing three hats. Each states a conclusion nothing established.

#31--dry-run wrote to disk

It printed (dry run - no files will be changed) and then created CLAUDE.md.codev-new and AGENTS.md.codev-new. Only the copySkills call was guarded by dryRun; copyRootFiles was not.

A dry run that writes is worse than no dry run at all, because it is the one mode an operator trusts specifically on the promise that it touches nothing. copyRootFiles now takes dryRun and reports what it would do.

#30 — "Content differs from template", where nothing compared content

A conflict was declared whenever the destination merely existed:

if (fs.existsSync(destPath)) {
  if (handleConflicts) {
    fs.writeFileSync(destPath + ".codev-new", content);
    conflicts.push(file);
  }
}

...and reported as reason: "Content differs from template". Every update handed the operator a merge task that was usually a no-op.

It now compares — after template substitution, so a correctly-substituted {{PROJECT_NAME}} is not a false conflict — and returns unchanged for a byte-identical file. An unreadable destination counts as differing: that direction surfaces the file for a human rather than silently calling it clean.

#29 — needed more than a comparison

Once .claude/skills/<name>/ existed, its contents were frozen at install time forever. --force never helped; that branch only wrapped copyRootFiles.

The comment said "without replacing customizations". With nothing compared, the code could not tell a customization from a stale copy, so it preserved both — which in practice means it preserved rot. The issue's examples: a vendored afx skill claiming there is no --branch flag (there is), and an arch-save skill claiming delayed sends are not persisted (they are).

Comparing against the current skeleton does not fix this. It answers "same or different", not "customized or stale" — which is the actual question, and re-guessing it would repeat the original sin. So copySkills now keeps a per-provider .codev-skill-manifest.json recording the hash it installed:

State Action
dest == skeleton already current — backfill the hash so the next update can tell
dest == installed hash unmodified but stale — refresh
dest != installed hash local edits — leave alone, and say so
no manifest entry, or unreadable cannot tell — leave alone, and say so

The last row is the one that matters. "I cannot tell" must not be spelled the same way as "safe to overwrite", or the first update after this ships eats a year of someone's local edits. Projects vendored before manifests existed land there and are held back on purpose.

Customized skills are now reported, not silently skipped — a customization quietly blocking every update is exactly what an operator needs told:

  ~ (refreshed) .claude/skills/afx/
  ! (local edits, not refreshed) .claude/skills/consult/

init and adopt keep the old semantics. Only update opts in via refreshUnmodified.

Tests

15 new. The #30 and #31 cases fail against the old implementation directly; the #29 ones exercise the new API.

Worth calling out three:

  • substitution-aware comparison{{PROJECT_NAME}} expanded to the same value must read as unchanged, not as a conflict.
  • dry run predicts the real run — the reported copied / conflicts / unchanged from a dry run must equal the real one. That is the entire point of the mode.
  • unknown provenance is customized — a skill vendored before manifests must be held back, never overwritten.

update + scaffold + template-delivery + new: 83 passed. adopt: 8. doctor: 25.

(Note: src/__tests__/init.test.ts is in the vitest exclude list, so codev init has no suite running in CI. Not touched here, but worth knowing.)

🤖 Generated with Claude Code

pseudoseed and others added 2 commits August 22, 2026 14:13
Three bugs, one defect wearing three hats. Each states a conclusion nothing
established.

#31 --dry-run printed "no files will be changed" and then wrote
CLAUDE.md.codev-new and AGENTS.md.codev-new. Only the skills call was
guarded; copyRootFiles was not. A dry run that writes is worse than no dry
run, because it is the one mode an operator trusts specifically on the
promise that it touches nothing. copyRootFiles now takes dryRun and reports
what it WOULD do.

#30 A conflict was declared whenever the destination merely existed, with
the reason "Content differs from template" -- while the only test performed
was fs.existsSync. Every update handed over a merge task that was usually a
no-op. It now compares, after template substitution, and returns `unchanged`
for a byte-identical file. An unreadable destination counts as differing:
that direction surfaces the file for a human instead of calling it clean.

#29 needed more than a comparison. Once a skill directory existed its
contents were frozen at install time forever, and --force never helped (that
branch only wrapped copyRootFiles). The comment said "without replacing
customizations", but with nothing compared the code could not tell a
customization from a stale copy, so it preserved both -- which in practice
preserved rot. Real cost: a vendored afx skill claiming --branch does not
exist, and agents burning turns on it.

Comparing against the CURRENT skeleton does not fix that; it only says
"same or different", not "customized or stale". Answering the actual
question needs provenance, so copySkills now keeps a per-provider
.codev-skill-manifest.json of the hash it installed:

  dest == skeleton            -> already current, backfill the hash
  dest == installed hash      -> unmodified but stale, REFRESH
  dest != installed hash      -> local edits, leave alone and SAY SO
  no manifest entry / unread  -> cannot tell, leave alone and SAY SO

The last row is the one that matters. "I cannot tell" must not be spelled
the same way as "safe to overwrite", or the first update after this ships
eats a year of someone's local edits.

Customized skills are now reported rather than silently skipped: a
customization that is quietly blocking every update is exactly what an
operator needs told. init/adopt keep the old semantics -- only update opts in
via refreshUnmodified.

15 tests. The #30 and #31 ones fail against the old implementation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…jects

The review found a path that re-creates the exact freeze #29 exists to fix,
and verified it by running the code rather than reading it.

copyDirRecursive is additive -- it never removes destination files absent
from the source. So after a refresh where the skeleton DELETED a file:

  - the deleted file survives in the vendored skill, which is the stale-doc
    symptom #29 is about, surviving the fix meant to remove it
  - the recorded hash is the skeleton's, but the destination now hashes to
    src+leftover, so the NEXT update reads destHash != installedHash, calls
    the skill customized, and freezes it forever with zero local edits

Silent, unrecoverable without deleting the directory by hand, and it lands on
the first release that removes a skill file. The refresh branch now rmSync's
the destination first -- safe there and only there, because destHash ===
installedHash was just proven, so nothing local is being discarded.

Also from the review:

--dry-run --force still wrote. I guarded the conflict branch and left the
force branch calling copyRootFiles with no options at all, so the exact
combination of two real CLI flags still printed "no files will be changed"
and then created the file.

init and adopt never wrote a manifest, so a project installed at v1 that
never happened to run `update` at v1 arrived at v2 as unknown provenance and
was held back from every future refresh -- the fix would never have reached
the projects most in need of it. A separate recordManifest option (implied by
refreshUnmodified) leaves provenance without changing init/adopt semantics:
adopt still refuses to rewrite an existing project's skills.

hashSkillDir read files as utf-8, which collapses invalid bytes to U+FFFD, so
two different binary assets hashed identically and a modified one would have
read as unmodified and been overwritten. Now hashes raw Buffers with
length-framed fields, which also stops a+bc colliding with ab+c.

The held-back message now names the remedy, since --force does not reach
skills.

9 more tests, including the two-update sequence that proves a skeleton
deletion no longer poisons provenance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pseudoseed
pseudoseed merged commit 9744563 into main Aug 23, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant