Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

.claude/settings.local.json
.claude/worktrees/
.claude/scheduled_tasks.lock
__pycache__/
*.py[cod]
.pytest_cache/
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,9 @@ coding-scaffold setup update --target .
```

Files that still match their generated checksum are updated in place. Files you edited are preserved
and the new generated version is staged next to them as `.new`.
and the new generated version is staged next to them as `.new`. The full upgrade contract —
`.new` reconciliation recipe, rollback, version pinning, and how to read the CHANGELOG's
breaking-changes — is in [`Upgrading`](docs/docs/wiki/Upgrading.md).

If you join an experienced team, connect to its onboarding manifest instead. This pulls the common
knowledge base and exposes approved skills, agents, policy, and config locally:
Expand Down
158 changes: 158 additions & 0 deletions docs/docs/wiki/Upgrading.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# Upgrading CodingScaffold

`coding-scaffold setup update` refreshes the generated files in
`.coding-scaffold/` (plus `AGENTS.md`, `CLAUDE.md`, etc.) without losing your
edits. This page explains the contract end-to-end so the upgrade path is
predictable.

## TL;DR

1. Upgrade the tool itself:
```bash
uv tool upgrade coding-scaffold # or: pipx upgrade coding-scaffold
```
2. Rerun `setup update` in each project:
```bash
coding-scaffold setup update --target .
```
3. If `.new` files were written, follow the printed reconciliation recipe
(also shown below).
4. Commit the result. Re-run `coding-scaffold eval run` to confirm health.

## What `setup update` does step-by-step

`setup update` is **idempotent**, **safe by default**, and **never destroys
user edits**. The flow:

1. Reads `.coding-scaffold/scaffold-version.json` (the SHA256 snapshot of every
file the scaffold has ever written).
2. Refuses to run if the installed scaffold version is older than the project's
recorded `min_supported_scaffold_version` (see [Version pinning](#version-pinning)
below). Pass `--force` to bypass after reading the migration note.
3. Rebuilds every generated file into a temporary directory using the current
intake + provider + routing inputs.
4. For each generated file:
- **Doesn't exist on disk** → write it. (You can safely delete files you
don't want; the next update will recreate them. To permanently exclude a
file, drop it from the writer set in your fork — there is no per-file
opt-out yet.)
- **Exists, matches snapshot, matches new** → skip. Nothing to do.
- **Exists, matches snapshot, differs from new** → silent rewrite. The
scaffold knows you didn't touch it, so the upstream version wins.
- **Exists, differs from snapshot** → user edited it. Write the new version
as a `<file>.new` sidecar; leave your file alone.
5. Refresh `scaffold-version.json` to reflect the new authoritative shape.

## The `.new` workflow

When `setup update` writes `<file>.new`, your edits to `<file>` are preserved
and an upstream version sits next to it for you to merge. The reconciliation
recipe (which `setup update` prints when `.new` files are produced):

```bash
# 1. Diff the pair so you can see what changed upstream:
diff -u .coding-scaffold/foo.json .coding-scaffold/foo.json.new

# 2. Merge the upstream changes into your edited file by hand.
# (Or pull both into your editor's three-way merge tool.)

# 3. Delete the .new sidecar once you're done:
rm .coding-scaffold/foo.json.new

# 4. Re-run eval to confirm the project is healthy:
coding-scaffold eval run --target .
```

Do **not** blindly `mv foo.new foo` — that throws away your edits, defeating
the whole point of `.new`. Always merge.

If you're confident the upstream version is the right starting point and your
edits should be re-derived from scratch, the deliberate workflow is:

```bash
git diff -- .coding-scaffold/foo.json # capture your edits in your head
mv .coding-scaffold/foo.json.new .coding-scaffold/foo.json
# re-apply your edits on top, then:
coding-scaffold eval run --target .
```

## How to roll back

`setup update` writes a new git-trackable shape. If something is wrong:

```bash
# Discard everything the update touched:
git restore .coding-scaffold/ AGENTS.md CLAUDE.md # add other paths if needed
git status # confirm clean

# Pin to the previous scaffold version so the next `setup update` doesn't
# re-apply the same change:
uv tool install 'coding-scaffold==0.5.1' # or your previous version
# or: pipx install --force 'coding-scaffold==0.5.1'
```

There is no built-in rollback; git is the safety net. Run `setup update` only
on a clean working tree so `git restore` is always sufficient.

## Files you deleted on purpose

If you intentionally delete a generated file (e.g., you don't want `tools.md`),
the next `setup update` recreates it. CodingScaffold has no per-project opt-out
yet; track this in your team manifest if it matters. The recommended pattern:

1. Delete the file.
2. Run `setup update`. The file comes back.
3. Either accept it (it's harmless if unused) or re-delete and add a project
policy note explaining why.

## Version pinning

`scaffold-version.json` carries a `min_supported_scaffold_version` field
(default: the version of CodingScaffold that wrote the file). `setup update`
refuses to run if the installed scaffold is older than this floor.

Example failure:

```
error: this project was last updated with CodingScaffold 0.6.0, but 0.5.1 is installed.
next: upgrade the scaffold (`pip install -U coding-scaffold` or
`uv tool upgrade coding-scaffold`), or rerun with `--force` after
reading the migration note.
see: https://jrs1986.github.io/CodingScaffold/wiki/Upgrading
```

This catches the case where a teammate updates the project with a newer
scaffold but a CI job or another machine still has an older version pinned.
Bypass with `--force` only after confirming the older scaffold's writers can
produce the project shape you actually want.

## Reading the CHANGELOG for breaking changes

The CHANGELOG groups changes by release. Look for these sections:

- **Breaking** — anything that changes the *shape* of generated files (renamed
keys, removed sections, file moves). When `setup update` runs across a
Breaking boundary, it always produces `.new` files for the affected outputs;
read the Breaking section to know what the merge should look like.
- **Deprecated** — features that still work but are scheduled for removal.
Plan to migrate before the next major bump.
- **Stability** — commands moved between `stable`/`preview`/`experimental`
markers. The Stability wiki page (shipped alongside this one) defines what
each marker promises.

A worked example: if 0.6.0's CHANGELOG says "Renamed `policy.network.allow`
to `policy.network.allowlist`", and your update produced
`.coding-scaffold/policy/network.json.new`, the diff will show exactly that
rename. Merge by renaming the key in your edited file and dropping the
sidecar.

## When `setup update` is not the right tool

- **First-time setup** → use `setup run`, not `setup update`. `update` needs an
existing scaffold to compare against.
- **Switching tools** (e.g., from OpenCode to Claude Code) → run
`setup run --target . --tool claude-code` to regenerate the adapter set
cleanly. `setup update` keeps your old tool's files alongside.
- **A breaking-change scenario you want to redo from scratch** → delete
`.coding-scaffold/` and rerun `setup run`. Read the relevant CHANGELOG
Breaking section first.
1 change: 1 addition & 0 deletions docs/docs/wiki/_meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"Policy-Packs",
"Security",
"Tool-Adapters",
"Upgrading",
"Advanced-Workflows",
"Team-Rollout",
"Review-Backlog",
Expand Down
54 changes: 52 additions & 2 deletions src/coding_scaffold/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,15 @@ def build_parser() -> argparse.ArgumentParser:
setup_update = setup_sub.add_parser("update", help="Refresh generated scaffold files without losing edits.")
setup_update.add_argument("--target", type=Path, default=Path.cwd(), help="Project directory.")
setup_update.add_argument("--json", action="store_true", help="Print machine-readable JSON.")
setup_update.add_argument(
"--force",
action="store_true",
help=(
"Bypass the min_supported_scaffold_version compatibility check. "
"Use when the installed scaffold is older than the project's recorded floor "
"and you've read https://jrs1986.github.io/CodingScaffold/wiki/Upgrading."
),
)

# NOTE: The parsers below (init, wizard, knowledge-status, context-budget,
# compress-context, orchestrate, setup-tool, setup-addon, setup-knowledge,
Expand Down Expand Up @@ -1690,6 +1699,35 @@ def _cmd_policy(args: argparse.Namespace) -> int:

def _cmd_update(args: argparse.Namespace) -> int:
target = args.target.expanduser().resolve()

# Compatibility gate: refuse to update if the installed scaffold is older
# than the project's recorded `min_supported_scaffold_version`. The .new
# workflow assumes the project structure the writers produce matches what
# `setup update` knows how to compare against; a downgrade can write files
# in a shape the old code doesn't recognize and silently clobber edits.
from . import __version__
from .scaffold_version import compare_versions, read_min_supported_version

min_required = read_min_supported_version(target)
if min_required and not getattr(args, "force", False):
if compare_versions(__version__, min_required) < 0:
print(
f"error: this project was last updated with CodingScaffold {min_required}, "
f"but {__version__} is installed.",
file=sys.stderr,
)
print(
" next: upgrade the scaffold "
"(`pip install -U coding-scaffold` or `uv tool upgrade coding-scaffold`), "
"or rerun with `--force` after reading the migration note.",
file=sys.stderr,
)
print(
" see: https://jrs1986.github.io/CodingScaffold/wiki/Upgrading",
file=sys.stderr,
)
return 1

intake = _load_project_intake(target)
hardware = probe_hardware()
providers = detect_providers(load_local_credentials(target))
Expand All @@ -1701,8 +1739,20 @@ def _cmd_update(args: argparse.Namespace) -> int:
print(f"Updated {len(result.updated)} generated file(s).")
print(f"Staged {len(result.staged)} edited file update(s) as .new.")
print(f"Skipped {len(result.skipped)} already-current file(s).")
for path in result.staged:
print(f"Review staged update: {path}")
if result.staged:
print()
print("Reconcile .new files — copy-pasteable next steps:")
print(" 1. Diff each pair so you can see what changed upstream:")
for path in result.staged:
original = str(path)[: -len(".new")] if str(path).endswith(".new") else str(path)
print(f" diff -u {original} {path}")
print(" 2. Merge the upstream changes into your edited file (resolve by hand).")
print(" 3. Delete the .new sidecar once you're done:")
for path in result.staged:
print(f" rm {path}")
print(" 4. Re-run `coding-scaffold eval run --target .` to confirm the project is healthy.")
print()
print("Full upgrade guide: https://jrs1986.github.io/CodingScaffold/wiki/Upgrading")
for warning in result.warnings:
print(f"Warning: {warning}", file=sys.stderr)
return 0
Expand Down
56 changes: 55 additions & 1 deletion src/coding_scaffold/scaffold_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import json
from pathlib import Path

from . import __version__


SCAFFOLD_VERSION_FILE = ".coding-scaffold/scaffold-version.json"

Expand All @@ -12,6 +14,13 @@ def write_scaffold_version(target: Path, files: list[Path]) -> Path:
root = target.expanduser().resolve()
payload = {
"version": 1,
# `min_supported_scaffold_version` pins the "do not downgrade past this
# CodingScaffold version" boundary for this project. `setup update` refuses
# to run when the installed scaffold is older than this — see
# ``read_min_supported_version`` and the `--force` flag handling in
# `cli._cmd_update`. Pinned to the current installed version on every
# write so a project upgraded on vN cannot silently regress on vN-1.
"min_supported_scaffold_version": __version__,
"files": {
display_path(path, root): sha256(path.read_bytes())
for path in sorted(files)
Expand All @@ -36,10 +45,30 @@ def read_scaffold_version(root: Path) -> dict[str, str]:
return {str(key): str(value) for key, value in files.items()}


def read_min_supported_version(root: Path) -> str | None:
"""Return ``min_supported_scaffold_version`` from the snapshot, or None.

None means: the snapshot predates this field (legacy projects) and no version
boundary is in effect.
"""

path = root / SCAFFOLD_VERSION_FILE
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
value = payload.get("min_supported_scaffold_version")
return str(value) if value else None


def write_scaffold_hashes(root: Path, hashes: dict[str, str]) -> Path:
path = root / SCAFFOLD_VERSION_FILE
path.parent.mkdir(parents=True, exist_ok=True)
payload = {"version": 1, "files": dict(sorted(hashes.items()))}
payload = {
"version": 1,
"min_supported_scaffold_version": __version__,
"files": dict(sorted(hashes.items())),
}
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
return path

Expand All @@ -53,3 +82,28 @@ def display_path(path: Path, root: Path) -> str:
return str(path.relative_to(root))
except ValueError:
return str(path)


def compare_versions(left: str, right: str) -> int:
"""Lexicographic semver-ish comparator. Returns -1/0/1.

Tolerates `0.5.1`, `0.5.1.dev0`, `1.0`, `1`. Pre-release suffixes after the
third part compare lexicographically; this is fine for refusing downgrades
where the exact ordering of dev-versions is not safety-critical.
"""

def parts(value: str) -> list[object]:
out: list[object] = []
for chunk in value.split("."):
try:
out.append((0, int(chunk)))
except ValueError:
out.append((1, chunk))
return out

a, b = parts(left), parts(right)
if a < b:
return -1
if a > b:
return 1
return 0
Loading
Loading