Skip to content

fix(update/sync): sync reports 'up to date' on content drift; update overwrites in-place customizations (constitution) #708

Description

@admarble

Summary

Dogfooding a manual sequant update in a downstream project surfaced two correctness bugs in the update/sync flow. Both lead a user to a wrong outcome on the default path: either silently miss real drift, or silently overwrite a file they customized in place.

Scope note: the headless/TTY ergonomics of update (crashes without a TTY, no --yes) are out of scope here — these two are the correctness bugs. Filing them together because they share one theme: the manual update flow can't be trusted to do the right thing by default.


Bug 1 — sync reports "up to date" while real content drift exists

sequant sync decides up-to-date status purely from the version marker:

src/commands/sync.ts:106
  if (!force && skillsVersion === packageVersion) {
    console.log(chalk.green("✔ Skills are already up to date!"));

Version equality ≠ content equality. On a tree where update (which diffs content) found 5 modified + 1 new file, sync printed ✔ Skills are already up to date! and exited a no-op — same 2.6.1 marker on both sides.

This matters because sync is the non-interactive / automation-recommended path (it even prints "Plugin users get auto-updates without running sync manually"). The path we point CI and scripts at is the one that declares success while leaving drift in place. The only escape is sync --force, which is undiscoverable from the "up to date" message.

Acceptance criteria

  • When the version marker matches but bundled template content differs from installed content, sync does not claim "up to date" — it reports the drift (e.g. version current, but N files differ — run \update` or `sync --force``) and/or applies it.
  • Truthful no-op: sync only prints "already up to date" when content is actually identical.

Bug 2 — In-place customizations (e.g. constitution.md) are classified modified and overwritten on the default Y

Override protection only fires when a separate .claude/.local/<file> exists:

src/commands/update.ts:151-159
  // Check if there's a local override
  const localOverridePath = localPath.replace(".claude/", ".claude/.local/");
  const hasLocalOverride = await fileExists(localOverridePath);
  if (hasLocalOverride) {
    changes.push({ path: localPath, status: "local-override" });
  } else {
    ... status: "modified"
  }

The constitution is the one file explicitly meant to be edited in place per project. A user who customizes it (custom principles/patterns) — without creating a parallel .claude/.local/ file they have no reason to know about — gets it listed under Modified, with Local overrides: 0, and the default Apply updates? (Y/n) overwrites their content. That's silent data loss of intentional customization.

Compounding it, the diff compares against the raw template:

src/commands/update.ts:148
  if (localContent === templateContent) { ... unchanged }

But {{PROJECT_NAME}} is only substituted at write time (update.ts:242). So constitution.md shows as modified for every initialized project even with zero user edits (installed # matcha-maps Constitution vs template # {{PROJECT_NAME}} Constitution). This trains users to hit Y on a "modified" constitution as routine — exactly when it might contain real edits.

Repro

  1. sequant init in a project; edit .claude/memory/constitution.md in place (add a custom principle).
  2. sequant update.
  3. Observe constitution under Modified, Local overrides: 0; default Y reverts custom content to template (+ name substitution).

Acceptance criteria

  • In-place divergence of a customizable/templated file (constitution at minimum) is detected as a protected local override — skipped by default, only overwritten with --force.
  • Template-vs-installed comparison renders {{PROJECT_NAME}} (and other tokens) before diffing, so an unmodified constitution reads as unchanged, not modified.
  • No silent loss of in-place customizations on the default (non---force) path.

Evidence (observed this session)

  • sequant sync✔ Skills are already up to date! while sequant update --dry-run on the same tree reported New files: 1 / Modified: 5 / Local overrides: 0.
  • update Modified list included .claude/memory/constitution.md, shown as # matcha-maps Constitution# {{PROJECT_NAME}} Constitution. Had to restore from git after applying.

Suggested approach

  • sync: fall back to (or always use) content comparison; treat version marker as a fast-path hint, not the source of truth.
  • update: (a) render template tokens before the localContent === templateContent check; (b) treat a customizable file diverging in place as local-override (skip-by-default), not modified.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingcliCLI commands and interfaceplannedImplementation plan approvedready-for-reviewReady for code review

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions