Files
msd-core/.changeset
Michel Moreira 2f0e99f9e0 fix(#4377): opt in to project-relative includes for local installs (#4425)
* enhance(#4377): opt-in project-relative includes for local installs

A local install wrote the includes that point at GSD's own files as absolute
paths — whatever the installer resolved at install time. For one checkout
that is invisible. Across git worktrees it is not: each worktree gets its own
.claude/ copy, but all of them point back at the checkout that ran the
installer, so a worktree runs its own gsd-tools.cjs while reading workflow
prose from a different checkout. Update that one checkout and every other
worktree is running new instructions against an old engine, with nothing to
stage the update with.

--relative-includes (or GSD_RELATIVE_INCLUDES=1) makes a local install emit
`@.claude/gsd-core/...`. Opt-in, and staying opt-in: absolute works for a
single checkout, which is most people, and flipping the default would change
every existing local install to solve a problem those users do not have.

The prefix is the runtime's own localConfigDir descriptor value, never a
literal — the same value resolveScope joins onto the cwd to produce the
install target, and the same one the rewrite engine already uses for its
./.claude/ -> ./<dir>/ substitutions. Copilot and Antigravity have shipped
this shape for local installs since they were added, with hardcoded .github/
and .agents/; this is that behavior, derived rather than written down.

Six seams compute a path prefix and all six had to be threaded, which is why
the opt-in travels through the environment the way --portable-hooks already
does: one variable they all read cannot fall out of sync the way six
signatures can.

The launcher shim deliberately keeps its ABSOLUTE fallbacks. It probes
gsd-tools through ${CLAUDE_CONFIG_DIR:-$HOME/.claude} and one such default
per runtime; those are shell word expansions, not includes, and a relative
value there resolves against the shell's cwd rather than the project.
Trading an include that points at the wrong checkout for a path that points
at nothing is not a fix. All three rewrite paths mask ${VAR:-default} spans
before substituting and restore them after, and the mask only runs when the
prefix is relative, so an absolute install is byte-for-byte unchanged.

Every unexpressible case falls back to absolute: no opt-in, a global install,
a missing dir name, the configHome.kind === 'none' sentinel, an absolute
descriptor value, or one climbing out of the project with '..'.

* chore(#4377): add changeset for project-relative local includes

* fix(#4377): compare against POSIX-normalized roots in the install e2e arms

The emitted prefix is POSIX-normalized by design — it is substituted into
markdown @-references, which use forward slashes universally, so a backslash
would leak into shipped content (#1615). The e2e arms compared against the
raw temp root, which on Windows is `D:\a\...` and appears in no emitted file.

That reddened the control arm on the windows shard, and it was worse than a
red: the negative arm ("nothing references the checkout") was passing
VACUOUSLY there, because a string that cannot occur is trivially absent. Both
now go through the same normalization, so the Windows lane asserts what the
Linux lane does.

* fix(#4377): tolerate a resolved temp root, and make the e2e diff self-diagnosing

Two changes, one confirmed and one to stop guessing.

Confirmed: the emitted content carries the RESOLVED root, not the spelling
mkdtemp handed back. Reproduced on Linux with a symlinked install root —
236 emitted files carry the realpath, zero carry the link path. macOS has
this structurally, since /var is a symlink to /private/var. Comparisons now
go through both spellings, or the negative arms pass vacuously: "nothing
references the checkout" is trivially true when the string being searched
for cannot occur.

Not confirmed: the macOS shard reported ~every workflow file differing in
the "differ ONLY" arm while the five arms around it passed, and the
assertion printed a list of filenames — which says a difference exists
somewhere across 236 files and leaves the reader to guess which bytes. I
cannot reproduce that platform locally, and guessing turns one CI round-trip
into four. The assertion now reports the first divergence as text: the file,
the byte offset, and a bounded window of both sides.

* fix(#4377): strip the longest root spelling first in the install e2e diff

The macOS failure was my test corrupting its own comparison, not a product
defect. /var/folders/…/X is a SUBSTRING of /private/var/folders/…/X, so
stripping the unresolved spelling first matched inside the resolved one and
left the /private prefix glued to what followed:

  @/private/var/…/X/.claude/gsd-core/…  ->  @/private.claude/gsd-core/…

a string present in neither install, which is why all 236 files "differed".
Sorting the spellings longest-first consumes the whole occurrence, and the
short form then has nothing left to match. Proven in isolation on the exact
macOS shapes: short-first yields @/private.claude/…, longest-first yields
@.claude/….

The self-diagnosing assertion added in the previous commit is what found
this — it named the file, the byte offset, and printed both sides, so the
corrupted string was visible rather than inferred from a list of 236
filenames. Keeping it.

* fix(#4377): address review findings

* test(#4377): scan nested shell defaults without regex backtracking

* fix(#4377): close relative include review gaps

* fix(#4377): preserve root-target runtime includes

* fix(#4377): guard project-root relative includes

* test(#4377): normalize Cline fallback roots

* fix(#4377): persist relative include style

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-15 03:40:04 -04:00
..

Changeset Fragments

This directory holds per-PR CHANGELOG fragments. Every PR with user-facing changes drops one (or more) <random-name>.md files here describing its CHANGELOG entry. Fragments are consolidated into the top-level CHANGELOG.md at release time.

Why

Two PRs that both edit the ### Fixed block of CHANGELOG.md always conflict on merge — git can't pick a serialization order without human input. Two PRs that each add a fresh .changeset/<unique-name>.md never conflict because they don't share lines.

See #2975 for the full rationale.

Adding a fragment

node scripts/changeset/new.cjs \
  --type Fixed \
  --pr 1234 \
  --body "fix the thing — explain the user-visible change in one sentence"

This writes .changeset/<adjective>-<noun>-<noun>.md with frontmatter and a body. Three random words → concurrent PRs don't collide.

Format

---
type: Fixed
pr: 1234
---
**`/gsd-foo` no longer drops trailing slashes** — explain the user-visible change.

Allowed type: values follow Keep a Changelog: Added, Changed, Deprecated, Removed, Fixed, Security.

Opting out

PRs that legitimately have no user-facing impact can add the no-changelog label. CI honors it. When unsure, add the fragment.

At release time

Promotion is automatic. The release workflow's finalize job runs:

node scripts/changeset/cli.cjs render --version vX.Y.Z --date YYYY-MM-DD --allow-empty

This reads every fragment, groups bullets by type:, replaces ## [Unreleased] with a new ## [vX.Y.Z] - YYYY-MM-DD block, opens a fresh ## [Unreleased] above, and deletes consumed fragments. The --allow-empty flag ensures a no-change release still gets a dated heading (with a _No notable changes._ placeholder). A subsequent verify step confirms the promotion landed correctly. Maintainers do not run this by hand.

Archived fragments

.changeset/archived/ holds fragments for already-shipped releases (≤ 1.3.1), retained for provenance. Their content was hand-curated into the dated ## [1.x.y] sections of CHANGELOG.md during the #690 backfill — they were never consumed by render. All changeset tooling enumerates .changeset/ non-recursively, so archived fragments are never picked up or rendered. Do not move them back to the top level.